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

31 KiB
Raw Blame History

Context

Витрина объявлена свёрткой по журналу (import(экспорт) + replay(доставки по received_at)), но кода свёртки нет: fold.Fold умеет свернуть одну доставку по её идентификатору, а того, кто перечислит журнал и позовёт её по каждой записи, не существует. Следствия уже наблюдаемы: после миграции 00005 доставки числятся pending (этим разбором не смотрели), и подобрать их некому; а точки, разобранные прежним кодом, лежат в объектах и не удаляются никогда — исправление разбора к ним не применится.

Что уже сделано и на что опираемся:

  • fold.Fold(ctx, deliveryID) читает тело из архива, а не из памяти — ровно потому, что путь чтения у приёма и у пересборки обязан быть один.
  • Слияние точек — функция множества кандидатов, а не порядка (частичный порядок полноты + тотальный тай-брейк), поэтому повторная свёртка той же доставки ничего не меняет.
  • Вывод слоя зависит от префикса журнала: доставка без плотных метрик наследует слой предшествующей доставки той же автоматизации (LastDerivedLayer с границей по (received_at, id)).
  • store.Fingerprint даёт отпечаток витрины по координатам и хешам объектов, не раскрывая значений. Он уже служит оракулом в task verify:archive.

Ограничения окружения: сервис живёт в контейнере и держит базу открытой, телефон шлёт молча и непрерывно, объём архива за квартал — порядка 2 ГБ.

Goals / Non-Goals

Goals:

  • Проиграть журнал целиком и получить состояние, совпадающее с накопленным приёмом; повторный прогон ничего не меняет.
  • Считать журналом архив, а не таблицу доставок: тело без учётной записи тоже событие.
  • Дать оракул сходимости прямо в команде — сравнение отпечатков, а не «глазом по логам».
  • Оставить дверь для healthlog import: пересборка из архива это вырожденный случай с пустым снапшотом, а не отдельная утилита.

Non-Goals:

  • Подмена рабочей базы. Команда не заменяет файл базы и не останавливает сервис (см. решение 2).
  • Импорт родного экспорта Apple. Стадия снапшота в этой дельте пуста.
  • Ретеншен архива. Пересборка тел не удаляет; она их только читает.
  • Восстановление верхних слоёв за периоды с удалёнными телами. Считать их вниз из sample запрещено инвариантом — это была бы наша агрегация под видом присланной.
  • Онлайн-пересборка под живым приёмом. Пересборка идёт в отдельный файл, и доставки, приехавшие во время неё, в него не попадают; это названная граница, а не дефект (см. риски).

Три формы решения и компромисс каждой

Рассматривались три, а не одна; выбрана вторая.

A. Очистить рабочую витрину и проиграть журнал в неё же. Дёшево, второй базы нет, результат применяется сам собой. Компромисс: единственная необратимая операция всей задачи (DELETE FROM bucket) выполняется до того, как станет известно, удалась ли пересборка. Отказ на середине оставляет витрину пустой наполовину, и это состояние ничем не отличается от нормального. Под живым сервисом — ещё и окно, в котором история отдаётся полупустой как полная.

B. Собрать витрину в отдельный файл базы, подмену оставить человеку (выбрано). Отказ бесплатен: рабочая база не тронута, временный файл удаляется; результат можно сверить с рабочим прежде, чем применять. Компромисс: результат не применяется сам — нужна процедура из четырёх команд с остановкой сервиса, а доставки, приехавшие во время сборки, в новый файл не попадают и подбираются только следующим прогоном.

C. Теневая таблица внутри той же базы: собрать в bucket_new, затем переименовать в транзакции. Подмена атомарна средствами самой SQLite, вторая база не нужна, остановка сервиса теоретически не требуется. Компромисс решающий: имя bucket зашито литералом во весь слой записи (internal/store), и вариант требует параметризовать таблицей всю запись — то есть переписать самый опасный код проекта ради операции, которая выполняется раз в полгода. Вдобавок он не решает того, ради чего затевался: живой приём во время пересборки пишет в старую таблицу, и при подмене его точки пропадают, — значит приём всё равно надо останавливать, и сверх B вариант не даёт ничего.

Decisions

1. Пересборка идёт в отдельный файл базы, а не поверх рабочей

Пересборка обязана начинаться с пустой витрины: точки из объекта не удаляются никогда, поэтому проигрывание поверх накопленного оставило бы в нём результат старого, неверного разбора — то есть не сделало бы ровно того, ради чего задача и заведена.

Начать с пустой витрины можно двумя способами: очистить рабочую таблицу и проиграть журнал в неё же, либо собрать новую витрину рядом и подменить.

