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

- свёртка спрашивает журнал, встречалось ли имя строго раньше по паре
  (received_at, id), и пишет WARN с атрибутом uncovered_new; повторные молчат.
  Признак выводится, а не хранится — реестр был бы второй копией факта
- добавлена подкоманда `healthlog uncovered`: перечень накопленного, чтение
  только на чтение, экранированные имена и названные границы носителя
- синк документации: ADR о выводе новизны из журнала, две записи в журнал
  дефектов, два правила промоутом в конвенции, терминал оператора назван
  адресатом недоверенного входа
This commit is contained in:
av
2026-08-04 13:39:48 +03:00
parent 2130763d3c
commit bd5d17b079
27 changed files with 3125 additions and 9 deletions
+3 -1
View File
@@ -60,7 +60,8 @@ iPhone ──HTTPS POST──► healthlog ──► журнал доставо
— с выводом слоя из данных, канонизацией содержимого и слиянием точек по — с выводом слоя из данных, канонизацией содержимого и слиянием точек по
полноте. Тренировки и записи со своим `id` (`workouts`, `stateOfMind`) тоже полноте. Тренировки и записи со своим `id` (`workouts`, `stateOfMind`) тоже
разбираются; секции, которых разбор не покрывает, принимаются, хранятся и разбираются; секции, которых разбор не покрывает, принимаются, хранятся и
честно помечаются как неразобранные. честно помечаются как неразобранные — а имя, которого поток раньше не приносил,
даёт `WARN` в логе свёртки один раз и попадает в перечень `healthlog uncovered`.
Есть и пересборка: `healthlog reindex` проигрывает журнал доставок в свежую Есть и пересборка: `healthlog reindex` проигрывает журнал доставок в свежую
витрину и сверяет её отпечаток с накопленной — на живом архиве из 116 тел витрину и сверяет её отпечаток с накопленной — на живом архиве из 116 тел
@@ -83,6 +84,7 @@ iPhone ──HTTPS POST──► healthlog ──► журнал доставо
healthlog serve приём + read API + MCP healthlog serve приём + read API + MCP
healthlog import родной экспорт Apple Health (в планах) healthlog import родной экспорт Apple Health (в планах)
healthlog reindex пересборка витрины из журнала healthlog reindex пересборка витрины из журнала
healthlog uncovered перечень секций, которых разбор не покрыл
healthlog healthcheck проверка живости для docker HEALTHCHECK healthlog healthcheck проверка живости для docker HEALTHCHECK
``` ```
+3
View File
@@ -4,6 +4,7 @@
// //
// healthlog [serve] --config <path> принимать пакеты (по умолчанию) // healthlog [serve] --config <path> принимать пакеты (по умолчанию)
// healthlog reindex --config <path> пересобрать витрину из журнала // healthlog reindex --config <path> пересобрать витрину из журнала
// healthlog uncovered --config <path> перечень секций, которых разбор не покрыл
// healthlog healthcheck --config <p> проверить /healthz (для docker HEALTHCHECK) // healthlog healthcheck --config <p> проверить /healthz (для docker HEALTHCHECK)
package main package main
@@ -30,6 +31,8 @@ func main() {
err = runServe(args) err = runServe(args)
case "reindex": case "reindex":
err = runReindex(args) err = runReindex(args)
case "uncovered":
err = runUncovered(args)
case "healthcheck": case "healthcheck":
err = runHealthcheck(args) err = runHealthcheck(args)
default: default:
+115
View File
@@ -0,0 +1,115 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"io"
"os"
"text/tabwriter"
"git.vakhrushev.me/av/healthlog/internal/config"
"git.vakhrushev.me/av/healthlog/internal/store"
)
// uncoveredLimit — сколько строк перечня печатается по умолчанию.
//
// Предел объявлен, а не подразумевается: граница разбора в 32 имени действует на
// ОДНУ доставку, а различных имён журнал накопит сколько угодно — достаточно
// версии HAE, кладущей в ключ переменную часть. Двести взято с запасом: секций у
// HAE восемь, и перечень длиннее сотни означает не рост потока, а смену формы
// ключей — про неё скажет строка остатка.
const uncoveredLimit = 200
func runUncovered(args []string) error {
fs := flag.NewFlagSet("uncovered", flag.ContinueOnError)
cfgPath := fs.String("config", config.DefaultPath, "путь к config.toml")
limit := fs.Int("limit", uncoveredLimit, "сколько строк перечня печатать")
if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
// Справка — не отказ: иначе `uncovered -h` печатает usage и выходит
// со словом «fatal» и кодом 1.
return nil
}
return fmt.Errorf("parse flags: %w", err)
}
cfg, err := config.Load(*cfgPath)
if err != nil {
return err
}
// Только на чтение и без наката миграций: команда диагностическая, и запуск
// её при живом сервисе не имеет права ни мигрировать схему, ни писать.
// Расхождение версий — отказ с указанием обеих, и он доезжает до кода
// возврата: молчаливый пустой перечень неотличим от «ничего не приезжало».
st, err := store.OpenForRead(cfg.Storage.DBPath)
if err != nil {
return err
}
defer func() { _ = st.Close() }()
sections, total, err := st.UncoveredSections(context.Background(), *limit)
if err != nil {
return err
}
writeUncovered(os.Stdout, sections, total)
return nil
}
// writeUncovered печатает перечень человеку.
//
// Имя секции идёт ЭКРАНИРОВАННЫМ (`%q`): оно приходит верхнеуровневым ключом
// чужого тела, обрезано по длине на разборе, но по содержимому не ограничено
// ничем — сырая печать впустила бы в терминал управляющие последовательности.
//
// Данных о здоровье здесь нет: имя секции — структурный ключ, а не измерение.
// Идентификатор доставки печатается затем, чтобы по нему достать тело из архива
// и посмотреть форму секции глазами.
func writeUncovered(w io.Writer, sections []store.UncoveredSection, total int64) {
if len(sections) == 0 {
// НЕ «журнал такого не приносил»: перечень отвечает по колонкам
// доживших учётных записей, а не по истории потока. Обещание, которое
// носитель не даёт, закрыло бы владельцу вопрос ложным ответом.
fmt.Fprintln(w, "В учётных записях журнала непокрытых секций сейчас нет.")
writeUncoveredLimits(w)
return
}
fmt.Fprintf(w, "Непокрытых секций: %d\n\n", total)
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "СЕКЦИЯ\tДОСТАВОК\tПЕРВАЯ\tПОСЛЕДНЯЯ")
for _, s := range sections {
fmt.Fprintf(tw, "%q\t%d\t%s %s\t%s %s\n",
s.Name, s.Deliveries,
store.FormatTime(s.FirstSeen), s.FirstDeliveryID,
store.FormatTime(s.LastSeen), s.LastDeliveryID)
}
_ = tw.Flush()
// Остаток называется числом, а не обрывается молча: перечень — инструмент
// диагностики, и «здесь всё» против «здесь двести из тысячи» это разные
// ответы.
if rest := total - int64(len(sections)); rest > 0 {
fmt.Fprintf(w, "\nЕщё %d имён не показано.\n", rest)
}
writeUncoveredLimits(w)
}
// writeUncoveredLimits называет границы носителя — в любом исходе, включая
// пустой.
//
// Перечень производен от колонки учёта, а не от истории потока, и умолчать об
// этом значило бы отдать владельцу ответ, которого носитель не даёт: пустой
// перечень он прочитал бы как «ничего не приезжало» и закрыл бы вопрос.
func writeUncoveredLimits(w io.Writer) {
fmt.Fprint(w, `
Перечень собран по колонке учёта `+"`delivery.uncovered_sections`"+`, и границ у неё три:
- имена сверх 32 на одну доставку разбор в неё не кладёт;
- пересборка заполняет колонку заново и только по сохранившимся телам;
- секция, которую разбор научился покрывать, уходит из перечня при пересвёртке.
`)
}
+298
View File
@@ -0,0 +1,298 @@
package main
import (
"bytes"
"context"
"encoding/json"
"io"
"log/slog"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"time"
"git.vakhrushev.me/av/healthlog/internal/archive"
"git.vakhrushev.me/av/healthlog/internal/fold"
"git.vakhrushev.me/av/healthlog/internal/store"
)
func at(t *testing.T, s string) time.Time {
t.Helper()
v, err := time.Parse(time.RFC3339, s)
if err != nil {
t.Fatalf("метка %q: %v", s, err)
}
return v.UTC()
}
// Перечень — инструмент диагностики, и границы встреч в нём нужны затем, чтобы
// достать тело из архива по идентификатору доставки.
func TestПереченьНазываетГраницыВстреч(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
writeUncovered(&buf, []store.UncoveredSection{{
Name: "ecg",
Deliveries: 3,
FirstSeen: at(t, "2026-08-01T10:00:00Z"),
FirstDeliveryID: "01AAA",
LastSeen: at(t, "2026-08-02T11:00:00Z"),
LastDeliveryID: "01BBB",
}}, 1)
out := buf.String()
for _, want := range []string{"ecg", "3", "2026-08-01T10:00:00Z", "01AAA", "2026-08-02T11:00:00Z", "01BBB"} {
if !strings.Contains(out, want) {
t.Errorf("в выводе нет %q:\n%s", want, out)
}
}
}
// Пустой перечень говорит о себе словами: молчаливый пустой вывод неотличим от
// «команда ничего не сделала».
func TestПустойПереченьНазванСловами(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
writeUncovered(&buf, nil, 0)
if strings.TrimSpace(buf.String()) == "" {
t.Error("пустой перечень напечатал пустоту")
}
// И не обещает того, чего носитель не даёт: колонка отвечает про дожившие
// учётные записи, а не про историю потока.
if strings.Contains(buf.String(), "не приносил") {
t.Errorf("пустой перечень говорит за весь поток:\n%s", buf.String())
}
}
// Границы носителя называются в любом исходе: пустой перечень без них владелец
// прочитает как «ничего не приезжало» и закроет вопрос.
func TestГраницыНосителяНазваныВОбоихИсходах(t *testing.T) {
t.Parallel()
rows := []store.UncoveredSection{{
Name: "ecg", Deliveries: 1,
FirstSeen: at(t, "2026-08-01T10:00:00Z"), FirstDeliveryID: "01AAA",
LastSeen: at(t, "2026-08-01T10:00:00Z"), LastDeliveryID: "01AAA",
}}
for name, sections := range map[string][]store.UncoveredSection{
"пустой": nil,
"непустой": rows,
} {
var buf bytes.Buffer
writeUncovered(&buf, sections, int64(len(sections)))
if !strings.Contains(buf.String(), "uncovered_sections") {
t.Errorf("%s перечень не назвал носителя:\n%s", name, buf.String())
}
if !strings.Contains(buf.String(), "32") {
t.Errorf("%s перечень не назвал границу списка:\n%s", name, buf.String())
}
}
}
// Имя приходит верхнеуровневым ключом чужого тела: длина ограничена разбором,
// содержимое — ничем. Сырая печать впустила бы в терминал оператора управляющие
// последовательности.
func TestИмяСекцииЭкранируется(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
writeUncovered(&buf, []store.UncoveredSection{{
Name: "ecg\x1b[31m\nfake",
Deliveries: 1,
FirstSeen: at(t, "2026-08-01T10:00:00Z"),
FirstDeliveryID: "01AAA",
LastSeen: at(t, "2026-08-01T10:00:00Z"),
LastDeliveryID: "01AAA",
}}, 1)
out := buf.String()
if strings.Contains(out, "\x1b") {
t.Errorf("управляющий байт доехал до терминала:\n%q", out)
}
// Строка перечня обязана остаться одной: перевод строки из имени разорвал
// бы её надвое, и вторая половина читалась бы как отдельная секция.
var rows int
for line := range strings.SplitSeq(strings.TrimSpace(out), "\n") {
if strings.HasPrefix(line, `"`) {
rows++
}
}
if rows != 1 {
t.Errorf("строк перечня %d, ожидалась одна:\n%q", rows, out)
}
if !strings.Contains(out, `\n`) {
t.Errorf("перевод строки в имени не экранирован:\n%q", out)
}
}
// Остаток называется числом: «здесь всё» и «здесь двести из тысячи» — разные
// ответы, и молчаливый обрыв делает их неотличимыми.
func TestОстатокПеречняНазванЧислом(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
writeUncovered(&buf, []store.UncoveredSection{{
Name: "ecg",
Deliveries: 1,
FirstSeen: at(t, "2026-08-01T10:00:00Z"),
FirstDeliveryID: "01AAA",
LastSeen: at(t, "2026-08-01T10:00:00Z"),
LastDeliveryID: "01AAA",
}}, 5)
if !strings.Contains(buf.String(), "4") {
t.Errorf("остаток не назван числом:\n%s", buf.String())
}
}
// Базы по указанному пути нет — отказ с причиной и ненулевым кодом. Пустой
// перечень здесь был бы ложью: «ничего не приезжало» и «смотреть не во что» —
// разные ответы.
func TestОтсутствиеБазыДаётОтказ(t *testing.T) {
t.Parallel()
dir := t.TempDir()
cfgPath := filepath.Join(dir, "config.toml")
cfg := "[server]\naddr = \":8080\"\ningest_token = \"t\"\nread_token = \"r\"\n" +
"[storage]\ndb_path = \"" + filepath.Join(dir, "нет.db") + "\"\n" +
"raw_dir = \"" + filepath.Join(dir, "raw") + "\"\n"
if err := os.WriteFile(cfgPath, []byte(cfg), 0o600); err != nil {
t.Fatalf("конфиг: %v", err)
}
if err := runUncovered([]string{"--config", cfgPath}); err == nil {
t.Error("команда на несуществующей базе завершилась успехом")
}
}
// Сквозной прогон: перечень, собранный командой, сходится с тем, что посчитано
// по ТЕЛАМ архива независимо от её кода.
//
// Оракул строится от тел намеренно: сверка вывода с `SELECT DISTINCT` по той же
// колонке тем же `json_each` доказывала бы только согласие кода с самим собой —
// и молчала бы обо всём, что команда добавляет сверх множества имён.
//
// Не параллельный: подменяет `os.Stdout`.
func TestПереченьСходитсяСТеламиАрхива(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "healthlog.db")
rawDir := filepath.Join(dir, "raw")
arch, err := archive.New(rawDir)
if err != nil {
t.Fatalf("архив: %v", err)
}
st, err := store.Open(dbPath)
if err != nil {
t.Fatalf("база: %v", err)
}
svc := fold.New(arch, st, 0, slog.New(slog.DiscardHandler))
body, err := os.ReadFile(filepath.Join("..", "..", "internal", "hae", "testdata", "uncovered_sections.json"))
if err != nil {
t.Fatalf("тело: %v", err)
}
want := uncoveredInBody(t, body)
if len(want) == 0 {
t.Fatal("в теле нет непокрытых секций — проверять нечего")
}
ctx := context.Background()
for _, id := range []string{"d1", "d2"} {
at := store.Now()
rawPath, err := arch.Write(id, at, body)
if err != nil {
t.Fatalf("запись в архив: %v", err)
}
err = st.CreateDelivery(ctx, store.Delivery{
ID: id, ReceivedAt: at, AutomationID: "a1", Aggregation: "Minutes",
Bytes: int64(len(body)), SHA256: "-", RawPath: rawPath,
ParseStatus: store.ParsePending,
})
if err != nil {
t.Fatalf("учёт доставки: %v", err)
}
if _, err := svc.Fold(ctx, id); err != nil {
t.Fatalf("свёртка %s: %v", id, err)
}
}
// База закрывается до команды: та открывает её сама, только на чтение.
if err := st.Close(); err != nil {
t.Fatalf("закрытие базы: %v", err)
}
cfgPath := filepath.Join(dir, "config.toml")
cfg := "[storage]\ndb_path = \"" + dbPath + "\"\narchive_dir = \"" + rawDir + "\"\n"
if err := os.WriteFile(cfgPath, []byte(cfg), 0o600); err != nil {
t.Fatalf("конфиг: %v", err)
}
out := captureStdout(t, func() {
if err := runUncovered([]string{"--config", cfgPath}); err != nil {
t.Fatalf("команда: %v", err)
}
})
for _, name := range want {
if !strings.Contains(out, name) {
t.Errorf("в выводе нет секции %q, которая есть в теле:\n%s", name, out)
}
}
// Обе доставки принесли одно и то же тело, значит у каждой секции ровно две
// доставки, а границы — первая и последняя.
if !strings.Contains(out, " 2 ") && !strings.Contains(out, "\t2\t") {
t.Errorf("число доставок в выводе не 2:\n%s", out)
}
if !strings.Contains(out, "d1") || !strings.Contains(out, "d2") {
t.Errorf("границы встреч не названы обеими доставками:\n%s", out)
}
}
// uncoveredInBody считает непокрытые секции ПО ТЕЛУ, не трогая разбор: ключи
// `data` минус три покрытых имени.
func uncoveredInBody(t *testing.T, body []byte) []string {
t.Helper()
var envelope struct {
Data map[string]json.RawMessage `json:"data"`
}
if err := json.Unmarshal(body, &envelope); err != nil {
t.Fatalf("тело не разбирается: %v", err)
}
covered := map[string]bool{"metrics": true, "workouts": true, "stateOfMind": true}
var out []string
for name := range envelope.Data {
if !covered[name] {
out = append(out, name)
}
}
sort.Strings(out)
return out
}
// captureStdout ловит пользовательский вывод команды.
func captureStdout(t *testing.T, run func()) string {
t.Helper()
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("канал: %v", err)
}
saved := os.Stdout
os.Stdout = w
defer func() { os.Stdout = saved }()
run()
_ = w.Close()
var buf bytes.Buffer
if _, err := io.Copy(&buf, r); err != nil {
t.Fatalf("чтение вывода: %v", err)
}
return buf.String()
}
@@ -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` — решение этого изменения, а не запрет навсегда.
+4
View File
@@ -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) - [ADR-2026-08-04-tie-break-po-poryadku-zhurnala](ADR-2026-08-04-tie-break-po-poryadku-zhurnala.md)
— тай-брейк точек при равной полноте: побеждает пришедшая, то есть правило — тай-брейк точек при равной полноте: побеждает пришедшая, то есть правило
становится явной функцией порядка журнала; хранимая метка провенанса становится явной функцией порядка журнала; хранимая метка провенанса
+35 -1
View File
@@ -226,7 +226,7 @@ capability**, и здесь стоит ссылка, а не пересказ т
| `archive` | сырой архив: запись тела, чтение для reindex, ретеншен | [`storage`](../openspec/specs/storage/spec.md) | | `archive` | сырой архив: запись тела, чтение для reindex, ретеншен | [`storage`](../openspec/specs/storage/spec.md) |
| `hae` | разбор формата HAE, канонизация, хеш содержимого | [`parsing`](../openspec/specs/parsing/spec.md) | | `hae` | разбор формата HAE, канонизация, хеш содержимого | [`parsing`](../openspec/specs/parsing/spec.md) |
| `ingest` | use-case приёма, общий для HTTP и CLI `import` | [`ingest`](../openspec/specs/ingest/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) | | `replay` | проигрывание журнала в витрину: состав, порядок, отчёт | [`reindex`](../openspec/specs/reindex/spec.md) |
| `catalog` | каталог разрезов и измерение рода агрегации | [`catalog`](../openspec/specs/catalog/spec.md) | | `catalog` | каталог разрезов и измерение рода агрегации | [`catalog`](../openspec/specs/catalog/spec.md) |
| `store` | SQLite: доставки, часовые объекты, тренировки, записи | [`storage`](../openspec/specs/storage/spec.md) | | `store` | SQLite: доставки, часовые объекты, тренировки, записи | [`storage`](../openspec/specs/storage/spec.md) |
@@ -355,6 +355,40 @@ capability**, и здесь стоит ссылка, а не пересказ т
`partial` — не отклонение, а установившееся состояние, поэтому уровень лога от `partial` — не отклонение, а установившееся состояние, поэтому уровень лога от
него не растёт. Постоянный `WARN` каждые пять минут обесценил бы уровень. него не растёт. Постоянный `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` со старым списком, и ретеншен будет вечно щадить покрытой, останутся `partial` со старым списком, и ретеншен будет вечно щадить
+9
View File
@@ -71,3 +71,12 @@
- Миграции — goose (`internal/store/migrations`), SQL для DDL. При изменении - Миграции — goose (`internal/store/migrations`), SQL для DDL. При изменении
структуры обновляем схему в [database.md](../database.md) тем же изменением — структуры обновляем схему в [database.md](../database.md) тем же изменением —
это проверяет `task gate`. это проверяет `task gate`.
- **Значение, читаемое табличной функцией SQLite (`json_each` и родня), проходит
проверку ВНУТРИ её аргумента, а не условием в `WHERE`.** Функция получает
значение строки раньше, чем применится фильтр, и порядок этот SQLite не
обещает: неразбираемое значение роняет **весь** запрос, а не пропускает
строку. Условие в `WHERE` работает, пока планировщик проталкивает его вниз, и
перестаёт молча. Проверено на закреплённом драйвере: одна испорченная строка
`delivery.uncovered_sections` обесценивала и сверку новизны (вечное «сверка не
состоялась» на каждой доставке), и перечень целиком.
+11
View File
@@ -35,6 +35,17 @@
посчитает другое и разойдётся молча (так и вышло: ключ без слоя дал 29-кратное посчитает другое и разойдётся молча (так и вышло: ключ без слоя дал 29-кратное
расхождение). Три случая одного класса за три дня: записи 2026-08-02, расхождение). Три случая одного класса за три дня: записи 2026-08-02,
2026-08-03 и 2026-08-04 в [review.md](../review.md). 2026-08-03 и 2026-08-04 в [review.md](../review.md).
Метода мало — **синтетический корпус обязан содержать измеряемый случай в той
форме, в какой он бывает в жизни**. Сверка новизны секции мерялась на журнале,
где новое имя стояло во всех доставках, то есть его первая встреча лежала в
начале — ранний выход давал 31 мкс. В жизни секцию включают сегодня, первая
встреча оказывается в хвосте, и та же операция стоит 52 мс: три порядка
разницы, а на числе стояло решение «индекс не нужен» (запись 2026-08-04).
И **число живёт в одном месте.** Один и тот же замер, записанный в
комментарий кода и в `architecture.md`, разошёлся внутри одного изменения.
Дом числа — `design.md` изменения; остальные формулируют правило и ссылаются.
- **Оракул сходимости называет свою посылку рядом с собой, и прогон её - **Оракул сходимости называет свою посылку рядом с собой, и прогон её
печатает.** «Пересборка = приём» — не тождество, а утверждение с условиями: печатает.** «Пересборка = приём» — не тождество, а утверждение с условиями:
живая свёртка шла в порядке журнала, в журнале нет доставок, чью свёртку живой живая свёртка шла в порядке журнала, в журнале нет доставок, чью свёртку живой
+6 -1
View File
@@ -1852,7 +1852,12 @@ instant heart_rate, respiratory_rate, blood_oxygen_saturation,
Меняется ли что-то на глубине часов и суток — покажет более длинный ряд Меняется ли что-то на глубине часов и суток — покажет более длинный ряд
доставок. доставок.
- **Секции, которых мы не видели живьём:** `symptoms`, `ecg`, - **Секции, которых мы не видели живьём:** `symptoms`, `ecg`,
`heartRateNotifications`, `cycleTracking`, `medications`. `heartRateNotifications`, `cycleTracking`, `medications`. Разбор покрывает
ровно остальные три (`metrics`, `workouts`, `stateOfMind` — `decodeCovered` в
`internal/hae`), сверено поимённо 2026-08-04. Момент их появления больше не
требует догадки: первая встреча имени даёт `WARN` в логе свёртки, а перечень
накопленного отдаёт `healthlog uncovered`. Разбор самой секции пишется, когда
её будет на чём проверить, — вслепую он не пишется.
- **Что из этих секций вообще есть в родном экспорте.** ЭКГ выгружается - **Что из этих секций вообще есть в родном экспорте.** ЭКГ выгружается
отдельными CSV, а не в XML. Если `stateOfMind`, симптомы или лекарства в отдельными CSV, а не в XML. Если `stateOfMind`, симптомы или лекарства в
экспорте отсутствуют, то по ним экспорт не источник истины, и ретеншен экспорте отсутствуют, то по ним экспорт не источник истины, и ретеншен
+53
View File
@@ -499,3 +499,56 @@
- **Что осталось незакрытым:** гейт после интеграции обязан звать `BASE` - **Что осталось незакрытым:** гейт после интеграции обязан звать `BASE`
вершиной **до** слияния. Сейчас это знание живёт только в этой записи — вершиной **до** слияния. Сейчас это знание живёт только в этой записи —
ни `Taskfile.yml`, ни скилл батча его не несут. ни `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` оно теперь распространено записью
ниже, но механизировать его нечем.
+7
View File
@@ -44,6 +44,13 @@ disabled`, `read auth disabled`), но стартовать не отказыв
`export.xml`, который выбирает человек, но формируется он устройством и по `export.xml`, который выбирает человек, но формируется он устройством и по
объёму (3,6 млн записей) глазами не проверяется. объёму (3,6 млн записей) глазами не проверяется.
**Новый адресат недоверенного входа — терминал оператора.** Подкоманда
`healthlog uncovered` печатает имена секций, а имя это верхнеуровневый ключ
чужого тела: длина у него ограничена разбором (64 байта, не больше 32 имён),
содержимое — ничем. Печатается оно экранированным (`%q`), иначе управляющая
последовательность из тела подделала бы строки вывода. Тот же вход попадает
структурным атрибутом в лог свёртки, где его экранирует кодировщик `slog`.
Ответы внешних систем в недоверенный вход не входят: исходящих вызовов у Ответы внешних систем в недоверенный вход не входят: исходящих вызовов у
сервиса нет. сервиса нет.
+119 -4
View File
@@ -92,6 +92,17 @@ type Stats struct {
Uncovered []string Uncovered []string
// UncoveredDropped — сколько имён отброшено границей списка. // UncoveredDropped — сколько имён отброшено границей списка.
UncoveredDropped int UncoveredDropped int
// UncoveredNew — имена непокрытых секций, которых не было ни в одной
// доставке, стоящей в журнале раньше этой. Событие однократное за всю жизнь
// имени: поток дописывает метрики на телефоне молча, и момент появления
// секции наблюдать больше нечем.
UncoveredNew []string
// UncoveredSeenUnknown — сверка с журналом не состоялась, и потому все
// непокрытые имена доставки объявлены новыми. Лишняя запись стоит внимания
// один раз, промолчавшее событие не восстанавливается ничем.
UncoveredSeenUnknown bool
// UncoveredSeenError — почему не состоялась.
UncoveredSeenError error
// Categoricals — сколько РАЗЛИЧНЫХ категориальных значений наблюдалось; // Categoricals — сколько РАЗЛИЧНЫХ категориальных значений наблюдалось;
// CategoricalUnknown — сколько из них словарь не знает; // CategoricalUnknown — сколько из них словарь не знает;
@@ -158,6 +169,16 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (stats Stats, err
FallbackLayer: hae.Layer(fallback), FallbackLayer: hae.Layer(fallback),
Locale: localeOf(d.Headers), Locale: localeOf(d.Headers),
}) })
// Сверка с журналом идёт ДО ветвления на успех и отказ. Список непокрытых
// секций переживает отказ разбора, то есть имя уже записано в учёт; смолчи
// здесь — и следующая доставка сочтёт его виденным, а событие не вернётся
// ничем, кроме ручного запроса в базу.
novelty := s.novelty(ctx, parsed.Uncovered, store.DeliveryRef{
ID: d.ID,
ReceivedAt: d.ReceivedAt,
})
if err != nil { 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 return stats, err
} }
stats.Uncovered = parsed.Uncovered stats.Uncovered = parsed.Uncovered
stats.UncoveredDropped = parsed.UncoveredDropped stats.UncoveredDropped = parsed.UncoveredDropped
stats.UncoveredNew = novelty.fresh
stats.UncoveredSeenUnknown = novelty.unknown
stats.UncoveredSeenError = novelty.cause
stats.Metrics = parsed.Metrics stats.Metrics = parsed.Metrics
stats.Points = len(parsed.Points) stats.Points = len(parsed.Points)
stats.SkippedNoTime = parsed.SkippedNoTime 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{ s.fail(ctx, deliveryID, err, parseResidue{
uncovered: parsed.Uncovered, uncovered: parsed.Uncovered,
skipped: skippedEntities(parsed), skipped: skippedEntities(parsed),
// Новизна доезжает и сюда. Эта ветвь пишет имя в учёт ровно так же,
// как ветвь отказа разбора, — значит и терять событие ей нельзя:
// следующая доставка сочтёт имя виденным, а отказ слияния бывает
// нетранзиентным (исчерпанный дедлайн свёртки под большим телом).
novelty: novelty,
}) })
return stats, err return stats, err
} }
@@ -283,6 +312,18 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
// построчный разбор логов. Содержимого секций здесь нет. // построчный разбор логов. Содержимого секций здесь нет.
"uncovered", st.Uncovered, "uncovered", st.Uncovered,
"uncovered_dropped", st.UncoveredDropped, "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 allEntitiesSkipped := st.Workouts == 0 && st.Records == 0 && skippedEntities > 0
switch { switch {
case len(st.UncoveredNew) > 0:
// ПЕРВОЙ ветвью, и это существенно. Все прочие говорят о событиях,
// повторяющихся на живом потоке; это — однократное за всю жизнь имени, и
// замаскировать его перезаписью точек значило бы потерять ровно то, ради
// чего наблюдение заведено. Уровень `WARN`, а не `ERROR`: приезд новой
// секции — штатное событие внешнего мира, «посмотри», а не «разбери
// сбой». Имена секций в лог попадать могут: имя ключа — форма пакета, а
// не измерение.
s.log.WarnContext(ctx, "delivery folded, new uncovered section", attrs...)
case st.EntitiesHeld > 0: case st.EntitiesHeld > 0:
// Приехавшая версия сущности отклонена как теряющая содержание. Плата // Приехавшая версия сущности отклонена как теряющая содержание. Плата
// за отказ объединять поля: событие обязано быть видно, потому что на // за отказ объединять поля: событие обязано быть видно, потому что на
@@ -446,10 +496,61 @@ type parseResidue struct {
// считал». Ноль означал бы «проверено, терять нечего», а по этому числу // считал». Ноль означал бы «проверено, терять нечего», а по этому числу
// ретеншен принимает необратимое решение об удалении тела. // ретеншен принимает необратимое решение об удалении тела.
skipped *int64 skipped *int64
// novelty в учёте не участвует — она едет в запись лога об отказе. Полем, а
// не пятым параметром `fail`: параметры путают местами, а поле называет
// себя само.
novelty sectionNovelty
} }
func residueOf(parsed hae.Result) parseResidue { func residueOf(parsed hae.Result, novelty sectionNovelty) parseResidue {
return parseResidue{uncovered: parsed.Uncovered} 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` разбор пропустил. // skippedEntities — сколько сущностей с собственным `id` разбор пропустил.
@@ -469,6 +570,20 @@ func (s *Service) fail(ctx context.Context, deliveryID string, cause error, resi
return 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 level := slog.LevelError
switch { switch {
case errors.Is(cause, hae.ErrLayerUnknown): case errors.Is(cause, hae.ErrLayerUnknown):
@@ -481,7 +596,7 @@ func (s *Service) fail(ctx context.Context, deliveryID string, cause error, resi
// одного класса ошибки давали бы постоянный ERROR-шум. // одного класса ошибки давали бы постоянный ERROR-шум.
level = slog.LevelWarn 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...)
// Слой НЕ затирается: доставка могла свернуться успешно раньше, и пустая // Слой НЕ затирается: доставка могла свернуться успешно раньше, и пустая
// строка здесь оборвала бы цепочку наследования, то есть изменила бы // строка здесь оборвала бы цепочку наследования, то есть изменила бы
+9
View File
@@ -238,6 +238,15 @@ func TestFoldЧастичныйРазборВЛоге(t *testing.T) {
t.Fatalf("свёртка: %v", err) 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() out := buf.String()
if !strings.Contains(out, "ecg") { if !strings.Contains(out, "ecg") {
t.Error("имени непокрытой секции нет в логе — момент появления новой секции незаметен") t.Error("имени непокрытой секции нет в логе — момент появления новой секции незаметен")
+138
View File
@@ -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)
}
}
}
+395
View File
@@ -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)
}
}
+243
View File
@@ -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
}
+268
View File
@@ -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("уцелевшая строка перестала участвовать в сверке")
}
}
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-04
@@ -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` при отказе сверки** (спека «Несостоявшаяся сверка не молчит»)
→ ограничены доставками с непокрытыми секциями и сопровождаются атрибутом о
причине.
@@ -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` — раздел про
неразобранные секции и сверка перечня не виденных живьём секций с множеством
покрытых имён разбора.
@@ -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` с журналом, типовыми
ложноположительными и обоими списками «Недоступно проверке». Ни один проход не
заявил недостающего документа; отдельных строк деградации нет.
@@ -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** содержит имя отдельным атрибутом новых секций
@@ -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** символ выводится экранированной последовательностью, а не сырым
байтом
@@ -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 <path>`: `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 перестроен от тел архива)
+15 -2
View File
@@ -412,8 +412,14 @@ HTML-экранирования: `&`, `<` и `>` внутри точки обя
Частичный разбор уровня записи не повышает: `partial` — установившееся состояние Частичный разбор уровня записи не повышает: `partial` — установившееся состояние
половины потока (53 доставки из 118), и постоянный `WARN` обесценил бы уровень. половины потока (53 доставки из 118), и постоянный `WARN` обесценил бы уровень.
Повышает уровень другое — срабатывание границ списка: тело с сотнями секций или Повышает уровень другое, и оснований два:
с именем длиннее предела на HAE не похоже вовсе.
- срабатывание границ списка: тело с сотнями секций или с именем длиннее предела
на HAE не похоже вовсе;
- **первая по журналу встреча имени непокрытой секции** — событие однократное за
всю жизнь имени, и правила его живут в capability наблюдения за непокрытыми
секциями. Здесь оно названо, чтобы перечень оснований оставался полным: иначе
следующий читатель снимет ветвь как незаказанную.
#### Scenario: Разбор доставки логируется без значений #### Scenario: Разбор доставки логируется без значений
@@ -441,6 +447,13 @@ HTML-экранирования: `&`, `<` и `>` внутри точки обя
- **THEN** запись лога имеет уровень `WARN` - **THEN** запись лога имеет уровень `WARN`
- **AND** содержит число отброшенных имён - **AND** содержит число отброшенных имён
#### Scenario: Имя непокрытой секции встречено впервые
- **WHEN** доставка принесла имя непокрытой секции, которого не было ни в одной
доставке раньше неё в журнале
- **THEN** запись лога имеет уровень `WARN`
- **AND** содержит имя отдельным атрибутом новых секций
### Requirement: Учёт частично разобранной доставки ### Requirement: Учёт частично разобранной доставки
Система SHALL отличать доставку, разобранную целиком, от доставки, в теле Система SHALL отличать доставку, разобранную целиком, от доставки, в теле
+277
View File
@@ -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** символ выводится экранированной последовательностью, а не сырым
байтом