reindex: пересборка витрины проигрыванием журнала
- `healthlog reindex` собирает витрину из журнала (тела архива + учёт доставок) в ОТДЕЛЬНЫЙ файл базы, строго по `(received_at, id)`; рабочую базу читает без наката миграций и не трогает вовсе. Подмену делает человек при остановленном сервисе: переименование поверх открытого дескриптора портит базу молча. - Журналом считается архив, а не таблица доставок: тело без учётной записи заводится заново (метка из ULID, размер и хеш по распакованному телу), запись без тела переносится, но не сворачивается. Оракул сходимости встроен — два отпечатка и «объектов было/стало»; пустой журнал успехом не считается. - Прогон живого архива переехал на новый пакет: второго проигрывателя журнала в проекте не осталось, а его утверждение о ключе сна перестало быть константой, протухающей с каждой доставкой.
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-08-02
|
||||
@@ -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` даёт ей готовую операцию.
|
||||
@@ -0,0 +1,69 @@
|
||||
## Why
|
||||
|
||||
Разбор пишется по реальным данным и будет ошибаться — это норма. Без
|
||||
пересборки ошибка разбора становится потерей данных: исправленный код не
|
||||
применится к тому, что уже разобрано неверно, а точки из объекта не удаляются
|
||||
никогда. Сегодня журнал есть (116 тел в архиве), а кода, который его
|
||||
проигрывает, нет: после миграции 00005 доставки числятся `pending`, и подобрать
|
||||
их некому.
|
||||
|
||||
Оговорка, которую легко прочитать наоборот: пересборка точнее приёма **не тем,
|
||||
что видит более длинный ряд**. Слой обязан быть функцией префикса журнала, и
|
||||
наследование «от последней доставки вообще» уже ловили дефектом — 1737 объектов
|
||||
против 1742. Точнее она ровно тем, что применяет **исправленный** разбор к
|
||||
тому, что уже разобрано неверно.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Новая подкоманда `healthlog reindex`: собирает витрину из журнала —
|
||||
`import(снапшот) + replay(доставки по received_at)` — и пишет её в **отдельный
|
||||
файл базы**, не трогая рабочую. Снапшот в этой дельте пуст: `import` появится
|
||||
вместе с задачей про родной экспорт Apple, и та встроится сюда же, а не
|
||||
заведёт вторую операцию.
|
||||
- Журналом считается **архив**, а не таблица доставок: тела, у которых учётной
|
||||
записи нет (приём успел записать тело и упал на вставке строки), заводятся
|
||||
заново по имени файла. Это обещание, уже записанное в `internal/ingest`.
|
||||
- Порядок проигрывания — строго `(received_at, id)`, а не порядок обхода
|
||||
каталога: слой наследуется от предшествующей доставки той же автоматизации,
|
||||
и порядок входит в результат.
|
||||
- Оракул сходимости встроен в команду: отпечаток пересобранной витрины
|
||||
печатается рядом с отпечатком рабочей, и команда прямо говорит, совпали они
|
||||
или нет. Отпечаток значений точек не раскрывает.
|
||||
- Подмена рабочей базы пересобранной **остаётся за человеком** и в команду не
|
||||
входит: сервис держит открытый дескриптор, и `rename` поверх него оставил бы
|
||||
процесс писать в отвязанный inode — молча.
|
||||
- Границы, названные вслух: `sealed` в пересобранной витрине пуст (правила его
|
||||
выставления ещё нет), а заголовки доставок берутся из рабочей базы — в архиве
|
||||
их нет вовсе.
|
||||
- Прогон живого архива (`task verify:archive`) переезжает на новый код: сегодня
|
||||
он **второй проигрыватель журнала** со своим порядком и своим синтезом учёта,
|
||||
и после появления настоящей пересборки зеленел бы, проверяя путь, которым
|
||||
команда не ходит.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `reindex`: пересборка витрины проигрыванием журнала — состав журнала,
|
||||
порядок, детерминированность, отчёт и его оракул, граница «что не
|
||||
восстанавливается».
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
Изменённых нет. Правило «тело без учётной записи заводится заново» могло бы
|
||||
показаться правилом учёта, но оно описывает состав журнала при проигрывании и
|
||||
живёт в `reindex`; дублировать его в `storage` значило бы завести два места, где
|
||||
сказано одно и то же. Схема БД, слияние точек и вывод слоя не меняются: вся
|
||||
дельта — новый потребитель существующей свёртки.
|
||||
|
||||
## Impact
|
||||
|
||||
- Новый пакет `internal/replay` — проигрывание журнала поверх существующего
|
||||
`internal/fold`; собственного разбора и собственного слияния не заводит.
|
||||
- Новый файл `cmd/healthlog/reindex.go`, строка в `main.go`.
|
||||
- `internal/store`: перечисление доставок в порядке журнала, очистка витрины,
|
||||
чтение отпечатка (уже есть).
|
||||
- `internal/ident`: время создания из ULID — метка приёма для тела без учётной
|
||||
записи.
|
||||
- Схема БД не меняется, миграций нет.
|
||||
- `README.md` (`reindex` перестаёт быть «в планах»), `docs/architecture.md`
|
||||
(почему подмена базы не автоматизируется), `docs/plan.md`, `docs/backlog`.
|
||||
@@ -0,0 +1,430 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Пересборка витрины проигрыванием журнала
|
||||
|
||||
Система SHALL уметь собрать витрину заново, проиграв журнал целиком:
|
||||
`import(снапшот) + replay(доставки)`. Стадия снапшота в этой дельте пуста —
|
||||
пересборка из архива есть вырожденный случай с пустым снапшотом, — и отдельной
|
||||
операции «пересборка из архива» рядом с импортом экспорта заводить MUST NOT.
|
||||
|
||||
Проигрываться SHALL **все** доставки журнала, а не только те, чей
|
||||
`parse_status` говорит о неразобранности. Отбор по учётному статусу сделал бы
|
||||
результат функцией предыдущего прогона, а не журнала; дешевизну повторного
|
||||
проигрывания обеспечивает хеш-детектор объекта, а не пропуск доставок.
|
||||
|
||||
Пересборка собственного разбора и собственного слияния иметь MUST NOT: она
|
||||
зовёт тот же код, что и приём, по идентификатору доставки, и тело читает из
|
||||
архива тем же путём, с тем же пределом размера распакованного тела. Второй путь
|
||||
разбора разошёлся бы с первым молча, а другой предел означал бы, что тело,
|
||||
принятое со `200`, вечно отказывает на каждой пересборке.
|
||||
|
||||
#### Scenario: Пересобранная витрина совпадает с накопленной приёмом
|
||||
|
||||
- **GIVEN** рабочая витрина накоплена тем же разбором, приём во время
|
||||
накопления шёл последовательно, и за время пересборки новых доставок не
|
||||
приезжало
|
||||
- **WHEN** журнал проигрывается заново с пустой витрины
|
||||
- **THEN** отпечаток пересобранной витрины совпадает с отпечатком накопленной
|
||||
|
||||
#### Scenario: Повторная пересборка ничего не меняет
|
||||
|
||||
- **WHEN** пересборка того же журнала выполняется второй раз
|
||||
- **THEN** отпечаток витрины не меняется
|
||||
|
||||
#### Scenario: Разобранная доставка проигрывается наравне с неразобранной
|
||||
|
||||
- **WHEN** в журнале есть доставки со статусом `parsed` и со статусом `pending`
|
||||
- **THEN** проигрываются обе
|
||||
|
||||
### Requirement: Порядок проигрывания задаётся журналом
|
||||
|
||||
Система SHALL проигрывать доставки строго в порядке `(received_at, id)`, а не
|
||||
в порядке обхода каталога архива.
|
||||
|
||||
Порядок входит в результат: слой доставки без плотных метрик наследуется от
|
||||
**предшествующей** доставки той же автоматизации, то есть слой есть функция
|
||||
префикса журнала. Обход каталога совпадает с хронологией только по датам
|
||||
каталогов и внутри суток не упорядочивает ничего.
|
||||
|
||||
Второй ключ обязателен, а не для красоты: `received_at` хранится с секундной
|
||||
точностью, и доставки одной секунды без него шли бы в неопределённом порядке —
|
||||
а значит два прогона одного журнала могли бы разойтись.
|
||||
|
||||
Проигрывание SHALL быть последовательным. Распараллеливать его MUST NOT: слой
|
||||
есть функция префикса журнала, а запись часового объекта — чтение, слияние и
|
||||
запись обратно.
|
||||
|
||||
#### Scenario: Порядок не зависит от раскладки файлов в архиве
|
||||
|
||||
- **WHEN** тела одного журнала лежат в архиве так, что порядок обхода каталога
|
||||
не совпадает с хронологией приёма
|
||||
- **THEN** доставка без плотных метрик получает слой предшествующей ей по
|
||||
`received_at` доставки той же автоматизации, а не слой соседа по каталогу
|
||||
|
||||
#### Scenario: Доставки одной секунды упорядочены идентификатором
|
||||
|
||||
- **WHEN** две доставки имеют одинаковый `received_at`
|
||||
- **THEN** порядок между ними задаётся идентификатором и одинаков в каждом
|
||||
прогоне
|
||||
|
||||
### Requirement: Журналом считается архив, а не таблица доставок
|
||||
|
||||
Система SHALL брать состав журнала из **сырого архива**, сверяя его с учётом
|
||||
доставок, а не перечислять только строки `delivery`. Иначе витрина
|
||||
пересобиралась бы из витрины.
|
||||
|
||||
Тело, у которого учётной записи нет, SHALL заводиться заново и проигрываться
|
||||
наравне с остальными: приём кладёт тело на диск раньше строки в базе — обратный
|
||||
порядок дал бы учтённую доставку без данных, — поэтому отказ на вставке строки
|
||||
оставляет тело в архиве без учёта.
|
||||
|
||||
Для такого тела идентификатор берётся из имени файла, метка приёма — из времени
|
||||
создания в ULID, приведённого к **UTC**. Дата каталога меткой служить MUST NOT:
|
||||
она задаёт сутки, а порядок нужен внутри суток. Размер и хеш пересчитываются по
|
||||
**распакованному** телу — так же, как их считает приём; иначе в тех же колонках
|
||||
появились бы значения другой природы.
|
||||
|
||||
Заголовки доставки при этом восстановлены быть не могут — **в архиве их нет**.
|
||||
Подобранное тело SHALL проигрываться без них, с честной деградацией вывода
|
||||
слоя: наследовать не от чего и подтверждать нечем.
|
||||
|
||||
Строка учёта, у которой тела в архиве нет, отказом быть MUST NOT: она станет
|
||||
штатной, когда появится ретеншен архива. Такая строка SHALL считаться и
|
||||
называться в отчёте.
|
||||
|
||||
Файл архива, не подходящий под форму тела (чужое расширение, остаток `*.tmp`
|
||||
от прерванной записи, имя не разбирается как ULID), SHALL пропускаться и
|
||||
учитываться счётчиком. Молчаливый пропуск недопустим: тело есть, а в отчёте его
|
||||
нет. Тело, чей идентификатор уже встретился в другом каталоге, SHALL
|
||||
пропускаться тем же порядком: вторая запись журнала с тем же ключом сорвала бы
|
||||
весь прогон, то есть один посторонний файл лишал бы пересборки всё остальное.
|
||||
|
||||
Каждый пропуск, повтор и неудачный подбор SHALL оставлять запись в логе с путём
|
||||
файла — путь в архиве это дата и идентификатор, измерений в нём нет. Без неё
|
||||
счётчик в отчёте не на что раскрыть, а отчёт при этом отсылает человека
|
||||
разбираться по логу.
|
||||
|
||||
Ошибка **чтения** каталога архива, включая отсутствие самого корня, SHALL быть
|
||||
отказом всей пересборки, а не пустым журналом. Нечитаемый каталог означает
|
||||
«неизвестно, есть ли там тела», а не «тел нет». Каталог архива пересборка
|
||||
создавать MUST NOT — создание превратило бы запуск не из того каталога в
|
||||
успешный прогон по пустому журналу.
|
||||
|
||||
#### Scenario: Тело без учётной записи подбирается
|
||||
|
||||
- **WHEN** в архиве лежит тело, для которого строки `delivery` нет
|
||||
- **THEN** доставка заводится заново с идентификатором из имени файла и меткой
|
||||
приёма из ULID в UTC
|
||||
- **AND** её размер и хеш посчитаны по распакованному телу
|
||||
- **AND** её точки попадают в витрину
|
||||
|
||||
#### Scenario: Учётная запись без тела не роняет пересборку
|
||||
|
||||
- **WHEN** у строки `delivery` нет тела в архиве
|
||||
- **THEN** пересборка продолжается
|
||||
- **AND** факт учитывается счётчиком в отчёте
|
||||
- **AND** сама запись переносится в базу назначения, но не сворачивается
|
||||
|
||||
#### Scenario: Файл, не являющийся телом, считается отдельно
|
||||
|
||||
- **WHEN** в архиве лежит файл, чьё имя не разбирается как ULID либо чьё
|
||||
расширение не соответствует форме тела
|
||||
- **THEN** он пропускается и учитывается счётчиком, а пересборка продолжается
|
||||
|
||||
#### Scenario: Повтор идентификатора не срывает прогон
|
||||
|
||||
- **WHEN** одно и то же имя тела встречается в двух каталогах суток
|
||||
- **THEN** проигрывается первое, второе учитывается счётчиком
|
||||
- **AND** пересборка доходит до конца
|
||||
|
||||
#### Scenario: Каталог архива не читается
|
||||
|
||||
- **WHEN** корня архива нет либо подкаталог не читается
|
||||
- **THEN** команда завершается ошибкой и витрину не собирает
|
||||
|
||||
### Requirement: Пересборка не трогает рабочую базу
|
||||
|
||||
Система SHALL собирать витрину в **отдельный файл базы** и MUST NOT записывать
|
||||
в рабочую базу ничего — ни объектов, ни строк учёта, ни миграций схемы.
|
||||
|
||||
Пересборка обязана начинаться с пустой витрины: точки из объекта не удаляются
|
||||
никогда, поэтому проигрывание поверх накопленного оставило бы результат
|
||||
прежнего, неверного разбора. Но очистка рабочей витрины необратима и наступает
|
||||
**до** того, как известно, что пересборка удалась: отказ на середине (битое
|
||||
тело, отменённый контекст, кончившееся место) оставил бы витрину пустой
|
||||
наполовину в состоянии, неотличимом от нормального.
|
||||
|
||||
Рабочая база SHALL открываться **только для чтения и без наката миграций**.
|
||||
Обычное открытие накатывает миграции безусловно, а миграции меняют и данные (та,
|
||||
что ввела частичный разбор, переписала `parse_status` у всех строк) — то есть
|
||||
штатный путь чтения нарушал бы запрет выше. Хуже: свежий бинарь мигрировал бы
|
||||
схему под работающим старым сервисом.
|
||||
|
||||
Расхождение версии схемы рабочей базы с версией, которую знает бинарь, SHALL
|
||||
быть отказом с указанием обеих версий, а не поводом мигрировать.
|
||||
|
||||
#### Scenario: Рабочая база остаётся нетронутой
|
||||
|
||||
- **WHEN** пересборка отработала успешно
|
||||
- **THEN** отпечаток рабочей витрины не изменился
|
||||
- **AND** учёт доставок в рабочей базе не изменился
|
||||
- **AND** собранная витрина лежит в отдельном файле
|
||||
|
||||
#### Scenario: Отказ посреди пересборки не портит рабочую базу
|
||||
|
||||
- **WHEN** пересборка прерывается на середине журнала
|
||||
- **THEN** рабочая витрина и учёт доставок в рабочей базе остаются такими же,
|
||||
какими были
|
||||
|
||||
#### Scenario: Схема рабочей базы старше бинаря
|
||||
|
||||
- **WHEN** версия схемы рабочей базы не совпадает с версией бинаря
|
||||
- **THEN** команда завершается ошибкой, называя обе версии
|
||||
- **AND** не пишет в рабочую базу ни одной строки
|
||||
|
||||
### Requirement: Файл назначения и его жизненный цикл
|
||||
|
||||
Файл назначения по умолчанию SHALL быть соседом рабочей базы — так подмена
|
||||
остаётся переименованием внутри одной файловой системы.
|
||||
|
||||
Тождество файла назначения с рабочей базой SHALL определяться **по файлу, а не
|
||||
по строке пути**: путь через `..`, симлинк или другой префикс монтирования
|
||||
дают то же тождество при разных строках. Совпадение MUST быть отказом: иначе
|
||||
проигрывание пошло бы прямо в живую рабочую базу — ровно то, что запрещено выше.
|
||||
|
||||
Сборка SHALL идти под временным именем, а переименование в файл назначения быть
|
||||
**последним шагом успешного прогона**. При любом ином исходе — отказ, отмена,
|
||||
падение — файла по пути назначения появляться MUST NOT, а временный SHALL
|
||||
убираться вместе со спутниками журнала SQLite.
|
||||
|
||||
Полусобранная база выглядит как обычная: это ровно то состояние, ради отрицания
|
||||
которого отвергнута очистка рабочей витрины. Обломок по пути назначения ещё и
|
||||
приучил бы обходить защиту от перезаписи флагом принудительности.
|
||||
|
||||
Существующий файл назначения перезаписываться молча MUST NOT: это отказ, если
|
||||
человек явно не потребовал перезаписи. Затребованная перезапись SHALL давать ту
|
||||
же витрину, что и сборка в отсутствующий файл, — сборка всегда начинается с
|
||||
пустой витрины, а не дописывается в чужое содержимое.
|
||||
|
||||
#### Scenario: Файл назначения совпадает с рабочей базой
|
||||
|
||||
- **WHEN** файл назначения — тот же файл, что рабочая база, пусть и по другому
|
||||
пути
|
||||
- **THEN** команда завершается ошибкой и не пишет ничего
|
||||
|
||||
#### Scenario: Файл назначения уже существует
|
||||
|
||||
- **WHEN** файл назначения существует, а перезапись не затребована явно
|
||||
- **THEN** команда завершается ошибкой и существующий файл не трогает
|
||||
|
||||
#### Scenario: Затребованная перезапись даёт ту же витрину
|
||||
|
||||
- **WHEN** пересборка выполняется поверх существующего файла назначения с
|
||||
явно затребованной перезаписью
|
||||
- **THEN** отпечаток собранной витрины совпадает с отпечатком сборки того же
|
||||
журнала в отсутствующий файл
|
||||
|
||||
#### Scenario: Прерванная пересборка не оставляет файла назначения
|
||||
|
||||
- **WHEN** пересборка прерывается на середине журнала
|
||||
- **THEN** файла по пути назначения не существует
|
||||
|
||||
### Requirement: База назначения пригодна к подмене
|
||||
|
||||
База назначения SHALL нести полноценный учёт доставок, а не только объекты
|
||||
витрины: подменяется файл базы **целиком**, а не одна таблица.
|
||||
|
||||
Состав переноса нормируется явно, потому что колонки `delivery` двух разных
|
||||
родов:
|
||||
|
||||
```
|
||||
факты журнала id, received_at, automation_name, automation_id, aggregation,
|
||||
period, session_id, bytes, sha256, raw_path, headers
|
||||
← переносятся дословно
|
||||
производные parse_status, points, derived_layer, uncovered_sections
|
||||
← начинаются пустыми
|
||||
```
|
||||
|
||||
Факты журнала SHALL переноситься дословно, включая записи, тела которых в
|
||||
архиве уже нет. Заголовки восстановлению не подлежат ничем — в архиве их нет, —
|
||||
и неполный перенос уничтожил бы их первой же подменой, а с ними и вывод слоя
|
||||
для **всех** доставок, не только подобранных. Запись без тела при этом не
|
||||
сворачивается и в наследовании слоя не участвует: выведенного слоя у неё нет.
|
||||
|
||||
Производные от разбора поля MUST начинаться пустыми. Перенос `derived_layer`
|
||||
особенно опасен и незаметен: доставка, чей повторный разбор отказал (штатный
|
||||
исход, когда слой не выводится), сохранила бы слой **прежнего** разбора, и
|
||||
следующая доставка той же автоматизации унаследовала бы его. Витрина снова стала
|
||||
бы функцией предыдущего прогона, а не журнала, причём оба прогона были бы
|
||||
самосогласованы — проверка «повторная пересборка ничего не меняет» этого не
|
||||
ловит.
|
||||
|
||||
#### Scenario: Учёт переносится полностью
|
||||
|
||||
- **WHEN** пересборка завершилась
|
||||
- **THEN** число строк учёта в базе назначения равно числу строк рабочей базы
|
||||
плюс число подобранных тел
|
||||
- **AND** заголовки перенесённых доставок совпадают с рабочей базой дословно
|
||||
|
||||
#### Scenario: Слой прошлого разбора в наследование не попадает
|
||||
|
||||
- **WHEN** в рабочей базе у доставок проставлен `derived_layer`
|
||||
- **THEN** отпечаток пересобранной витрины совпадает с отпечатком пересборки
|
||||
того же журнала из учёта без проставленных слоёв
|
||||
|
||||
### Requirement: Подмену рабочей базы делает человек
|
||||
|
||||
Система SHALL оставлять замену рабочей базы пересобранной человеку и
|
||||
выполнять её сама MUST NOT.
|
||||
|
||||
Файл базы держит открытым процесс сервиса, а переименование не касается уже
|
||||
открытого дескриптора: процесс продолжит писать в отвязанный inode, читатели
|
||||
увидят новый файл, и данные разойдутся молча. Документация SQLite называет
|
||||
переименование используемого файла прямой причиной порчи базы. Безопасная
|
||||
подмена требует остановленного сервиса, а остановить его команда не может:
|
||||
сервисом управляет окружение снаружи.
|
||||
|
||||
Отчёт SHALL печатать процедуру подмены буквально — команды, а не намёк, — и
|
||||
только тогда, когда прогон признан успешным (см. «Отчёт, оракул и исход
|
||||
команды»).
|
||||
|
||||
#### Scenario: Отчёт называет процедуру подмены
|
||||
|
||||
- **WHEN** прогон признан успешным
|
||||
- **THEN** отчёт содержит путь собранного файла и команды подмены
|
||||
|
||||
### Requirement: Отчёт, оракул и исход команды
|
||||
|
||||
Система SHALL завершать пересборку отчётом, который несёт счётчики
|
||||
(проиграно, свёрнуто, отказов по классам, тел без учётной записи, строк без
|
||||
тела, пропущенных файлов, повторов, объектов **до и после**) и **два
|
||||
отпечатка** — рабочей витрины и пересобранной, — с прямым ответом, совпали они
|
||||
или нет.
|
||||
|
||||
Отказы SHALL считаться **по классам**: слой не выводится, содержимое не
|
||||
разбирается, всё прочее. Невыведенный слой есть в каждом журнале и штатен;
|
||||
общий счётчик отправлял бы человека искать дефект там, где его нет. Отдельно
|
||||
называть человеку следует только нештатные отказы.
|
||||
|
||||
Число объектов «было и стало» SHALL печататься рядом с отпечатками: отпечатки
|
||||
отвечают «да/нет», а решение о подмене необратимо, и по «да/нет» нельзя
|
||||
судить о **направлении** расхождения. Именно пара чисел — 1737 против 1742 —
|
||||
поймала прошлый дефект наследования слоя.
|
||||
|
||||
Отпечаток здесь оракул, а не украшение: число объектов к правилу разрешения
|
||||
столкновений нечувствительно — на координате всегда ровно одна точка, и правило
|
||||
выбирает, какая, а не сколько. «Объектов столько же» совпало бы и при заведомо
|
||||
сломанном правиле.
|
||||
|
||||
Отпечаток рабочей витрины SHALL сниматься **до** начала проигрывания, а число
|
||||
доставок в рабочей базе — до и после. Ненулевая разница SHALL называться в
|
||||
отчёте, и при ней процедура подмены печататься MUST NOT: доставки, приехавшие за
|
||||
время прогона, есть в рабочей базе и в архиве, но не в собранном файле, и
|
||||
подмена стёрла бы их учёт вместе с заголовками, которых в архиве нет.
|
||||
|
||||
Величины, которые не снимались, отчёт печатать MUST NOT. При отмене отпечаток
|
||||
пересобранной витрины и число доставок после прогона не измеряются вовсе —
|
||||
печатать их сравнение значило бы выдать неизмеренное за измеренное, причём в
|
||||
единственном оракуле задачи. Ожидаемые классы расхождения (новые доставки за время прогона,
|
||||
непереносимый признак запечатанного часа, исправленный разбор) SHALL называться
|
||||
отдельно от самого факта расхождения.
|
||||
|
||||
**Исход команды.** Расхождение отпечатков отказом быть MUST NOT: после
|
||||
исправления разбора оно ожидаемо и есть сам смысл пересборки. Отказ отдельной
|
||||
доставки отказом команды тоже MUST NOT быть: доставка, слой которой не
|
||||
выводится, — штатный исход.
|
||||
|
||||
Отказом команды SHALL быть: пустой журнал, отсутствие хотя бы одной свёрнутой
|
||||
доставки, отмена и любая ошибка окружения. Пустая витрина совпадает по
|
||||
отпечатку с пустой витриной, поэтому прогон по пустому журналу выглядит
|
||||
идеальной сходимостью — а все умолчания подыгрывают такому запуску: конфига
|
||||
может не быть вовсе, и тогда пути указывают в рабочий каталог процесса. Человек,
|
||||
выполнивший напечатанную процедуру, заменил бы витрину пустой.
|
||||
|
||||
Отчёт значений точек, имён метрик, имён устройств и содержимого тел содержать
|
||||
MUST NOT: отпечаток берёт содержимое хешем. Ограничение относится к отчёту в
|
||||
стандартном выводе; лог свёртки живёт по правилам спеки хранения, где координаты
|
||||
столкновения (метрика, слой, час) разрешены явно.
|
||||
|
||||
Отчёт идёт в стандартный вывод человеческим текстом. Прогресс длинного прогона
|
||||
SHALL идти в поток ошибок, а не смешиваться с отчётом: прогон на полном архиве
|
||||
молчит минутами, и зависший неотличим от идущего.
|
||||
|
||||
#### Scenario: Отчёт сравнивает отпечатки
|
||||
|
||||
- **WHEN** пересборка завершилась
|
||||
- **THEN** отчёт содержит отпечаток рабочей витрины и отпечаток пересобранной
|
||||
- **AND** прямо называет, совпали они или нет
|
||||
- **AND** называет, изменилось ли число доставок в рабочей базе за время прогона
|
||||
|
||||
#### Scenario: Расхождение отпечатков не является отказом
|
||||
|
||||
- **WHEN** отпечаток пересобранной витрины отличается от рабочей, и при этом
|
||||
хотя бы одна доставка свёрнута
|
||||
- **THEN** команда завершается успешно, а расхождение названо в отчёте
|
||||
|
||||
#### Scenario: Пустой журнал — отказ, а не идеальная сходимость
|
||||
|
||||
- **WHEN** в архиве не нашлось ни одного тела
|
||||
- **THEN** команда завершается ненулевым кодом
|
||||
- **AND** процедуры подмены не печатает
|
||||
|
||||
#### Scenario: Ни одна доставка не свернулась
|
||||
|
||||
- **WHEN** журнал непуст, но свернуть не удалось ни одной доставки
|
||||
- **THEN** команда завершается ненулевым кодом
|
||||
- **AND** процедуры подмены не печатает
|
||||
|
||||
#### Scenario: Приезд доставок за время прогона отменяет подмену
|
||||
|
||||
- **WHEN** число доставок в рабочей базе за время прогона изменилось
|
||||
- **THEN** отчёт называет разницу
|
||||
- **AND** процедуры подмены не печатает
|
||||
|
||||
#### Scenario: Отчёт после отмены не сравнивает неизмеренного
|
||||
|
||||
- **WHEN** прогон отменён
|
||||
- **THEN** отчёт не содержит ни ответа о совпадении отпечатков, ни разницы
|
||||
числа доставок
|
||||
|
||||
#### Scenario: Рабочей базы нет вовсе
|
||||
|
||||
- **WHEN** файла рабочей базы не существует
|
||||
- **THEN** пересборка идёт по одним подобранным телам
|
||||
- **AND** отчёт называет, что сверять не с чем и что заголовки доставок не
|
||||
восстанавливаются
|
||||
|
||||
#### Scenario: Отчёт не раскрывает данных о здоровье
|
||||
|
||||
- **WHEN** отчёт напечатан
|
||||
- **THEN** он не содержит ни значений точек, ни имён метрик, ни имён устройств
|
||||
|
||||
### Requirement: Отказ на одной доставке не останавливает пересборку
|
||||
|
||||
Система SHALL продолжать проигрывание, когда отдельная доставка не сворачивается
|
||||
(тело не читается, тело больше предела, слой не выводится, содержимое не
|
||||
разбирается), и учитывать такие доставки счётчиком отказов.
|
||||
|
||||
Останавливаться на первой нельзя: журнал заведомо содержит доставки, слой
|
||||
которых не выводится, — это штатный исход, а не поломка, и он не должен лишать
|
||||
пересборки остальные тела.
|
||||
|
||||
Отмена, наоборот, останавливать проигрывание SHALL: это требование прекратить
|
||||
работу, а не свойство доставки. Источник отмены SHALL быть назван: команду
|
||||
прерывает человек, и без перевода сигнала прерывания в отмену контекста
|
||||
требование к поведению по отмене недостижимо в эксплуатации — процесс умирает
|
||||
мимо всей логики. По отмене команда SHALL напечатать частичный отчёт и
|
||||
завершиться ненулевым кодом.
|
||||
|
||||
#### Scenario: Битое тело не срывает прогон
|
||||
|
||||
- **WHEN** одно из тел архива не распаковывается
|
||||
- **THEN** остальные доставки проигрываются
|
||||
- **AND** отказ учитывается счётчиком в отчёте
|
||||
|
||||
#### Scenario: Отмена прекращает проигрывание
|
||||
|
||||
- **WHEN** сигнал прерывания приходит посреди журнала
|
||||
- **THEN** проигрывание прекращается, печатается частичный отчёт
|
||||
- **AND** команда завершается ненулевым кодом
|
||||
- **AND** файла по пути назначения не остаётся
|
||||
@@ -0,0 +1,127 @@
|
||||
## 1. Опоры в существующих пакетах
|
||||
|
||||
- [x] 1.1 `internal/ident`: `TimeOf(id string) (time.Time, error)` — время
|
||||
создания из ULID, **в UTC**, усечённое до секунды (как `store.Now`).
|
||||
`ulid.Time` внутри зовёт `time.Unix` и отдаёт локальную зону, а
|
||||
`Truncate` зону не нормализует — значит `.UTC()` обязателен явно.
|
||||
- [x] 1.2 `internal/archive`: перечисление тел — обход корня, отбор форм тела,
|
||||
возврат относительных путей и отдельно — пропущенных файлов. Ошибка
|
||||
чтения каталога (включая отсутствие корня) возвращается наружу, а не
|
||||
превращается в пустой список; каталог не создаётся.
|
||||
- [x] 1.3 `internal/store`: `ListDeliveries(ctx)` — доставки в порядке
|
||||
`(received_at, id)` со **всеми фактами журнала**, включая `headers`.
|
||||
- [x] 1.4 `internal/store`: открытие рабочей базы **только для чтения и без
|
||||
наката миграций** + проверка версии схемы; расхождение — ошибка,
|
||||
называющая обе версии.
|
||||
|
||||
## 2. Проигрывание журнала — `internal/replay`
|
||||
|
||||
- [x] 2.1 Собрать вход: объединить тела архива с учётом доставок; развести
|
||||
четыре случая (штатная / тело без учёта / учёт без тела / файл не тело) и
|
||||
отсортировать по `(received_at, id)`.
|
||||
- [x] 2.2 Перенести учёт в базу назначения по нормированному составу: факты
|
||||
журнала дословно (включая `headers`), производные от разбора —
|
||||
пустыми (`parse_status=pending`, `points=0`, `derived_layer=''`,
|
||||
`uncovered_sections='[]'`). Подобранные тела завести заново: id из имени
|
||||
файла, метка из ULID в UTC, `bytes`/`sha256` — по **распакованному** телу.
|
||||
- [x] 2.3 Проиграть журнал последовательно: `fold.Fold` по каждой доставке,
|
||||
`fold.New` собирается с тем же пределом тела, что и приём
|
||||
(`cfg.Ingest.MaxBodyMB`). Отказ одной доставки не прекращает прогон,
|
||||
отмена контекста — прекращает.
|
||||
- [x] 2.4 Собрать отчёт данными: счётчики по классам, отпечаток пересобранной
|
||||
витрины, признак отмены.
|
||||
|
||||
## 3. Команда `healthlog reindex`
|
||||
|
||||
- [x] 3.1 `cmd/healthlog/reindex.go`: флаги `--config`, `--out`, `--force`;
|
||||
умолчание `--out` — сосед рабочей базы; тождество с рабочей базой
|
||||
проверяется **по файлу** (`os.SameFile`), а для несуществующего файла —
|
||||
по родительскому каталогу и имени; `flag.ErrHelp` не превращается в
|
||||
`fatal`.
|
||||
- [x] 3.2 Жизненный цикл файла назначения: сборка под временным именем,
|
||||
переименование — последний шаг успеха; при любом ином исходе файла по
|
||||
пути назначения нет, временный и его спутники (`-wal`, `-shm`) убраны.
|
||||
- [x] 3.3 Отмена: `signal.NotifyContext(SIGINT, SIGTERM)`, частичный отчёт,
|
||||
ненулевой код.
|
||||
- [x] 3.4 `func writeReport(w io.Writer, …)` — отчёт в stdout, прогресс в
|
||||
stderr; процедура подмены печатается только при успешном исходе.
|
||||
- [x] 3.5 Коды возврата: успех — журнал непуст и свёрнута хотя бы одна
|
||||
доставка; ненулевой — пустой журнал, ноль свёрнутых, отмена, ошибка
|
||||
окружения. Отказ отдельной доставки исхода команды не меняет.
|
||||
- [x] 3.6 Подключить подкоманду в `main.go`.
|
||||
|
||||
## 4. Проверки
|
||||
|
||||
- [x] 4.1 Сходимость: живой приём N доставок → пересборка в отдельную базу →
|
||||
отпечатки совпали; второй прогон → отпечаток не изменился.
|
||||
- [x] 4.2 Порядок: журнал, у которого раскладка файлов расходится с
|
||||
хронологией, даёт доставке без плотных метрик слой **предшествующей** по
|
||||
`received_at`, а не соседа по каталогу; доставки одной секунды упорядочены
|
||||
идентификатором.
|
||||
- [x] 4.3 Состав журнала: тело без учётной записи подбирается, точки доезжают,
|
||||
`bytes`/`sha256` посчитаны по распакованному телу; учётная запись без тела
|
||||
считается и не роняет прогон; файл не-тело считается отдельно; нечитаемый
|
||||
каталог — отказ команды.
|
||||
- [x] 4.4 Учёт в базе назначения: число строк и `headers` совпадают с рабочей
|
||||
плюс подобранные; проставленный в рабочей базе `derived_layer` на
|
||||
результат не влияет (отпечаток тот же, что из учёта без слоёв).
|
||||
- [x] 4.5 Отказы и обратимость: битое тело не срывает прогон; отмена прекращает
|
||||
проигрывание и не оставляет файла назначения; рабочая база после
|
||||
прерванной пересборки не изменилась ни витриной, ни учётом.
|
||||
- [x] 4.6 Аргументы: `--out` — тот же файл, что рабочая база, по другому пути;
|
||||
существующий файл без `--force`; `--force` даёт ту же витрину, что сборка
|
||||
в отсутствующий файл.
|
||||
- [x] 4.7 Исход команды: пустой журнал — ненулевой код и без процедуры подмены;
|
||||
ноль свёрнутых — то же.
|
||||
- [x] 4.8 Отчёт не несёт значений точек, имён метрик и имён устройств.
|
||||
- [x] 4.9 Прогон живого архива переписан поверх `replay` (см. 6.5), измеренные
|
||||
утверждения сохранены.
|
||||
|
||||
## 5. Приёмочные критерии из ревью дизайна
|
||||
|
||||
Рубрика порождена проходом `healthlog-review-rubric` до чтения предложения.
|
||||
Пункты, уже закрытые разделами выше, отмечены ссылкой.
|
||||
|
||||
- [x] 5.1 **Идемпотентность прогона.** Второй прогон даёт то же состояние не
|
||||
только по отпечатку витрины, но и по учёту доставок в базе назначения
|
||||
(число строк, `id`, `received_at`, `bytes`, `sha256` подобранных тел).
|
||||
- [x] 5.2 **Тотальный детерминированный порядок.** Результат не зависит от
|
||||
порядка обхода каталога, часового пояса процесса и числа перечитываний
|
||||
каталога (см. 4.2).
|
||||
- [x] 5.3 **Независимость от «сейчас».** Ни одно поле, влияющее на отпечаток, не
|
||||
производно от времени прогона: метки берутся из события, а не из
|
||||
`store.Now`. Проигрывание последовательно.
|
||||
- [x] 5.4 **Незавершённая сборка неотличимой от завершённой быть не может**
|
||||
(см. 3.2, 4.5).
|
||||
- [x] 5.5 **Отмена доводится до конца и различима снаружи** (см. 3.3).
|
||||
- [x] 5.6 **Оракул успеха не сводится к «ошибок не было»** (см. 3.5, 4.7).
|
||||
- [x] 5.7 **Политика частичного отказа явная и счётная.** У каждого класса
|
||||
отказа свой счётчик, сумма счётчиков сходится с числом входов.
|
||||
- [x] 5.8 **Рабочая база открывается только на чтение и без миграций**
|
||||
(см. 1.4).
|
||||
- [x] 5.9 **Расход памяти не растёт с объёмом архива.** Тела читаются по
|
||||
одному, предел распакованного тела тот же, что у приёма (см. 2.3);
|
||||
превышение — учтённый отказ доставки, а не падение прогона.
|
||||
- [x] 5.10 **Длинный прогон наблюдаем** (см. 3.4).
|
||||
- [x] 5.11 **Аргументы безопасны и обратимы** (см. 3.1, 4.6).
|
||||
- [x] 5.12 **Невосстановимое названо, а не досчитано.** Отчёт называет классы
|
||||
ожидаемого расхождения (новые доставки за время прогона, непереносимый
|
||||
`sealed`, исправленный разбор) отдельно от самого факта расхождения.
|
||||
- [x] 5.13 **Ни отчёт, ни лог выше `DEBUG` не несут данных о здоровье**
|
||||
(см. 4.8).
|
||||
|
||||
## 6. Документация
|
||||
|
||||
- [x] 6.1 `docs/architecture.md`: почему подмена базы не автоматизируется
|
||||
(открытый дескриптор, порча базы SQLite) и почему пересборка идёт в
|
||||
отдельный файл (blue-green rebuild проекции); отвергнутые варианты — с
|
||||
причиной. Там же — правка утверждения «пересчёт при `reindex` идёт по всей
|
||||
истории и потому точнее»: точнее не длина ряда, а исправленный разбор.
|
||||
- [x] 6.2 `README.md`: `reindex` перестаёт быть «в планах», процедура применения.
|
||||
- [x] 6.3 `docs/plan.md`: `reindex` вычеркнут из остатка шага «Разбор и
|
||||
хранилище».
|
||||
- [x] 6.4 Беклог: задача удалена, заведена новая — «заголовки доставки в архиве
|
||||
рядом с телом» (prior art: WARC), с указанием, что без неё пересборка без
|
||||
рабочей базы деградирует по выводу слоя.
|
||||
- [x] 6.5 `task verify:archive` переезжает на `internal/replay`: отдельной
|
||||
задачи прогона не заводим, второго проигрывателя в проекте не остаётся.
|
||||
Reference in New Issue
Block a user