Взято второе. Prior art здесь однозначен и стар: это blue-green rebuild проекции — «вместо усечения существующей модели строим новую в параллельном хранилище и переключаем чтение, когда она догонит» (Rebuilding Event-Driven Read Models, Projections and Read Models); тем же приёмом работает _reindex + переключение алиаса в Elasticsearch, и та же форма у собственного VACUUM INTO SQLite — «собери целую копию в новый файл».

Причина предпочесть его здесь конкретнее общей моды: усечение рабочей витрины — единственная необратимая операция во всей задаче, и она наступает до того, как станет известно, что пересборка вообще удалась. Отказ на середине (битое тело, отменённый контекст, кончившееся место) оставил бы витрину пустой наполовину, причём в состоянии, которое ничем не отличается от нормального. Сборка рядом делает отказ бесплатным: рабочая база не тронута, временный файл удаляется.

Отвергнуто: очистка рабочей витрины с проигрыванием в неё же. Причина — названа выше; плюс под живым сервисом это ещё и окно, в котором Read API отдавал бы полупустую историю как полную.

2. Подмену рабочей базы делает человек, а не команда

Файл базы держит открытым процесс сервиса. В POSIX переименование не касается уже открытого дескриптора: процесс продолжит писать в отвязанный inode, а читатели увидят новый файл — данные разойдутся молча. Документация SQLite говорит об этом прямо: переименование или удаление файла базы во время записи оставляет журнал под чужим именем и портит базу (How To Corrupt An SQLite Database File), и в форуме проекта то же короче: «никогда не безопасно переименовывать используемый файл sqlite3».

Значит безопасная подмена требует, чтобы сервис был остановлен. Остановить его команда не может: сервисом управляет docker compose снаружи, и CLI, который делает вид, что управляет, обещал бы безопасность, которой не обеспечивает. Поэтому подмена остаётся процедурой человека (task downmvtask up), а команда печатает её в отчёте буквально.

Это же совпадает с правилом проекта: спрашиваем про необратимое. Перезапись рабочей базы — ровно оно.

Отвергнуто: флаг --replace, делающий подмену сам. Причина — безопасен он только при остановленном сервисе, а проверить это изнутри нечем; флаг, безопасный лишь при невыраженном условии, хуже его отсутствия.

3. Журнал перечисляется по архиву, а учёт по нему сверяется

Перечислять только строки delivery значило бы пересобирать витрину из витрины. Тело может лежать в архиве без учётной записи: приём пишет тело на диск раньше строки в базе — намеренно, обратный порядок дал бы учтённую доставку без данных, — и на отказе вставки в internal/ingest уже записано обещание, что такое тело подберёт пересборка.

Поэтому вход пересборки — объединение двух множеств:

тело в архиве + строка delivery  → штатная доставка, метаданные из строки
тело в архиве, строки нет        → заводится заново: id из имени файла,
                                   received_at из метки ULID, bytes и sha256
                                   пересчитываются по телу
строка есть, тела нет            → считается и называется в отчёте, не отказ

Третий случай станет штатным, когда появится ретеншен архива: тела до даты проверенного экспорта срезаются, а строки живут дольше. Отказом он быть не должен уже сейчас.

Метка приёма для тела без записи берётся из ULID, а не из даты каталога: каталог даёт сутки, а порядок внутри суток важен — от него зависит наследование слоя. ULID монотонен по времени создания, а создаётся идентификатор в приёме непосредственно перед меткой received_at.

Заголовки доставки при этом не восстанавливаются: в архиве их нет вовсе. Для штатных доставок они берутся из рабочей базы; у подобранного тела их не будет, и вывод слоя для него опустится на общее правило (нет автоматизации — нечего наследовать, нет заголовка — нечем подтвердить). Это честная деградация, и она названа в спеке. Устранять её (класть заголовки в архив рядом с телом — так делает WARC) в этой дельте нельзя: правка пути приёма стоит дороже всей остальной задачи.

4. Порядок — строго (received_at, id)

Слияние точек коммутативно, но слой — нет: он функция префикса журнала. Порядок обхода каталога (filepath.Walk по датам) совпадает с хронологией только случайно, а внутри суток не даёт ничего. Сортировка по (received_at, id) доопределяет и совпадение меток: received_at усечён до секунды, и доставки в одной секунде без второго ключа шли бы в произвольном порядке.

5. Отчёт печатается человеку, оракул — отпечаток, исход — код возврата

