Files
healthlog/openspec/changes/archive/2026-08-02-reindex-iz-arhiva/specs/reindex/spec.md
T
av 5ae0c5ff81 reindex: пересборка витрины проигрыванием журнала
- `healthlog reindex` собирает витрину из журнала (тела архива + учёт
  доставок) в ОТДЕЛЬНЫЙ файл базы, строго по `(received_at, id)`; рабочую
  базу читает без наката миграций и не трогает вовсе. Подмену делает
  человек при остановленном сервисе: переименование поверх открытого
  дескриптора портит базу молча.
- Журналом считается архив, а не таблица доставок: тело без учётной записи
  заводится заново (метка из ULID, размер и хеш по распакованному телу),
  запись без тела переносится, но не сворачивается. Оракул сходимости
  встроен — два отпечатка и «объектов было/стало»; пустой журнал успехом не
  считается.
- Прогон живого архива переехал на новый пакет: второго проигрывателя
  журнала в проекте не осталось, а его утверждение о ключе сна перестало
  быть константой, протухающей с каждой доставкой.
2026-08-02 09:07:46 +03:00

33 KiB
Raw Blame History

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 файла по пути назначения не остаётся