- `healthlog reindex` собирает витрину из журнала (тела архива + учёт доставок) в ОТДЕЛЬНЫЙ файл базы, строго по `(received_at, id)`; рабочую базу читает без наката миграций и не трогает вовсе. Подмену делает человек при остановленном сервисе: переименование поверх открытого дескриптора портит базу молча. - Журналом считается архив, а не таблица доставок: тело без учётной записи заводится заново (метка из ULID, размер и хеш по распакованному телу), запись без тела переносится, но не сворачивается. Оракул сходимости встроен — два отпечатка и «объектов было/стало»; пустой журнал успехом не считается. - Прогон живого архива переехал на новый пакет: второго проигрывателя журнала в проекте не осталось, а его утверждение о ключе сна перестало быть константой, протухающей с каждой доставкой.
31 KiB
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,
Projections and Read Models);
тем же приёмом работает _reindex + переключение алиаса в Elasticsearch, и та
же форма у собственного VACUUM INTO SQLite — «собери целую копию в новый
файл».
Причина предпочесть его здесь конкретнее общей моды: усечение рабочей витрины — единственная необратимая операция во всей задаче, и она наступает до того, как станет известно, что пересборка вообще удалась. Отказ на середине (битое тело, отменённый контекст, кончившееся место) оставил бы витрину пустой наполовину, причём в состоянии, которое ничем не отличается от нормального. Сборка рядом делает отказ бесплатным: рабочая база не тронута, временный файл удаляется.
Отвергнуто: очистка рабочей витрины с проигрыванием в неё же. Причина — названа выше; плюс под живым сервисом это ещё и окно, в котором Read API отдавал бы полупустую историю как полную.
2. Подмену рабочей базы делает человек, а не команда
Файл базы держит открытым процесс сервиса. В POSIX переименование не касается уже открытого дескриптора: процесс продолжит писать в отвязанный inode, а читатели увидят новый файл — данные разойдутся молча. Документация SQLite говорит об этом прямо: переименование или удаление файла базы во время записи оставляет журнал под чужим именем и портит базу (How To Corrupt An SQLite Database File), и в форуме проекта то же короче: «никогда не безопасно переименовывать используемый файл 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даёт ей готовую операцию.