Files
healthlog/openspec/changes/archive/2026-08-02-reindex-iz-arhiva/tasks.md
T
av 5ae0c5ff81 reindex: пересборка витрины проигрыванием журнала
- `healthlog reindex` собирает витрину из журнала (тела архива + учёт
  доставок) в ОТДЕЛЬНЫЙ файл базы, строго по `(received_at, id)`; рабочую
  базу читает без наката миграций и не трогает вовсе. Подмену делает
  человек при остановленном сервисе: переименование поверх открытого
  дескриптора портит базу молча.
- Журналом считается архив, а не таблица доставок: тело без учётной записи
  заводится заново (метка из ULID, размер и хеш по распакованному телу),
  запись без тела переносится, но не сворачивается. Оракул сходимости
  встроен — два отпечатка и «объектов было/стало»; пустой журнал успехом не
  считается.
- Прогон живого архива переехал на новый пакет: второго проигрывателя
  журнала в проекте не осталось, а его утверждение о ключе сна перестало
  быть константой, протухающей с каждой доставкой.
2026-08-02 09:07:46 +03:00

128 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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`: отдельной
задачи прогона не заводим, второго проигрывателя в проекте не остаётся.