Итог пересборки — счётчики (доставок проиграно, свёрнуто, отказов, тел без учёта, строк без тел, пропущенных файлов, объектов) и два отпечатка: рабочей витрины и пересобранной. Совпали — состояние воспроизводимо; разошлись — это либо исправленный разбор (ожидаемо), либо расхождение, которое надо смотреть. Отпечаток значений точек не раскрывает: содержимое входит в него хешем.

Отпечаток рабочей витрины снимается до проигрывания, а число доставок — до и после. Без этого оракул под живым приёмом отвечает «разошлись» всегда: любая доставка, приехавшая за время прогона, двигает рабочую витрину. Оракул, который врёт без предупреждения, перестают читать — и он не сработает ровно тогда, когда разбор действительно разойдётся.

Отдельное решение — что считать успехом. Расхождение отпечатков успехом быть не перестаёт: оно и есть смысл пересборки. А вот пустой журнал успехом не является, хотя выглядит идеально: отпечаток пустой витрины совпадает с отпечатком пустой витрины. Все умолчания подыгрывают такому запуску — конфиг необязателен, и без него пути указывают в рабочий каталог процесса, а каталог архива по этому пути пересборка не создаёт. Поэтому пустой журнал, ноль свёрнутых доставок, отмена и ошибка окружения дают ненулевой код и не печатают процедуру подмены; отказ отдельной доставки — не даёт, он штатный.

Потоки разведены: отчёт — в stdout человеческим текстом, прогресс — в stderr. Прогон на полном архиве молчит минутами, и зависший неотличим от идущего; смешивать прогресс с отчётом нельзя, иначе отчёт нельзя перенаправить. Рендер отчёта принимает io.Writer и не знает про os.Stdout — иначе проверка «отчёт не раскрывает данных о здоровье» превращается в тест на глобальном состоянии, а это единственная защита новой поверхности вывода.

Логи свёртки при этом остаются логами и пишутся slog, как при приёме: один чекпоинт на доставку, без значений точек. Запрет на имена метрик относится к отчёту, а не к логу: координаты столкновения разрешены спекой хранения явно.

6. Новый пакет internal/replay, а не метод у fold

fold отвечает за одну доставку и ничего не знает ни про каталог архива, ни про порядок. Проигрывание журнала — другая ответственность: перечислить, упорядочить, догрузить недостающий учёт, свести отчёт. Имя replay, а не reindex, потому что это половина формулы import + replay: задача про родной экспорт добавит стадию снапшота перед проигрыванием и переиспользует ту же операцию, а не заведёт вторую похожую.

7. Прогон живого архива переезжает на пересборку — второго проигрывателя не остаётся

internal/fold/replay_test.go (он же task verify:archive) сегодня проигрывает живой архив своими руками: свой обход каталога, свой порядок (сортировка путей), свой синтез учёта (все тела под одной автоматизацией). Это и есть второй проигрыватель, и его правила уже расходятся с дельтой: порядок не (received_at, id), случаев «учёт без тела» и «имя не тело» у него нет вовсе.

После появления internal/replay он продолжил бы зеленеть, проверяя путь, которым healthlog reindex не ходит. Цена ошибки здесь известна: именно этот прогон поймал дефект LastDerivedLayer — тот, из-за которого пересборка давала 1742 объекта вместо 1737 (docs/review-journal.md).

Поэтому прогон переписывается поверх replay.Run, а его утверждения остаются на месте — они и есть его ценность. Одно из них по дороге пришлось переписать: константа «174 координаты sleep_analysis» снята на 94 доставках и протухла на 116, потому что производна от размера корпуса. Утверждается теперь само свойство — координат строго больше, чем различных меток, — а измеренные числа печатаются. task verify:archive сохраняет имя и смысл, отдельной задачи «прогон пересборки» в Taskfile.yml не появляется.

Отвергнуто: (б) заменить прогон вызовом самой команды на живом архиве — тогда измеренные утверждения умирают, а остаётся «отработало без ошибки»; (в) держать оба проигрывателя — каждый будущий правщик порядка или подбора обязан править два места, а расхождение между ними не поймает никто.

8. Рабочая база открывается на чтение и без миграций

Обычное открытие (store.Open) накатывает миграции безусловно, а миграции здесь меняют и данные: та, что ввела частичный разбор, переписала parse_status у всех строк. То есть штатный путь чтения нарушал бы собственное требование «рабочую базу не трогаем», и приёмочный сценарий этого не заметил бы — отпечаток считается по объектам, а не по учёту.

Вводится отдельный конструктор чтения: без наката миграций, с проверкой версии схемы. Расхождение версий — отказ с указанием обеих, а не молчаливая миграция под работающим сервисом.

