reindex: пересборка витрины проигрыванием журнала
- `healthlog reindex` собирает витрину из журнала (тела архива + учёт доставок) в ОТДЕЛЬНЫЙ файл базы, строго по `(received_at, id)`; рабочую базу читает без наката миграций и не трогает вовсе. Подмену делает человек при остановленном сервисе: переименование поверх открытого дескриптора портит базу молча. - Журналом считается архив, а не таблица доставок: тело без учётной записи заводится заново (метка из ULID, размер и хеш по распакованному телу), запись без тела переносится, но не сворачивается. Оракул сходимости встроен — два отпечатка и «объектов было/стало»; пустой журнал успехом не считается. - Прогон живого архива переехал на новый пакет: второго проигрывателя журнала в проекте не осталось, а его утверждение о ключе сна перестало быть константой, протухающей с каждой доставкой.
This commit is contained in:
+86
-3
@@ -213,6 +213,8 @@ HRV); у накопительных — только `date`. Поэтому то
|
||||
| `archive` | сырой архив: запись тела, чтение для reindex, ретеншен |
|
||||
| `hae` | разбор формата HAE, канонизация, хеш содержимого |
|
||||
| `ingest` | use-case приёма, общий для HTTP и CLI `import` |
|
||||
| `fold` | свёртка одной доставки в часовые объекты |
|
||||
| `replay` | проигрывание журнала в витрину: состав, порядок, отчёт |
|
||||
| `store` | SQLite: доставки, часовые объекты, тренировки, записи |
|
||||
| `httpapi` | приём и read API |
|
||||
|
||||
@@ -319,7 +321,85 @@ HRV); у накопительных — только `date`. Поэтому то
|
||||
|
||||
**`reindex` и `import` — одна операция, а не две.** Восстановление это импорт
|
||||
снапшота плюс проигрывание хвоста; отдельной «пересборки из архива» не
|
||||
существует, она просто вырожденный случай с пустым снапшотом.
|
||||
существует, она просто вырожденный случай с пустым снапшотом. Проигрывание
|
||||
живёт в `internal/replay`; `healthlog import` добавит стадию снапшота **перед**
|
||||
ним, а не заведёт вторую похожую операцию.
|
||||
|
||||
**Журналом считается архив, а не таблица доставок.** Перечислять строки
|
||||
`delivery` значило бы пересобирать витрину из витрины. Тело может лежать в
|
||||
архиве без учётной записи: приём кладёт его на диск раньше строки в базе
|
||||
(обратный порядок дал бы учтённую доставку без данных), и отказ на вставке
|
||||
оставляет тело без учёта — такое тело пересборка заводит заново, восстанавливая
|
||||
метку приёма из ULID, а размер и хеш пересчитывая по распакованному телу.
|
||||
Обратный случай — строка без тела — станет штатным вместе с ретеншеном и потому
|
||||
считается, а не роняет прогон.
|
||||
|
||||
**Заголовков доставки в архиве нет**, и это named предел модели: они живут
|
||||
только в `delivery`, поэтому пересборка читает рабочую базу, а полная потеря
|
||||
базы деградирует вывод слоя навсегда. Закрывается это тем, что заголовки надо
|
||||
класть в архив рядом с телом (так делает WARC) — отдельная задача беклога.
|
||||
|
||||
#### Пересборка идёт в отдельный файл, а подмену делает человек
|
||||
|
||||
Пересборка обязана начинаться с **пустой** витрины: точки из объекта не
|
||||
удаляются никогда, поэтому проигрывание поверх накопленного оставило бы в ней
|
||||
результат прежнего, неверного разбора — то есть не сделало бы того, ради чего
|
||||
она существует.
|
||||
|
||||
Начать с пустой можно двумя способами, и выбран второй.
|
||||
|
||||
- **Очистить рабочую витрину и проиграть в неё же** — отвергнуто. Единственная
|
||||
необратимая операция всей задачи (`DELETE FROM bucket`) выполнялась бы **до**
|
||||
того, как станет известно, удалась ли пересборка; отказ на середине оставлял
|
||||
бы витрину пустой наполовину в состоянии, неотличимом от нормального.
|
||||
- **Собрать рядом и подменить** — взято. Это blue-green rebuild проекции,
|
||||
стандартный приём event sourcing («вместо усечения существующей модели строим
|
||||
новую в параллельном хранилище и переключаем чтение»); той же формы `_reindex`
|
||||
с переключением алиаса в Elasticsearch и собственный `VACUUM INTO` SQLite.
|
||||
Отказ становится бесплатным: рабочая база не тронута, промежуточный файл
|
||||
удаляется.
|
||||
- **Теневая таблица в той же базе** (`bucket_new` → переименование в
|
||||
транзакции) — отвергнуто дважды. Имя `bucket` зашито литералом во весь слой
|
||||
записи, то есть вариант требует параметризовать таблицей самый опасный код
|
||||
проекта ради операции раз в полгода; и он не решает того, ради чего
|
||||
затевался, — живой приём во время пересборки пишет в **старую** таблицу, и
|
||||
при подмене его точки пропадают.
|
||||
|
||||
**Подмену рабочей базы делает человек, и это не лень.** Файл базы держит
|
||||
открытым процесс сервиса, а переименование не касается уже открытого
|
||||
дескриптора: процесс продолжит писать в отвязанный inode, читатели увидят новый
|
||||
файл, данные разойдутся молча. Документация SQLite называет переименование
|
||||
используемого файла прямой причиной порчи базы. Безопасная подмена требует
|
||||
остановленного сервиса, а остановить его команда не может — сервисом управляет
|
||||
окружение снаружи, и CLI, делающий вид, что управляет, обещал бы безопасность,
|
||||
которой не обеспечивает. Поэтому команда печатает процедуру, а выполняет её
|
||||
человек:
|
||||
|
||||
```
|
||||
task down
|
||||
healthlog reindex --config ./config.toml
|
||||
mv ./data/healthlog.db.rebuild ./data/healthlog.db
|
||||
rm -f ./data/healthlog.db-wal ./data/healthlog.db-shm
|
||||
task up
|
||||
```
|
||||
|
||||
Пересборка при этом **читает рабочую базу без наката миграций**: обычное
|
||||
открытие мигрирует безусловно, а миграции меняют и данные (та, что ввела
|
||||
частичный разбор, переписала `parse_status` у всех строк). Расхождение версии
|
||||
схемы — отказ с указанием обеих, а не миграция под работающим сервисом.
|
||||
|
||||
**Оракул сходимости встроен в команду**: печатаются отпечаток рабочей витрины и
|
||||
отпечаток пересобранной, снятые так, что первый берётся **до** проигрывания —
|
||||
иначе под живым приёмом он движется, и ответ «разошлись» не значил бы ничего.
|
||||
Пустой журнал при этом успехом не считается: отпечаток пустой витрины совпадает
|
||||
с отпечатком пустой витрины, то есть выглядит идеальной сходимостью, а человек,
|
||||
выполнивший напечатанную процедуру, заменил бы накопленное пустым.
|
||||
|
||||
Что пересборка **не** переносит: признак `sealed` (правила его выставления ещё
|
||||
нет, переносить нечего) и производные от разбора поля учёта — `parse_status`,
|
||||
`points`, `derived_layer`, `uncovered_sections`. Последнее не косметика:
|
||||
доставка, чей повторный разбор отказал, отдала бы в наследование слой прежнего
|
||||
разбора, и витрина снова стала бы функцией предыдущего прогона, а не журнала.
|
||||
|
||||
#### Что не восстанавливается, и это сказано вслух
|
||||
|
||||
@@ -500,8 +580,11 @@ hour метки выровнены на час heart_rate 00:00:00
|
||||
`sleep_analysis_summary`, — и слой у сводки не выводится, а фиксирован как
|
||||
`day`. Хранение остаётся дословным: разводятся имена, а не содержимое.
|
||||
|
||||
Пересчёт при `reindex` идёт по всей истории сразу и потому точнее, чем на
|
||||
приёме: это ещё одна причина держать сырой архив.
|
||||
Пересборка применяет к уже разобранному **исправленный** разбор — это и есть
|
||||
причина держать сырой архив. Точнее она именно этим, а не тем, что видит более
|
||||
длинный ряд: слой обязан оставаться функцией **префикса** журнала, и наследование
|
||||
«от последней доставки вообще» уже ловили дефектом (1737 объектов против 1742,
|
||||
`docs/review-journal.md`).
|
||||
|
||||
Следствие: **пересечение наборов метрик между автоматизациями перестаёт быть
|
||||
проблемой**. Минутный и несуммированный `heart_rate` наполняют разные слои и
|
||||
|
||||
Reference in New Issue
Block a user