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

347 lines
31 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.
## 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` даёт ей готовую операцию.