From bd5d17b07911603594111a9341d3e38ec010c820 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Tue, 4 Aug 2026 13:39:48 +0300 Subject: [PATCH] =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B2=D0=B0=D1=8F=20=D0=B2?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=B5=D1=87=D0=B0=20=D0=BD=D0=B5=D0=BF=D0=BE?= =?UTF-8?q?=D0=BA=D1=80=D1=8B=D1=82=D0=BE=D0=B9=20=D1=81=D0=B5=D0=BA=D1=86?= =?UTF-8?q?=D0=B8=D0=B8=20=D1=81=D1=82=D0=B0=D0=BB=D0=B0=20=D0=BD=D0=B0?= =?UTF-8?q?=D0=B1=D0=BB=D1=8E=D0=B4=D0=B0=D0=B5=D0=BC=D1=8B=D0=BC=20=D1=81?= =?UTF-8?q?=D0=BE=D0=B1=D1=8B=D1=82=D0=B8=D0=B5=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - свёртка спрашивает журнал, встречалось ли имя строго раньше по паре (received_at, id), и пишет WARN с атрибутом uncovered_new; повторные молчат. Признак выводится, а не хранится — реестр был бы второй копией факта - добавлена подкоманда `healthlog uncovered`: перечень накопленного, чтение только на чтение, экранированные имена и названные границы носителя - синк документации: ADR о выводе новизны из журнала, две записи в журнал дефектов, два правила промоутом в конвенции, терминал оператора назван адресатом недоверенного входа --- README.md | 4 +- cmd/healthlog/main.go | 3 + cmd/healthlog/uncovered.go | 115 +++++ cmd/healthlog/uncovered_test.go | 298 +++++++++++++ ...4-novizna-sekcii-vyvoditsya-iz-zhurnala.md | 52 +++ docs/adr/README.md | 4 + docs/architecture.md | 36 +- docs/conventions/storage.md | 9 + docs/conventions/testing.md | 11 + docs/research/apple-health.md | 7 +- docs/review.md | 53 +++ docs/security.md | 7 + internal/fold/fold.go | 123 +++++- internal/fold/log_test.go | 9 + internal/fold/novelty_internal_test.go | 138 ++++++ internal/fold/uncovered_test.go | 395 ++++++++++++++++++ internal/store/uncovered.go | 243 +++++++++++ internal/store/uncovered_test.go | 268 ++++++++++++ .../.openspec.yaml | 2 + .../design.md | 248 +++++++++++ .../proposal.md | 68 +++ .../review/triage.md | 277 ++++++++++++ .../specs/storage/spec.md | 63 +++ .../specs/uncovered-sections/spec.md | 269 ++++++++++++ .../tasks.md | 138 ++++++ openspec/specs/storage/spec.md | 17 +- openspec/specs/uncovered-sections/spec.md | 277 ++++++++++++ 27 files changed, 3125 insertions(+), 9 deletions(-) create mode 100644 cmd/healthlog/uncovered.go create mode 100644 cmd/healthlog/uncovered_test.go create mode 100644 docs/adr/ADR-2026-08-04-novizna-sekcii-vyvoditsya-iz-zhurnala.md create mode 100644 internal/fold/novelty_internal_test.go create mode 100644 internal/fold/uncovered_test.go create mode 100644 internal/store/uncovered.go create mode 100644 internal/store/uncovered_test.go create mode 100644 openspec/changes/archive/2026-08-04-aktivnaya-proverka-novyh-sekcij/.openspec.yaml create mode 100644 openspec/changes/archive/2026-08-04-aktivnaya-proverka-novyh-sekcij/design.md create mode 100644 openspec/changes/archive/2026-08-04-aktivnaya-proverka-novyh-sekcij/proposal.md create mode 100644 openspec/changes/archive/2026-08-04-aktivnaya-proverka-novyh-sekcij/review/triage.md create mode 100644 openspec/changes/archive/2026-08-04-aktivnaya-proverka-novyh-sekcij/specs/storage/spec.md create mode 100644 openspec/changes/archive/2026-08-04-aktivnaya-proverka-novyh-sekcij/specs/uncovered-sections/spec.md create mode 100644 openspec/changes/archive/2026-08-04-aktivnaya-proverka-novyh-sekcij/tasks.md create mode 100644 openspec/specs/uncovered-sections/spec.md diff --git a/README.md b/README.md index 4fdfa1b..fc048d7 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,8 @@ iPhone ──HTTPS POST──► healthlog ──► журнал доставо — с выводом слоя из данных, канонизацией содержимого и слиянием точек по полноте. Тренировки и записи со своим `id` (`workouts`, `stateOfMind`) тоже разбираются; секции, которых разбор не покрывает, принимаются, хранятся и -честно помечаются как неразобранные. +честно помечаются как неразобранные — а имя, которого поток раньше не приносил, +даёт `WARN` в логе свёртки один раз и попадает в перечень `healthlog uncovered`. Есть и пересборка: `healthlog reindex` проигрывает журнал доставок в свежую витрину и сверяет её отпечаток с накопленной — на живом архиве из 116 тел @@ -83,6 +84,7 @@ iPhone ──HTTPS POST──► healthlog ──► журнал доставо healthlog serve приём + read API + MCP healthlog import родной экспорт Apple Health (в планах) healthlog reindex пересборка витрины из журнала +healthlog uncovered перечень секций, которых разбор не покрыл healthlog healthcheck проверка живости для docker HEALTHCHECK ``` diff --git a/cmd/healthlog/main.go b/cmd/healthlog/main.go index 46d0b53..ef275f8 100644 --- a/cmd/healthlog/main.go +++ b/cmd/healthlog/main.go @@ -4,6 +4,7 @@ // // healthlog [serve] --config принимать пакеты (по умолчанию) // healthlog reindex --config пересобрать витрину из журнала +// healthlog uncovered --config перечень секций, которых разбор не покрыл // healthlog healthcheck --config

проверить /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: diff --git a/cmd/healthlog/uncovered.go b/cmd/healthlog/uncovered.go new file mode 100644 index 0000000..85cf3ac --- /dev/null +++ b/cmd/healthlog/uncovered.go @@ -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 на одну доставку разбор в неё не кладёт; + - пересборка заполняет колонку заново и только по сохранившимся телам; + - секция, которую разбор научился покрывать, уходит из перечня при пересвёртке. +`) +} diff --git a/cmd/healthlog/uncovered_test.go b/cmd/healthlog/uncovered_test.go new file mode 100644 index 0000000..f5237e5 --- /dev/null +++ b/cmd/healthlog/uncovered_test.go @@ -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() +} diff --git a/docs/adr/ADR-2026-08-04-novizna-sekcii-vyvoditsya-iz-zhurnala.md b/docs/adr/ADR-2026-08-04-novizna-sekcii-vyvoditsya-iz-zhurnala.md new file mode 100644 index 0000000..10a6188 --- /dev/null +++ b/docs/adr/ADR-2026-08-04-novizna-sekcii-vyvoditsya-iz-zhurnala.md @@ -0,0 +1,52 @@ +# Новизна имени секции выводится из журнала, а не хранится реестром + +- **Дата:** 2026-08-04 +- **Источник:** openspec/changes/archive/2026-08-04-aktivnaya-proverka-novyh-sekcij/design.md + +## Решение + +Признак «имя непокрытой секции встречено впервые» **не хранится**: он считается +запросом к журналу — «встречалось ли имя в доставках, стоящих строго раньше этой +по паре `(received_at, id)`». Реестр-таблица по образцу `category_value` — +очевидный ответ на тот же вопрос, уже применённый в этом проекте, — отвергнут. + +## Почему + +Цитата из источника: + +> Форма ответа взята у `category_value` — «когда имя встретилось впервые по +> журналу», — а носитель другой: факт уже лежит в `delivery.uncovered_sections`. +> Реестр здесь не добавляет ни одного сведения, он кэш запроса, а запрос идёт +> считанные разы за жизнь имени. + +И там же, о цене реестра: + +> Компромисс: вторая копия факта, обязанная сходиться с колонкой при каждой +> пересборке, плюс миграция и новая единица хранения витрины (а значит и +> отпечатка). Ноль новых сведений: имя выводимо из журнала. + +Третья рассмотренная форма — множество виденных имён в памяти процесса — +отвергнута по инварианту «хранилище есть свёртка по журналу»: состояние стало бы +функцией жизни процесса, и живой приём разошёлся бы с пересборкой в том, что +считает первой встречей. + +## Чем платим + +Ценой названы три вещи, и все они следствия выбранного носителя: + +- **проход по журналу** на каждой доставке с непокрытыми секциями. Измерено на + синтетическом журнале годового объёма: у секции, приезжающей давно, ранний + выход даёт десятки микросекунд, у появившейся только что — около 52 мс на + доставку, пока её не покроет отдельная задача; +- **границы носителя наследуются целиком**: имя, вытесненное границей списка в + 32 имени, события не даёт вовсе; пересборка заполняет колонку заново и только + по сохранившимся телам; покрытая разбором секция уходит из перечня; +- **история не переживает удаления тел.** Ретеншен, срезающий архив, унесёт с + собой и записи о непокрытых секциях за те же периоды. + +## Когда пересматривать + +Последнее и есть условие пересмотра, названное заранее: **задаче ретеншена +архива реестр понадобится** — именно затем, чтобы история пережила удаление тел, +и тогда это уже другая цена, а не вторая копия факта. Запрет реестра в спеке +`uncovered-sections` — решение этого изменения, а не запрет навсегда. diff --git a/docs/adr/README.md b/docs/adr/README.md index 02f1443..a10b33a 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -33,6 +33,10 @@ | Дата | Запись | Статус | | --- | --- | --- | +- [ADR-2026-08-04-novizna-sekcii-vyvoditsya-iz-zhurnala](ADR-2026-08-04-novizna-sekcii-vyvoditsya-iz-zhurnala.md) + — признак «секция встречена впервые» выводится запросом к журналу; реестр по + образцу `category_value` отвергнут как вторая копия факта, с названным + условием пересмотра — ретеншен архива. - [ADR-2026-08-04-tie-break-po-poryadku-zhurnala](ADR-2026-08-04-tie-break-po-poryadku-zhurnala.md) — тай-брейк точек при равной полноте: побеждает пришедшая, то есть правило становится явной функцией порядка журнала; хранимая метка провенанса diff --git a/docs/architecture.md b/docs/architecture.md index d420893..bd19f37 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -226,7 +226,7 @@ capability**, и здесь стоит ссылка, а не пересказ т | `archive` | сырой архив: запись тела, чтение для reindex, ретеншен | [`storage`](../openspec/specs/storage/spec.md) | | `hae` | разбор формата HAE, канонизация, хеш содержимого | [`parsing`](../openspec/specs/parsing/spec.md) | | `ingest` | use-case приёма, общий для HTTP и CLI `import` | [`ingest`](../openspec/specs/ingest/spec.md) | -| `fold` | свёртка одной доставки в часовые объекты | [`storage`](../openspec/specs/storage/spec.md) | +| `fold` | свёртка одной доставки в часовые объекты | [`storage`](../openspec/specs/storage/spec.md), [`uncovered-sections`](../openspec/specs/uncovered-sections/spec.md) | | `replay` | проигрывание журнала в витрину: состав, порядок, отчёт | [`reindex`](../openspec/specs/reindex/spec.md) | | `catalog` | каталог разрезов и измерение рода агрегации | [`catalog`](../openspec/specs/catalog/spec.md) | | `store` | SQLite: доставки, часовые объекты, тренировки, записи | [`storage`](../openspec/specs/storage/spec.md) | @@ -355,6 +355,40 @@ capability**, и здесь стоит ссылка, а не пересказ т `partial` — не отклонение, а установившееся состояние, поэтому уровень лога от него не растёт. Постоянный `WARN` каждые пять минут обесценил бы уровень. +**Первая встреча имени — другое дело** +([`uncovered-sections`](../openspec/specs/uncovered-sections/spec.md)). Момент, когда поток принёс секцию, +которой раньше не было, фиксировался колонкой, но не наблюдался ничем: увидеть +его мог только тот, кто догадается заглянуть в базу. Теперь свёртка спрашивает +журнал, встречалось ли имя в доставках **строго раньше** этой (пара +`(received_at, id)`, запросом вне транзакции записи), и первая встреча даёт +`WARN` с именами отдельным атрибутом `uncovered_new`. Повторные молчат. Признак +выводится, а не хранится: реестр был бы второй копией факта, обязанной сходиться +с колонкой при каждой пересборке. Отсюда же идемпотентность — проигрывание +полного журнала повторяет ровно те же события. + +Событие переживает **отказ** свёртки: список непокрытых секций переживает его +(доставка с невыводимым слоем всё равно пишет имена), и смолчать значило бы +потерять событие навсегда — следующая доставка сочла бы имя виденным. А +отложенный по обстоятельствам исход событий не даёт: учётной записи он не +меняет, доставка вернётся следующим проходом. + +Перечень накопленного отдаёт `healthlog uncovered` — имя, число доставок, +первая и последняя встреча, чтением только на чтение и с экранированием имён +(ключ приходит из чужого тела). Границы у перечня три, и они названы, а не +замолчаны: имя, вытесненное границей списка в 32 имени, в колонку не попадает +вовсе; пересборка обнуляет колонку и заполняет её заново только по сохранившимся +телам; а имя, секцию которого разбор научился покрывать, уходит из колонки при +пересвёртке — то есть перечень отвечает о текущем состоянии покрытия, а не об +истории. + +Цена сверки измерена на синтетическом журнале годового объёма; числа и метод +живут в одном месте — `design.md` изменения `aktivnaya-proverka-novyh-sekcij`, +решение 3, — и здесь не дублируются. Правило из замера: ранний выход есть только +у секции, приезжающей давно (строки просматриваются от старых к новым); у только +что появившейся секции проход идёт почти по всему журналу на каждой доставке, +пока её не покроет отдельная задача. Имён больше одного спрашиваются одним +запросом — тридцать два запроса подряд стоили секунду с лишним на доставку. + **Правило для будущих задач: покрыли секцию — пересверните.** Список это снимок покрытия на момент свёртки; доставки, свёрнутые до того, как секция стала покрытой, останутся `partial` со старым списком, и ретеншен будет вечно щадить diff --git a/docs/conventions/storage.md b/docs/conventions/storage.md index 39e1490..2ca5663 100644 --- a/docs/conventions/storage.md +++ b/docs/conventions/storage.md @@ -71,3 +71,12 @@ - Миграции — goose (`internal/store/migrations`), SQL для DDL. При изменении структуры обновляем схему в [database.md](../database.md) тем же изменением — это проверяет `task gate`. + +- **Значение, читаемое табличной функцией SQLite (`json_each` и родня), проходит + проверку ВНУТРИ её аргумента, а не условием в `WHERE`.** Функция получает + значение строки раньше, чем применится фильтр, и порядок этот SQLite не + обещает: неразбираемое значение роняет **весь** запрос, а не пропускает + строку. Условие в `WHERE` работает, пока планировщик проталкивает его вниз, и + перестаёт молча. Проверено на закреплённом драйвере: одна испорченная строка + `delivery.uncovered_sections` обесценивала и сверку новизны (вечное «сверка не + состоялась» на каждой доставке), и перечень целиком. diff --git a/docs/conventions/testing.md b/docs/conventions/testing.md index d974e57..48962c2 100644 --- a/docs/conventions/testing.md +++ b/docs/conventions/testing.md @@ -35,6 +35,17 @@ посчитает другое и разойдётся молча (так и вышло: ключ без слоя дал 29-кратное расхождение). Три случая одного класса за три дня: записи 2026-08-02, 2026-08-03 и 2026-08-04 в [review.md](../review.md). + + Метода мало — **синтетический корпус обязан содержать измеряемый случай в той + форме, в какой он бывает в жизни**. Сверка новизны секции мерялась на журнале, + где новое имя стояло во всех доставках, то есть его первая встреча лежала в + начале — ранний выход давал 31 мкс. В жизни секцию включают сегодня, первая + встреча оказывается в хвосте, и та же операция стоит 52 мс: три порядка + разницы, а на числе стояло решение «индекс не нужен» (запись 2026-08-04). + + И **число живёт в одном месте.** Один и тот же замер, записанный в + комментарий кода и в `architecture.md`, разошёлся внутри одного изменения. + Дом числа — `design.md` изменения; остальные формулируют правило и ссылаются. - **Оракул сходимости называет свою посылку рядом с собой, и прогон её печатает.** «Пересборка = приём» — не тождество, а утверждение с условиями: живая свёртка шла в порядке журнала, в журнале нет доставок, чью свёртку живой diff --git a/docs/research/apple-health.md b/docs/research/apple-health.md index 70c632b..8bae78b 100644 --- a/docs/research/apple-health.md +++ b/docs/research/apple-health.md @@ -1852,7 +1852,12 @@ instant heart_rate, respiratory_rate, blood_oxygen_saturation, Меняется ли что-то на глубине часов и суток — покажет более длинный ряд доставок. - **Секции, которых мы не видели живьём:** `symptoms`, `ecg`, - `heartRateNotifications`, `cycleTracking`, `medications`. + `heartRateNotifications`, `cycleTracking`, `medications`. Разбор покрывает + ровно остальные три (`metrics`, `workouts`, `stateOfMind` — `decodeCovered` в + `internal/hae`), сверено поимённо 2026-08-04. Момент их появления больше не + требует догадки: первая встреча имени даёт `WARN` в логе свёртки, а перечень + накопленного отдаёт `healthlog uncovered`. Разбор самой секции пишется, когда + её будет на чём проверить, — вслепую он не пишется. - **Что из этих секций вообще есть в родном экспорте.** ЭКГ выгружается отдельными CSV, а не в XML. Если `stateOfMind`, симптомы или лекарства в экспорте отсутствуют, то по ним экспорт не источник истины, и ретеншен diff --git a/docs/review.md b/docs/review.md index 54ab2f9..1b1f904 100644 --- a/docs/review.md +++ b/docs/review.md @@ -499,3 +499,56 @@ - **Что осталось незакрытым:** гейт после интеграции обязан звать `BASE` вершиной **до** слияния. Сейчас это знание живёт только в этой записи — ни `Taskfile.yml`, ни скилл батча его не несут. + +## 2026-08-04 — событие о новой секции терялось на отказе слияния [пойман] + +- **Где:** `internal/fold/fold.go`, ветвь отказа `store.Merge` в change + `2026-08-04-aktivnaya-proverka-novyh-sekcij` +- **Симптом:** доставка, принёсшая имя секции впервые, при нетранзиентном отказе + слияния писала имя в `delivery.uncovered_sections`, но запись об отказе его не + называла. Следующая доставка считала имя виденным — событие, однократное за + всю жизнь имени, пропадало **навсегда**, то есть ровно то, ради чего задача и + делалась. +- **Причина:** ветвей записи исхода в свёртке четыре, а дизайн рассмотрел одну. + Признак новизны считался до ветвления и корректно доезжал до `residueOf` + (отказ разбора), но ветвь отказа слияния собирала остаток **вручную** и поле + новизны в него не клала. Дельта-спека говорила «до ветвления на успех и + отказ», подразумевая один отказ. +- **Чем воспроизведён:** свёртка доставки с новой секцией при снесённой таблице + `bucket` — запись `ERROR` без `uncovered_new`, а `SectionsSeenBefore` на + следующей доставке уже отвечает «виденное». Тест закреплён: + `TestFoldОтказСлиянияНазываетНовуюСекцию`. +- **Чем пойман:** тремя проходами независимо (`specs`, `code`, `adversary`), + причём двое написали падающий тест. Дешёвый `code`-проход нашёл его наравне с + дорогими — признак того, что дефект был в форме «ветвь собрана руками рядом с + ветвью, собранной функцией», а такое видно чтением. +- **Что изменено:** новизна передаётся и в эту ветвь; дельта-спека переписана в + терминах «каждый исход, который пишет список в учётную запись», и отдельно + названы исходы, которые список очищают (нечитаемое тело, паника) и потому + события не теряют. + +## 2026-08-04 — замер стоимости снят на корпусе, где измеряемого случая не бывает [пойман] + +- **Где:** `design.md` того же change, решение 3; утверждение «в режиме + постоянного приезда секции сверка стоит 18 мкс на доставку» +- **Симптом:** на числе стояло решение «частичный индекс не нужен». Число + описывало **не тот** режим. +- **Причина:** синтетический журнал наполнялся так, что новая секция была во + **всех** доставках, то есть её первая встреча лежала в самом начале журнала — + и `LIMIT 1` выходил рано. В жизни секцию включают на телефоне сегодня: первая + встреча оказывается в хвосте, и проход идёт почти по всему журналу на каждой + доставке. Разница — три порядка (31 мкс против 52 мс). +- **Чем воспроизведён:** `tmp/seenmeasure` с хвостовым именем: голова 31 мкс, + хвост 52 мс, отсутствующее имя 50 мс. +- **Чем пойман:** `adversary` — он не поверил числу и построил корпус, в котором + измеряемый случай выглядит как в жизни. Это третий случай за три дня, когда + оценка оказалась функцией того, **как устроен корпус**, а не того, что + измеряют (записи 2026-08-02, 2026-08-04 про `verify:archive`). +- **Что изменено:** замер перемерян тремя случаями (голова, хвост, отсутствие), + числа сведены в одно место (`design.md`), код и `architecture.md` формулируют + правило и ссылаются на источник. Развилка «принять цену или завести индекс» + вынесена владельцу. +- **Что осталось незакрытым:** правило «число замера обязано нести метод и + описывать тот случай, ради которого снято» действует только для тестов + (`conventions/testing.md`). На `design.md` оно теперь распространено записью + ниже, но механизировать его нечем. diff --git a/docs/security.md b/docs/security.md index 9f33d94..ec1ba33 100644 --- a/docs/security.md +++ b/docs/security.md @@ -44,6 +44,13 @@ disabled`, `read auth disabled`), но стартовать не отказыв `export.xml`, который выбирает человек, но формируется он устройством и по объёму (3,6 млн записей) глазами не проверяется. +**Новый адресат недоверенного входа — терминал оператора.** Подкоманда +`healthlog uncovered` печатает имена секций, а имя это верхнеуровневый ключ +чужого тела: длина у него ограничена разбором (64 байта, не больше 32 имён), +содержимое — ничем. Печатается оно экранированным (`%q`), иначе управляющая +последовательность из тела подделала бы строки вывода. Тот же вход попадает +структурным атрибутом в лог свёртки, где его экранирует кодировщик `slog`. + Ответы внешних систем в недоверенный вход не входят: исходящих вызовов у сервиса нет. diff --git a/internal/fold/fold.go b/internal/fold/fold.go index 55a4084..77a5798 100644 --- a/internal/fold/fold.go +++ b/internal/fold/fold.go @@ -92,6 +92,17 @@ type Stats struct { Uncovered []string // UncoveredDropped — сколько имён отброшено границей списка. UncoveredDropped int + // UncoveredNew — имена непокрытых секций, которых не было ни в одной + // доставке, стоящей в журнале раньше этой. Событие однократное за всю жизнь + // имени: поток дописывает метрики на телефоне молча, и момент появления + // секции наблюдать больше нечем. + UncoveredNew []string + // UncoveredSeenUnknown — сверка с журналом не состоялась, и потому все + // непокрытые имена доставки объявлены новыми. Лишняя запись стоит внимания + // один раз, промолчавшее событие не восстанавливается ничем. + UncoveredSeenUnknown bool + // UncoveredSeenError — почему не состоялась. + UncoveredSeenError error // Categoricals — сколько РАЗЛИЧНЫХ категориальных значений наблюдалось; // CategoricalUnknown — сколько из них словарь не знает; @@ -158,6 +169,16 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (stats Stats, err FallbackLayer: hae.Layer(fallback), Locale: localeOf(d.Headers), }) + + // Сверка с журналом идёт ДО ветвления на успех и отказ. Список непокрытых + // секций переживает отказ разбора, то есть имя уже записано в учёт; смолчи + // здесь — и следующая доставка сочтёт его виденным, а событие не вернётся + // ничем, кроме ручного запроса в базу. + novelty := s.novelty(ctx, parsed.Uncovered, store.DeliveryRef{ + ID: d.ID, + ReceivedAt: d.ReceivedAt, + }) + if err != nil { // Список непокрытых секций переживает отказ: доставка, у которой не // определился слой, обязана остаться записью о том, что в теле есть @@ -166,12 +187,15 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (stats Stats, err // А вот число пропущенных сущностей — НЕ переживает: разбор, вернувший // ошибку, отдаёт нулевые счётчики по построению, а не по измерению, и // записать этот ноль значило бы объявить доставку проверенной. - s.fail(ctx, deliveryID, err, residueOf(parsed)) + s.fail(ctx, deliveryID, err, residueOf(parsed, novelty)) return stats, err } stats.Uncovered = parsed.Uncovered stats.UncoveredDropped = parsed.UncoveredDropped + stats.UncoveredNew = novelty.fresh + stats.UncoveredSeenUnknown = novelty.unknown + stats.UncoveredSeenError = novelty.cause stats.Metrics = parsed.Metrics stats.Points = len(parsed.Points) stats.SkippedNoTime = parsed.SkippedNoTime @@ -196,6 +220,11 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (stats Stats, err s.fail(ctx, deliveryID, err, parseResidue{ uncovered: parsed.Uncovered, skipped: skippedEntities(parsed), + // Новизна доезжает и сюда. Эта ветвь пишет имя в учёт ровно так же, + // как ветвь отказа разбора, — значит и терять событие ей нельзя: + // следующая доставка сочтёт имя виденным, а отказ слияния бывает + // нетранзиентным (исчерпанный дедлайн свёртки под большим телом). + novelty: novelty, }) return stats, err } @@ -283,6 +312,18 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) { // построчный разбор логов. Содержимого секций здесь нет. "uncovered", st.Uncovered, "uncovered_dropped", st.UncoveredDropped, + // Имена, встреченные впервые по журналу, — атрибутом ВСЕГДА, а уровень + // поднимается отдельной ветвью ниже. Наблюдаемый признак события это + // он: имя непокрытой секции стоит в атрибуте `uncovered` у каждой + // доставки, которая её принесла, и по нему первую встречу не отличить. + "uncovered_new", st.UncoveredNew, + "uncovered_seen_unknown", st.UncoveredSeenUnknown, + // Причина несостоявшейся сверки — рядом с признаком. Занятость базы + // проходит сама, испорченная колонка не проходит никогда и поднимает + // признак на каждой доставке; по одному булеву это неразличимо. + // Значений точек в ошибке нет: до текста доезжает только имя секции, и + // оно обрезано. + "uncovered_seen_error", st.UncoveredSeenError, // Категориальные значения — ЧИСЛАМИ. Ни строк, ни выведенных кодов: // «Сидячий образ жизни» — это контекст пульса, то есть данные о // здоровье. Какие именно строки ждут словаря, отвечает реестр в базе. @@ -318,6 +359,15 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) { allEntitiesSkipped := st.Workouts == 0 && st.Records == 0 && skippedEntities > 0 switch { + case len(st.UncoveredNew) > 0: + // ПЕРВОЙ ветвью, и это существенно. Все прочие говорят о событиях, + // повторяющихся на живом потоке; это — однократное за всю жизнь имени, и + // замаскировать его перезаписью точек значило бы потерять ровно то, ради + // чего наблюдение заведено. Уровень `WARN`, а не `ERROR`: приезд новой + // секции — штатное событие внешнего мира, «посмотри», а не «разбери + // сбой». Имена секций в лог попадать могут: имя ключа — форма пакета, а + // не измерение. + s.log.WarnContext(ctx, "delivery folded, new uncovered section", attrs...) case st.EntitiesHeld > 0: // Приехавшая версия сущности отклонена как теряющая содержание. Плата // за отказ объединять поля: событие обязано быть видно, потому что на @@ -446,10 +496,61 @@ type parseResidue struct { // считал». Ноль означал бы «проверено, терять нечего», а по этому числу // ретеншен принимает необратимое решение об удалении тела. skipped *int64 + // novelty в учёте не участвует — она едет в запись лога об отказе. Полем, а + // не пятым параметром `fail`: параметры путают местами, а поле называет + // себя само. + novelty sectionNovelty } -func residueOf(parsed hae.Result) parseResidue { - return parseResidue{uncovered: parsed.Uncovered} +func residueOf(parsed hae.Result, novelty sectionNovelty) parseResidue { + return parseResidue{uncovered: parsed.Uncovered, novelty: novelty} +} + +// sectionNovelty — исход сверки имён непокрытых секций с журналом. +type sectionNovelty struct { + // fresh — имена, которых не было ни в одной доставке раньше этой. + fresh []string + // unknown — сверка не состоялась, и потому новыми объявлены ВСЕ имена + // доставки. + unknown bool + // cause — почему не состоялась. Без неё занятость базы (пройдёт сама) и + // испорченное содержимое колонки (не пройдёт никогда, и признак будет + // подниматься на каждой доставке) неотличимы, а разбираться пришлось бы тем + // самым ручным запросом в базу, от которого задача избавляет. + cause error +} + +// novelty спрашивает журнал, какие из непокрытых имён встречаются впервые. +// +// Отказ запроса свёртку не роняет и исходом доставки не становится: правила +// классификации исходов наблюдение не трогает, занятая база и отменённый +// контекст остаются обстоятельствами. Но и молчания здесь быть не может — имя, +// о котором смолчали, уже записано в учёт, — поэтому при отказе новыми +// объявляются все имена, а признак несостоявшейся сверки идёт в запись. +// +// Запрос берётся только при непустом списке: на живом потоке все три +// приезжающие секции покрыты, то есть в штатном режиме сверка не стоит ничего. +func (s *Service) novelty(ctx context.Context, uncovered []string, at store.DeliveryRef) sectionNovelty { + if len(uncovered) == 0 { + return sectionNovelty{} + } + + seen, err := s.store.SectionsSeenBefore(ctx, uncovered, at) + if err != nil { + return sectionNovelty{fresh: uncovered, unknown: true, cause: err} + } + + fresh := make([]string, 0, len(uncovered)) + for _, name := range uncovered { + if _, ok := seen[name]; ok { + continue + } + fresh = append(fresh, name) + } + if len(fresh) == 0 { + return sectionNovelty{} + } + return sectionNovelty{fresh: fresh} } // skippedEntities — сколько сущностей с собственным `id` разбор пропустил. @@ -469,6 +570,20 @@ func (s *Service) fail(ctx context.Context, deliveryID string, cause error, resi return } + // Новые имена доезжают до записи об отказе: она уже выше рутинного уровня, + // а событие опознаётся атрибутом. В отложенном исходе выше их нет намеренно + // — там учётная запись не меняется, доставка вернётся следующим проходом, и + // повторение признака на каждом проходе занятой базы превратило бы + // однократное событие в дребезг. + attrs := []any{"error", cause, "delivery_id", deliveryID} + if len(residue.novelty.fresh) > 0 { + attrs = append(attrs, "uncovered_new", residue.novelty.fresh) + } + if residue.novelty.unknown { + attrs = append(attrs, "uncovered_seen_unknown", true, + "uncovered_seen_error", residue.novelty.cause) + } + level := slog.LevelError switch { case errors.Is(cause, hae.ErrLayerUnknown): @@ -481,7 +596,7 @@ func (s *Service) fail(ctx context.Context, deliveryID string, cause error, resi // одного класса ошибки давали бы постоянный ERROR-шум. level = slog.LevelWarn } - s.log.Log(ctx, level, "delivery fold failed", "error", cause, "delivery_id", deliveryID) + s.log.Log(ctx, level, "delivery fold failed", attrs...) // Слой НЕ затирается: доставка могла свернуться успешно раньше, и пустая // строка здесь оборвала бы цепочку наследования, то есть изменила бы diff --git a/internal/fold/log_test.go b/internal/fold/log_test.go index ece5237..cd07d14 100644 --- a/internal/fold/log_test.go +++ b/internal/fold/log_test.go @@ -238,6 +238,15 @@ func TestFoldЧастичныйРазборВЛоге(t *testing.T) { t.Fatalf("свёртка: %v", err) } + // Смотрим на ВТОРУЮ доставку с той же секцией. Первая встреча имени — + // событие само по себе и уровень поднимает законно; здесь же проверяется + // другое: что уровень не поднимает сама по себе частичность, установившееся + // состояние половины потока. + deliver(t, arch, st, "d2", "Minutes", "a1", []byte(body)) + if _, err := f.Fold(ctx, "d2"); err != nil { + t.Fatalf("повторная свёртка: %v", err) + } + out := buf.String() if !strings.Contains(out, "ecg") { t.Error("имени непокрытой секции нет в логе — момент появления новой секции незаметен") diff --git a/internal/fold/novelty_internal_test.go b/internal/fold/novelty_internal_test.go new file mode 100644 index 0000000..f9e474b --- /dev/null +++ b/internal/fold/novelty_internal_test.go @@ -0,0 +1,138 @@ +package fold + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "log/slog" + "path/filepath" + "strings" + "testing" + "time" + + "git.vakhrushev.me/av/healthlog/internal/store" +) + +// Внутренний тест, а не через `Fold`, и это осознанно. Сломать сверку снаружи +// больше нечем: испорченную учётную запись запрос теперь пропускает, а +// единственная настоящая причина отказа — отмена контекста и занятость базы — +// в полном проходе свёртки утащила бы за собой и слияние, то есть проверялась +// бы уже другая ветвь. Заводить интерфейс хранилища ради мока дороже: он +// пережил бы тест и остался бы в коде навсегда. +func TestNoveltyОтменённыйКонтекстНеМолчит(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + st, err := store.Open(filepath.Join(dir, "healthlog.db")) + if err != nil { + t.Fatalf("база: %v", err) + } + t.Cleanup(func() { _ = st.Close() }) + + s := &Service{store: st, log: slog.New(slog.DiscardHandler)} + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + got := s.novelty(ctx, []string{"ecg", "symptoms"}, store.DeliveryRef{ + ID: "d1", + ReceivedAt: time.Now().UTC(), + }) + + if !got.unknown { + t.Fatal("сверка на отменённом контексте объявила себя состоявшейся") + } + if len(got.fresh) != 2 { + t.Errorf("новыми объявлены %v, ожидались все имена доставки", got.fresh) + } + if got.cause == nil { + t.Error("причина отказа потеряна: занятость базы и вечную порчу колонки по признаку не различить") + } +} + +// Пустой список сверку не берёт вовсе: на живом потоке все три приезжающие +// секции покрыты, и запрос по журналу в штатном режиме не стоит ничего. +func TestNoveltyПустойСписокЗапросаНеБерёт(t *testing.T) { + t.Parallel() + + s := &Service{store: nil, log: slog.New(slog.DiscardHandler)} + + // Хранилище nil: возьмись запрос — тест упал бы паникой. + got := s.novelty(context.Background(), nil, store.DeliveryRef{ID: "d1"}) + if got.unknown || len(got.fresh) != 0 { + t.Errorf("на пустом списке сверка что-то решила: %+v", got) + } +} + +// Признак несостоявшейся сверки без причины неразбираем: занятость базы пройдёт +// сама, испорченная колонка не пройдёт никогда и будет поднимать признак на +// каждой доставке. +func TestLogResultПечатаетПричинуНесостоявшейсяСверки(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + s := &Service{log: slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))} + + s.logResult(context.Background(), "d1", Stats{ + Uncovered: []string{"ecg"}, + UncoveredNew: []string{"ecg"}, + UncoveredSeenUnknown: true, + UncoveredSeenError: errors.New("журнал недоступен"), + }) + + var rec map[string]any + line := strings.TrimSpace(buf.String()) + if err := json.Unmarshal([]byte(line), &rec); err != nil { + t.Fatalf("запись не разбирается: %v", err) + } + if rec["uncovered_seen_unknown"] != true { + t.Error("признак несостоявшейся сверки не выставлен") + } + if rec["uncovered_seen_error"] != "журнал недоступен" { + t.Errorf("причина в записи: %v", rec["uncovered_seen_error"]) + } + if rec["level"] != "WARN" { + t.Errorf("уровень %v, ожидался WARN: имена объявлены новыми", rec["level"]) + } +} + +// Запись об отказе несёт и признак несостоявшейся сверки, и причину: у отказа +// разбора та же цена молчания, что у успешного пути — имя уже в учёте. +func TestFailПечатаетНесостоявшуюсяСверку(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + st, err := store.Open(filepath.Join(dir, "healthlog.db")) + if err != nil { + t.Fatalf("база: %v", err) + } + t.Cleanup(func() { _ = st.Close() }) + + ctx := context.Background() + err = st.CreateDelivery(ctx, store.Delivery{ + ID: "d1", ReceivedAt: time.Now().UTC(), RawPath: "d1.json.gz", + SHA256: "-", ParseStatus: store.ParsePending, + }) + if err != nil { + t.Fatalf("учёт доставки: %v", err) + } + + var buf bytes.Buffer + s := &Service{ + store: st, + log: slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})), + } + + s.fail(ctx, "d1", errors.New("непонятое содержимое"), parseResidue{ + uncovered: []string{"ecg"}, + novelty: sectionNovelty{fresh: []string{"ecg"}, unknown: true, cause: errors.New("журнал недоступен")}, + }) + + out := buf.String() + for _, want := range []string{"uncovered_new", "ecg", "uncovered_seen_unknown", "журнал недоступен"} { + if !strings.Contains(out, want) { + t.Errorf("в записи об отказе нет %q:\n%s", want, out) + } + } +} diff --git a/internal/fold/uncovered_test.go b/internal/fold/uncovered_test.go new file mode 100644 index 0000000..4b2a2ef --- /dev/null +++ b/internal/fold/uncovered_test.go @@ -0,0 +1,395 @@ +package fold_test + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "log/slog" + "path/filepath" + "regexp" + "strings" + "testing" + + "git.vakhrushev.me/av/healthlog/internal/archive" + "git.vakhrushev.me/av/healthlog/internal/fold" + "git.vakhrushev.me/av/healthlog/internal/store" + + _ "modernc.org/sqlite" // прямое подключение к файлу базы — ради теста, ломающего сверку +) + +// loggedFold — свёртка с логгером, чьи записи можно прочитать. +// +// Отдельный конструктор, потому что событие о новой секции наблюдаемо ТОЛЬКО +// через лог: в учёте его нет и быть не может — колонка говорит, какие секции в +// теле есть, а не какая из них встретилась впервые. Путь к файлу базы нужен +// тесту, ломающему сверку: сломать её изнутри нечем — свёртка держит настоящее +// хранилище, а заводить интерфейс ради мока значит менять код под тест. +type loggedFold struct { + svc *fold.Service + arch *archive.Archive + st *store.Store + log *bytes.Buffer + dbPath string +} + +func newLoggedFold(t *testing.T) loggedFold { + t.Helper() + + dir := t.TempDir() + arch, err := archive.New(filepath.Join(dir, "raw")) + if err != nil { + t.Fatalf("архив: %v", err) + } + dbPath := filepath.Join(dir, "healthlog.db") + st, err := store.Open(dbPath) + if err != nil { + t.Fatalf("база: %v", err) + } + t.Cleanup(func() { _ = st.Close() }) + + var buf bytes.Buffer + log := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})) + return loggedFold{ + svc: fold.New(arch, st, 0, log), + arch: arch, + st: st, + log: &buf, + dbPath: dbPath, + } +} + +// breakMerge сносит таблицу объектов: слияние отказывает нетранзиентно, а разбор +// и запись исхода продолжают работать. +// +// В жизни в ту же ветвь ведёт исчерпанный дедлайн свёртки под большим телом +// (измерено: 63 МиБ держат блокировку 5.019 с) — `store.Transient` дедлайн +// намеренно не признаёт. +func breakMerge(t *testing.T, dbPath string) { + t.Helper() + + db, err := sql.Open("sqlite", "file:"+dbPath+"?_pragma=busy_timeout(5000)") + if err != nil { + t.Fatalf("открытие базы напрямую: %v", err) + } + defer func() { _ = db.Close() }() + + if _, err := db.ExecContext(context.Background(), `DROP TABLE bucket`); err != nil { + t.Fatalf("снос таблицы объектов: %v", err) + } +} + +// logRecords разбирает захваченные записи лога. +func logRecords(t *testing.T, buf *bytes.Buffer) []map[string]any { + t.Helper() + + var out []map[string]any + for line := range strings.SplitSeq(strings.TrimSpace(buf.String()), "\n") { + if line == "" { + continue + } + var rec map[string]any + if err := json.Unmarshal([]byte(line), &rec); err != nil { + t.Fatalf("строка лога не JSON: %v", err) + } + out = append(out, rec) + } + return out +} + +// newSections достаёт имена новых секций из записи лога. +func newSections(t *testing.T, rec map[string]any) []string { + t.Helper() + + raw, ok := rec["uncovered_new"] + if !ok || raw == nil { + // Пустой список кодировщик пишет как `null` — это и есть «новых имён + // нет», а не сломанный атрибут. + return nil + } + list, ok := raw.([]any) + if !ok { + t.Fatalf("атрибут uncovered_new не список: %#v", raw) + } + out := make([]string, 0, len(list)) + for _, v := range list { + s, ok := v.(string) + if !ok { + t.Fatalf("имя секции не строка: %#v", v) + } + out = append(out, s) + } + return out +} + +// withSection дописывает в тело непокрытую секцию с данным именем. +func withSection(t *testing.T, body []byte, name string) []byte { + t.Helper() + + const anchor = `"data": {` + if !bytes.Contains(body, []byte(anchor)) { + t.Fatalf("в теле нет объекта data — дописать секцию некуда") + } + return bytes.Replace(body, []byte(anchor), []byte(anchor+"\n \""+name+"\": [],"), 1) +} + +// Момент появления секции наблюдать больше нечем: телефон дописывает метрики +// молча, а колонка учёта говорит, какие секции в теле есть, а не какая из них +// приехала впервые. +func TestFoldПерваяВстречаСекцииДаётСобытие(t *testing.T) { + t.Parallel() + + lf := newLoggedFold(t) + ctx := context.Background() + body := withSection(t, fixture(t, "minute.json"), "symptoms") + + deliver(t, lf.arch, lf.st, "d1", "Minutes", "auto-1", body) + stats, err := lf.svc.Fold(ctx, "d1") + if err != nil { + t.Fatalf("свёртка: %v", err) + } + + if len(stats.UncoveredNew) != 1 || stats.UncoveredNew[0] != "symptoms" { + t.Fatalf("новых секций %v, ожидалась ровно `symptoms`", stats.UncoveredNew) + } + + recs := logRecords(t, lf.log) + var announced int + for _, rec := range recs { + for _, name := range newSections(t, rec) { + if name != "symptoms" { + continue + } + announced++ + if rec["level"] != "WARN" { + t.Errorf("уровень записи %v, ожидался WARN: приезд новой секции — «посмотри», а не «разбери сбой»", + rec["level"]) + } + } + } + if announced != 1 { + t.Errorf("записей о новой секции %d, ожидалась ровно одна", announced) + } +} + +// Вторая доставка с тем же именем молчит: событие однократно за всю жизнь +// имени, иначе поток раз в пять минут обесценил бы уровень. +func TestFoldВтораяВстречаСобытияНеДаёт(t *testing.T) { + t.Parallel() + + lf := newLoggedFold(t) + ctx := context.Background() + body := withSection(t, fixture(t, "minute.json"), "symptoms") + + deliver(t, lf.arch, lf.st, "d1", "Minutes", "auto-1", body) + if _, err := lf.svc.Fold(ctx, "d1"); err != nil { + t.Fatalf("первая свёртка: %v", err) + } + lf.log.Reset() + + deliver(t, lf.arch, lf.st, "d2", "Minutes", "auto-1", body) + stats, err := lf.svc.Fold(ctx, "d2") + if err != nil { + t.Fatalf("вторая свёртка: %v", err) + } + + if len(stats.UncoveredNew) != 0 { + t.Errorf("вторая доставка объявила новыми %v", stats.UncoveredNew) + } + + recs := logRecords(t, lf.log) + if len(recs) == 0 { + t.Fatal("вторая свёртка не записала ничего — чекпоинт молчит вовсе") + } + for _, rec := range recs { + if len(newSections(t, rec)) != 0 { + t.Errorf("вторая доставка назвала новые секции: %v", rec) + } + if rec["msg"] == "delivery folded" && rec["level"] != "INFO" { + t.Errorf("уровень повторной доставки %v, ожидался INFO", rec["level"]) + } + } + + // Перечисление непокрытых секций при этом не меняется: атрибут `uncovered` + // продолжает называть их все — по нему первую встречу и не отличить. + if !strings.Contains(lf.log.String(), "symptoms") { + t.Error("имя секции пропало из записи вовсе: атрибут непокрытых секций не трогается") + } +} + +// Новое имя рядом с уже виденным: событие адресное, а не «в теле что-то новое». +func TestFoldНовоеИмяРядомСВиденным(t *testing.T) { + t.Parallel() + + lf := newLoggedFold(t) + ctx := context.Background() + first := withSection(t, fixture(t, "minute.json"), "symptoms") + second := withSection(t, first, "ecg") + + deliver(t, lf.arch, lf.st, "d1", "Minutes", "auto-1", first) + if _, err := lf.svc.Fold(ctx, "d1"); err != nil { + t.Fatalf("первая свёртка: %v", err) + } + + deliver(t, lf.arch, lf.st, "d2", "Minutes", "auto-1", second) + stats, err := lf.svc.Fold(ctx, "d2") + if err != nil { + t.Fatalf("вторая свёртка: %v", err) + } + + if len(stats.UncoveredNew) != 1 || stats.UncoveredNew[0] != "ecg" { + t.Errorf("новых секций %v, ожидалась ровно `ecg`", stats.UncoveredNew) + } +} + +// Событие однократно за жизнь имени, значит замаскировать его рутинным +// событием нельзя: перезаписи точек случаются на живом потоке постоянно, а +// приезд новой секции — один раз. +func TestFoldНоваяСекцияНеМаскируетсяПерезаписью(t *testing.T) { + t.Parallel() + + lf := newLoggedFold(t) + ctx := context.Background() + + base := fixture(t, "minute.json") + deliver(t, lf.arch, lf.st, "d1", "Minutes", "auto-1", base) + if _, err := lf.svc.Fold(ctx, "d1"); err != nil { + t.Fatalf("первая свёртка: %v", err) + } + lf.log.Reset() + + // Те же координаты, другое значение: набор полей совпадает, полнота равна, + // поэтому побеждает пришедшая — это перезапись. + changed := regexp.MustCompile(`"qty"\s*:\s*[-0-9.eE+]+`).ReplaceAll(base, []byte(`"qty": 777.5`)) + if bytes.Equal(changed, base) { + t.Fatal("фикстура не содержит qty — перезапись не устроить") + } + deliver(t, lf.arch, lf.st, "d2", "Minutes", "auto-1", withSection(t, changed, "ecg")) + stats, err := lf.svc.Fold(ctx, "d2") + if err != nil { + t.Fatalf("вторая свёртка: %v", err) + } + if stats.Overwrites == 0 { + t.Fatal("перезаписей нет — рутинного события, которое могло бы замаскировать новое, не случилось") + } + + var found bool + for _, rec := range logRecords(t, lf.log) { + if rec["msg"] != "delivery folded, new uncovered section" { + continue + } + found = true + if rec["overwrites"] == nil { + t.Error("счётчик перезаписей исчез из записи: признаки идут атрибутами всегда") + } + } + if !found { + t.Errorf("запись назвала перезапись, а не новую секцию:\n%s", lf.log.String()) + } +} + +// Список непокрытых секций переживает отказ разбора, то есть имя уже записано в +// учёт. Промолчи здесь — и следующая доставка сочтёт его виденным, а событие не +// вернётся ничем. +func TestFoldОтказРазбораНазываетНовуюСекцию(t *testing.T) { + t.Parallel() + + lf := newLoggedFold(t) + ctx := context.Background() + + // Слой не выводится: плотных метрик в теле нет вовсе. + body := withSection(t, fixture(t, "sparse_sleep.json"), "cycleTracking") + deliver(t, lf.arch, lf.st, "d1", "Default", "auto-1", body) + if _, err := lf.svc.Fold(ctx, "d1"); err == nil { + t.Fatal("свёртка с неопределимым слоем прошла успешно") + } + + var found bool + for _, rec := range logRecords(t, lf.log) { + for _, name := range newSections(t, rec) { + if name == "cycleTracking" { + found = true + } + } + } + if !found { + t.Errorf("запись об отказе не назвала новую секцию:\n%s", lf.log.String()) + } + + // И имя доехало до учёта — иначе терять было бы нечего. + d, err := lf.st.LastDelivery(ctx) + if err != nil { + t.Fatalf("чтение доставки: %v", err) + } + if !strings.Contains(d.UncoveredSections, "cycleTracking") { + t.Errorf("список в учёте %q не содержит секции", d.UncoveredSections) + } +} + +// Пересборка проигрывает журнал заново, и повторная свёртка обязана дать тот же +// состав событий: признак новизны — функция журнала, а не числа прогонов. +func TestFoldПовторнаяСвёрткаДаётТотЖеСоставСобытий(t *testing.T) { + t.Parallel() + + lf := newLoggedFold(t) + ctx := context.Background() + body := withSection(t, fixture(t, "minute.json"), "symptoms") + + deliver(t, lf.arch, lf.st, "d1", "Minutes", "auto-1", body) + first, err := lf.svc.Fold(ctx, "d1") + if err != nil { + t.Fatalf("первая свёртка: %v", err) + } + second, err := lf.svc.Fold(ctx, "d1") + if err != nil { + t.Fatalf("повторная свёртка: %v", err) + } + + if len(first.UncoveredNew) != len(second.UncoveredNew) { + t.Fatalf("новых секций %v против %v", first.UncoveredNew, second.UncoveredNew) + } + for i := range first.UncoveredNew { + if first.UncoveredNew[i] != second.UncoveredNew[i] { + t.Errorf("на месте %d %q против %q", i, first.UncoveredNew[i], second.UncoveredNew[i]) + } + } +} + +// Отказ СЛИЯНИЯ пишет имя в учёт ровно так же, как отказ разбора, — значит и +// событие терять ему нельзя. Ветвь отдельная, и однажды она уже теряла его: +// residue собирался вручную и новизну не нёс. +func TestFoldОтказСлиянияНазываетНовуюСекцию(t *testing.T) { + t.Parallel() + + lf := newLoggedFold(t) + ctx := context.Background() + + breakMerge(t, lf.dbPath) + deliver(t, lf.arch, lf.st, "d1", "Minutes", "auto-1", + withSection(t, fixture(t, "minute.json"), "ecg")) + if _, err := lf.svc.Fold(ctx, "d1"); err == nil { + t.Fatal("свёртка без таблицы объектов прошла успешно") + } + + var told bool + for _, rec := range logRecords(t, lf.log) { + for _, name := range newSections(t, rec) { + if name == "ecg" { + told = true + } + } + } + if !told { + t.Errorf("запись об отказе слияния не назвала новую секцию:\n%s", lf.log.String()) + } + + // И событие действительно потеряно быть не могло: имя уже в учёте, то есть + // следующая доставка сочтёт его виденным. + d, err := lf.st.LastDelivery(ctx) + if err != nil { + t.Fatalf("чтение доставки: %v", err) + } + if !strings.Contains(d.UncoveredSections, "ecg") { + t.Errorf("список в учёте %q не содержит секции — терять было бы нечего", d.UncoveredSections) + } +} diff --git a/internal/store/uncovered.go b/internal/store/uncovered.go new file mode 100644 index 0000000..757af00 --- /dev/null +++ b/internal/store/uncovered.go @@ -0,0 +1,243 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" +) + +// UncoveredSection — строка перечня непокрытых секций: имя, которое поток +// приносил, и границы его встреч по журналу. +// +// Число доставок и границы считаются по ВСЕМ статусам разбора: список непокрытых +// секций переживает отказ свёртки, и молчать о таком имени значило бы терять как +// раз подозрительное. +type UncoveredSection struct { + // Name — верхнеуровневый ключ `data`, дословно как прислал HAE. Приходит из + // чужого тела: длина ограничена разбором, содержимое — ничем. + Name string + // Deliveries — сколько доставок принесло это имя. + Deliveries int64 + // FirstSeen и FirstDeliveryID — первая встреча ПО ЖУРНАЛУ; LastSeen и + // LastDeliveryID — последняя. Идентификатор нужен, чтобы достать тело из + // архива и посмотреть форму секции глазами. + FirstSeen time.Time + FirstDeliveryID string + LastSeen time.Time + LastDeliveryID string +} + +// usableList — условие «списку непокрытых секций есть что сказать». +// +// Отдельной константой, потому что стоит во всех трёх запросах и означает одно: +// по умолчанию колонка держит `[]`, а строки с ним до json_each доходить не +// должны. +// +// `json_valid` здесь не перестраховка. `json_each` над неразбираемым значением +// отвечает ошибкой и валит ВЕСЬ запрос, а не пропускает одну строку (проверено +// на закреплённом драйвере: `SQL logic error: malformed JSON`). Одна такая +// строка — из ручной правки, из будущей миграции данных мимо `json.Marshal`, +// из восстановления базы чужим инструментом — обесценила бы и сверку (вечное +// «сверка не состоялась» на каждой доставке), и перечень (полный отказ команды +// без указания виновника). Штатный путь записи такого значения не производит, и +// именно поэтому отказ был бы необъясним. +const usableList = `uncovered_sections <> '[]' AND uncovered_sections <> ''` + +// listOf — содержимое колонки, приведённое к разбираемому виду. +// +// Проверка стоит ВНУТРИ аргумента `json_each`, а не условием в `WHERE`: +// табличная функция получает значение строки раньше, чем применится фильтр, и +// порядок этот SQLite не обещает. Условие в `WHERE` работало бы, пока +// планировщик проталкивает его вниз, и молча перестало бы — с ошибкой, роняющей +// весь запрос, а не строку. +const listOf = `json_each(CASE WHEN json_valid(d.uncovered_sections) + THEN d.uncovered_sections ELSE '[]' END)` + +// SectionsSeenBefore отвечает, какие из имён уже встречались в доставках, +// стоящих в журнале СТРОГО РАНЬШЕ указанной. +// +// Строгое сравнение пары `(received_at, id)` решает три вещи разом: собственная +// строка доставки в счёт не идёт при любом порядке записи исхода; повторная +// свёртка той же доставки даёт тот же ответ, поэтому пересборка воспроизводит те +// же события; и судит имя порядок ЖУРНАЛА, а не порядок прогона — строка учёта +// становится видимой воркеру только после записи тела, так что при конкурентном +// приёме доставка может стать видимой после более новой. +// +// Индекса по `uncovered_sections` в схеме нет, поэтому проход по журналу +// полный, и форма запроса выбрана ЗАМЕРОМ (числа и метод — в `design.md` +// изменения `aktivnaya-proverka-novyh-sekcij`, решение 3; повторяется прогоном +// `tmp/seenmeasure`). Здесь только правило, которое из замера следует: +// +// - ранний выход `LIMIT 1` срабатывает, лишь когда первая встреча имени лежит +// БЛИЗКО К НАЧАЛУ журнала, — строки просматриваются от старых к новым. +// Секция, приезжающая давно, стоит десятки микросекунд; секция, появившаяся +// только что, — почти полный проход на каждой доставке, пока её не покроет +// отдельная задача; +// - имён больше одного спрашиваются ОДНИМ запросом: тридцать два запроса +// подряд стоили секунду с лишним на доставку, одна выборка — столько же, +// сколько один полный проход, независимо от числа имён. +// +// Вызывается ВНЕ транзакции записи: проход по растущему журналу внутри неё +// удерживал бы блокировку, а конкурирующий приём отвечает `500` по доставке, чьё +// тело уже на диске. +func (s *Store) SectionsSeenBefore(ctx context.Context, names []string, before DeliveryRef) (map[string]struct{}, error) { + unique := make([]string, 0, len(names)) + dedup := make(map[string]struct{}, len(names)) + for _, name := range names { + if _, ok := dedup[name]; ok { + continue + } + dedup[name] = struct{}{} + unique = append(unique, name) + } + if len(unique) == 0 { + return nil, nil + } + + at := FormatTime(before.ReceivedAt) + if len(unique) == 1 { + return s.sectionSeenOnce(ctx, unique[0], at, before.ID) + } + return s.sectionsSeenBatch(ctx, unique, at, before.ID) +} + +// sectionSeenOnce спрашивает про одно имя с ранним выходом. +func (s *Store) sectionSeenOnce(ctx context.Context, name, at, id string) (map[string]struct{}, error) { + const q = ` + SELECT 1 + FROM delivery d, ` + listOf + ` j + WHERE ` + usableList + ` + AND (d.received_at, d.id) < (?, ?) + AND j.value = ? + LIMIT 1` + + var one int + err := s.db.QueryRowxContext(ctx, q, at, id, name).Scan(&one) + switch { + case err == nil: + return map[string]struct{}{name: {}}, nil + case errors.Is(err, sql.ErrNoRows): + return nil, nil + default: + // Имя в текст ошибки не попадает целиком: оно приходит из чужого тела, а + // ошибка уходит в лог уровня выше DEBUG. Та же граница, что у координат + // наблюдения категориального значения. + return nil, fmt.Errorf("section seen before (%s): %w", clipCoord(name), err) + } +} + +// sectionsSeenBatch спрашивает про все имена одним проходом по журналу. +func (s *Store) sectionsSeenBatch(ctx context.Context, names []string, at, id string) (map[string]struct{}, error) { + q := ` + SELECT DISTINCT j.value + FROM delivery d, ` + listOf + ` j + WHERE ` + usableList + ` + AND (d.received_at, d.id) < (?, ?) + AND j.value IN (?` + strings.Repeat(", ?", len(names)-1) + `)` + + args := make([]any, 0, len(names)+2) + args = append(args, at, id) + for _, name := range names { + args = append(args, name) + } + + rows, err := s.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, fmt.Errorf("sections seen before: %w", err) + } + defer func() { _ = rows.Close() }() + + seen := make(map[string]struct{}, len(names)) + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, fmt.Errorf("scan section seen before: %w", err) + } + seen[name] = struct{}{} + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("sections seen before: %w", err) + } + return seen, nil +} + +// UncoveredSections возвращает перечень непокрытых секций — не больше limit +// строк — и общее число различных имён в журнале. +// +// Предел объявлен, а не подразумевается: граница разбора в 32 имени действует на +// ОДНУ доставку, а различных имён журнал накопит сколько угодно — достаточно +// версии HAE, кладущей в ключ переменную часть. Второе возвращаемое значение +// нужно, чтобы вывод мог назвать остаток числом, а не молча оборваться. +// +// Границы встреч берутся минимумом и максимумом склейки `received_at` и `id`: +// метка хранится в RFC3339 фиксированной ширины, поэтому склейка сравнивается +// лексикографически ровно как пара, а одна выборка с двумя агрегатами не +// опирается на то, из какой строки SQLite возьмёт голые колонки. +func (s *Store) UncoveredSections(ctx context.Context, limit int) ([]UncoveredSection, int64, error) { + if limit <= 0 { + return nil, 0, fmt.Errorf("предел строк перечня должен быть положительным, дано %d", limit) + } + + const countQ = ` + SELECT count(DISTINCT j.value) + FROM delivery d, ` + listOf + ` j + WHERE ` + usableList + + var total int64 + if err := s.db.GetContext(ctx, &total, countQ); err != nil { + return nil, 0, fmt.Errorf("count uncovered sections: %w", err) + } + + const q = ` + SELECT j.value, + count(DISTINCT d.id), + min(d.received_at || ' ' || d.id), + max(d.received_at || ' ' || d.id) + FROM delivery d, ` + listOf + ` j + WHERE ` + usableList + ` + GROUP BY j.value + ORDER BY j.value + LIMIT ?` + + rows, err := s.db.QueryContext(ctx, q, limit) + if err != nil { + return nil, 0, fmt.Errorf("select uncovered sections: %w", err) + } + defer func() { _ = rows.Close() }() + + var out []UncoveredSection + for rows.Next() { + var u UncoveredSection + var first, last string + if err := rows.Scan(&u.Name, &u.Deliveries, &first, &last); err != nil { + return nil, 0, fmt.Errorf("scan uncovered section: %w", err) + } + if u.FirstSeen, u.FirstDeliveryID, err = splitJournalKey(first); err != nil { + return nil, 0, err + } + if u.LastSeen, u.LastDeliveryID, err = splitJournalKey(last); err != nil { + return nil, 0, err + } + out = append(out, u) + } + if err := rows.Err(); err != nil { + return nil, 0, fmt.Errorf("select uncovered sections: %w", err) + } + return out, total, nil +} + +// splitJournalKey разбирает склейку метки журнала и идентификатора доставки. +func splitJournalKey(key string) (time.Time, string, error) { + at, id, ok := strings.Cut(key, " ") + if !ok { + return time.Time{}, "", fmt.Errorf("ключ журнала без разделителя: %q", key) + } + t, err := ParseTime(at) + if err != nil { + return time.Time{}, "", err + } + return t, id, nil +} diff --git a/internal/store/uncovered_test.go b/internal/store/uncovered_test.go new file mode 100644 index 0000000..0ada7db --- /dev/null +++ b/internal/store/uncovered_test.go @@ -0,0 +1,268 @@ +package store_test + +import ( + "context" + "database/sql" + "path/filepath" + "testing" + "time" + + "git.vakhrushev.me/av/healthlog/internal/store" + + _ "modernc.org/sqlite" // прямая запись в файл базы — ради теста на битую колонку +) + +// seedUncovered заводит доставку с уже записанным исходом разбора: список +// непокрытых секций живёт в учётной записи, и заполняет его свёртка. +func seedUncovered(t *testing.T, st *store.Store, id string, at time.Time, status string, sections ...string) { + t.Helper() + + seedPending(t, st, id, at) + err := st.FinishParse(context.Background(), id, store.ParseOutcome{ + Status: status, + Layer: "hour", + Uncovered: sections, + }) + if err != nil { + t.Fatalf("исход разбора %q: %v", id, err) + } +} + +// Признак новизны обязан быть функцией ЖУРНАЛА, а не порядка свёртки: имя +// считается виденным, только если встречалось строго раньше по паре +// `(received_at, id)`. +func TestSectionsSeenBeforeСудитПоЖурналу(t *testing.T) { + t.Parallel() + + st := open(t) + ctx := context.Background() + at := ts(t, "2026-08-01T12:00:00Z") + + early := at + late := at.Add(time.Second) + seedUncovered(t, st, "d-early", early, store.ParsePartial, "ecg") + seedUncovered(t, st, "d-late", late, store.ParsePartial, "ecg") + + seen, err := st.SectionsSeenBefore(ctx, []string{"ecg"}, store.DeliveryRef{ID: "d-late", ReceivedAt: late}) + if err != nil { + t.Fatalf("SectionsSeenBefore: %v", err) + } + if _, ok := seen["ecg"]; !ok { + t.Error("для поздней доставки имя обязано быть виденным: раньше неё в журнале оно есть") + } + + // Ранняя доставка судится тем же запросом и обязана признать имя новым — + // собственная строка в счёт не идёт, а более поздняя лежит после неё. + seen, err = st.SectionsSeenBefore(ctx, []string{"ecg"}, store.DeliveryRef{ID: "d-early", ReceivedAt: early}) + if err != nil { + t.Fatalf("SectionsSeenBefore: %v", err) + } + if _, ok := seen["ecg"]; ok { + t.Error("для ранней доставки имя обязано быть новым: раньше неё его в журнале нет") + } +} + +// Метка приёма хранится с секундной точностью, поэтому доставки одной секунды +// разводятся идентификатором — иначе исход зависел бы от того, какая из них +// свернулась первой. +func TestSectionsSeenBeforeРазводитОднуСекундуИдентификатором(t *testing.T) { + t.Parallel() + + st := open(t) + ctx := context.Background() + at := ts(t, "2026-08-01T12:00:00Z") + + seedUncovered(t, st, "d-a", at, store.ParsePartial, "symptoms") + seedUncovered(t, st, "d-b", at, store.ParsePartial, "symptoms") + + seen, err := st.SectionsSeenBefore(ctx, []string{"symptoms"}, store.DeliveryRef{ID: "d-b", ReceivedAt: at}) + if err != nil { + t.Fatalf("SectionsSeenBefore: %v", err) + } + if _, ok := seen["symptoms"]; !ok { + t.Error("для второй доставки той же секунды имя обязано быть виденным") + } + + seen, err = st.SectionsSeenBefore(ctx, []string{"symptoms"}, store.DeliveryRef{ID: "d-a", ReceivedAt: at}) + if err != nil { + t.Fatalf("SectionsSeenBefore: %v", err) + } + if len(seen) != 0 { + t.Errorf("для первой доставки той же секунды виденных имён быть не может, получено %v", seen) + } +} + +// Повторный вопрос обязан давать тот же ответ: на этом стоит воспроизводимость +// событий при пересборке журнала. +func TestSectionsSeenBeforeИдемпотентен(t *testing.T) { + t.Parallel() + + st := open(t) + ctx := context.Background() + at := ts(t, "2026-08-01T12:00:00Z") + + seedUncovered(t, st, "d1", at, store.ParsePartial, "ecg", "symptoms") + seedUncovered(t, st, "d2", at.Add(time.Minute), store.ParsePartial, "ecg") + + ref := store.DeliveryRef{ID: "d2", ReceivedAt: at.Add(time.Minute)} + first, err := st.SectionsSeenBefore(ctx, []string{"ecg", "medications"}, ref) + if err != nil { + t.Fatalf("SectionsSeenBefore: %v", err) + } + second, err := st.SectionsSeenBefore(ctx, []string{"ecg", "medications"}, ref) + if err != nil { + t.Fatalf("SectionsSeenBefore: %v", err) + } + if len(first) != 1 || len(second) != 1 { + t.Fatalf("виденных имён %d и %d, ожидалось по одному", len(first), len(second)) + } + if _, ok := first["ecg"]; !ok { + t.Error("`ecg` обязан быть виденным") + } + if _, ok := second["medications"]; ok { + t.Error("`medications` журнал не приносил и виденным быть не может") + } +} + +// Перечень отвечает по всем статусам разбора: список непокрытых секций +// переживает отказ свёртки, и молчать о таком имени значило бы терять как раз +// подозрительное. +func TestUncoveredSectionsСчитаетВстречиИГраницы(t *testing.T) { + t.Parallel() + + st := open(t) + ctx := context.Background() + at := ts(t, "2026-08-01T12:00:00Z") + + seedUncovered(t, st, "d1", at, store.ParsePartial, "symptoms") + seedUncovered(t, st, "d2", at.Add(time.Minute), store.ParsePartial, "symptoms", "ecg") + // Отказавшая доставка: имя записано, статус — `failed`. + seedUncovered(t, st, "d3", at.Add(2*time.Minute), store.ParseFailed, "ecg") + + got, total, err := st.UncoveredSections(ctx, 10) + if err != nil { + t.Fatalf("UncoveredSections: %v", err) + } + if total != 2 { + t.Errorf("различных имён %d, ожидалось 2", total) + } + if len(got) != 2 { + t.Fatalf("строк перечня %d, ожидалось 2", len(got)) + } + + // Порядок детерминирован — по имени. + if got[0].Name != "ecg" || got[1].Name != "symptoms" { + t.Fatalf("порядок строк %q, %q", got[0].Name, got[1].Name) + } + + ecg := got[0] + if ecg.Deliveries != 2 { + t.Errorf("`ecg` принесли %d доставок, ожидалось 2", ecg.Deliveries) + } + if ecg.FirstDeliveryID != "d2" || ecg.LastDeliveryID != "d3" { + t.Errorf("границы `ecg`: %q…%q, ожидалось d2…d3", ecg.FirstDeliveryID, ecg.LastDeliveryID) + } + if !ecg.FirstSeen.Equal(at.Add(time.Minute)) || !ecg.LastSeen.Equal(at.Add(2*time.Minute)) { + t.Errorf("метки `ecg`: %s…%s", ecg.FirstSeen, ecg.LastSeen) + } + + sym := got[1] + if sym.Deliveries != 2 || sym.FirstDeliveryID != "d1" || sym.LastDeliveryID != "d2" { + t.Errorf("`symptoms`: %d доставок, %q…%q", sym.Deliveries, sym.FirstDeliveryID, sym.LastDeliveryID) + } +} + +// Предел объявлен, а остаток обязан быть назван числом: «здесь всё» и «здесь +// часть» — разные ответы, и молчаливый обрыв делает их неотличимыми. +func TestUncoveredSectionsОстатокНазванЧислом(t *testing.T) { + t.Parallel() + + st := open(t) + ctx := context.Background() + at := ts(t, "2026-08-01T12:00:00Z") + + seedUncovered(t, st, "d1", at, store.ParsePartial, "aaa", "bbb", "ccc") + + got, total, err := st.UncoveredSections(ctx, 2) + if err != nil { + t.Fatalf("UncoveredSections: %v", err) + } + if len(got) != 2 { + t.Fatalf("строк перечня %d, ожидалось 2", len(got)) + } + if total != 3 { + t.Errorf("всего имён %d, ожидалось 3", total) + } +} + +// Пустой журнал и журнал без непокрытых секций отвечают пустым перечнем, а не +// отказом: колонка по умолчанию держит `[]`, и до json_each такие строки не +// доходят. +func TestUncoveredSectionsНаЧистомЖурнале(t *testing.T) { + t.Parallel() + + st := open(t) + ctx := context.Background() + at := ts(t, "2026-08-01T12:00:00Z") + + seedUncovered(t, st, "d1", at, store.ParseDone) + + got, total, err := st.UncoveredSections(ctx, 10) + if err != nil { + t.Fatalf("UncoveredSections: %v", err) + } + if len(got) != 0 || total != 0 { + t.Errorf("перечень %v, всего %d — ожидалась пустота", got, total) + } +} + +// Одна учётная запись с неразбираемым содержимым колонки не имеет права +// обесценить и сверку, и перечень: `json_each` над таким значением отвечает +// ошибкой и валит ВЕСЬ запрос, а не пропускает строку. +// +// Штатный путь записи такого значения не производит — тем хуже был бы отказ: +// он необъясним, а последствие вечно (сверка на каждой доставке говорила бы +// «не состоялась», команда отказывала бы целиком). +func TestUncoveredSectionsПереживаетБитуюСтроку(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + dbPath := filepath.Join(dir, "healthlog.db") + st, err := store.Open(dbPath) + if err != nil { + t.Fatalf("открытие базы: %v", err) + } + t.Cleanup(func() { _ = st.Close() }) + + ctx := context.Background() + at := ts(t, "2026-08-01T12:00:00Z") + seedUncovered(t, st, "d1", at, store.ParsePartial, "ecg") + seedUncovered(t, st, "d2", at.Add(time.Minute), store.ParsePartial, "symptoms") + + db, err := sql.Open("sqlite", "file:"+dbPath+"?_pragma=busy_timeout(5000)") + if err != nil { + t.Fatalf("прямое подключение: %v", err) + } + defer func() { _ = db.Close() }() + if _, err := db.ExecContext(ctx, + `UPDATE delivery SET uncovered_sections = '{не JSON' WHERE id = ?`, "d1"); err != nil { + t.Fatalf("порча колонки: %v", err) + } + + got, total, err := st.UncoveredSections(ctx, 10) + if err != nil { + t.Fatalf("перечень отказал целиком из-за одной строки: %v", err) + } + if total != 1 || len(got) != 1 || got[0].Name != "symptoms" { + t.Errorf("перечень %v (всего %d), ожидалась одна уцелевшая секция", got, total) + } + + seen, err := st.SectionsSeenBefore(ctx, []string{"symptoms"}, + store.DeliveryRef{ID: "d3", ReceivedAt: at.Add(2 * time.Minute)}) + if err != nil { + t.Fatalf("сверка отказала из-за одной строки: %v", err) + } + if _, ok := seen["symptoms"]; !ok { + t.Error("уцелевшая строка перестала участвовать в сверке") + } +} diff --git a/openspec/changes/archive/2026-08-04-aktivnaya-proverka-novyh-sekcij/.openspec.yaml b/openspec/changes/archive/2026-08-04-aktivnaya-proverka-novyh-sekcij/.openspec.yaml new file mode 100644 index 0000000..1b062d3 --- /dev/null +++ b/openspec/changes/archive/2026-08-04-aktivnaya-proverka-novyh-sekcij/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-04 diff --git a/openspec/changes/archive/2026-08-04-aktivnaya-proverka-novyh-sekcij/design.md b/openspec/changes/archive/2026-08-04-aktivnaya-proverka-novyh-sekcij/design.md new file mode 100644 index 0000000..8c68270 --- /dev/null +++ b/openspec/changes/archive/2026-08-04-aktivnaya-proverka-novyh-sekcij/design.md @@ -0,0 +1,248 @@ +## Context + +Разбор перечисляет верхнеуровневые ключи `data`, которых он не покрывает, и +свёртка пишет их в `delivery.uncovered_sections` JSON-массивом (change +`2026-08-01-nerazobrannye-sekcii-dostavki`). Покрыты три секции — `metrics`, +`workouts`, `stateOfMind`; не виденными живьём остаются `symptoms`, `ecg`, +`heartRateNotifications`, `cycleTracking`, `medications`. + +Колонка отвечает на вопрос «что останется потерянным, если тело удалить». На +вопрос «а когда именно поток принёс что-то новое» она отвечает только тому, кто +догадается спросить: события нет, а лог свёртки печатает `uncovered` атрибутом +на уровне `INFO`, где оно неотличимо от рутины — поток идёт раз в пять минут. + +Ограничения, в которых живёт решение: + +- **Хранилище — свёртка по журналу** (`critical`). Пересборка обязана дать то же + состояние; всё, что наблюдаемо, обязано быть функцией журнала, а не порядка + прогонов. +- **Поток не останавливается** — наблюдательный механизм не имеет права ронять + свёртку и не имеет права держать блокировку базы: конкурирующий приём при + исчерпанном `busy_timeout` отвечает `500` по доставке, тело которой уже на + диске. +- Схема не трогается: колонка уже есть, задача её и использует. + +## Goals / Non-Goals + +**Goals:** + +- Первая по журналу встреча имени непокрытой секции видна владельцу без запроса + в базу; повторные встречи молчат. +- Событие переживает отказ свёртки — иначе оно теряется навсегда. +- Перечень накопленного достаётся одной командой. +- Пересборка полного журнала воспроизводит ровно те же события. + +**Non-Goals:** + +- Разбор новых секций. Формы никто не видел; вслепую разбор не пишется. +- Хранение реестра секций, HTTP-эндпоинт, уведомление наружу. Первое — + вторая копия факта, второе и третье — отдельные цели (`Read API`, + `Наблюдаемость`). +- История непокрытых секций, переживающая удаление тел архива. Это предмет + ретеншена, и цена решения названа ниже. + +## Три формы решения и компромисс каждой + +Рассматривались три носителя признака «имя встречено впервые»; выбрана первая. + +1. **Вывод из журнала запросом (выбрано).** Носителя нет вовсе — признак + считается по существующей колонке. Компромисс: платим запросом по журналу на + каждую доставку с непокрытыми секциями (см. решение 3) и наследуем все + границы колонки — обрезку списка и обнуление пересборкой (решение 7). +2. **Реестр-таблица по образцу `category_value`.** Строка на имя с провенансом + первой встречи; запрос новизны становится точечным по первичному ключу. + Компромисс: вторая копия факта, обязанная сходиться с колонкой при каждой + пересборке, плюс миграция и новая единица хранения витрины (а значит и + отпечатка). Ноль новых сведений: имя выводимо из журнала. Отвергнуто — + но не навсегда: ретеншену, срезающему тела, реестр понадобится именно затем, + чтобы история пережила удаление, и тогда это уже другая цена. +3. **Множество виденных имён в памяти процесса**, наполняемое при старте. + Запрос один на запуск, дальше — проверка по map. Компромисс: состояние + становится функцией жизни процесса, а не журнала; наполнение при старте — тот + же полный проход, только раньше; пересборка и живой приём расходятся в том, + что считают первой встречей. Отвергнуто по инварианту «свёртка по журналу». + +## Decisions + +### 1. Новизна выводится из журнала, а не хранится реестром + +Форма ответа взята у `category_value` — «когда имя встретилось впервые по +журналу», — а носитель другой: факт уже лежит в `delivery.uncovered_sections`. +Реестр здесь не добавляет ни одного сведения, он кэш запроса, а запрос идёт +считанные разы за жизнь имени. + +Новизна считается запросом: «какие из этих имён встречались в доставках, стоящих +в журнале **строго раньше** текущей». Пусто — имя новое. + +Прочтение колонки — `json_each` по `delivery`; колонка заведена массивом ровно с +этим расчётом («читается из SQLite через `json_each`», миграция `00005`). + +### 2. Сравнение парой `(received_at, id)`, а не по идентификатору + +Строгое сравнение пары решает три вещи разом: + +- **самоисключение** — своя же строка (её пишет `finish` после) в счёт не идёт + при любом порядке записи; +- **идемпотентность** — повторная свёртка той же доставки даёт тот же исход, + поэтому пересборка не выдумывает и не глотает события; +- **порядок журнала, а не порядок прогона.** `received_at` хранится с секундной + точностью, а строка учёта становится видимой воркеру только после записи тела: + при конкурентном приёме доставка может стать видимой после более новой. Пока + окно существует, сравнение по журналу делает исход от него независимым; когда + окно закроют на самом приёме, сравнение всё равно останется — на нём стоят + самоисключение и идемпотентность. + +Та же лексикографическая пара уже стоит в `mergeCategories` и по той же причине; +здесь она в `WHERE`, а не в `ON CONFLICT`. + +Следствие названо вслух: если две доставки стали видимы не в журнальном порядке, +имя может дать событие дважды — сначала на более новой, потом на более старой. +Это не дефект, а честное «первой в журнале была вот эта»; потери здесь нет, а +дублирование ограничено одной парой. + +### 3. Цена запроса измерена, а форма выбрана по замеру + +Индекса по `uncovered_sections` в схеме нет, и это изменение его не заводит. +Значит запрос — **полный проход по `delivery`** с фильтром по непустому списку, +а не «индексный». + +**Это единственное место, где живут числа замера.** Комментарий кода и +`docs/architecture.md` формулируют правило и ссылаются сюда; дублировать цифры +запрещено — они уже разошлись однажды внутри одного изменения. + +Метод: синтетический журнал в `./tmp` (`tmp/seenmeasure`, прогон повторяем), +105 тысяч доставок — годовой объём при 288 в сутки; 20 прогонов на случай; +машина ничем другим не занята. + +| случай | по имени отдельно | одним запросом (в коде) | +|---|---|---| +| одно имя, первая встреча в начале журнала | 31 мкс | 31 мкс | +| одно имя, первая встреча в хвосте | — | 52 мс | +| одно имя, в журнале не встречалось | 50 мс | 50 мс | +| 32 имени | 1.36 с | 65 мс | + +Читается это так, и первое было названо неверно в первой редакции дизайна: + +- **ранний выход есть только у давно приезжающей секции.** Строки + просматриваются от старых к новым, поэтому `LIMIT 1` выходит рано, лишь когда + первая встреча имени лежит в начале журнала; +- **у секции, появившейся только что, раннему выходу не на чем сработать** — её + первая встреча в хвосте, и проход идёт почти по всему журналу. Это и есть + заявленный сценарий задачи: 52 мс на каждой доставке, 288 раз в сутки — около + 15 секунд чтения в сутки, пока секцию не покроет отдельная задача. Позиция + первой встречи зафиксирована навсегда, поэтому цена сама не рассосётся; +- **32 имени по одному стоили секунду с лишним на доставку**, и тело, выбившее + границу списка, приезжает раз в пять минут — это режим, а не случай. Поэтому + имён больше одного спрашиваются одним запросом; частому случаю (одно имя + давней секции) это ничего не стоит. + +Что это стоит в жизни: **пока непокрытых секций нет** (сегодняшний режим — все +три приезжающие секции покрыты) запрос не берётся вовсе, он берётся только при +непустом списке. + +**Развилка, вынесенная владельцу** (записана в докладе задачи): принять ли эти +52 мс на доставку или завести частичный индекс по непустому списку. Индекс — это +миграция и правка `docs/database.md`, то есть выход за рамку «схема не +трогается», поэтому в этом изменении он не делается. Остаток доведён с принятой +ценой, названной здесь числом. + +### 4. Запрос идёт вне транзакции записи + +Прецедент проекта прямой: канонизация внутри транзакции держала блокировку +5.019 с и дала 768 МиБ пика. Проход по журналу внутри транзакции записи объектов +повторил бы ровно этот класс — с той разницей, что отказ конкурирующего приёма +необратим. Сверка выполняется отдельным чтением до записи. + +### 5. Событие живёт в едином логирующем чекпоинте свёртки + +Своей строки лога у события нет: конвенция проекта — один логирующий чекпоинт на +доменной границе. Имена новых секций идут **атрибутом всегда**, а уровень +поднимается веткой `switch`. + +Нормируется **поведение**, а не позиция ветви: запись обязана называть новую +секцию, какие бы повторяющиеся события ни случились в той же доставке. В коде это +достигается тем, что ветвь стоит первой, и это остаётся решением кода, а не +требованием спеки — иначе следующая задача, добавляющая свою ветвь, получит +арбитра в чужой capability. + +Уровень назван поимённо — `WARN`. «Выше `INFO`» зеленело бы и на `ERROR`, а +`ERROR` у владельца означает сбой, который надо разбирать. + +### 6. Путь отказа проверяется, отложенный — нет + +Список непокрытых секций переживает отказ разбора (`residueOf`): доставка, у +которой не вывелся слой, всё равно записывает имена. Значит проверка новизны идёт +**до** ветвления на успех и отказ, а новые имена доезжают до `fail` и печатаются +его строкой. + +Отложенный исход (занятость базы, отмена) — отдельный случай: он не пишет учётной +записи вовсе, доставка возвращается следующим проходом. Событие там не +печатается: оно не потеряно, а повторение на каждом проходе занятой базы +превратило бы однократный признак в дребезг. + +### 7. Границы носителя названы, а не замолчаны + +Признак и перечень производны от колонки, у которой три слепые зоны, и все три +идут в спеку: + +- **обрезка списка**: имя, стоящее в теле после 32 незнакомых ключей, в колонку + не попадает — события не будет. Наблюдаемым остаётся счётчик отброшенных имён, + который уже поднимает уровень записи («тело на HAE не похоже вовсе»). + Объявлять при обрезке новыми **видимые** имена рассматривалось и отвергнуто: + невидимого это не возвращает, а сигнал об обрезке уже есть и уже громкий; +- **пересборка** обнуляет производные от разбора поля и заполняет их заново + только по сохранившимся телам. Ретеншен, срезающий тела, стирает историю + непокрытых секций вместе с ними — поэтому «те же события» пересборка + воспроизводит при **полном** архиве. Это же и есть будущая цена решения 1; +- **покрытие секции**: пересвёртка убирает имя из колонки, и перечень отвечает о + текущем состоянии покрытия, а не об истории. Она же может дать событие + повторно — журнал тот же, а колонка заполняется заново. + +### 8. Перечень отдаёт подкоманда `healthlog uncovered` + +Той же формы, что `reindex` и `healthcheck`: конфиг флагом, человекочитаемый +вывод в stdout, ненулевой код на отказ. HTTP-маршрут отвергнут — Read API ещё +нет, а инструмент нужен на той же машине, где лежит база. + +Имя `uncovered`, а не `sections`: команда перечисляет только непокрытое, а +широкое имя заняло бы место под будущий вопрос «какие секции покрыты» (его +сегодня закрывает сверка глазами, критерий К4). Словарь при этом закреплён: +`uncovered` — состояние покрытия (колонка, атрибут, capability +`uncovered-sections`), `new` — событие первой встречи (атрибут новых секций). + +База открывается **только на чтение** (`store.OpenForRead`): команда +диагностическая, и запуск её при живом сервисе не должен ни мигрировать схему, +ни писать. Отказ открытия говорит причину и даёт ненулевой код — пустой перечень +от молчаливого отказа неотличим. + +Строки — имя, число доставок, первая и последняя встреча (метка журнала и +идентификатор доставки; по идентификатору достают тело из архива). Вывод имеет +объявленный предел строк с остатком числом: предел разбора в 32 имени действует +на одну доставку, а различных имён журнал накопит сколько угодно — достаточно +версии HAE, кладущей в ключ переменную часть. + +Имя печатается **экранированным** (`%q`): оно приходит верхнеуровневым ключом +чужого тела, обрезано по длине на разборе, но по содержимому не ограничено ничем +— сырая печать в терминал впустила бы туда управляющие последовательности. + +Данных о здоровье в выводе нет: имя секции — структурный ключ, а не измерение. + +## Risks / Trade-offs + +- **Полный проход по журналу на каждой доставке с непокрытыми секциями** + (решение 3) → измерено на синтетическом годовом журнале; цена принята и + названа числом там же, развилка про индекс вынесена владельцу. Замер + повторяем (`tmp/seenmeasure`). +- **Долгий читатель против чекпоинта WAL**: команда перечня делает проход по + `delivery` вторым процессом, а удерживаемый читатель останавливает продвижение + чекпоинта (измерено раньше: журнал 51 МБ при лимите 8 МиБ) → запрос + одиночный и короткий, снимок не удерживается дольше вывода; команда + диагностическая и запускается руками. +- **Слепые зоны носителя** (решение 7) → названы в спеке требованием «перечень + честен относительно своего носителя»; молчание о них было бы хуже самих зон. +- **Дублирование события при несовпадении видимости с журналом** (решение 2) → + ограничено парой строк, потери нет; альтернатива сделала бы событие функцией + очереди и разошлась бы с пересборкой. +- **Лишние `WARN` при отказе сверки** (спека «Несостоявшаяся сверка не молчит») + → ограничены доставками с непокрытыми секциями и сопровождаются атрибутом о + причине. diff --git a/openspec/changes/archive/2026-08-04-aktivnaya-proverka-novyh-sekcij/proposal.md b/openspec/changes/archive/2026-08-04-aktivnaya-proverka-novyh-sekcij/proposal.md new file mode 100644 index 0000000..2d721c6 --- /dev/null +++ b/openspec/changes/archive/2026-08-04-aktivnaya-proverka-novyh-sekcij/proposal.md @@ -0,0 +1,68 @@ +## Why + +Разбор уже перечисляет секции тела, которых он не покрывает, и пишет их в +`delivery.uncovered_sections`. Момент, ради которого колонка заводилась — +«поток принёс секцию, которой раньше не было», — **фиксируется, но ничем не +наблюдается**: узнать о нём можно только запросом в базу руками, а догадаться +заглянуть в колонку некому. + +Пользователь дописывает на телефоне оставшиеся метрики (`symptoms`, `ecg`, +`heartRateNotifications`, `cycleTracking`, `medications`, вес), и данные +появятся сами. Пропущенный момент стоит дорого не сразу: тело живёт в архиве до +следующего проверенного экспорта, и разбор новой секции, написанный через +квартал, писать будет уже не по чему. + +## What Changes + +- Первая по журналу встреча имени непокрытой секции даёт запись уровня `WARN` в + логирующем чекпоинте свёртки, с именами отдельным атрибутом; повторные встречи + того же имени события не дают. +- Тот же признак переживает **отказ** свёртки: доставка, у которой не вывелся + слой, всё равно записывает список непокрытых секций, поэтому промолчать о + новом имени здесь значило бы потерять событие навсегда — следующая доставка + сочтёт имя уже виденным. Отложенный по обстоятельствам исход события не даёт: + учётной записи он не меняет, доставка вернётся следующим проходом. +- Новизна **выводится из журнала**, а не хранится: строгое сравнение пары + `(received_at, id)` с прежними доставками, запросом вне транзакции записи. + Схема не трогается, отдельного реестра не заводится, состояние остаётся + свёрткой по журналу — пересборка полного архива повторяет те же события. +- Появляется подкоманда `healthlog uncovered`: перечень непокрытых секций, + накопленных журналом, — имя, число доставок, первая и последняя встреча. + Без ручного SQL по рабочей базе, чтением только на чтение, с объявленным + пределом вывода и экранированием имён. +- Границы носителя названы спекой, а не замолчаны: обрезка списка на 32 имени, + обнуление производных полей пересборкой и уход имени из колонки после того, + как секцию покрыли. + +Разбор самих новых секций сюда **не входит**: их формы никто не видел, и вслепую +разбор не пишется. Каждая приехавшая секция станет отдельной задачей — тогда, +когда её будет на чём проверить. + +## Capabilities + +### New Capabilities + +- `uncovered-sections`: наблюдение за секциями, которых разбор не покрывает — + событие первой по журналу встречи имени и перечень накопленного. + +### Modified Capabilities + +- `storage`: перечень оснований, повышающих уровень записи чекпоинта свёртки, + дополнен первой встречей имени непокрытой секции. Перечень нормирован там и + читается как исчерпывающий — без правки следующий автор снял бы новую ветвь как + незаказанную. + +Capability `parsing` не меняется: разбор непокрытых секций остаётся как есть, +новое поведение читает его результат. + +## Impact + +- `internal/store` — запрос «какие из этих имён встречались строго раньше в + журнале» и запрос перечня для команды; обе — чтение по существующей колонке + через `json_each`, вне транзакции записи. +- `internal/fold` — признак новизны в `Stats`, ветвь эскалации в едином + логирующем чекпоинте и в пути отказа. +- `cmd/healthlog` — подкоманда `uncovered`. +- `docs/architecture.md`, `docs/research/apple-health.md` — раздел про + неразобранные секции и сверка перечня не виденных живьём секций с множеством + покрытых имён разбора. diff --git a/openspec/changes/archive/2026-08-04-aktivnaya-proverka-novyh-sekcij/review/triage.md b/openspec/changes/archive/2026-08-04-aktivnaya-proverka-novyh-sekcij/review/triage.md new file mode 100644 index 0000000..a035fb0 --- /dev/null +++ b/openspec/changes/archive/2026-08-04-aktivnaya-proverka-novyh-sekcij/review/triage.md @@ -0,0 +1,277 @@ +# Триаж ревью: aktivnaya-proverka-novyh-sekcij + +## Сводка + +- **Профиль:** deep, режим по графу. **Гейт:** зелёный (exit 0 дважды, + покрытие диффа 96%, 2 непокрытые строки — унаследованный пробел `main.go`). +- **Сверка состава с профилем:** расхождений нет. Deep — 7–8 проходов + (`docs/review.md`, итог по конвейеру); запущено 7, восьмой (`reimpl`) — + по триггеру, и триггер не сработал (см. границы покрытия). +- **Проходы поимённо:** + - `gate` — отработал, 2 находки, зелёный; + - `specs` — отработал, 5 находок + наблюдения вне спеки; + - `code` — отработал, 3 находки; + - `adversary` — отработал, 4 находки с прогнанными оракулами + 3 свойства + без построенного пути; + - `ops` — отработал, 2 находки, обязательные вопросы отвечены; + - `architecture` — отработал, 2 находки + 1 правка «дешевле до мерджа»; + - `reimpl` — **не запускался**: триггер «новое правило слияния, идентичности + или разбора» не сработал — изменение вводит наблюдение поверх существующей + логики; + - `triage` — этот отчёт. +- **Счёт:** на входе 19 именованных находок, после дедупликации по причине — + 13. В отчёте: 2 блокируют, 4 сейчас, 6 в гипотезах, 2 promote, 1 подтверждённая + не влезла в потолок (названа в границах покрытия). +- Находка №1 найдена тремя проходами независимо — это подняло её приоритет; + подтверждением служит прогнанный оракул, а не согласие проходов. +- Совпадений с «Типовыми ложноположительными» из `docs/review.md` нет; отсев + шёл по проектному списку. + +## Блокирует мердж + +### Отказ слияния навсегда съедает событие о новой секции — владелец о ней не узнает никогда + +- Файл: `internal/fold/fold.go:214-222` +- Severity: major +- Confidence: high +- Оракул: `tmp/adv/lost_event_test.go`, перепрогнан триажем — FAIL + воспроизведён: запись об отказе (`delivery fold failed`) не несёт + `uncovered_new`, при этом `fail` пишет `residue.uncovered` в колонку + (`fold.go:587`), и следующая доставка считает `ecg` виденным. Путь достижим: + `context.DeadlineExceeded` не транзиентен, тело ~63 МиБ держит блокировку + 5.019 с (замер adversary). +- Последствие: единственная возможность события «поток начал приносить новую + секцию» — первая доставка с этим именем. Если её свёртка упала на слиянии, + событие теряется молча и не повторяется: имя уже записано как виденное. + Прямое нарушение требования дельта-спеки «Отказ свёртки не глотает событие» + и класс «молчание» — самый дорогой после порчи данных. +- Предложение: в ветви отказа слияния передавать `novelty` в остаток: + `s.fail(ctx, deliveryID, err, parseResidue{uncovered: parsed.Uncovered, + skipped: skippedEntities(parsed), novelty: novelty})`. Тест из + `tmp/adv/lost_event_test.go` перенести в постоянные. +- Найдено проходом: specs, code, adversary (дедуплицировано; оракул один) +- Действие: инлайн + +### Вывод команды обещает «журнал не приносил», хотя носитель перечня обрезается и обнуляется — оператор поверит пустоте, которой нельзя верить + +- Файл: `cmd/healthlog/uncovered.go:71-95` +- Severity: major +- Confidence: high +- Оракул: дословный пункт дельта-спеки — + `openspec/changes/aktivnaya-proverka-novyh-sekcij/specs/uncovered-sections/spec.md:198-203`: + «Система SHALL называть границы перечня в спеке **и в выводе команды**, а не + обещать „всё, что поток когда-либо приносил"». Вывод границ не называет, а + пустая ветвь печатает ровно запрещённое обещание: «Непокрытых секций журнал + не приносил.» +- Последствие: перечень производен от колонки, которую обрезает разбор + (32 имени на доставку) и обнуляет пересборка (тела, удалённые ретеншеном, + список теряют). Пустой вывод после ретеншена или вытеснения читается как + «поток ничего не приносил» — оператор примет решение по неполному ответу, + считая его полным. +- Предложение: переписать пустую ветвь («в учётных записях журнала непокрытых + имён нет») и добавить в вывод строку границ носителя (обрезка разбором, + обнуление пересборкой). Закрепить сквозным тестом `runUncovered` на непустой + базе — он же закрывает критерий приёмки К3 и находку specs о его отсутствии. +- Найдено проходом: specs +- Действие: инлайн + +## Стоит исправить сейчас + +### Одна битая строка `uncovered_sections` роняет и сверку (вечный WARN-дребезг), и команду перечня целиком, не называя виновника + +- Файл: `internal/store/uncovered.go:37,92-98,167-186` +- Severity: major +- Confidence: high +- Оракул: тест триажа `tmp/triage/jsoneach_test.go` — воспроизведено: + `SQL logic error: malformed JSON (1)` на весь запрос; с + `AND json_valid(uncovered_sections)` битая строка пропускается, живая + считается. Достижимо не штатным путём (`json.Marshal` битого не пишет): + ручная правка, будущая миграция мимо кода, восстановление БД. Схемной защиты + `CHECK(json_valid(...))` в миграции 00005 нет. +- Последствие: `json_each` над невалидным JSON — ошибка всего запроса, а guard + `notEmptyList` проверяет только `'[]'` и `''`. Итог: сверка новизны вечно + `unknown=true` (постоянный WARN, неотличимый от занятости базы), а + `healthlog uncovered` отказывает целиком и не говорит, какая строка битая. +- Предложение: добавить `AND json_valid(uncovered_sections)` в `notEmptyList` + (действует на оба запроса) — проверено, чинит. +- Найдено проходом: ops +- Действие: инлайн + +### Обоснование «индекс не нужен» стоит на замере не того случая: в заявленном сценарии скан почти полный на каждой доставке, а `LIMIT` не ограничивает работу + +- Файл: `internal/store/uncovered.go:49-64,177-186`; `docs/architecture.md:383-387` +- Severity: minor (факт high, операционная угроза low) +- Confidence: high +- Оракул: `tmp/adv/seen_cost_test.go` — журнал 105 000: имя с первой встречей в + начале — 31 мкс, в хвосте — 13.9 мс, новое — 14.1 мс. Ранний выход `LIMIT 1` + окупается только для имени из **начала** журнала; в заявленном сценарии + (секция начала приезжать недавно) первая встреча — в хвосте, и цена платится + на каждой доставке, а не «один раз за жизнь имени». Плюс `EXPLAIN QUERY PLAN` + для `UncoveredSections` (ops): `SCAN d`, `USE TEMP B-TREE FOR GROUP BY` — + `--limit 5` стоит столько же, сколько `--limit 200`. +- Последствие: сегодня терпимо (~14 мс × 288 доставок ≈ 4 с чтения в сутки), + но записанное рассуждение, на котором держится решение «индекса нет», + неверно, и следующая задача обопрётся на него как на факт. Вдобавок числа + замера разошлись между двумя местами: `uncovered.go:53-58` — 28 мкс / 45 мс; + `architecture.md:383-386` — 18 мкс / 46 мс / 57 мс (находка gate и code). +- Предложение (развилка, варианты): + - (а) принять цену и переписать обоснование в `uncovered.go` и + `architecture.md` честно (худший случай — почти полный скан на доставку), + заодно свести разошедшиеся числа и оставить их в одном месте со ссылкой из + другого — ноль кода, минуты работы; + - (б) завести индекс или таблицу первых встреч имён — миграция плюс правка + `docs/database.md`; окупится только если различных имён станет много; + - (в) ничего не менять — отвергается: обоснование записано неверно. +- Найдено проходом: adversary, ops (дедуплицировано: одна причина — оценка + стоимости снята на нерепрезентативном случае); расхождение чисел — gate, code +- Действие: развилка + +### Несостоявшаяся сверка не называет причину: `uncovered_seen_unknown=true` без ошибки, разбираться не по чему + +- Файл: `internal/fold/fold.go:514-522`; `internal/store/uncovered.go:111` +- Severity: minor +- Confidence: high +- Оракул: положение конвенции `docs/conventions/logging.md` (проход code); + чтение кода — `novelty()` отбрасывает `err` из `SectionsSeenBefore` молча, + из-за чего `clipCoord` в тексте ошибки `sectionSeenOnce` — мёртвый + предохранитель: ошибка никуда не доезжает. +- Последствие: оператор видит «сверка не состоялась, новыми объявлены все + имена», но не видит, почему — занятость, отмена, битая строка (см. находку + про `json_valid`) неразличимы. Молчание причины при говорящем признаке. +- Предложение: при `unknown` доводить причину до записи — либо логировать в + `novelty()`, либо нести ошибку в `sectionNovelty` до атрибутов чекпоинта / + записи об отказе. +- Найдено проходом: specs, code, adversary (дедуплицировано) +- Действие: инлайн + +### Сообщение чекпоинта вводит третий термин мимо словаря — операторский контракт (grep, алерты) затвердеет с неверным именем + +- Файл: `internal/fold/fold.go` (msg `"delivery folded, unseen section name"`) +- Severity: minor +- Confidence: high +- Оракул: словарь изменения — `uncovered` (состояние) и `new` (событие); + «unseen» не существует ни в спеке, ни в атрибутах (атрибут называется + `uncovered_new` — виден в прогоне `tmp/adv/dupclip_test.go`). +- Последствие: после мерджа сообщение — операторский контракт; grep и алерты + завяжутся на «unseen», и переименование станет дороже с каждой неделей. + Сейчас — одна строка и один тест. +- Предложение: переименовать msg в термины словаря, например + `"delivery folded, uncovered section new"`. +- Найдено проходом: architecture +- Действие: инлайн + +## Гипотезы без доказательства + +- **Вторая сериализация журнального ключа** (`received_at || ' ' || id` + + `splitJournalKey`, `internal/store/uncovered.go:180-181,216-226`) — + корректность лексикографического сравнения держится на незакреплённой + фиксированной ширине `FormatTime`. Оракула (теста, ломающего ширину) не + построено; понижено до гипотезы. Дешёвая страховка — тест-шпилька на ширину + формата. (architecture, minor/medium) +- **Два SQL-пути в `SectionsSeenBefore`** (одно имя / батч, + `internal/store/uncovered.go:84-87`) — экономия быстрого пути ≈ 13 с чтения + в сутки; вопрос «что опытный человек удалил бы». Последствие — только цена + сопровождения двух запросов; ущерб не построен. Если владелец возьмёт + вариант (б) развилки про индекс — вопрос снимется сам. (architecture, + minor/low) +- **Сценарий «Отложенная доставка события не порождает» не закреплён тестом** — + держится на порядке двух блоков в `fail` (`fold.go:546-565`); перестановка + при рефакторинге даст дребезг события на каждом проходе занятой базы. + (specs, minor) +- **Ветвь новизны при переменной части в ключе навсегда занимает `msg`** — если + HAE начнёт класть в ключ переменную часть, каждая доставка будет «с новой + секцией» и чекпоинт станет постоянным WARN. Путь не построен (наблюдённые + ключи стабильны). (adversary, свойство без пути) +- **Два снимка в `UncoveredSections` (count и перечень) могут разойтись на + единицу** — два запроса без общей транзакции; окно — конкурентная запись + между ними. Команда диагностическая, ущерб — косметика остатка. (adversary, + свойство без пути) +- **Поведение вне спеки, исход не меняющее:** флаг `-limit` не заказан спекой; + `context.Background()` вместо `signal.NotifyContext` (у соседнего `reindex` — + второе). Оставлено на решение оркестратора без severity. (specs) + +## Promote candidates + +- **`CHECK (json_valid(...))` для JSON-колонок в будущих миграциях** — схемная + защита, которой не хватило в 00005; правило для `docs/conventions/` или + чек миграций в гейте. (из находки ops про битую строку) +- **«Число замера живёт в одном месте, остальные ссылаются»** — числа одного + замера разошлись между `uncovered.go` и `architecture.md` уже до мерджа; + класс тот же, что у записи 2026-08-04 в `docs/review.md` (числа, на которых + стоит нормативный текст, читаются как факт и не перепроверяются). (gate, code) + +## Границы покрытия + +**Прогон:** профиль deep, режим по графу. База диффа — рабочее дерево +(HEAD == master). Гейт зелёный, exit 0 дважды; diff-coverage 96%. + +**Запускалось:** gate, specs, code, adversary, ops, architecture, triage. +Оракулы adversary прогнаны в `./tmp/adv`, оракул триажа — `./tmp/triage`. +`task verify:archive` прогнан оркестратором — зелёный, 90 с. + +**Не запускалось:** + +- `reimpl` — триггер `docs/review.md` («новое правило слияния, идентичности или + разбора») не сработал: изменение наблюдает поверх существующей логики, правил + не двигает. Если считать сверку новизны «правилом наблюдения» — это + расширительное чтение триггера; решение не запускать принял оркестратор, и + класс «независимая реализация нашла бы другую форму» не покрыт. +- `task verify:busy` — не гонялся. Именно он проверил бы поведение новой сверки + при удерживаемой блокировке (ветвь `uncovered_seen_unknown` — ровно про + занятую базу). Изменение правил разбора/слияния не двигает, поэтому + обязательный порог CLAUDE.md формально не задет, но ветвь unknown проверена + только юнит-тестами, не прогоном. + +**Не влезло в потолок (подтверждено, но ниже линии):** + +- Два разных ключа, совпадающих в первых 64 байтах, дают дубль после + `clipSection`: одна доставка показана в перечне как две + (`internal/store/uncovered.go:177-186`, `count(*)` считает строки + `json_each`, а не доставки). Оракул: `tmp/adv/dupclip_test.go`, перепрогнан — + FAIL воспроизведён. Вероятность мизерная (нужны имена длиннее 64 байт с общим + префиксом), последствие — неверное число в диагностической команде. Чинится + `count(DISTINCT d.received_at || ' ' || d.id)` либо дедупликацией имён после + обрезки на разборе. +- Непокрытые строки диспетчера подкоманд `cmd/healthlog/main.go:34-35` — + унаследованный пробел (весь `main()` 0% и до изменения). (gate) + +**Что запущенные проходы не могли проверить по построению:** + +- `gate` — только механизируемое; «в Go так не пишут» не проверяет никто (см. + ниже). +- `specs` — судит против записанной спеки; сами границы спеки: отказ слияния + как исход в дельте не описан (находка №1 стоит на общем требовании «не + глотает событие»); приоритет ветви новизны над событиями потери содержания + (`EntitiesHeld`, `PointsErased`) — выбор кода, не спеки; `Deliveries` считает + пары (доставка, элемент массива) — семантика зафиксирована кодом. +- `adversary` — три свойства названы без построенного пути (перечислены в + гипотезах); не построенный путь не означает недостижимый. +- `ops` — истории инцидентов у нового кода нет; поведение под реальным потоком + не наблюдалось. Сигналов о молчании потока нет — существующий пробел, не + этого диффа. +- `architecture` — судит форму, не поведение; шов `loggedFold`/`breakSeenQuery` + рассмотрен и находкой не признан. +- `triage` — ничего нового не находит по построению; пропуск любого прохода — + пропуск триажа тоже. + +**Целиком на человеке** (`docs/review.md`, «Недоступно проверке» — два списка, +намеренно раздельных): + +*Не проверит ни один проход:* реальный профиль нагрузки (телефон шлёт молча и +непрерывно); поведение HAE за пределами наблюдённого; полнота словаря переводов +после обновления iOS; секции, которых поток ещё не приносил (`symptoms`, `ecg`, +`heartRateNotifications`, `cycleTracking`, `medications`) — для этого изменения +это особенно прямо: команда `uncovered` написана ровно про них, и судить можно +только форму кода, не встречу с реальностью. Плюс общее: история инцидентов, +поведение внешних систем в их версиях, завязка потребителей на текущее +поведение, вопрос «а нужна ли эта функциональность вообще». + +*Перестали проверять сознательно:* `verify:archive`/`verify:busy` вне гейта +(в этом прогоне archive прогнан, busy — нет, см. выше); класс «в Go так не +пишут» — не покрыт вовсе после упразднения `idiom` (запись 2026-08-02); класс +«чего нет в зрелой реализации такого узла» — вне профиля `design`. + +**Документы проекта:** всё нужное было на месте — инварианты `CLAUDE.md` +(severity брались из них, не выводились), `docs/review.md` с журналом, типовыми +ложноположительными и обоими списками «Недоступно проверке». Ни один проход не +заявил недостающего документа; отдельных строк деградации нет. diff --git a/openspec/changes/archive/2026-08-04-aktivnaya-proverka-novyh-sekcij/specs/storage/spec.md b/openspec/changes/archive/2026-08-04-aktivnaya-proverka-novyh-sekcij/specs/storage/spec.md new file mode 100644 index 0000000..85cd165 --- /dev/null +++ b/openspec/changes/archive/2026-08-04-aktivnaya-proverka-novyh-sekcij/specs/storage/spec.md @@ -0,0 +1,63 @@ +## MODIFIED Requirements + +### Requirement: Значения точек не попадают в логи + +Данные о здоровье чувствительнее токенов. Система MUST NOT писать значения +точек, содержимое сущностей и тела доставок в записи лога уровня выше `DEBUG`. + +Содержимое сущности здесь не менее чувствительно, чем значение точки, а местами +более: маршрут тренировки — это геотрек до дома, а `labels` и `associations` +записи состояния разума — эмоциональные метки. Разрешены **координаты**: +идентификатор и род сущности, метка времени, идентификатор доставки — они +описывают, что случилось, а не что измерено. + +Непокрытые секции называются в логе **именами ключей**: имя секции — это форма +пакета, а не измерение. Содержимое секции в лог не попадает ни при каком уровне +выше `DEBUG`. Имена идут структурным атрибутом, а не склейкой в текст сообщения: +кодировщик экранирует управляющие символы, и имя из чужого тела не разрывает +построчный разбор логов. То же относится к идентификатору сущности: он приходит +из чужого тела и ограничен по длине при разборе. + +Частичный разбор уровня записи не повышает: `partial` — установившееся состояние +половины потока (53 доставки из 118), и постоянный `WARN` обесценил бы уровень. +Повышает уровень другое, и оснований два: + +- срабатывание границ списка: тело с сотнями секций или с именем длиннее предела + на HAE не похоже вовсе; +- **первая по журналу встреча имени непокрытой секции** — событие однократное за + всю жизнь имени, и правила его живут в capability наблюдения за непокрытыми + секциями. Здесь оно названо, чтобы перечень оснований оставался полным: иначе + следующий читатель снимет ветвь как незаказанную. + +#### Scenario: Разбор доставки логируется без значений + +- **WHEN** доставка разобрана +- **THEN** запись лога содержит счётчики (метрик, точек, объектов, сущностей) и + идентификатор доставки +- **AND** не содержит ни значений точек, ни имён устройств + +#### Scenario: Удержанная обеднённая версия логируется координатами + +- **WHEN** приехавшая версия сущности отклонена как теряющая содержание +- **THEN** запись `WARN` содержит идентификатор и род сущности +- **AND** не содержит ни точек маршрута, ни того, какие поля потерялись + +#### Scenario: Непокрытые секции названы именами ключей + +- **WHEN** доставка содержит непокрытую секцию +- **THEN** запись лога содержит имена непокрытых ключей отдельным атрибутом +- **AND** не содержит ничего из содержимого этих секций +- **AND** уровень записи из-за одной лишь частичности не повышается + +#### Scenario: Границы списка сработали + +- **WHEN** список непокрытых ключей усечён по числу имён или по длине имени +- **THEN** запись лога имеет уровень `WARN` +- **AND** содержит число отброшенных имён + +#### Scenario: Имя непокрытой секции встречено впервые + +- **WHEN** доставка принесла имя непокрытой секции, которого не было ни в одной + доставке раньше неё в журнале +- **THEN** запись лога имеет уровень `WARN` +- **AND** содержит имя отдельным атрибутом новых секций diff --git a/openspec/changes/archive/2026-08-04-aktivnaya-proverka-novyh-sekcij/specs/uncovered-sections/spec.md b/openspec/changes/archive/2026-08-04-aktivnaya-proverka-novyh-sekcij/specs/uncovered-sections/spec.md new file mode 100644 index 0000000..e3508be --- /dev/null +++ b/openspec/changes/archive/2026-08-04-aktivnaya-proverka-novyh-sekcij/specs/uncovered-sections/spec.md @@ -0,0 +1,269 @@ +## ADDED Requirements + +### Requirement: Первая встреча непокрытой секции порождает событие + +Свёртка SHALL писать запись уровня `WARN`, когда доставка принесла имя +непокрытой секции, которого **не было ни в одной доставке, стоящей в журнале +раньше**. Имена, признанные новыми, SHALL идти отдельным атрибутом записи — +всегда, независимо от выбранного уровня. + +Уровень назван поимённо: `WARN` — «может стать проблемой, посмотри». `ERROR` +означал бы сбой, который надо разбирать, а приезд новой секции — штатное событие +внешнего мира. + +Событие однократно за всю жизнь имени. Поэтому запись SHALL называть его +независимо от того, какие повторяющиеся события (перезаписи точек, запечатанный +час, расхождение слоя) случились в той же доставке: замаскировать однократное +рутинным значило бы потерять ровно то, ради чего наблюдение заведено. + +Отдельной строки лога у события нет: чекпоинт свёртки остаётся единственным. + +#### Scenario: Имя, которого журнал раньше не видел + +- **GIVEN** ни одна прежняя доставка не содержала секции `symptoms` +- **WHEN** доставка с секцией `symptoms` свёрнута +- **THEN** запись чекпоинта свёртки имеет уровень `WARN` +- **AND** атрибут новых секций содержит ровно `symptoms` + +#### Scenario: Новая секция не маскируется рутинным событием + +- **GIVEN** ни одна прежняя доставка не содержала секции `ecg` +- **WHEN** доставка с секцией `ecg` одновременно перезаписывает точки уже + сохранённого часа +- **THEN** запись чекпоинта называет новую секцию, а не перезапись точек +- **AND** счётчик перезаписей остаётся в атрибутах записи + +### Requirement: Повторная встреча имени события не порождает + +Система SHALL считать виденным всякое имя непокрытой секции, уже встречавшееся в +доставке, стоящей в журнале раньше текущей: в атрибут новых секций оно не +попадает и уровень записи не поднимает. Перечисление непокрытых секций при этом +не меняется: атрибут `uncovered` SHALL продолжать называть их все. + +Отсюда следствие для приёмки: наблюдаемый признак события — **атрибут новых +секций**, а не присутствие имени в записи вообще. Имя непокрытой секции стоит в +записи каждой доставки, которая её принесла, и так было до этого изменения. + +#### Scenario: Вторая доставка с тем же именем молчит + +- **GIVEN** доставка с секцией `symptoms` уже свёрнута +- **WHEN** следующая доставка приносит ту же секцию `symptoms` +- **THEN** атрибут новых секций у второй записи пуст +- **AND** уровень второй записи рутинный +- **AND** секция `symptoms` остаётся в атрибуте непокрытых секций + +#### Scenario: Новое имя рядом с уже виденным + +- **GIVEN** доставка с секцией `symptoms` уже свёрнута +- **WHEN** следующая доставка приносит `symptoms` и `ecg` +- **THEN** атрибут новых секций содержит ровно `ecg` + +### Requirement: Новизна выводится из журнала, а не из порядка свёртки + +Признак новизны SHALL определяться сравнением с доставками, стоящими в журнале +**строго раньше** текущей — по паре `(received_at, id)`, как порядок журнала +определён capability пересборки. Собственная учётная запись доставки в сравнение +не входит при любом порядке записи исхода. + +Отсюда следует, что признак идемпотентен: повторная свёртка той же доставки даёт +тот же исход, а проигрывание полного журнала пересборкой воспроизводит ровно те +же события — свёртка по журналу остаётся функцией журнала, а не числа прогонов. + +Хранимого реестра встреченных имён это изменение заводить MUST NOT: факт уже +лежит в учётной записи доставки, и вторая его копия расходилась бы с первой при +пересборке молча. Это решение **этого** изменения, а не запрет навсегда: +потребителю, которому понадобится история непокрытых секций, переживающая +удаление тел (ретеншен архива), придётся его пересмотреть — цена названа в +требовании о честности перечня. + +Запрос новизны SHALL выполняться **вне транзакции записи** свёртки: он идёт по +растущему журналу, а транзакция записи объектов не имеет права держать блокировку +дольше, чем позволено конкурирующему приёму. + +#### Scenario: Повторная свёртка той же доставки не меняет исход + +- **GIVEN** доставка с новой секцией свёрнута и событие записано +- **WHEN** та же доставка свёрнута повторно +- **THEN** событие повторяется тем же составом имён + +#### Scenario: Доставка, свёрнутая позже более новой, судится по журналу + +- **GIVEN** доставка A стоит в журнале раньше доставки B, и обе содержат + секцию `ecg` +- **WHEN** B свёрнута первой, а A — после неё +- **THEN** A признаёт `ecg` новым, потому что раньше неё в журнале этого имени + не было + +#### Scenario: Запрос новизны не удерживает блокировку записи + +- **WHEN** доставка с непокрытыми секциями сворачивается +- **THEN** обращение к журналу за признаком новизны идёт вне транзакции записи + объектов + +### Requirement: Отказ свёртки не глотает событие + +Список непокрытых секций переживает отказ разбора, поэтому проверка новизны SHALL +идти до ветвления на успех и отказ, а новые имена SHALL попадать в запись об +отказе — она уже выше рутинного уровня. Уровень такой записи определяет отказ; +событие опознаётся атрибутом новых секций. + +Это относится к **каждому** исходу, который пишет список в учётную запись, а не +к одному только отказу разбора: отказ слияния тоже записывает имена, и терять +событие ему нельзя ровно по той же причине. Отказы, случившиеся до разбора +(нечитаемое тело, паника), список очищают и потому события не теряют. + +Иначе событие теряется необратимо: имя записано в учётную запись отказавшей +доставки, и следующая доставка сочтёт его виденным. + +Исход, отложенный по обстоятельствам (занятость базы, отмена снаружи), — другое +дело: он учётной записи не меняет вовсе, список непокрытых секций в базу не +попадает, и доставка вернётся следующим проходом. Новые имена в такой записи +система называть MUST NOT: событие не потеряно, а повторение его на каждом +проходе занятой базы превратило бы однократный признак в дребезг. + +#### Scenario: Отказ слияния при новой секции + +- **GIVEN** ни одна прежняя доставка не содержала секции `ecg` +- **WHEN** разбор доставки прошёл, а слияние отказало нетранзиентно +- **THEN** запись об отказе называет новую секцию `ecg` +- **AND** учётная запись доставки сохраняет её в списке непокрытых + +#### Scenario: Доставка с новой секцией и невыводимым слоем + +- **GIVEN** ни одна прежняя доставка не содержала секции `cycleTracking` +- **WHEN** доставка с этой секцией не свернулась, потому что слой не вывелся +- **THEN** запись об отказе называет новую секцию `cycleTracking` +- **AND** учётная запись доставки сохраняет её в списке непокрытых + +#### Scenario: Отложенная доставка события не порождает + +- **WHEN** свёртка доставки отложена по занятости базы +- **THEN** запись об отложенном исходе новых секций не называет +- **AND** доставка остаётся в очереди + +### Requirement: Несостоявшаяся сверка не молчит + +Отказ самого запроса новизны исходом доставки система объявлять MUST NOT: правила +классификации исходов свёртки это изменение не трогает, отменённый контекст и +занятая база остаются обстоятельствами, а не свойствами доставки. + +Когда доставка дошла до записи исхода по прочим правилам, а сверка не удалась, +все её непокрытые имена SHALL считаться новыми, и запись SHALL нести отдельный +признак того, что сверка не состоялась, **вместе с причиной**: занятость базы +проходит сама, а испорченное содержимое колонки не пройдёт никогда и будет +поднимать признак на каждой доставке — по одному булеву эти случаи неразличимы. + +Асимметрия названа: лишняя запись стоит внимания владельца один раз, а +промолчавшее событие не восстанавливается ничем, кроме ручного запроса в базу. + +#### Scenario: Сверка не удалась, разбор прошёл + +- **WHEN** запрос новизны отказал, а разбор доставки прошёл +- **THEN** свёртка доходит до конца и записывает исход +- **AND** все непокрытые имена доставки объявлены новыми +- **AND** запись несёт признак несостоявшейся сверки и причину + +### Requirement: Перечень непокрытых секций отдаётся одной командой + +Система SHALL отдавать перечень непокрытых секций, накопленных журналом, +отдельной подкомандой — без ручного SQL по рабочей базе. Строка перечня SHALL +называть имя секции, число доставок с ним, первую и последнюю встречу (метку +журнала и идентификатор доставки); идентификатор нужен, чтобы достать тело из +архива. + +В перечень SHALL входить доставки **всех** статусов разбора: список непокрытых +секций сохраняется и при отказе, и молчать о таком имени значило бы терять как +раз подозрительное. + +Порядок строк SHALL быть детерминированным — по имени секции. + +Вывод SHALL иметь объявленный предел числа строк, а остаток называться числом: +предел разбора в 32 имени действует на **одну доставку**, а различных имён +журнал способен накопить сколько угодно. + +База SHALL открываться только на чтение: команда диагностическая, и запуск её при +живом сервисе не должен ни мигрировать схему, ни писать. Отказ открытия (базы +нет, версия схемы не та) SHALL давать ненулевой код возврата и внятное +сообщение — молчаливый пустой перечень неотличим от «ничего не приезжало». + +#### Scenario: Перечень на базе с непокрытыми секциями + +- **GIVEN** журнал содержит доставки с секциями `symptoms` и `ecg` +- **WHEN** оператор запускает подкоманду перечня +- **THEN** вывод содержит обе секции с числом доставок и границами встреч +- **AND** порядок строк детерминирован + +#### Scenario: Перечень на базе без непокрытых секций + +- **WHEN** ни одна доставка непокрытых секций не приносила +- **THEN** команда завершается нулевым кодом и говорит, что перечень пуст + +#### Scenario: Базы по указанному пути нет + +- **WHEN** оператор запускает подкоманду с конфигом, указывающим на + несуществующую базу +- **THEN** команда завершается ненулевым кодом и называет причину + +#### Scenario: Имён больше предела вывода + +- **WHEN** различных имён в журнале больше объявленного предела +- **THEN** вывод содержит предел строк и называет число оставшихся имён + +### Requirement: Перечень честен относительно своего носителя + +Система SHALL называть границы перечня в спеке и в выводе команды, а не обещать +«всё, что поток когда-либо приносил»: перечень и признак новизны производны от +`delivery.uncovered_sections` — колонки, которая обрезается разбором и +обнуляется пересборкой. + +Границы SHALL называться и в **выводе команды**, а не только в спеке: пустой +перечень без них читается как «поток ничего не приносил» — обещание, которого +носитель не даёт. + +- Имя, вытесненное границей списка (не больше 32 имён на доставку), в колонку не + попадает вовсе — ни события, ни строки перечня оно не даст; наблюдаемым + остаётся счётчик отброшенных имён, который уже поднимает уровень записи. +- Пересборка обнуляет производные от разбора поля и заполняет их заново только по + сохранившимся телам: доставка, тело которой удалено ретеншеном, свой список + теряет, поэтому «те же события» пересборка воспроизводит **при полном архиве**. +- Имя, секцию которого разбор научился покрывать, уходит из колонки при + пересвёртке — перечень отвечает о текущем состоянии покрытия, а не об истории. +- Поэтому пересвёртка ранее частично разобранных доставок (правило «покрыли + секцию — пересверните») может дать событие о новизне повторно: журнал тот же, а + колонка заполняется заново. + +#### Scenario: Пустой перечень не говорит за весь поток + +- **WHEN** в учётных записях журнала непокрытых секций нет +- **THEN** вывод команды говорит именно это, а не «журнал такого не приносил» +- **AND** называет границы носителя + +#### Scenario: Учётная запись с неразбираемым списком не роняет ответ + +- **GIVEN** в колонке одной доставки лежит значение, не разбираемое как JSON +- **WHEN** выполняется сверка новизны или собирается перечень +- **THEN** ответ строится по остальным записям, а не отказывает целиком + +#### Scenario: Имя вытеснено границей списка + +- **GIVEN** тело доставки содержит 32 незнакомых ключа перед секцией `ecg` +- **WHEN** доставка свёрнута +- **THEN** события о новизне `ecg` нет, потому что имя в учётную запись не попало +- **AND** запись несёт счётчик отброшенных имён и уровень `WARN` + +### Requirement: Вывод перечня не доверяет содержимому тела + +Печатаемое имя секции SHALL быть экранировано, чтобы управляющие +последовательности из тела не влияли на терминал оператора: имя приходит +верхнеуровневым ключом чужого тела, длина его ограничена разбором, содержимое — +ничем. + +Вывод SHALL оставаться свободным от значений точек, имён устройств и любых +других данных о здоровье: имя секции — структурный ключ, а не измерение. + +#### Scenario: Имя секции с управляющими символами + +- **GIVEN** учётная запись доставки содержит имя секции с управляющим символом +- **WHEN** оператор запускает подкоманду перечня +- **THEN** символ выводится экранированной последовательностью, а не сырым + байтом diff --git a/openspec/changes/archive/2026-08-04-aktivnaya-proverka-novyh-sekcij/tasks.md b/openspec/changes/archive/2026-08-04-aktivnaya-proverka-novyh-sekcij/tasks.md new file mode 100644 index 0000000..c2c6ad0 --- /dev/null +++ b/openspec/changes/archive/2026-08-04-aktivnaya-proverka-novyh-sekcij/tasks.md @@ -0,0 +1,138 @@ +## 1. Хранилище: два запроса по существующей колонке + +- [x] 1.1 `store.SectionsSeenBefore(ctx, names, before DeliveryRef) (map[string]struct{}, error)` — + какие из имён встречались в доставках, стоящих в журнале строго раньше + `before`; сравнение парой `(received_at, id)`, чтение через `json_each`, + `EXISTS` на имя (ранний выход для виденного), вне транзакции записи +- [x] 1.2 `store.UncoveredSections(ctx, limit)` — перечень: имя, число доставок, + первая и последняя встреча (метка и идентификатор доставки), порядок по + имени, предел строк с числом остатка; доставки всех статусов разбора +- [x] 1.3 Тесты хранилища: имя из более ранней доставки виденное, из более + поздней — нет, своя же строка в счёт не идёт, доставки одной секунды + разводятся идентификатором; перечень сходится с независимо посчитанным + ожиданием; отказавшая доставка в перечень входит; предел вывода срабатывает +- [x] 1.4 Замер стоимости на **синтетическом** журнале в `./tmp` (~105 тысяч + доставок, годовой объём): время запроса для виденного и для нового имени; + число печатается, метод замера записывается рядом с результатом + +## 2. Свёртка: признак новизны и эскалация + +- [x] 2.1 Проверка новизны сразу после разбора — до ветвления на успех и отказ, + **вне транзакции записи**; берётся только при непустом списке непокрытых +- [x] 2.2 Отказ запроса не роняет свёртку и исходом доставки не становится: все + имена считаются новыми, признак несостоявшейся сверки идёт в запись +- [x] 2.3 Новые имена в `Stats` и в атрибутах чекпоинта; ветвь `WARN` ставится + первой в `switch` (решение кода — спека нормирует поведение, а не позицию) +- [x] 2.4 Новые имена доезжают до пути отказа (`parseResidue` → `fail`) и + печатаются его записью; отложенный исход их не называет +- [x] 2.5 Тесты свёртки: первая встреча даёт `WARN` с атрибутом новых секций, + вторая доставка молчит, новое имя рядом с виденным, новая секция не + маскируется перезаписью точек, отказ слоя называет новое имя, отложенный + исход не называет, отказ сверки объявляет всё новым и не роняет свёртку + +## 3. Команда перечня + +- [x] 3.1 Подкоманда `healthlog uncovered --config `: `store.OpenForRead`, + человекочитаемый вывод в stdout, ненулевой код и причина на отказ открытия +- [x] 3.2 Имя печатается экранированным (`%q`); пустой перечень — отдельная + строка и нулевой код; предел строк с числом остатка +- [x] 3.3 Регистрация подкоманды в `main.go` и в шапке пакета +- [x] 3.4 Тесты команды: перечень на базе с секциями, пустой перечень, имя с + управляющим символом, базы по пути нет, имён больше предела + +## 4. Документация + +- [x] 4.1 `docs/architecture.md` — раздел «Неразобранные секции доставки» + дополнен событием, командой и границами носителя +- [x] 4.2 `docs/research/apple-health.md` — перечень не виденных живьём секций + сверен с множеством покрытых имён в `internal/hae` поимённо +- [x] 4.3 `README.md` / `CLAUDE.md` — команда названа там, где перечислены + прочие подкоманды (если перечислены) + +## 5. Приёмка + +- [x] 5.1 `task gate` зелёный +- [x] 5.2 `task verify:archive` — повторный прогон живого архива даёт то же + состояние (правило наблюдения затрагивает путь свёртки) +- [x] 5.3 Поведенческая верификация: сервис поднят, подсунута доставка с + выдуманной секцией — ровно одна запись с атрибутом новых секций; вторая + доставка молчит; `healthlog uncovered` на живой базе + +## 6. Отработка ревью кода (профиль `deep`) + +- [x] 6.1 Отказ **слияния** доносит новизну до записи об отказе: он пишет имя в + учёт так же, как отказ разбора, и терял событие навсегда (найдено тремя + проходами с оракулом). Тест `TestFoldОтказСлиянияНазываетНовуюСекцию` +- [x] 6.2 Вывод команды называет границы носителя в **обоих** исходах; пустая + ветвь больше не говорит за весь поток +- [x] 6.3 Учётная запись с неразбираемым списком не роняет ни сверку, ни + перечень: проверка стоит внутри аргумента `json_each`, а не условием в + `WHERE` (порядок вычисления SQLite не обещает) +- [x] 6.4 Причина несостоявшейся сверки идёт в запись рядом с признаком +- [x] 6.5 Сообщение чекпоинта приведено к словарю: `delivery folded, new + uncovered section` +- [x] 6.6 Число доставок считается `count(DISTINCT d.id)`: обрезка имён по 64 + байтам давала дубли внутри одной доставки +- [x] 6.7 Замер перемерян и исправлен: ранний выход есть только у давно + приезжающей секции; у только что появившейся — почти полный проход. + Числа живут в одном месте (`design.md`), код и `architecture.md` ссылаются +- [x] 6.8 Сквозной тест команды: перечень сходится с посчитанным по **телам** + архива независимо от кода команды (оракул К3) + +## Критерии приёмки задачи + +Из `docs/tasks/items/unseen-sections-check.md`; оракулы К1–К3 уточнены после +ревью предложения — наблюдаемый признак события есть **атрибут новых секций**, а +не присутствие имени в записи (имя непокрытой секции стоит в записи каждой +доставки, которая её принесла, и так было до этого изменения). + +- [x] К1 имя секции, которого разбор раньше не встречал, порождает событие + уровня выше рутины — оракул: тест на доставке с выдуманной секцией, ровно + одна запись уровня `WARN` с этим именем **в атрибуте новых секций** +- [x] К2 то же имя во второй доставке события больше не порождает — оракул: тот + же тест на двух доставках подряд, у второй атрибут новых секций пуст +- [x] К3 список всего, что поток когда-либо приносил и разбор не покрыл, + достаётся одной командой, без ручного SQL — оракул: перечень собран из + **тел** `internal/hae/testdata` независимо от кода команды и сходится со + строками её вывода (имя, число доставок, границы); дешёвой добавкой — + сверка с `SELECT DISTINCT` по `delivery.uncovered_sections` на живой базе. + **Живая база проверку не пропустила по своей причине:** её схема (версия 8) + отстала от бинаря (10) — контейнер не пересобирался, — и `OpenForRead` + строго отказал, как и заказано. Сверка сделана на поднятом сервисе с + отдельной базой; на живой базе непокрытых секций сегодня нет вовсе + (`SELECT DISTINCT` даёт пустое множество), так что сверять было бы нечего +- [x] К4 перечень не виденных живьём секций в `docs/research/apple-health.md` + сходится с множеством покрытых имён в `internal/hae` — оракул: глазами, + сверка двух списков поимённо +- [x] К5 повторный прогон живого архива даёт то же состояние — оракул: + `task verify:archive` + +## Приёмочные критерии из рубрики ревью (профиль `design`) + +Рубрика прохода `review-rubric`, порождённая до чтения предложения. Пункты, +которые предложение не закрывало, отработаны правкой спеки и дизайна; здесь они +остаются проверяемыми свойствами. + +- [x] Р1 признак «впервые» есть функция префикса журнала: перестановка порядка + свёртки не переносит событие с доставки на доставку +- [x] Р2 узел не читает состояние, которое сам же пишет в этом шаге: + собственная строка исключена сравнением, а не порядком записи +- [x] Р3 идемпотентность и отсутствие второй копии факта: повторная свёртка даёт + тот же состав имён, хранимого реестра нет +- [x] Р4 наблюдательный механизм не роняет поток: отказ сверки не меняет + `parse_status` и не превращает `pending` в `failed` +- [x] Р5 запрос новизны выполняется вне транзакции записи; в транзакции записи + обращений к `delivery` с `json_each` нет +- [x] Р6 стоимость названа числом с методом замера (задача 1.4), а не посылкой + «список почти всегда пуст» +- [x] Р7 событие однократно на имя: сто доставок подряд с одним новым именем + дают ровно одну запись повышенного уровня; уровень назван поимённо (`WARN`) +- [x] Р8 признак и перечень честны относительно носителя: обрезка списка, + обнуление пересборкой и уход имени после покрытия названы в спеке +- [x] Р9 имя секции — недоверенный вход: экранировано на выводе, ограничено по + длине разбором, данных о здоровье в выводе нет +- [x] Р10 CLI читает и только читает: без миграций и записи, расхождение версии + схемы — явный отказ +- [x] Р11 вывод детерминирован, ограничен пределом и отличает пустоту от отказа +- [x] Р12 оракулы не пришпилены к числу, производному от размера корпуса, и не + тавтологичны (К3 перестроен от тел архива) diff --git a/openspec/specs/storage/spec.md b/openspec/specs/storage/spec.md index b0e35be..0257c8a 100644 --- a/openspec/specs/storage/spec.md +++ b/openspec/specs/storage/spec.md @@ -412,8 +412,14 @@ HTML-экранирования: `&`, `<` и `>` внутри точки обя Частичный разбор уровня записи не повышает: `partial` — установившееся состояние половины потока (53 доставки из 118), и постоянный `WARN` обесценил бы уровень. -Повышает уровень другое — срабатывание границ списка: тело с сотнями секций или -с именем длиннее предела на HAE не похоже вовсе. +Повышает уровень другое, и оснований два: + +- срабатывание границ списка: тело с сотнями секций или с именем длиннее предела + на HAE не похоже вовсе; +- **первая по журналу встреча имени непокрытой секции** — событие однократное за + всю жизнь имени, и правила его живут в capability наблюдения за непокрытыми + секциями. Здесь оно названо, чтобы перечень оснований оставался полным: иначе + следующий читатель снимет ветвь как незаказанную. #### Scenario: Разбор доставки логируется без значений @@ -441,6 +447,13 @@ HTML-экранирования: `&`, `<` и `>` внутри точки обя - **THEN** запись лога имеет уровень `WARN` - **AND** содержит число отброшенных имён +#### Scenario: Имя непокрытой секции встречено впервые + +- **WHEN** доставка принесла имя непокрытой секции, которого не было ни в одной + доставке раньше неё в журнале +- **THEN** запись лога имеет уровень `WARN` +- **AND** содержит имя отдельным атрибутом новых секций + ### Requirement: Учёт частично разобранной доставки Система SHALL отличать доставку, разобранную целиком, от доставки, в теле diff --git a/openspec/specs/uncovered-sections/spec.md b/openspec/specs/uncovered-sections/spec.md new file mode 100644 index 0000000..99a1669 --- /dev/null +++ b/openspec/specs/uncovered-sections/spec.md @@ -0,0 +1,277 @@ +# uncovered-sections Specification + +## Purpose + +Наблюдение за секциями тела Health Auto Export, которых разбор не покрывает: +первая по журналу встреча имени видна владельцу событием в логе, а перечень +накопленного отдаётся отдельной подкомандой. Разбор самих секций сюда не входит +— их формы никто не видел, и вслепую он не пишется. + +## Requirements +### Requirement: Первая встреча непокрытой секции порождает событие + +Свёртка SHALL писать запись уровня `WARN`, когда доставка принесла имя +непокрытой секции, которого **не было ни в одной доставке, стоящей в журнале +раньше**. Имена, признанные новыми, SHALL идти отдельным атрибутом записи — +всегда, независимо от выбранного уровня. + +Уровень назван поимённо: `WARN` — «может стать проблемой, посмотри». `ERROR` +означал бы сбой, который надо разбирать, а приезд новой секции — штатное событие +внешнего мира. + +Событие однократно за всю жизнь имени. Поэтому запись SHALL называть его +независимо от того, какие повторяющиеся события (перезаписи точек, запечатанный +час, расхождение слоя) случились в той же доставке: замаскировать однократное +рутинным значило бы потерять ровно то, ради чего наблюдение заведено. + +Отдельной строки лога у события нет: чекпоинт свёртки остаётся единственным. + +#### Scenario: Имя, которого журнал раньше не видел + +- **GIVEN** ни одна прежняя доставка не содержала секции `symptoms` +- **WHEN** доставка с секцией `symptoms` свёрнута +- **THEN** запись чекпоинта свёртки имеет уровень `WARN` +- **AND** атрибут новых секций содержит ровно `symptoms` + +#### Scenario: Новая секция не маскируется рутинным событием + +- **GIVEN** ни одна прежняя доставка не содержала секции `ecg` +- **WHEN** доставка с секцией `ecg` одновременно перезаписывает точки уже + сохранённого часа +- **THEN** запись чекпоинта называет новую секцию, а не перезапись точек +- **AND** счётчик перезаписей остаётся в атрибутах записи + +### Requirement: Повторная встреча имени события не порождает + +Система SHALL считать виденным всякое имя непокрытой секции, уже встречавшееся в +доставке, стоящей в журнале раньше текущей: в атрибут новых секций оно не +попадает и уровень записи не поднимает. Перечисление непокрытых секций при этом +не меняется: атрибут `uncovered` SHALL продолжать называть их все. + +Отсюда следствие для приёмки: наблюдаемый признак события — **атрибут новых +секций**, а не присутствие имени в записи вообще. Имя непокрытой секции стоит в +записи каждой доставки, которая её принесла, и так было до этого изменения. + +#### Scenario: Вторая доставка с тем же именем молчит + +- **GIVEN** доставка с секцией `symptoms` уже свёрнута +- **WHEN** следующая доставка приносит ту же секцию `symptoms` +- **THEN** атрибут новых секций у второй записи пуст +- **AND** уровень второй записи рутинный +- **AND** секция `symptoms` остаётся в атрибуте непокрытых секций + +#### Scenario: Новое имя рядом с уже виденным + +- **GIVEN** доставка с секцией `symptoms` уже свёрнута +- **WHEN** следующая доставка приносит `symptoms` и `ecg` +- **THEN** атрибут новых секций содержит ровно `ecg` + +### Requirement: Новизна выводится из журнала, а не из порядка свёртки + +Признак новизны SHALL определяться сравнением с доставками, стоящими в журнале +**строго раньше** текущей — по паре `(received_at, id)`, как порядок журнала +определён capability пересборки. Собственная учётная запись доставки в сравнение +не входит при любом порядке записи исхода. + +Отсюда следует, что признак идемпотентен: повторная свёртка той же доставки даёт +тот же исход, а проигрывание полного журнала пересборкой воспроизводит ровно те +же события — свёртка по журналу остаётся функцией журнала, а не числа прогонов. + +Хранимого реестра встреченных имён это изменение заводить MUST NOT: факт уже +лежит в учётной записи доставки, и вторая его копия расходилась бы с первой при +пересборке молча. Это решение **этого** изменения, а не запрет навсегда: +потребителю, которому понадобится история непокрытых секций, переживающая +удаление тел (ретеншен архива), придётся его пересмотреть — цена названа в +требовании о честности перечня. + +Запрос новизны SHALL выполняться **вне транзакции записи** свёртки: он идёт по +растущему журналу, а транзакция записи объектов не имеет права держать блокировку +дольше, чем позволено конкурирующему приёму. + +#### Scenario: Повторная свёртка той же доставки не меняет исход + +- **GIVEN** доставка с новой секцией свёрнута и событие записано +- **WHEN** та же доставка свёрнута повторно +- **THEN** событие повторяется тем же составом имён + +#### Scenario: Доставка, свёрнутая позже более новой, судится по журналу + +- **GIVEN** доставка A стоит в журнале раньше доставки B, и обе содержат + секцию `ecg` +- **WHEN** B свёрнута первой, а A — после неё +- **THEN** A признаёт `ecg` новым, потому что раньше неё в журнале этого имени + не было + +#### Scenario: Запрос новизны не удерживает блокировку записи + +- **WHEN** доставка с непокрытыми секциями сворачивается +- **THEN** обращение к журналу за признаком новизны идёт вне транзакции записи + объектов + +### Requirement: Отказ свёртки не глотает событие + +Список непокрытых секций переживает отказ разбора, поэтому проверка новизны SHALL +идти до ветвления на успех и отказ, а новые имена SHALL попадать в запись об +отказе — она уже выше рутинного уровня. Уровень такой записи определяет отказ; +событие опознаётся атрибутом новых секций. + +Это относится к **каждому** исходу, который пишет список в учётную запись, а не +к одному только отказу разбора: отказ слияния тоже записывает имена, и терять +событие ему нельзя ровно по той же причине. Отказы, случившиеся до разбора +(нечитаемое тело, паника), список очищают и потому события не теряют. + +Иначе событие теряется необратимо: имя записано в учётную запись отказавшей +доставки, и следующая доставка сочтёт его виденным. + +Исход, отложенный по обстоятельствам (занятость базы, отмена снаружи), — другое +дело: он учётной записи не меняет вовсе, список непокрытых секций в базу не +попадает, и доставка вернётся следующим проходом. Новые имена в такой записи +система называть MUST NOT: событие не потеряно, а повторение его на каждом +проходе занятой базы превратило бы однократный признак в дребезг. + +#### Scenario: Отказ слияния при новой секции + +- **GIVEN** ни одна прежняя доставка не содержала секции `ecg` +- **WHEN** разбор доставки прошёл, а слияние отказало нетранзиентно +- **THEN** запись об отказе называет новую секцию `ecg` +- **AND** учётная запись доставки сохраняет её в списке непокрытых + +#### Scenario: Доставка с новой секцией и невыводимым слоем + +- **GIVEN** ни одна прежняя доставка не содержала секции `cycleTracking` +- **WHEN** доставка с этой секцией не свернулась, потому что слой не вывелся +- **THEN** запись об отказе называет новую секцию `cycleTracking` +- **AND** учётная запись доставки сохраняет её в списке непокрытых + +#### Scenario: Отложенная доставка события не порождает + +- **WHEN** свёртка доставки отложена по занятости базы +- **THEN** запись об отложенном исходе новых секций не называет +- **AND** доставка остаётся в очереди + +### Requirement: Несостоявшаяся сверка не молчит + +Отказ самого запроса новизны исходом доставки система объявлять MUST NOT: правила +классификации исходов свёртки это изменение не трогает, отменённый контекст и +занятая база остаются обстоятельствами, а не свойствами доставки. + +Когда доставка дошла до записи исхода по прочим правилам, а сверка не удалась, +все её непокрытые имена SHALL считаться новыми, и запись SHALL нести отдельный +признак того, что сверка не состоялась, **вместе с причиной**: занятость базы +проходит сама, а испорченное содержимое колонки не пройдёт никогда и будет +поднимать признак на каждой доставке — по одному булеву эти случаи неразличимы. + +Асимметрия названа: лишняя запись стоит внимания владельца один раз, а +промолчавшее событие не восстанавливается ничем, кроме ручного запроса в базу. + +#### Scenario: Сверка не удалась, разбор прошёл + +- **WHEN** запрос новизны отказал, а разбор доставки прошёл +- **THEN** свёртка доходит до конца и записывает исход +- **AND** все непокрытые имена доставки объявлены новыми +- **AND** запись несёт признак несостоявшейся сверки и причину + +### Requirement: Перечень непокрытых секций отдаётся одной командой + +Система SHALL отдавать перечень непокрытых секций, накопленных журналом, +отдельной подкомандой — без ручного SQL по рабочей базе. Строка перечня SHALL +называть имя секции, число доставок с ним, первую и последнюю встречу (метку +журнала и идентификатор доставки); идентификатор нужен, чтобы достать тело из +архива. + +В перечень SHALL входить доставки **всех** статусов разбора: список непокрытых +секций сохраняется и при отказе, и молчать о таком имени значило бы терять как +раз подозрительное. + +Порядок строк SHALL быть детерминированным — по имени секции. + +Вывод SHALL иметь объявленный предел числа строк, а остаток называться числом: +предел разбора в 32 имени действует на **одну доставку**, а различных имён +журнал способен накопить сколько угодно. + +База SHALL открываться только на чтение: команда диагностическая, и запуск её при +живом сервисе не должен ни мигрировать схему, ни писать. Отказ открытия (базы +нет, версия схемы не та) SHALL давать ненулевой код возврата и внятное +сообщение — молчаливый пустой перечень неотличим от «ничего не приезжало». + +#### Scenario: Перечень на базе с непокрытыми секциями + +- **GIVEN** журнал содержит доставки с секциями `symptoms` и `ecg` +- **WHEN** оператор запускает подкоманду перечня +- **THEN** вывод содержит обе секции с числом доставок и границами встреч +- **AND** порядок строк детерминирован + +#### Scenario: Перечень на базе без непокрытых секций + +- **WHEN** ни одна доставка непокрытых секций не приносила +- **THEN** команда завершается нулевым кодом и говорит, что перечень пуст + +#### Scenario: Базы по указанному пути нет + +- **WHEN** оператор запускает подкоманду с конфигом, указывающим на + несуществующую базу +- **THEN** команда завершается ненулевым кодом и называет причину + +#### Scenario: Имён больше предела вывода + +- **WHEN** различных имён в журнале больше объявленного предела +- **THEN** вывод содержит предел строк и называет число оставшихся имён + +### Requirement: Перечень честен относительно своего носителя + +Система SHALL называть границы перечня в спеке и в выводе команды, а не обещать +«всё, что поток когда-либо приносил»: перечень и признак новизны производны от +`delivery.uncovered_sections` — колонки, которая обрезается разбором и +обнуляется пересборкой. + +Границы SHALL называться и в **выводе команды**, а не только в спеке: пустой +перечень без них читается как «поток ничего не приносил» — обещание, которого +носитель не даёт. + +- Имя, вытесненное границей списка (не больше 32 имён на доставку), в колонку не + попадает вовсе — ни события, ни строки перечня оно не даст; наблюдаемым + остаётся счётчик отброшенных имён, который уже поднимает уровень записи. +- Пересборка обнуляет производные от разбора поля и заполняет их заново только по + сохранившимся телам: доставка, тело которой удалено ретеншеном, свой список + теряет, поэтому «те же события» пересборка воспроизводит **при полном архиве**. +- Имя, секцию которого разбор научился покрывать, уходит из колонки при + пересвёртке — перечень отвечает о текущем состоянии покрытия, а не об истории. +- Поэтому пересвёртка ранее частично разобранных доставок (правило «покрыли + секцию — пересверните») может дать событие о новизне повторно: журнал тот же, а + колонка заполняется заново. + +#### Scenario: Пустой перечень не говорит за весь поток + +- **WHEN** в учётных записях журнала непокрытых секций нет +- **THEN** вывод команды говорит именно это, а не «журнал такого не приносил» +- **AND** называет границы носителя + +#### Scenario: Учётная запись с неразбираемым списком не роняет ответ + +- **GIVEN** в колонке одной доставки лежит значение, не разбираемое как JSON +- **WHEN** выполняется сверка новизны или собирается перечень +- **THEN** ответ строится по остальным записям, а не отказывает целиком + +#### Scenario: Имя вытеснено границей списка + +- **GIVEN** тело доставки содержит 32 незнакомых ключа перед секцией `ecg` +- **WHEN** доставка свёрнута +- **THEN** события о новизне `ecg` нет, потому что имя в учётную запись не попало +- **AND** запись несёт счётчик отброшенных имён и уровень `WARN` + +### Requirement: Вывод перечня не доверяет содержимому тела + +Печатаемое имя секции SHALL быть экранировано, чтобы управляющие +последовательности из тела не влияли на терминал оператора: имя приходит +верхнеуровневым ключом чужого тела, длина его ограничена разбором, содержимое — +ничем. + +Вывод SHALL оставаться свободным от значений точек, имён устройств и любых +других данных о здоровье: имя секции — структурный ключ, а не измерение. + +#### Scenario: Имя секции с управляющими символами + +- **GIVEN** учётная запись доставки содержит имя секции с управляющим символом +- **WHEN** оператор запускает подкоманду перечня +- **THEN** символ выводится экранированной последовательностью, а не сырым + байтом