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,346 @@
## 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](https://www.architecture-weekly.com/p/rebuilding-event-driven-read-models),
[Projections and Read Models](https://event-driven.io/en/projections_and_read_models_in_event_driven_architecture/));
тем же приёмом работает `_reindex` + переключение алиаса в Elasticsearch, и та
же форма у собственного `VACUUM INTO` SQLite — «собери целую копию в новый
файл».
Причина предпочесть его здесь конкретнее общей моды: усечение рабочей витрины —
единственная **необратимая** операция во всей задаче, и она наступает **до**
того, как станет известно, что пересборка вообще удалась. Отказ на середине
(битое тело, отменённый контекст, кончившееся место) оставил бы витрину пустой
наполовину, причём в состоянии, которое ничем не отличается от нормального.
Сборка рядом делает отказ бесплатным: рабочая база не тронута, временный файл
удаляется.
Отвергнуто: очистка рабочей витрины с проигрыванием в неё же. Причина —
названа выше; плюс под живым сервисом это ещё и окно, в котором Read API
отдавал бы полупустую историю как полную.
### 2. Подмену рабочей базы делает человек, а не команда
Файл базы держит открытым процесс сервиса. В POSIX переименование не касается
уже открытого дескриптора: процесс продолжит писать в отвязанный inode, а
читатели увидят новый файл — данные разойдутся молча. Документация SQLite
говорит об этом прямо: переименование или удаление файла базы во время записи
оставляет журнал под чужим именем и **портит базу**
([How To Corrupt An SQLite Database File](https://www.sqlite.org/howtocorrupt.html)),
и в форуме проекта то же короче: «никогда не безопасно переименовывать
используемый файл sqlite3».
Значит безопасная подмена требует, чтобы сервис был остановлен. Остановить его
команда не может: сервисом управляет docker compose снаружи, и CLI, который
делает вид, что управляет, обещал бы безопасность, которой не обеспечивает.
Поэтому подмена остаётся процедурой человека (`task down``mv``task 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` даёт ей готовую операцию.