Цена читающего маршрута: чекпойнт WAL по таймеру и условный запрос
- рядом с воркером свёртки живёт горутина, раз в минуту разбирающая журнал пассивным чекпойнтом; «журнал не разбирается» видно строкой владельцу, а не только по `df`. Признак — пара чисел, а не флаг занятости: тот молчит под удерживаемым читателем (`busy=0` при 6256 страницах и пяти перенесённых), а при занятой блокировке отдаёт `-1` вместо ответа, и `-1 >= -1` читалось бы как «разобрано целиком» - каталог отвечает `304` на `If-None-Match`, не открывая снимок витрины. Метка собрана из всего, от чего зависит ответ: версии витрины (`data_version` с закреплённого соединения плюс поколение — значение локально для соединения и не переживает переоткрытия), горизонта измерения и области действия ресурса. Версия снимается до и после сборки: снятая после пометила бы устаревший снимок свежим номером - предел и дедлайн ответа отложены в задачу Read API точек вместе с измеренной ценой первого запроса; попутно починен флаки-тест чужой задачи, искавший значение точки в сыром буфере записи лога
This commit is contained in:
@@ -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** база не закрывается, а запись лога называет этап, не указывая
|
||||
виновной горутины
|
||||
|
||||
|
||||
Reference in New Issue
Block a user