Отвергнуто: соглашение «запускать на одной версии бинаря». Договорённость с самим собой не является механизмом, а цена нарушения — DDL под живым приёмом.

9. Тождество файла назначения — по файлу, а не по строке пути

Сравнение путей строкой не отвечает на вопрос «это тот же файл»: .. в пути, симлинк, другой префикс монтирования внутри контейнера дают ту же цель при другой строке. Ошибка здесь означает проигрывание журнала прямо в живую рабочую базу — то самое необратимое, ради предотвращения которого выбрана сборка рядом. Тождество определяется свойствами файла (устройство и inode); когда файла назначения ещё нет — свойствами родительского каталога и именем.

10. Идемпотентность держится существующим слиянием, а не новым кодом

Повторный прогон не меняет состояния потому, что победитель координаты — функция множества кандидатов. Пересборка не добавляет к этому ничего своего и не имеет права: любая её собственная «оптимизация» вроде пропуска доставок по parse_status сделала бы результат зависящим от предыдущего прогона. Поэтому проигрываются все доставки, а дешевизну повтора обеспечивает хеш-детектор объекта.

Risks / Trade-offs

  • Доставки, приехавшие во время пересборки, в новый файл не попадут → они остаются в архиве и в рабочей базе; после подмены их тела окажутся телами без учётной записи, и следующий прогон их подберёт (решение 3). Штатная процедура — остановить сервис на время подмены; окно в минуты закрывают средний и глубокий проходы синхронизации, у которых окна фиксированные.
  • Остановка сервиса на время подмены — окно, в котором доставка не принимается → переживает ли «Since Last Sync» неудачную отправку, неизвестно (открытый вопрос docs/local-research.md). Поэтому на остановку полагаться нельзя, и страхует её другое: средний и глубокий проходы синхронизации работают фиксированными окнами (сутки и неделя), то есть переприсылают период целиком независимо от того, что было доставлено. Практический вывод для процедуры: подменять базу стоит минутами, а не часами, и не откладывать перезапуск.
  • Пересборка читает рабочую базу, пока сервис в неё пишет → чтение под WAL безопасно, но store.Open накатывает миграции. На актуальной схеме это no-op; на устаревшей — миграция под живым трафиком, чего команда не ожидает. Смягчение: подмена и пересборка выполняются на одной версии бинаря, как и сказано в процедуре.
  • sealed в пересобранной витрине пуст → правила его выставления ещё нет (порог глубины досчёта не выбран), так что переносить нечего. Когда правило появится, признак станет функцией от часа и воспроизведётся сам. Пока это означает: отпечатки разойдутся, если кто-то выставил sealed руками.
  • Время прогона растёт линейно по архиву → 2 ГБ за квартал, чтение и разбор каждого тела. Для ручной операции приемлемо; порционность («разбивать реплей на куски») из prior art не берём — она нужна миллиардам событий, а не сотням тел.
  • Отчёт печатает пути и счётчики → путей внутри ./data в отчёте достаточно, чтобы человек сделал mv, но ни имён метрик, ни значений точек в нём нет.

Migration Plan

Миграций схемы нет. Процедура применения пересобранной витрины (её же печатает команда):

task down                                 # сервис отпускает файл базы И перестаёт принимать
healthlog reindex --config ./config.toml  # собирает ./data/healthlog.db.rebuild
mv ./data/healthlog.db.rebuild ./data/healthlog.db
rm -f ./data/healthlog.db-wal ./data/healthlog.db-shm
task up

Порядок здесь существен: сервис останавливается до пересборки, а не после неё. Доставки, приехавшие за время прогона, в собранный файл не попадут, и подмена стёрла бы их учёт вместе с заголовками. Команда это ловит — печатает разницу числа доставок и в таком случае процедуру подмены не печатает вовсе, — но платить за это лишним прогоном не нужно. Пересборка без подмены (сверка отпечатков) при живом сервисе, наоборот, безопасна и полезна.

Откат: рабочая база не тронута до mv, поэтому откат — не делать mv. После mv откат — повторная пересборка из того же архива: журнал не изменился.

Open Questions

  • Заголовки доставки в архиве не лежат, поэтому пересборка «с нуля», без рабочей базы, деградирует по выводу слоя. Класть ли рядом с телом его заголовки (как WARC) — отдельная задача беклога, не эта дельта.
  • Нужен ли режим «догнать только неразобранное» для фонового подбора pending — это половина задачи «разнести ответ приёма и свёртку»; здесь не решается, но replay даёт ей готовую операцию.