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

12 KiB
Raw Blame History

1. Опоры в существующих пакетах

  • 1.1 internal/ident: TimeOf(id string) (time.Time, error) — время создания из ULID, в UTC, усечённое до секунды (как store.Now). ulid.Time внутри зовёт time.Unix и отдаёт локальную зону, а Truncate зону не нормализует — значит .UTC() обязателен явно.
  • 1.2 internal/archive: перечисление тел — обход корня, отбор форм тела, возврат относительных путей и отдельно — пропущенных файлов. Ошибка чтения каталога (включая отсутствие корня) возвращается наружу, а не превращается в пустой список; каталог не создаётся.
  • 1.3 internal/store: ListDeliveries(ctx) — доставки в порядке (received_at, id) со всеми фактами журнала, включая headers.
  • 1.4 internal/store: открытие рабочей базы только для чтения и без наката миграций + проверка версии схемы; расхождение — ошибка, называющая обе версии.

2. Проигрывание журнала — internal/replay

  • 2.1 Собрать вход: объединить тела архива с учётом доставок; развести четыре случая (штатная / тело без учёта / учёт без тела / файл не тело) и отсортировать по (received_at, id).
  • 2.2 Перенести учёт в базу назначения по нормированному составу: факты журнала дословно (включая headers), производные от разбора — пустыми (parse_status=pending, points=0, derived_layer='', uncovered_sections='[]'). Подобранные тела завести заново: id из имени файла, метка из ULID в UTC, bytes/sha256 — по распакованному телу.
  • 2.3 Проиграть журнал последовательно: fold.Fold по каждой доставке, fold.New собирается с тем же пределом тела, что и приём (cfg.Ingest.MaxBodyMB). Отказ одной доставки не прекращает прогон, отмена контекста — прекращает.
  • 2.4 Собрать отчёт данными: счётчики по классам, отпечаток пересобранной витрины, признак отмены.

3. Команда healthlog reindex

  • 3.1 cmd/healthlog/reindex.go: флаги --config, --out, --force; умолчание --out — сосед рабочей базы; тождество с рабочей базой проверяется по файлу (os.SameFile), а для несуществующего файла — по родительскому каталогу и имени; flag.ErrHelp не превращается в fatal.
  • 3.2 Жизненный цикл файла назначения: сборка под временным именем, переименование — последний шаг успеха; при любом ином исходе файла по пути назначения нет, временный и его спутники (-wal, -shm) убраны.
  • 3.3 Отмена: signal.NotifyContext(SIGINT, SIGTERM), частичный отчёт, ненулевой код.
  • 3.4 func writeReport(w io.Writer, …) — отчёт в stdout, прогресс в stderr; процедура подмены печатается только при успешном исходе.
  • 3.5 Коды возврата: успех — журнал непуст и свёрнута хотя бы одна доставка; ненулевой — пустой журнал, ноль свёрнутых, отмена, ошибка окружения. Отказ отдельной доставки исхода команды не меняет.
  • 3.6 Подключить подкоманду в main.go.

4. Проверки

  • 4.1 Сходимость: живой приём N доставок → пересборка в отдельную базу → отпечатки совпали; второй прогон → отпечаток не изменился.
  • 4.2 Порядок: журнал, у которого раскладка файлов расходится с хронологией, даёт доставке без плотных метрик слой предшествующей по received_at, а не соседа по каталогу; доставки одной секунды упорядочены идентификатором.
  • 4.3 Состав журнала: тело без учётной записи подбирается, точки доезжают, bytes/sha256 посчитаны по распакованному телу; учётная запись без тела считается и не роняет прогон; файл не-тело считается отдельно; нечитаемый каталог — отказ команды.
  • 4.4 Учёт в базе назначения: число строк и headers совпадают с рабочей плюс подобранные; проставленный в рабочей базе derived_layer на результат не влияет (отпечаток тот же, что из учёта без слоёв).
  • 4.5 Отказы и обратимость: битое тело не срывает прогон; отмена прекращает проигрывание и не оставляет файла назначения; рабочая база после прерванной пересборки не изменилась ни витриной, ни учётом.
  • 4.6 Аргументы: --out — тот же файл, что рабочая база, по другому пути; существующий файл без --force; --force даёт ту же витрину, что сборка в отсутствующий файл.
  • 4.7 Исход команды: пустой журнал — ненулевой код и без процедуры подмены; ноль свёрнутых — то же.
  • 4.8 Отчёт не несёт значений точек, имён метрик и имён устройств.
  • 4.9 Прогон живого архива переписан поверх replay (см. 6.5), измеренные утверждения сохранены.

5. Приёмочные критерии из ревью дизайна

Рубрика порождена проходом healthlog-review-rubric до чтения предложения. Пункты, уже закрытые разделами выше, отмечены ссылкой.

  • 5.1 Идемпотентность прогона. Второй прогон даёт то же состояние не только по отпечатку витрины, но и по учёту доставок в базе назначения (число строк, id, received_at, bytes, sha256 подобранных тел).
  • 5.2 Тотальный детерминированный порядок. Результат не зависит от порядка обхода каталога, часового пояса процесса и числа перечитываний каталога (см. 4.2).
  • 5.3 Независимость от «сейчас». Ни одно поле, влияющее на отпечаток, не производно от времени прогона: метки берутся из события, а не из store.Now. Проигрывание последовательно.
  • 5.4 Незавершённая сборка неотличимой от завершённой быть не может (см. 3.2, 4.5).
  • 5.5 Отмена доводится до конца и различима снаружи (см. 3.3).
  • 5.6 Оракул успеха не сводится к «ошибок не было» (см. 3.5, 4.7).
  • 5.7 Политика частичного отказа явная и счётная. У каждого класса отказа свой счётчик, сумма счётчиков сходится с числом входов.
  • 5.8 Рабочая база открывается только на чтение и без миграций (см. 1.4).
  • 5.9 Расход памяти не растёт с объёмом архива. Тела читаются по одному, предел распакованного тела тот же, что у приёма (см. 2.3); превышение — учтённый отказ доставки, а не падение прогона.
  • 5.10 Длинный прогон наблюдаем (см. 3.4).
  • 5.11 Аргументы безопасны и обратимы (см. 3.1, 4.6).
  • 5.12 Невосстановимое названо, а не досчитано. Отчёт называет классы ожидаемого расхождения (новые доставки за время прогона, непереносимый sealed, исправленный разбор) отдельно от самого факта расхождения.
  • 5.13 Ни отчёт, ни лог выше DEBUG не несут данных о здоровье (см. 4.8).

6. Документация

  • 6.1 docs/architecture.md: почему подмена базы не автоматизируется (открытый дескриптор, порча базы SQLite) и почему пересборка идёт в отдельный файл (blue-green rebuild проекции); отвергнутые варианты — с причиной. Там же — правка утверждения «пересчёт при reindex идёт по всей истории и потому точнее»: точнее не длина ряда, а исправленный разбор.
  • 6.2 README.md: reindex перестаёт быть «в планах», процедура применения.
  • 6.3 docs/plan.md: reindex вычеркнут из остатка шага «Разбор и хранилище».
  • 6.4 Беклог: задача удалена, заведена новая — «заголовки доставки в архиве рядом с телом» (prior art: WARC), с указанием, что без неё пересборка без рабочей базы деградирует по выводу слоя.
  • 6.5 task verify:archive переезжает на internal/replay: отдельной задачи прогона не заводим, второго проигрывателя в проекте не остаётся.