## 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` даёт ей готовую операцию.