Files
healthlog/openspec/specs/reindex/spec.md
T
av 63bffe2865 Приём отвечает 200 до свёртки, свёртку ведёт фоновый воркер
- Очередью служит сама таблица: доставка ждёт свёртки в статусе `pending`,
  канал несёт только бит «есть работа». Переполнять нечего, падение процесса
  очередь не теряет, а подбор `pending` при старте — обычный проход воркера, а
  не отдельный код. Классификация исхода общая с пересборкой журнала.
- Исход разбора начал отражать доставку, а не обстоятельства: отмена и
  занятость базы статус не меняют (иначе конкуренция за базу выводила бы
  доставку из очереди навсегда), паника свёртки больше не валит процесс, а
  учёт доставки идёт через транзакцию с повторами.
- Длинный бюджет ответа выдан маршруту приёма, а не всему серверу:
  `write_timeout` в Go покрывает и чтение тела, и общий подъём снял бы защиту с
  остальных маршрутов.
2026-08-02 11:01:42 +03:00

448 lines
35 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# reindex Specification
## Purpose
Пересборка витрины проигрыванием журнала: `import(снапшот) + replay(доставки)`.
Витрина производна, источник истины — сырой архив тел; значит любое повреждение,
включая ошибку нашего же разбора любой давности, лечится пересборкой, а не
восстановлением из бекапа. Здесь живут состав журнала, порядок проигрывания,
детерминированность, оракул сходимости и граница «что не восстанавливается».
## 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 считаться
нештатной **для пересборки**, хотя для фоновой свёртки она штатна: пересборка
идёт в свежий файл при единственном писателе, и такая доставка в собранной
витрине просто отсутствует — вместе с теми, кто наследовал от неё слой. Классы
при этом общие с фоновой свёрткой: второй классификатор разошёлся бы с первым
молча.
Число объектов «было и стало» 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** файла по пути назначения не остаётся