Files
healthlog/openspec/changes/archive/2026-08-02-cena-chitayushchego-marshruta/specs/storage/spec.md
T
av 8db2ec7ff4 Цена читающего маршрута: чекпойнт WAL по таймеру и условный запрос
- рядом с воркером свёртки живёт горутина, раз в минуту разбирающая журнал
  пассивным чекпойнтом; «журнал не разбирается» видно строкой владельцу, а не
  только по `df`. Признак — пара чисел, а не флаг занятости: тот молчит под
  удерживаемым читателем (`busy=0` при 6256 страницах и пяти перенесённых), а
  при занятой блокировке отдаёт `-1` вместо ответа, и `-1 >= -1` читалось бы как
  «разобрано целиком»
- каталог отвечает `304` на `If-None-Match`, не открывая снимок витрины. Метка
  собрана из всего, от чего зависит ответ: версии витрины (`data_version` с
  закреплённого соединения плюс поколение — значение локально для соединения и
  не переживает переоткрытия), горизонта измерения и области действия ресурса.
  Версия снимается до и после сборки: снятая после пометила бы устаревший снимок
  свежим номером
- предел и дедлайн ответа отложены в задачу Read API точек вместе с измеренной
  ценой первого запроса; попутно починен флаки-тест чужой задачи, искавший
  значение точки в сыром буфере записи лога
2026-08-02 20:42:22 +03:00

21 KiB

ADDED Requirements

Requirement: Хранилище отдаёт версию витрины

Система SHALL отдавать версию витрины — метку, которая MUST меняться при любом коммите в базу и MUST NOT меняться, пока коммитов не было. Метка предназначена условному запросу читающих маршрутов: равные метки означают, что между их снятием в базу никто ничего не записал.

Метка MUST быть парой «поколение + счётчик». Счётчик — PRAGMA data_version, поколение — идентификатор, выданный тому соединению, с которого счётчик читается. Монотонной метка не является и сравнению на «новее» не подлежит: гарантируется только неравенство.

Обе части обязательны, и каждая закрывает измеренный отказ:

  • счётчик несравним между соединениями. На одном и том же состоянии базы два соединения одного пула отвечают разными числами, а любое свежее соединение отвечает одним и тем же значением независимо от содержимого базы. Поэтому счётчик MUST читаться с одного закреплённого соединения, которое ничем другим не занято: собственная запись соединения его версию не двигает, и щуп, участвующий в записи, молчал бы о собственных изменениях.
  • счётчик не переживает переоткрытия. После рестарта он начинается заново, поэтому одно и то же значение до и после означает разные состояния витрины. Без поколения клиент со старой меткой получал бы «не изменилось» на изменившиеся данные — единственный по-настоящему опасный исход условного запроса.

Соединение-щуп MUST NOT удерживать открытую читающую транзакцию между снятиями версии: каждое снятие завершается до возврата. Иначе щуп — единственное долгоживущее соединение процесса — становится тем самым вечным читателем, против которого заведён чекпойнт, и версия витрины отменяет обслуживание журнала при полностью исправном обслуживании.

Поколение MUST меняться всякий раз, когда соединение-щуп создаётся заново. Переиспользовать поколение MUST NOT: это ровно тот случай, ради которого оно заведено.

Непригодность щупа — узкий класс, а не любая ошибка. Пересоздание допускается только при отказе, означающем закрытое соединение; отмена запроса клиентом, занятость базы и прочие обстоятельства (то, что проект уже отличает предикатом «не сделано» против «не выходит») поколение менять MUST NOT. Измерено: отмена контекста запроса щуп не убивает — следующий запрос на нём проходит. Считай система смертью щупа любую ошибку, каждый оборванный клиентом запрос обнулял бы метки всех потребителей, то есть механизм схлопывался бы под той самой нагрузкой, ради которой заведён.

Закрытие хранилища MUST освобождать щуп раньше пула и MUST исключать его пересоздание после закрытия. Закреплённое соединение переживает закрытие пула (измерено), а SQLite делает финальный чекпойнт только при закрытии последнего соединения: забытый щуп оставляет рядом с базой неразобранный -wal, и пересборка, переносящая один файл базы, теряет хвост записей молча.

Scenario: Витрина не менялась

  • GIVEN после первого запроса версии в базу никто не писал
  • WHEN версия запрашивается второй раз
  • THEN обе версии совпадают

Scenario: Свёртка записала объект

  • GIVEN версия витрины снята
  • WHEN фоновая свёртка закоммитила изменения в витрину
  • THEN следующая снятая версия отличается от прежней

Scenario: База переоткрыта

  • GIVEN версия витрины снята, база закрыта и открыта заново
  • WHEN версия снимается снова на том же файле
  • THEN она отличается от снятой до переоткрытия

