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