reindex: пересборка витрины проигрыванием журнала

- `healthlog reindex` собирает витрину из журнала (тела архива + учёт
  доставок) в ОТДЕЛЬНЫЙ файл базы, строго по `(received_at, id)`; рабочую
  базу читает без наката миграций и не трогает вовсе. Подмену делает
  человек при остановленном сервисе: переименование поверх открытого
  дескриптора портит базу молча.
- Журналом считается архив, а не таблица доставок: тело без учётной записи
  заводится заново (метка из ULID, размер и хеш по распакованному телу),
  запись без тела переносится, но не сворачивается. Оракул сходимости
  встроен — два отпечатка и «объектов было/стало»; пустой журнал успехом не
  считается.
- Прогон живого архива переехал на новый пакет: второго проигрывателя
  журнала в проекте не осталось, а его утверждение о ключе сна перестало
  быть константой, протухающей с каждой доставкой.
This commit is contained in:
av
2026-08-02 09:07:46 +03:00
parent 84bcbbea5c
commit 5ae0c5ff81
36 changed files with 4452 additions and 258 deletions
@@ -0,0 +1,127 @@
## 1. Опоры в существующих пакетах
- [x] 1.1 `internal/ident`: `TimeOf(id string) (time.Time, error)` — время
создания из ULID, **в UTC**, усечённое до секунды (как `store.Now`).
`ulid.Time` внутри зовёт `time.Unix` и отдаёт локальную зону, а
`Truncate` зону не нормализует — значит `.UTC()` обязателен явно.
- [x] 1.2 `internal/archive`: перечисление тел — обход корня, отбор форм тела,
возврат относительных путей и отдельно — пропущенных файлов. Ошибка
чтения каталога (включая отсутствие корня) возвращается наружу, а не
превращается в пустой список; каталог не создаётся.
- [x] 1.3 `internal/store`: `ListDeliveries(ctx)` — доставки в порядке
`(received_at, id)` со **всеми фактами журнала**, включая `headers`.
- [x] 1.4 `internal/store`: открытие рабочей базы **только для чтения и без
наката миграций** + проверка версии схемы; расхождение — ошибка,
называющая обе версии.
## 2. Проигрывание журнала — `internal/replay`
- [x] 2.1 Собрать вход: объединить тела архива с учётом доставок; развести
четыре случая (штатная / тело без учёта / учёт без тела / файл не тело) и
отсортировать по `(received_at, id)`.
- [x] 2.2 Перенести учёт в базу назначения по нормированному составу: факты
журнала дословно (включая `headers`), производные от разбора —
пустыми (`parse_status=pending`, `points=0`, `derived_layer=''`,
`uncovered_sections='[]'`). Подобранные тела завести заново: id из имени
файла, метка из ULID в UTC, `bytes`/`sha256` — по **распакованному** телу.
- [x] 2.3 Проиграть журнал последовательно: `fold.Fold` по каждой доставке,
`fold.New` собирается с тем же пределом тела, что и приём
(`cfg.Ingest.MaxBodyMB`). Отказ одной доставки не прекращает прогон,
отмена контекста — прекращает.
- [x] 2.4 Собрать отчёт данными: счётчики по классам, отпечаток пересобранной
витрины, признак отмены.
## 3. Команда `healthlog reindex`
- [x] 3.1 `cmd/healthlog/reindex.go`: флаги `--config`, `--out`, `--force`;
умолчание `--out` — сосед рабочей базы; тождество с рабочей базой
проверяется **по файлу** (`os.SameFile`), а для несуществующего файла —
по родительскому каталогу и имени; `flag.ErrHelp` не превращается в
`fatal`.
- [x] 3.2 Жизненный цикл файла назначения: сборка под временным именем,
переименование — последний шаг успеха; при любом ином исходе файла по
пути назначения нет, временный и его спутники (`-wal`, `-shm`) убраны.
- [x] 3.3 Отмена: `signal.NotifyContext(SIGINT, SIGTERM)`, частичный отчёт,
ненулевой код.
- [x] 3.4 `func writeReport(w io.Writer, …)` — отчёт в stdout, прогресс в
stderr; процедура подмены печатается только при успешном исходе.
- [x] 3.5 Коды возврата: успех — журнал непуст и свёрнута хотя бы одна
доставка; ненулевой — пустой журнал, ноль свёрнутых, отмена, ошибка
окружения. Отказ отдельной доставки исхода команды не меняет.
- [x] 3.6 Подключить подкоманду в `main.go`.
## 4. Проверки
- [x] 4.1 Сходимость: живой приём N доставок → пересборка в отдельную базу →
отпечатки совпали; второй прогон → отпечаток не изменился.
- [x] 4.2 Порядок: журнал, у которого раскладка файлов расходится с
хронологией, даёт доставке без плотных метрик слой **предшествующей** по
`received_at`, а не соседа по каталогу; доставки одной секунды упорядочены
идентификатором.
- [x] 4.3 Состав журнала: тело без учётной записи подбирается, точки доезжают,
`bytes`/`sha256` посчитаны по распакованному телу; учётная запись без тела
считается и не роняет прогон; файл не-тело считается отдельно; нечитаемый
каталог — отказ команды.
- [x] 4.4 Учёт в базе назначения: число строк и `headers` совпадают с рабочей
плюс подобранные; проставленный в рабочей базе `derived_layer` на
результат не влияет (отпечаток тот же, что из учёта без слоёв).
- [x] 4.5 Отказы и обратимость: битое тело не срывает прогон; отмена прекращает
проигрывание и не оставляет файла назначения; рабочая база после
прерванной пересборки не изменилась ни витриной, ни учётом.
- [x] 4.6 Аргументы: `--out` — тот же файл, что рабочая база, по другому пути;
существующий файл без `--force`; `--force` даёт ту же витрину, что сборка
в отсутствующий файл.
- [x] 4.7 Исход команды: пустой журнал — ненулевой код и без процедуры подмены;
ноль свёрнутых — то же.
- [x] 4.8 Отчёт не несёт значений точек, имён метрик и имён устройств.
- [x] 4.9 Прогон живого архива переписан поверх `replay` (см. 6.5), измеренные
утверждения сохранены.
## 5. Приёмочные критерии из ревью дизайна
Рубрика порождена проходом `healthlog-review-rubric` до чтения предложения.
Пункты, уже закрытые разделами выше, отмечены ссылкой.
- [x] 5.1 **Идемпотентность прогона.** Второй прогон даёт то же состояние не
только по отпечатку витрины, но и по учёту доставок в базе назначения
(число строк, `id`, `received_at`, `bytes`, `sha256` подобранных тел).
- [x] 5.2 **Тотальный детерминированный порядок.** Результат не зависит от
порядка обхода каталога, часового пояса процесса и числа перечитываний
каталога (см. 4.2).
- [x] 5.3 **Независимость от «сейчас».** Ни одно поле, влияющее на отпечаток, не
производно от времени прогона: метки берутся из события, а не из
`store.Now`. Проигрывание последовательно.
- [x] 5.4 **Незавершённая сборка неотличимой от завершённой быть не может**
(см. 3.2, 4.5).
- [x] 5.5 **Отмена доводится до конца и различима снаружи** (см. 3.3).
- [x] 5.6 **Оракул успеха не сводится к «ошибок не было»** (см. 3.5, 4.7).
- [x] 5.7 **Политика частичного отказа явная и счётная.** У каждого класса
отказа свой счётчик, сумма счётчиков сходится с числом входов.
- [x] 5.8 **Рабочая база открывается только на чтение и без миграций**
(см. 1.4).
- [x] 5.9 **Расход памяти не растёт с объёмом архива.** Тела читаются по
одному, предел распакованного тела тот же, что у приёма (см. 2.3);
превышение — учтённый отказ доставки, а не падение прогона.
- [x] 5.10 **Длинный прогон наблюдаем** (см. 3.4).
- [x] 5.11 **Аргументы безопасны и обратимы** (см. 3.1, 4.6).
- [x] 5.12 **Невосстановимое названо, а не досчитано.** Отчёт называет классы
ожидаемого расхождения (новые доставки за время прогона, непереносимый
`sealed`, исправленный разбор) отдельно от самого факта расхождения.
- [x] 5.13 **Ни отчёт, ни лог выше `DEBUG` не несут данных о здоровье**
(см. 4.8).
## 6. Документация
- [x] 6.1 `docs/architecture.md`: почему подмена базы не автоматизируется
(открытый дескриптор, порча базы SQLite) и почему пересборка идёт в
отдельный файл (blue-green rebuild проекции); отвергнутые варианты — с
причиной. Там же — правка утверждения «пересчёт при `reindex` идёт по всей
истории и потому точнее»: точнее не длина ряда, а исправленный разбор.
- [x] 6.2 `README.md`: `reindex` перестаёт быть «в планах», процедура применения.
- [x] 6.3 `docs/plan.md`: `reindex` вычеркнут из остатка шага «Разбор и
хранилище».
- [x] 6.4 Беклог: задача удалена, заведена новая — «заголовки доставки в архиве
рядом с телом» (prior art: WARC), с указанием, что без неё пересборка без
рабочей базы деградирует по выводу слоя.
- [x] 6.5 `task verify:archive` переезжает на `internal/replay`: отдельной
задачи прогона не заводим, второго проигрывателя в проекте не остаётся.