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` наполняют разные слои и
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
|
||||
## высокий
|
||||
- [Тренировки и секции с собственными id](trenirovki-i-zapisi.md) — Тренировки с геотреком и состояние разума приходят, но не разбираются — без них не закрыть ни трекер, ни агента-медика
|
||||
- [Пересборка хранилища из сырого архива](reindex-iz-arhiva.md) — Ошибка разбора без пересборки становится потерей данных — исправленный код не применится к уже разобранному
|
||||
- [Измеренный род агрегации и каталог разрезов](rod-agregacii-i-katalog.md) — Без рода метрики свёртка в ответе неотличима от угадывания — а суммировать нижний слой значит завысить втрое
|
||||
- [Read API: точки, выбор слоя, свёртка по сетке](read-api-tochki.md) — Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может
|
||||
- [OpenAPI-спека и Swagger UI](openapi-swagger.md) — Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате
|
||||
@@ -41,6 +40,7 @@
|
||||
- [Умолчания конфига указывают на прежнюю раскладку](umolchaniya-konfiga-data.md) — Запуск без конфига заведёт пустую базу в корне рядом с настоящей — тихая ловушка
|
||||
- [Счётчики слияния переживают ротацию логов](nablyudenie-za-sliyaniem-v-bd.md) — единственный след несравнимых наборов — строка WARN в docker-логе с ротацией 3×10 МБ: событие может произойти и не оставить ничего
|
||||
- [Цена слияния на широкой доставке](cena-sliyaniya-na-shirokoj-dostavke.md) — 63 МБ на одной координате держат транзакцию 5.15 с при busy_timeout 5 с — соседние доставки уходят в failed
|
||||
- [Заголовки доставки в архиве рядом с телом](zagolovki-dostavki-v-arhive.md) — Заголовки живут только в базе — потеря базы навсегда ломает вывод слоя при пересборке
|
||||
|
||||
## низкий
|
||||
- [Устаревание нижнего слоя после экспорта](ustarevanie-nizhnego-sloya.md) — Нижний слой растёт на ~100 тысяч координат в сутки, а после экспорта Apple он избыточен
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
# Пересборка хранилища из сырого архива
|
||||
|
||||
**Приоритет:** высокий
|
||||
|
||||
Разбор пишется по реальным данным и будет ошибаться — это норма, а не риск.
|
||||
Риск в другом: без пересборки ошибка разбора становится потерей данных —
|
||||
исправленный код не применится к тому, что уже разобрано неверно.
|
||||
|
||||
Пересчёт по всей истории сразу ещё и **точнее** приёма: вывод слоя и род
|
||||
агрегации на полном ряду доставок надёжнее, чем на одной.
|
||||
|
||||
Проектировать это надо сразу как **свёртку по журналу**, а не как разовую
|
||||
утилиту: состояние есть `import(снапшот экспорта) + replay(доставки после его
|
||||
даты)`, и пересборка из архива — вырожденный случай с пустым снапшотом. Тогда
|
||||
`reindex` и `import` окажутся одной операцией с разным входом, а не двумя
|
||||
похожими.
|
||||
|
||||
Отсюда требование, которое легко упустить: **свёртка обязана быть
|
||||
детерминированной.** Проигрывание должно давать то же состояние, что приём в
|
||||
реальном времени. Слияние «выигрывает более полная точка» коммутативно, но две
|
||||
одинаково полные точки с разными значениями разрешает порядок — значит
|
||||
воспроизведение идёт строго по `received_at`, а не по порядку файлов в каталоге.
|
||||
|
||||
Готово, когда пересборка с нуля даёт состояние, совпадающее с накопленным
|
||||
приёмом, и повторный прогон ничего не меняет.
|
||||
|
||||
Связано: план → шаг «Разбор и хранилище», `docs/architecture.md` → «Сырой архив».
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# Заголовки доставки в архиве рядом с телом
|
||||
|
||||
**Приоритет:** средний
|
||||
|
||||
Состояние объявлено свёрткой по журналу, а журналом — сырой архив. Но в архиве
|
||||
лежит только **тело**: заголовки запроса (`automation-id`,
|
||||
`automation-aggregation`, `Accept-Language` и всё незадокументированное) живут
|
||||
единственной копией — в колонке `delivery.headers`.
|
||||
|
||||
Отсюда дыра, которую пересборка обнажила, а не создала. `healthlog reindex`
|
||||
читает учёт из рабочей базы именно потому, что восстановить заголовки неоткуда.
|
||||
Пока база цела, это работает. Если базу потерять, весь журнал становится
|
||||
«телами без учётной записи»: `automation-id` пуст, наследовать слой не от чего,
|
||||
заголовок не подтверждает ничего — и доставки без плотных метрик не сохранятся
|
||||
никогда, сколько ни пересобирай. То есть «пересобираемо из архива» верно с
|
||||
оговоркой, которой в инварианте нет.
|
||||
|
||||
Prior art прямой: **WARC** (формат веб-архивов) хранит запрос вместе с его
|
||||
заголовками именно потому, что тело без метаданных запроса события не
|
||||
воспроизводит. Смотреть у него стоит на устройство записи «заголовки + тело» и
|
||||
на то, что заголовки лежат рядом текстом, а не в отдельной базе.
|
||||
|
||||
Развилка формы (решать при взятии, не сейчас):
|
||||
|
||||
- заголовки внутрь того же `.json.gz` отдельным первым объектом — одна запись и
|
||||
одна операция, но файл перестаёт быть «телом как пришло»;
|
||||
- файл-спутник `<ulid>.headers.json` — тело остаётся дословным, зато на доставку
|
||||
два файла и два fsync, а атомарность пары надо обеспечивать самому;
|
||||
- отдельный журнал заголовков (файл на сутки, дописыванием) — дешевле всего по
|
||||
операциям, но появляется третья сущность.
|
||||
|
||||
Цена ошибки высокая: правится **путь приёма**, а доставка, не попавшая в архив,
|
||||
теряется навсегда. Значит профиль ревью — `deep`, и менять надо так, чтобы
|
||||
старые тела без заголовков продолжали читаться.
|
||||
|
||||
Готово, когда пересборка на архиве, у которого рабочей базы нет вовсе, даёт то
|
||||
же состояние, что пересборка с базой.
|
||||
|
||||
Связано: `docs/architecture.md` → «Сырой архив и восстановление состояния»,
|
||||
`internal/replay`.
|
||||
+9
-8
@@ -15,13 +15,14 @@
|
||||
недифференцированной кучей. Блокеры, накопившиеся из ревью, разобраны — их в
|
||||
беклоге ноль.
|
||||
|
||||
Дальше — **`reindex`**, и он сейчас срочнее остального остатка разбора. После
|
||||
миграции 00005 доставки числятся `pending`, а подобрать их некому: код
|
||||
пересборки не написан. Данные целы (тела в архиве, объекты в витрине), но
|
||||
учёт честно говорит «этим разбором не смотрели», и так будет, пока пересборки
|
||||
нет. Тем же кодом закрывается половина задачи «разнести ответ и свёртку».
|
||||
**`reindex` сделан**: журнал проигрывается в свежую витрину, отпечатки
|
||||
сравниваются, повторный прогон ничего не меняет. Доставки, числящиеся `pending`
|
||||
после миграции 00005, подбираются им же — но применяется результат подменой
|
||||
базы, а её делает человек при остановленном сервисе. Тем же кодом закрывается
|
||||
половина задачи «разнести ответ и свёртку»: проигрывание журнала теперь готовая
|
||||
операция.
|
||||
|
||||
Потом — остаток разбора: тренировки и записи со своими `id` (это половина
|
||||
Дальше — остаток разбора: тренировки и записи со своими `id` (это половина
|
||||
потока: `workouts` и `stateOfMind` принимаются и хранятся, но не разбираются),
|
||||
словарь категориальных значений.
|
||||
|
||||
@@ -32,8 +33,8 @@
|
||||
|
||||
- [x] **1. Каркас.**
|
||||
- [x] **2. Приём без разбора.** ← **подключаем телефон по локальной сети**
|
||||
- [~] **3. Разбор и хранилище.** Метрики — сделано; тренировки и записи со
|
||||
своими `id`, `reindex` и словарь категориальных значений — нет.
|
||||
- [~] **3. Разбор и хранилище.** Метрики и `reindex` — сделано; тренировки и
|
||||
записи со своими `id`, словарь категориальных значений — нет.
|
||||
- [ ] **4. Каталог и род агрегации.**
|
||||
- [ ] **5. Read API.**
|
||||
- [ ] **6. Самоописание.**
|
||||
|
||||
@@ -47,3 +47,29 @@
|
||||
обязан иметь границу по `received_at` разбираемой доставки. Тест сходимости
|
||||
на живом архиве (`internal/fold/replay_test.go`) остаётся постоянным —
|
||||
именно он это поймал.
|
||||
|
||||
## 2026-08-02 — прогон живого архива был красным и об этом никто не знал
|
||||
|
||||
- **Где:** `internal/fold/replay_test.go` (перенесён в `internal/replay/archive_test.go`)
|
||||
- **Симптом:** первый же запуск `task verify:archive` в задаче про пересборку
|
||||
дал `координат sleep_analysis 222, измерено 174`. Проверено прогоном прежней
|
||||
редакции теста на том же архиве: она даёт ровно те же 222, 2049 объектов и тот
|
||||
же отпечаток — значит тест покраснел не от изменений задачи, а сам, когда
|
||||
архив дорос с 94 доставок до 116.
|
||||
- **Причина:** утверждение было пришпилено к **числу, производному от корпуса**
|
||||
(174 координаты сна). Корпус растёт с каждой доставкой, то есть константа
|
||||
протухает по расписанию телефона. Проверяемое свойство при этом другое и от
|
||||
размера корпуса не зависит: ключ по интервалу не схлопывает записи до ключа
|
||||
по метке (222 координаты против 218 меток).
|
||||
- **Почему не поймали:** прогон живого архива намеренно не входит в `task gate`
|
||||
(минута работы, данные есть только на этой машине). У проверки, которую гейт
|
||||
не гоняет, краснота никому не видна — она обнаруживается только следующей
|
||||
задачей, которая до неё дотянется. Ни один проход ревью прогон не запускал:
|
||||
проходы читают код, а не гоняют опциональные команды.
|
||||
- **Что меняем:** утверждение переписано на само свойство (координат строго
|
||||
больше, чем различных меток), измеренные числа остались в `t.Logf`. Правило
|
||||
общее и годится в конвенции: **в проверке на живом корпусе нельзя утверждать
|
||||
число, производное от размера корпуса** — утверждать надо инвариант, а число
|
||||
печатать. Гейт при этом не трогаем: цена ежедневной минуты выше цены такой
|
||||
протухшей константы, а после этой задачи прогон стал ещё и единственным, кто
|
||||
проверяет настоящий проигрыватель журнала.
|
||||
|
||||
Reference in New Issue
Block a user