reindex: пересборка витрины проигрыванием журнала
- `healthlog reindex` собирает витрину из журнала (тела архива + учёт доставок) в ОТДЕЛЬНЫЙ файл базы, строго по `(received_at, id)`; рабочую базу читает без наката миграций и не трогает вовсе. Подмену делает человек при остановленном сервисе: переименование поверх открытого дескриптора портит базу молча. - Журналом считается архив, а не таблица доставок: тело без учётной записи заводится заново (метка из ULID, размер и хеш по распакованному телу), запись без тела переносится, но не сворачивается. Оракул сходимости встроен — два отпечатка и «объектов было/стало»; пустой журнал успехом не считается. - Прогон живого архива переехал на новый пакет: второго проигрывателя журнала в проекте не осталось, а его утверждение о ключе сна перестало быть константой, протухающей с каждой доставкой.
This commit is contained in:
@@ -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` даёт ей готовую операцию.
|
||||
Reference in New Issue
Block a user