Scenario: Соединение-щуп стало непригодным

  • GIVEN соединение, с которого читается счётчик, закрыто
  • WHEN версия запрашивается снова
  • THEN запрос отвечает версией НОВОГО поколения, а не отказом

Scenario: Запрос версии оборван клиентом

  • GIVEN запрос версии отменён контекстом
  • WHEN версия запрашивается следующим запросом
  • THEN поколение остаётся прежним

Scenario: Щуп не мешает разбирать журнал

  • GIVEN версия снималась много раз подряд
  • WHEN выполняется пассивный чекпойнт и других читателей нет
  • THEN журнал перенесён целиком

Scenario: Хранилище закрыто

  • GIVEN хранилище закрыто
  • WHEN запрашивается версия витрины
  • THEN запрос отказывает и нового соединения к базе не открывает, а рядом с базой не остаётся файла журнала

Requirement: Чтение подписывается версией только целиком

Система SHALL снимать версию витрины до и после чтения, которое ею подписывается, и SHALL отдавать версию, только если обе пробы совпали. При расхождении версии нет, и это не отказ: ответ уходит полным, просто без метки.

Версия, снятая ПОСЛЕ чтения, MUST NOT выставляться на его результате: она пометила бы устаревший снимок свежим номером и заперла бы клиента на нём навсегда — единственный по-настоящему опасный исход всей конструкции. Версия, снятая только ДО, допускает два разных ответа под одной меткой.

Правило MUST существовать в одном экземпляре: Read API точек и MCP заявлены потребителями той же машинерии, и вторая её реализация «по образцу» отличалась бы от первой ровно на этот порядок — а тест первой этого не увидел бы.

Отказ пробы версией не является и чтение не отменяет: маршрут деградирует до полного ответа, а не до отказа.

Scenario: Витрина стояла всё время чтения

  • WHEN чтение выполнено и обе пробы дали одну версию
  • THEN версия отдана

Scenario: Витрина изменилась во время чтения

  • GIVEN между пробами в базу закоммитили
  • THEN версии нет, а результат чтения отдан целиком

Scenario: Само чтение отказало

  • WHEN чтение вернуло ошибку
  • THEN ошибка отдана вызывающему, а не подменена отсутствием версии

Requirement: Журнал WAL разбирается по таймеру, и его непродвижение видно

Система SHALL выполнять PRAGMA wal_checkpoint(PASSIVE) раз в минуту, пока сервис работает. Автоматический чекпойнт SQLite MUST NOT считаться достаточным: он срабатывает по концу записи, а поток пачечный — журнал, раздутый всплеском, иначе остаётся неразобранным до следующей доставки, и ночью это часы.

Режим MUST быть PASSIVE. TRUNCATE и RESTART применять MUST NOT: они двигают счётчик версии витрины, то есть каждый тик обнулял бы условный запрос у всех потребителей, а TRUNCATE вдобавок ждёт читателей.

Пассивный чекпойнт не идёт дальше снимка самого старого активного читателя и ошибки при этом не возвращает: измерено busy=0 при 6256 страницах в журнале и 5 перенесённых. Поэтому система SHALL считать признаком беды пару чисел — страниц в журнале больше 16384 (64 МиБ при странице в 4 КиБ) и перенесено меньше, чем лежало, — и MUST сообщать об этом владельцу уровнем WARN. Флаг занятости признаком «не продвинулись» служить MUST NOT: он молчит ровно в измеренном случае удерживаемого читателя.

Зато флаг занятости выражает другое, и это MUST читаться: исход не измерен. Не взяв блокировку чекпойнта, SQLite отдаёт busy=1 и -1 вместо обоих чисел — измерено, 1492 таких тика из 5502 при писателе и чекпойнте в цикле. Сравнивать -1 на шкале страниц MUST NOT: -1 >= -1 истинно, то есть незамеренный тик читался бы как «журнал разобран целиком», владельцу уходила бы строка о выздоровлении посреди болезни, а подавитель повторов сбрасывался бы — и вместо задуманного молчания получалась бы пара строк в минуту. Незамеренный исход MUST не менять ни объявленного состояния, ни накопленного о нём.

Размер страницы MUST браться у самой базы, а не предполагаться: он фиксируется при создании файла, и база, созданная чужим инструментом, сместила бы порог в разы. Неизвестен — признак молчит.

Порог MUST быть выражен через ту же величину, что и предел файла журнала: это одно число в двух ролях (предел возвращает файл, порог сообщает, что вернуть его не выходит), и двумя разошедшимися константами признак стал бы либо недостижимым, либо шумным — молча.

Строка о непродвижении не пишется на каждый тик. Признак заведён ради состояния, которое само не проходит (вечный читатель живёт до конца процесса), а строка в минуту дала бы 1440 одинаковых записей в сутки. Система SHALL сообщать о входе в состояние и повторять, только когда журнал заметно вырос; возврат к норме MUST быть отдельным событием — молчание иначе неотличимо от «сервис перестал проверять».

