первая встреча непокрытой секции стала наблюдаемым событием

- свёртка спрашивает журнал, встречалось ли имя строго раньше по паре
  (received_at, id), и пишет WARN с атрибутом uncovered_new; повторные молчат.
  Признак выводится, а не хранится — реестр был бы второй копией факта
- добавлена подкоманда `healthlog uncovered`: перечень накопленного, чтение
  только на чтение, экранированные имена и названные границы носителя
- синк документации: ADR о выводе новизны из журнала, две записи в журнал
  дефектов, два правила промоутом в конвенции, терминал оператора назван
  адресатом недоверенного входа
This commit is contained in:
av
2026-08-04 13:39:48 +03:00
parent 2130763d3c
commit bd5d17b079
27 changed files with 3125 additions and 9 deletions
+3
View File
@@ -4,6 +4,7 @@
//
// healthlog [serve] --config <path> принимать пакеты (по умолчанию)
// healthlog reindex --config <path> пересобрать витрину из журнала
// healthlog uncovered --config <path> перечень секций, которых разбор не покрыл
// healthlog healthcheck --config <p> проверить /healthz (для docker HEALTHCHECK)
package main
@@ -30,6 +31,8 @@ func main() {
err = runServe(args)
case "reindex":
err = runReindex(args)
case "uncovered":
err = runUncovered(args)
case "healthcheck":
err = runHealthcheck(args)
default:
+115
View File
@@ -0,0 +1,115 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"io"
"os"
"text/tabwriter"
"git.vakhrushev.me/av/healthlog/internal/config"
"git.vakhrushev.me/av/healthlog/internal/store"
)
// uncoveredLimit — сколько строк перечня печатается по умолчанию.
//
// Предел объявлен, а не подразумевается: граница разбора в 32 имени действует на
// ОДНУ доставку, а различных имён журнал накопит сколько угодно — достаточно
// версии HAE, кладущей в ключ переменную часть. Двести взято с запасом: секций у
// HAE восемь, и перечень длиннее сотни означает не рост потока, а смену формы
// ключей — про неё скажет строка остатка.
const uncoveredLimit = 200
func runUncovered(args []string) error {
fs := flag.NewFlagSet("uncovered", flag.ContinueOnError)
cfgPath := fs.String("config", config.DefaultPath, "путь к config.toml")
limit := fs.Int("limit", uncoveredLimit, "сколько строк перечня печатать")
if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
// Справка — не отказ: иначе `uncovered -h` печатает usage и выходит
// со словом «fatal» и кодом 1.
return nil
}
return fmt.Errorf("parse flags: %w", err)
}
cfg, err := config.Load(*cfgPath)
if err != nil {
return err
}
// Только на чтение и без наката миграций: команда диагностическая, и запуск
// её при живом сервисе не имеет права ни мигрировать схему, ни писать.
// Расхождение версий — отказ с указанием обеих, и он доезжает до кода
// возврата: молчаливый пустой перечень неотличим от «ничего не приезжало».
st, err := store.OpenForRead(cfg.Storage.DBPath)
if err != nil {
return err
}
defer func() { _ = st.Close() }()
sections, total, err := st.UncoveredSections(context.Background(), *limit)
if err != nil {
return err
}
writeUncovered(os.Stdout, sections, total)
return nil
}
// writeUncovered печатает перечень человеку.
//
// Имя секции идёт ЭКРАНИРОВАННЫМ (`%q`): оно приходит верхнеуровневым ключом
// чужого тела, обрезано по длине на разборе, но по содержимому не ограничено
// ничем — сырая печать впустила бы в терминал управляющие последовательности.
//
// Данных о здоровье здесь нет: имя секции — структурный ключ, а не измерение.
// Идентификатор доставки печатается затем, чтобы по нему достать тело из архива
// и посмотреть форму секции глазами.
func writeUncovered(w io.Writer, sections []store.UncoveredSection, total int64) {
if len(sections) == 0 {
// НЕ «журнал такого не приносил»: перечень отвечает по колонкам
// доживших учётных записей, а не по истории потока. Обещание, которое
// носитель не даёт, закрыло бы владельцу вопрос ложным ответом.
fmt.Fprintln(w, "В учётных записях журнала непокрытых секций сейчас нет.")
writeUncoveredLimits(w)
return
}
fmt.Fprintf(w, "Непокрытых секций: %d\n\n", total)
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "СЕКЦИЯ\tДОСТАВОК\tПЕРВАЯ\tПОСЛЕДНЯЯ")
for _, s := range sections {
fmt.Fprintf(tw, "%q\t%d\t%s %s\t%s %s\n",
s.Name, s.Deliveries,
store.FormatTime(s.FirstSeen), s.FirstDeliveryID,
store.FormatTime(s.LastSeen), s.LastDeliveryID)
}
_ = tw.Flush()
// Остаток называется числом, а не обрывается молча: перечень — инструмент
// диагностики, и «здесь всё» против «здесь двести из тысячи» это разные
// ответы.
if rest := total - int64(len(sections)); rest > 0 {
fmt.Fprintf(w, "\nЕщё %d имён не показано.\n", rest)
}
writeUncoveredLimits(w)
}
// writeUncoveredLimits называет границы носителя — в любом исходе, включая
// пустой.
//
// Перечень производен от колонки учёта, а не от истории потока, и умолчать об
// этом значило бы отдать владельцу ответ, которого носитель не даёт: пустой
// перечень он прочитал бы как «ничего не приезжало» и закрыл бы вопрос.
func writeUncoveredLimits(w io.Writer) {
fmt.Fprint(w, `
Перечень собран по колонке учёта `+"`delivery.uncovered_sections`"+`, и границ у неё три:
- имена сверх 32 на одну доставку разбор в неё не кладёт;
- пересборка заполняет колонку заново и только по сохранившимся телам;
- секция, которую разбор научился покрывать, уходит из перечня при пересвёртке.
`)
}
+298
View File
@@ -0,0 +1,298 @@
package main
import (
"bytes"
"context"
"encoding/json"
"io"
"log/slog"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"time"
"git.vakhrushev.me/av/healthlog/internal/archive"
"git.vakhrushev.me/av/healthlog/internal/fold"
"git.vakhrushev.me/av/healthlog/internal/store"
)
func at(t *testing.T, s string) time.Time {
t.Helper()
v, err := time.Parse(time.RFC3339, s)
if err != nil {
t.Fatalf("метка %q: %v", s, err)
}
return v.UTC()
}
// Перечень — инструмент диагностики, и границы встреч в нём нужны затем, чтобы
// достать тело из архива по идентификатору доставки.
func TestПереченьНазываетГраницыВстреч(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
writeUncovered(&buf, []store.UncoveredSection{{
Name: "ecg",
Deliveries: 3,
FirstSeen: at(t, "2026-08-01T10:00:00Z"),
FirstDeliveryID: "01AAA",
LastSeen: at(t, "2026-08-02T11:00:00Z"),
LastDeliveryID: "01BBB",
}}, 1)
out := buf.String()
for _, want := range []string{"ecg", "3", "2026-08-01T10:00:00Z", "01AAA", "2026-08-02T11:00:00Z", "01BBB"} {
if !strings.Contains(out, want) {
t.Errorf("в выводе нет %q:\n%s", want, out)
}
}
}
// Пустой перечень говорит о себе словами: молчаливый пустой вывод неотличим от
// «команда ничего не сделала».
func TestПустойПереченьНазванСловами(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
writeUncovered(&buf, nil, 0)
if strings.TrimSpace(buf.String()) == "" {
t.Error("пустой перечень напечатал пустоту")
}
// И не обещает того, чего носитель не даёт: колонка отвечает про дожившие
// учётные записи, а не про историю потока.
if strings.Contains(buf.String(), "не приносил") {
t.Errorf("пустой перечень говорит за весь поток:\n%s", buf.String())
}
}
// Границы носителя называются в любом исходе: пустой перечень без них владелец
// прочитает как «ничего не приезжало» и закроет вопрос.
func TestГраницыНосителяНазваныВОбоихИсходах(t *testing.T) {
t.Parallel()
rows := []store.UncoveredSection{{
Name: "ecg", Deliveries: 1,
FirstSeen: at(t, "2026-08-01T10:00:00Z"), FirstDeliveryID: "01AAA",
LastSeen: at(t, "2026-08-01T10:00:00Z"), LastDeliveryID: "01AAA",
}}
for name, sections := range map[string][]store.UncoveredSection{
"пустой": nil,
"непустой": rows,
} {
var buf bytes.Buffer
writeUncovered(&buf, sections, int64(len(sections)))
if !strings.Contains(buf.String(), "uncovered_sections") {
t.Errorf("%s перечень не назвал носителя:\n%s", name, buf.String())
}
if !strings.Contains(buf.String(), "32") {
t.Errorf("%s перечень не назвал границу списка:\n%s", name, buf.String())
}
}
}
// Имя приходит верхнеуровневым ключом чужого тела: длина ограничена разбором,
// содержимое — ничем. Сырая печать впустила бы в терминал оператора управляющие
// последовательности.
func TestИмяСекцииЭкранируется(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
writeUncovered(&buf, []store.UncoveredSection{{
Name: "ecg\x1b[31m\nfake",
Deliveries: 1,
FirstSeen: at(t, "2026-08-01T10:00:00Z"),
FirstDeliveryID: "01AAA",
LastSeen: at(t, "2026-08-01T10:00:00Z"),
LastDeliveryID: "01AAA",
}}, 1)
out := buf.String()
if strings.Contains(out, "\x1b") {
t.Errorf("управляющий байт доехал до терминала:\n%q", out)
}
// Строка перечня обязана остаться одной: перевод строки из имени разорвал
// бы её надвое, и вторая половина читалась бы как отдельная секция.
var rows int
for line := range strings.SplitSeq(strings.TrimSpace(out), "\n") {
if strings.HasPrefix(line, `"`) {
rows++
}
}
if rows != 1 {
t.Errorf("строк перечня %d, ожидалась одна:\n%q", rows, out)
}
if !strings.Contains(out, `\n`) {
t.Errorf("перевод строки в имени не экранирован:\n%q", out)
}
}
// Остаток называется числом: «здесь всё» и «здесь двести из тысячи» — разные
// ответы, и молчаливый обрыв делает их неотличимыми.
func TestОстатокПеречняНазванЧислом(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
writeUncovered(&buf, []store.UncoveredSection{{
Name: "ecg",
Deliveries: 1,
FirstSeen: at(t, "2026-08-01T10:00:00Z"),
FirstDeliveryID: "01AAA",
LastSeen: at(t, "2026-08-01T10:00:00Z"),
LastDeliveryID: "01AAA",
}}, 5)
if !strings.Contains(buf.String(), "4") {
t.Errorf("остаток не назван числом:\n%s", buf.String())
}
}
// Базы по указанному пути нет — отказ с причиной и ненулевым кодом. Пустой
// перечень здесь был бы ложью: «ничего не приезжало» и «смотреть не во что» —
// разные ответы.
func TestОтсутствиеБазыДаётОтказ(t *testing.T) {
t.Parallel()
dir := t.TempDir()
cfgPath := filepath.Join(dir, "config.toml")
cfg := "[server]\naddr = \":8080\"\ningest_token = \"t\"\nread_token = \"r\"\n" +
"[storage]\ndb_path = \"" + filepath.Join(dir, "нет.db") + "\"\n" +
"raw_dir = \"" + filepath.Join(dir, "raw") + "\"\n"
if err := os.WriteFile(cfgPath, []byte(cfg), 0o600); err != nil {
t.Fatalf("конфиг: %v", err)
}
if err := runUncovered([]string{"--config", cfgPath}); err == nil {
t.Error("команда на несуществующей базе завершилась успехом")
}
}
// Сквозной прогон: перечень, собранный командой, сходится с тем, что посчитано
// по ТЕЛАМ архива независимо от её кода.
//
// Оракул строится от тел намеренно: сверка вывода с `SELECT DISTINCT` по той же
// колонке тем же `json_each` доказывала бы только согласие кода с самим собой —
// и молчала бы обо всём, что команда добавляет сверх множества имён.
//
// Не параллельный: подменяет `os.Stdout`.
func TestПереченьСходитсяСТеламиАрхива(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "healthlog.db")
rawDir := filepath.Join(dir, "raw")
arch, err := archive.New(rawDir)
if err != nil {
t.Fatalf("архив: %v", err)
}
st, err := store.Open(dbPath)
if err != nil {
t.Fatalf("база: %v", err)
}
svc := fold.New(arch, st, 0, slog.New(slog.DiscardHandler))
body, err := os.ReadFile(filepath.Join("..", "..", "internal", "hae", "testdata", "uncovered_sections.json"))
if err != nil {
t.Fatalf("тело: %v", err)
}
want := uncoveredInBody(t, body)
if len(want) == 0 {
t.Fatal("в теле нет непокрытых секций — проверять нечего")
}
ctx := context.Background()
for _, id := range []string{"d1", "d2"} {
at := store.Now()
rawPath, err := arch.Write(id, at, body)
if err != nil {
t.Fatalf("запись в архив: %v", err)
}
err = st.CreateDelivery(ctx, store.Delivery{
ID: id, ReceivedAt: at, AutomationID: "a1", Aggregation: "Minutes",
Bytes: int64(len(body)), SHA256: "-", RawPath: rawPath,
ParseStatus: store.ParsePending,
})
if err != nil {
t.Fatalf("учёт доставки: %v", err)
}
if _, err := svc.Fold(ctx, id); err != nil {
t.Fatalf("свёртка %s: %v", id, err)
}
}
// База закрывается до команды: та открывает её сама, только на чтение.
if err := st.Close(); err != nil {
t.Fatalf("закрытие базы: %v", err)
}
cfgPath := filepath.Join(dir, "config.toml")
cfg := "[storage]\ndb_path = \"" + dbPath + "\"\narchive_dir = \"" + rawDir + "\"\n"
if err := os.WriteFile(cfgPath, []byte(cfg), 0o600); err != nil {
t.Fatalf("конфиг: %v", err)
}
out := captureStdout(t, func() {
if err := runUncovered([]string{"--config", cfgPath}); err != nil {
t.Fatalf("команда: %v", err)
}
})
for _, name := range want {
if !strings.Contains(out, name) {
t.Errorf("в выводе нет секции %q, которая есть в теле:\n%s", name, out)
}
}
// Обе доставки принесли одно и то же тело, значит у каждой секции ровно две
// доставки, а границы — первая и последняя.
if !strings.Contains(out, " 2 ") && !strings.Contains(out, "\t2\t") {
t.Errorf("число доставок в выводе не 2:\n%s", out)
}
if !strings.Contains(out, "d1") || !strings.Contains(out, "d2") {
t.Errorf("границы встреч не названы обеими доставками:\n%s", out)
}
}
// uncoveredInBody считает непокрытые секции ПО ТЕЛУ, не трогая разбор: ключи
// `data` минус три покрытых имени.
func uncoveredInBody(t *testing.T, body []byte) []string {
t.Helper()
var envelope struct {
Data map[string]json.RawMessage `json:"data"`
}
if err := json.Unmarshal(body, &envelope); err != nil {
t.Fatalf("тело не разбирается: %v", err)
}
covered := map[string]bool{"metrics": true, "workouts": true, "stateOfMind": true}
var out []string
for name := range envelope.Data {
if !covered[name] {
out = append(out, name)
}
}
sort.Strings(out)
return out
}
// captureStdout ловит пользовательский вывод команды.
func captureStdout(t *testing.T, run func()) string {
t.Helper()
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("канал: %v", err)
}
saved := os.Stdout
os.Stdout = w
defer func() { os.Stdout = saved }()
run()
_ = w.Close()
var buf bytes.Buffer
if _, err := io.Copy(&buf, r); err != nil {
t.Fatalf("чтение вывода: %v", err)
}
return buf.String()
}