Цена читающего маршрута: чекпойнт WAL по таймеру и условный запрос

- рядом с воркером свёртки живёт горутина, раз в минуту разбирающая журнал
  пассивным чекпойнтом; «журнал не разбирается» видно строкой владельцу, а не
  только по `df`. Признак — пара чисел, а не флаг занятости: тот молчит под
  удерживаемым читателем (`busy=0` при 6256 страницах и пяти перенесённых), а
  при занятой блокировке отдаёт `-1` вместо ответа, и `-1 >= -1` читалось бы как
  «разобрано целиком»
- каталог отвечает `304` на `If-None-Match`, не открывая снимок витрины. Метка
  собрана из всего, от чего зависит ответ: версии витрины (`data_version` с
  закреплённого соединения плюс поколение — значение локально для соединения и
  не переживает переоткрытия), горизонта измерения и области действия ресурса.
  Версия снимается до и после сборки: снятая после пометила бы устаревший снимок
  свежим номером
- предел и дедлайн ответа отложены в задачу Read API точек вместе с измеренной
  ценой первого запроса; попутно починен флаки-тест чужой задачи, искавший
  значение точки в сыром буфере записи лога
This commit is contained in:
av
2026-08-02 20:42:22 +03:00
parent 6b729bbd2f
commit 8db2ec7ff4
37 changed files with 3575 additions and 156 deletions
+138
View File
@@ -524,3 +524,141 @@ Read API MUST опираться на фактические объекты за
- **WHEN** доставка учтена
- **THEN** в сохранённых заголовках вместо значения стоит пометка о сокрытии
### Requirement: Каталог отвечает на условный запрос
Система SHALL выставлять на ответе каталога заголовок `ETag` и SHALL отвечать
`304 Not Modified` на запрос с `If-None-Match`, чья метка совпадает с текущей
версией витрины. При совпадении снимок витрины открываться MUST NOT: смысл
условного запроса в том, что самый частый запрос потребителя — повтор
неизменившегося — не стоит ничего.
Метка MUST строиться из **всего, от чего зависит ответ**: версии витрины и
горизонта измерения. Горизонт едет вместе с часами, и метка из будущего,
лежащая в витрине, въезжает в окно сама — без единого коммита. Путь построен и
прогнан: та же версия витрины, `cumulative` против `unknown`. Значит версии
витрины для метки НЕ ДОСТАТОЧНО, и слабая форма метки этого не лечит: смена
измеренного рода — изменение семантическое, на нём Read API строит арифметику
года.
Горизонт входит в метку огрублённым до часа, и огрубление точное, а не
приблизительное: метки объектов лежат ровно на часах, поэтому отбор по горизонту
меняется ровно при переходе через час. Цена названа: один полный ответ в час на
потребителя при неизменившейся витрине.
Форма метки MUST оставаться слабой (`W/"…"`): она выведена из состояния, а не из
байтов ответа. На исход `304` это не влияет — `If-None-Match` сравнивается слабо
в любом случае.
Метка MUST выставляться, только если за всё время сборки ответа в базу никто не
коммитил; правило снятия версии принадлежит хранилищу и здесь не повторяется.
Ответ без метки — законный исход, а не отказ: клиент просто не сможет спросить
условно в следующий раз.
**Метка действительна только в пределах одного ресурса, и область действия
MUST входить в саму метку.** Маршрут, чей ответ есть функция параметров запроса
(Read API точек), и транспорт, у которого адреса нет вовсе (MCP), обязаны
подмешивать в неё канонизированную форму запроса — иначе «не изменилось»
ответит на другой набор данных. Требовать этого прозой недостаточно: правило
MUST быть выражено формой вызова, потому что забыть его — единственный путь всей
задачи, ведущий к выдаче не тех данных.
Ответ `304` MUST нести ту же метку и MUST NOT нести тела и представленческих
заголовков. Клиент, не приславший `If-None-Match`, MUST получать ровно то же,
что и до появления условного запроса.
Ответы каталога MUST быть помечены непригодными для разделяемого кеша
(`Cache-Control: private, no-cache`). До появления валидатора эвристическое
кеширование посредником было маловероятным; с меткой ответ становится штатно
кешируемым, а при выключенной проверке токенов (законная конфигурация) в
запросе нет и `Authorization` — тогда выгрузку истории здоровья вправе
сохранить любой прокси на пути.
Проверка токена чтения MUST предшествовать условному запросу: `304` без токена
подтверждал бы состояние витрины тому, кому она не открыта.
Разбор условия MUST следовать HTTP и MUST NOT превращать кривой заголовок в
отказ:
- звёздочка (`*`) совпадает с любой **существующей** меткой; метки нет —
условие не выполнено, и клиент со звёздочкой получает данные, а не вечный
`304`;
- неразбираемое значение условия не выполняет и даёт `200`, а не `400`.
**Следствие названо вслух: `304` не выполняет измерения и потому не пишет
предупреждений владельцу.** Предупреждения каталога (данные из будущего,
противоречащий род агрегации) привязаны к сборке ответа; с условным опросом они
становятся функцией смены версии витрины, а не числа запросов. Состояние при
этом не исчезает: следующая доставка меняет версию, ответ собирается, и
предупреждение пишется — а пока витрина стоит, повторять его на каждый опрос
трёх потребителей значило бы обесценить уровень.
#### Scenario: Повтор на неизменившейся витрине
- **GIVEN** клиент получил каталог и запомнил его `ETag`
- **WHEN** он повторяет запрос с `If-None-Match` этой метки, а витрина не
менялась
- **THEN** ответ — `304` без тела, с той же меткой
#### Scenario: Витрина изменилась
- **GIVEN** клиент получил каталог и запомнил его `ETag`
- **WHEN** свёртка записала объект и клиент повторяет запрос с прежней меткой
- **THEN** ответ — `200` с полным каталогом и новой меткой
#### Scenario: Горизонт сдвинулся
- **GIVEN** витрина не менялась
- **WHEN** горизонт измерения перешёл через час
- **THEN** метка отличается от прежней
#### Scenario: Метка другого ресурса
- **GIVEN** клиент присылает метку, выданную другим читающим маршрутом
- **WHEN** совпадает версия витрины
- **THEN** условие не выполнено, и ответ — `200`
#### Scenario: Клиент не спрашивает условно
- **WHEN** каталог запрашивается без `If-None-Match`
- **THEN** ответ — `200` с полным каталогом, меткой и правилом кеширования
#### Scenario: Две метки на неизменившейся витрине совпадают
- **GIVEN** витрина не менялась между двумя запросами
- **WHEN** каталог запрошен дважды
- **THEN** метки совпадают, и тела ответов совпадают побайтово
#### Scenario: Звёздочка в условии
- **WHEN** каталог запрашивается с `If-None-Match: *`
- **THEN** ответ — `304` с текущей меткой
#### Scenario: Условие нечитаемо
- **WHEN** каталог запрашивается с `If-None-Match`, который меткой не является
- **THEN** ответ — `200` с полным каталогом, а не `400` и не `304`
#### Scenario: Условный запрос без токена чтения
- **GIVEN** список токенов чтения непуст
- **WHEN** каталог запрашивается с `If-None-Match`, но без токена
- **THEN** ответ — `401`, а не `304`
#### Scenario: Витрина изменилась во время сборки ответа
- **GIVEN** между снятием версии до и после сборки в базу был коммит
- **WHEN** ответ сформирован
- **THEN** он уходит с полным телом и без заголовка `ETag`
#### Scenario: Версия витрины недоступна
- **GIVEN** версию витрины прочитать не удалось
- **WHEN** каталог запрашивается, в том числе с `If-None-Match`
- **THEN** ответ — `200` с полным каталогом и без метки, а не `500` и не `304`
#### Scenario: Условный ответ не собирает каталог
- **GIVEN** в витрине лежат данные, помеченные будущим
- **WHEN** каталог отвечает `304` по совпавшей метке
- **THEN** предупреждение владельцу не пишется, потому что измерения не было
+278
View File
@@ -1057,3 +1057,281 @@ SHALL: сегодня ровно этот случай даёт ноль и мо
- **WHEN** выполняется выборка
- **THEN** число обращений к базе не зависит от числа часов
### 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** база не закрывается, а запись лога называет этап, не указывая
виновной горутины