Отказ чекпойнта MUST NOT прекращать цикл и MUST быть виден записью лога: обслуживание, умершее от временного отказа базы, молча перестало бы разбирать журнал до конца жизни процесса. Отмена контекста отказом при этом не является.

Файл журнала MUST иметь названный предел (journal_size_limit, 64 МиБ). Предел роста этим не даётся, и это сказано вслух: измерено — под удерживаемым читателем файл вырос до 51 МБ при пределе 8 МиБ, и успешный чекпойнт его не укоротил; усечение делает первая запись после полного чекпойнта. Пока читатель держит снимок, журнал растёт, и единственный исход — WARN владельцу.

Scenario: Журнал разбирается в тишине

  • GIVEN доставок нет, а в журнале остались неразобранные страницы
  • WHEN проходит период чекпойнта
  • THEN страницы перенесены в базу без единой новой записи

Scenario: Читатель держит снимок

  • GIVEN идёт запись, и читающая транзакция удерживает старый снимок
  • WHEN выполняется пассивный чекпойнт
  • THEN он завершается без ошибки, переносит меньше, чем лежит в журнале, и флаг занятости остаётся снятым

Scenario: Чекпойнт не взял блокировку

  • GIVEN о непродвижении журнала уже сказано
  • WHEN очередной чекпойнт возвращает признак занятости и -1 вместо чисел
  • THEN ни строки о выздоровлении, ни строки о беде не пишется, а накопленное состояние не меняется

Scenario: Журнал невелик

  • GIVEN в журнале меньше страниц, чем названный порог
  • WHEN чекпойнт не смог перенести всё
  • THEN строка WARN не пишется

Scenario: Состояние держится

  • GIVEN о непродвижении журнала уже сказано
  • WHEN следующий чекпойнт застаёт журнал того же размера
  • THEN строка не повторяется

Scenario: Журнал разобрался

  • GIVEN о непродвижении журнала было сказано
  • WHEN очередной чекпойнт переносит журнал ЦЕЛИКОМ
  • THEN о возврате к норме сказано один раз

Scenario: Журнал стал мал, но не перенесён

  • GIVEN о непродвижении журнала было сказано
  • WHEN очередной чекпойнт переносит не всё, а журнал при этом ниже порога
  • THEN о возврате к норме не сообщается: перенос — это то, что проверено, а размер ниже порога — нет

Scenario: Чекпойнт отказал

  • GIVEN очередной чекпойнт вернул ошибку
  • WHEN наступает следующий период
  • THEN отказ виден строкой лога, а чекпойнт выполняется снова

Requirement: Обслуживание журнала останавливается дренированием

Система SHALL останавливать периодический чекпойнт осознанно: горутина MUST получать отмену и MUST быть дождана вместе с воркером свёртки — база закрывается только после выхода обеих. Обрывать её выходом процесса MUST NOT, а закрывать базу по выходу одной из двух MUST NOT: закрытая из-под воркера, она даёт ERROR по доставке, с которой всё в порядке.

Идущий чекпойнт при отмене прерывается, и терять ему нечего: перенос страниц идемпотентен, исхода разбора чекпойнт не пишет, а следующий старт берёт журнал с того же места. Прерывание по отмене отказом MUST NOT считаться.

Исчерпание бюджета остановки MUST называть этап, не утверждая большего, чем проверено: ждут двоих, и назвать виновным одного из них — догадка.

Отдельного чекпойнта на остановке система выполнять MUST NOT: закрытие последнего соединения к базе SQLite делает его само. Условие названо в требовании о версии витрины — щуп обязан быть закрыт раньше пула, иначе последнего соединения не наступает вовсе.

Остаток назван вслух: в ветке исчерпанного бюджета база не закрывается, а значит финального чекпойнта не наступает и рядом с ней остаётся -wal. Данные при этом целы — следующее открытие проиграет журнал, — но файл базы в этом состоянии переносить без его -wal нельзя. Процедура подмены при пересборке этого и требует: она удаляет -wal старой базы вместе с ней самой.

Scenario: Сервис останавливается

  • WHEN сервис получает сигнал остановки
  • THEN горутина чекпойнта завершается до закрытия базы

Scenario: Чекпойнт идёт в момент остановки

  • GIVEN чекпойнт выполняется, когда пришла отмена
  • WHEN он прерывается
  • THEN отказ не пишется, новый цикл не начинается, и горутина выходит

Scenario: Бюджет остановки исчерпан

  • GIVEN фоновые горутины не вышли в бюджет
  • WHEN сервис завершается
  • THEN база не закрывается, а запись лога называет этап, не указывая виновной горутины