Цена читающего маршрута: чекпойнт WAL по таймеру и условный запрос
- рядом с воркером свёртки живёт горутина, раз в минуту разбирающая журнал пассивным чекпойнтом; «журнал не разбирается» видно строкой владельцу, а не только по `df`. Признак — пара чисел, а не флаг занятости: тот молчит под удерживаемым читателем (`busy=0` при 6256 страницах и пяти перенесённых), а при занятой блокировке отдаёт `-1` вместо ответа, и `-1 >= -1` читалось бы как «разобрано целиком» - каталог отвечает `304` на `If-None-Match`, не открывая снимок витрины. Метка собрана из всего, от чего зависит ответ: версии витрины (`data_version` с закреплённого соединения плюс поколение — значение локально для соединения и не переживает переоткрытия), горизонта измерения и области действия ресурса. Версия снимается до и после сборки: снятая после пометила бы устаревший снимок свежим номером - предел и дедлайн ответа отложены в задачу Read API точек вместе с измеренной ценой первого запроса; попутно починен флаки-тест чужой задачи, искавший значение точки в сыром буфере записи лога
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-08-02
|
||||
@@ -0,0 +1,443 @@
|
||||
## Context
|
||||
|
||||
Каталог (`GET /api/v1/metrics`) — первый и пока единственный читающий маршрут.
|
||||
Он живёт в одном процессе с приёмом и фоновой свёрткой, и три измерения ревью
|
||||
показали, что цена чтения ложится на приём:
|
||||
|
||||
- снимок каталога держит разжатые точки окна по всем метрикам сразу: 693 мс и
|
||||
+153 МиБ живой кучи на враждебном запросе (20 метрик × 8 часов × 5000 точек);
|
||||
- непрерывная запись плюс четыре читающих транзакции внахлёст дают рост `-wal`
|
||||
около 7 МБ/с без верхней границы, тогда как тот же писатель без читателей
|
||||
стабилизируется на 4 МБ;
|
||||
- самый частый запрос трёх потребителей (агент-медик, трекер, игра) — повтор
|
||||
неизменившегося, а условного запроса нет.
|
||||
|
||||
Решение по задаче принято до её начала: **вариант (г) + вариант (в)**. Предел
|
||||
размера ответа и собственный дедлайн маршрута (вариант «а») отложены до Read
|
||||
API точек, где предел всё равно проектируется; потоковое измерение по метрике
|
||||
(«б») не берётся, пока счётчик не заговорит; кеш ответа («д») — последним.
|
||||
|
||||
Всё, что ниже, измерено на стенде этой машины тем же драйвером
|
||||
(`modernc.org/sqlite`) и с тем же набором PRAGMA, что у сервиса. Числа приведены
|
||||
там, где от них зависит решение.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- WAL перестаёт расти без верхней границы; когда он всё же растёт, это видно
|
||||
владельцу строкой лога, а не только `df`.
|
||||
- Повторный запрос неизменившегося каталога не открывает снимок витрины вовсе.
|
||||
- `ETag` честен: **равная метка ⟹ тот же ответ**. Ответ есть функция снимка и
|
||||
горизонта измерения, и в метку входят оба (решения 7а и 7в).
|
||||
- Машинерия одна на все читающие маршруты: точки и MCP берут её готовой.
|
||||
- Горутина чекпойнта дренируется осознанно, как воркер свёртки.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- Предел числа метрик и точек в ответе, пагинация, собственный дедлайн
|
||||
маршрута — это Read API точек.
|
||||
- Потоковое измерение рода по метрике: меняет форму границы `store`/`catalog`
|
||||
ради случая, которого живой поток не производит.
|
||||
- Кеш ответа: третье представление того же факта и новое место, где можно
|
||||
ошибиться молча.
|
||||
- Конфигурируемость периода чекпойнта и порогов: ни одного основания выбирать
|
||||
их снаружи сегодня нет.
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. Чекпойнт по таймеру нужен не вместо автоматического, а рядом с ним
|
||||
|
||||
`wal_autocheckpoint` включён по умолчанию (измерено: 1000 страниц) и запускает
|
||||
пассивный чекпойнт **по концу записи**. Отсюда дыра: всплеск, раздувший WAL,
|
||||
оставляет его неразобранным до следующей записи, а поток пачечный по природе —
|
||||
ночью телефон молчит часами. Таймер закрывает ровно этот случай: страницы
|
||||
возвращаются в базу вскоре после того, как читатели ушли, а не при следующей
|
||||
доставке.
|
||||
|
||||
Отвергнуто **«полагаться на автоматический чекпойнт»**: он не срабатывает без
|
||||
записи, то есть именно в том состоянии, ради которого таймер и заводится.
|
||||
|
||||
Отвергнут **`TRUNCATE`** — и по измеренной причине, а не по осторожности: он
|
||||
двигает `data_version` (3 → 4 на пустом ходу), то есть каждый тик обнулял бы
|
||||
`ETag` у всех потребителей. `PASSIVE` не двигает его даже при переносе 12502
|
||||
страниц (3 → 3). Это же измерение объясняет, почему две части задачи вообще
|
||||
уживаются в одном процессе.
|
||||
|
||||
**Период — минута**, тот же, что у тика воркера свёртки и у чекпойнта
|
||||
Litestream. Он ничего не решает в
|
||||
момент всплеска (пока читатели держат снимок, чекпойнт бессилен по построению),
|
||||
и решает всё в тишине: минута против пяти неразличима по эффекту, но делает
|
||||
признак «журнал не разбирается» своевременным. Холостой чекпойнт стоит одного
|
||||
запроса на пустом журнале.
|
||||
|
||||
### 2. Признак — не `busy`, а «перенесено меньше, чем лежит»
|
||||
|
||||
Пассивный чекпойнт не идёт дальше снимка самого старого активного читателя, и
|
||||
**ошибки при этом нет**. Измерено под удерживаемым читателем: `busy=0`,
|
||||
`log=6256`, `checkpointed=5` — то есть флаг занятости молчит, а журнал растёт.
|
||||
Значит признак строится на паре чисел: страниц в журнале больше порога **и**
|
||||
перенесено меньше, чем лежало.
|
||||
|
||||
Порог назван в байтах (64 МиБ) и переводится в страницы размером страницы самой
|
||||
базы — он свойство файла, и чужое умолчание сместило бы признак в разы.
|
||||
Величина взята из чужой практики (гайды по SQLite в проде ставят 26–64 МБ) и
|
||||
собственного измерения: суточный поток даёт около 23 МБ архива, то есть журнал
|
||||
такого размера означает не всплеск, а удерживаемый снимок. С пределом тела
|
||||
приёма связи нет, хотя порядок и совпадает: журнал растёт от чтения, а не от
|
||||
размера доставки.
|
||||
|
||||
`WARN`, а не `ERROR`: адресат — владелец, событие «может стать проблемой»
|
||||
(диск), лечится оно не кодом.
|
||||
|
||||
**Частота строки решается отдельно от порога.** Признак заведён ради состояния,
|
||||
которое само не проходит: вечный читатель (в Go чаще всего — незакрытый
|
||||
`sql.Rows`) держит снимок до конца жизни процесса. Строка на каждый тик дала бы
|
||||
1440 одинаковых записей в сутки — фон, а не сигнал. Поэтому строка пишется при
|
||||
входе в состояние и повторяется, только когда журнал вырос вдвое; возврат к
|
||||
норме — отдельная строка `INFO`, потому что тишина иначе неотличима от «сервис
|
||||
перестал проверять».
|
||||
|
||||
### 3. `journal_size_limit` — потому что пассивный чекпойнт файл не укорачивает
|
||||
|
||||
Измерено: после успешного чекпойнта (`log=12502`, `checkpointed=12502`) файл
|
||||
остаётся 51 МБ — страницы переиспользуются, но диск не возвращается. С
|
||||
`journal_size_limit=64 МиБ` первая же запись после чекпойнта усекает файл до
|
||||
предела (проверено: 51 МБ → 8 МиБ при лимите 8 МиБ), и `data_version` при этом
|
||||
двигает сама запись, а не усечение.
|
||||
|
||||
Это не второй механизм для одной цели: чекпойнт возвращает **страницы**, лимит
|
||||
возвращает **файл**. Без первого второй никогда не срабатывает, без второго
|
||||
пик, случившийся однажды, остаётся на диске навсегда.
|
||||
|
||||
**Предела РОСТА это не даёт, и говорить иначе нельзя.** Измерено там же: под
|
||||
удерживаемым читателем файл вырос до 51 МБ при лимите 8 МиБ — лимит действует
|
||||
только после полного чекпойнта. То есть ровно в сценарии задачи
|
||||
(перекрывающиеся читатели, 7 МБ/с) верхней границы у диска по-прежнему нет, и
|
||||
единственный исход — `WARN` владельцу. Аварийный клапан, который на это ставит
|
||||
Litestream (блокирующий `TRUNCATE` по порогу размера), не берётся по измеренной
|
||||
причине: он двигает `data_version` и ждёт читателей. Понадобится — станет
|
||||
отдельной задачей, и её ценой будет обнуление меток.
|
||||
|
||||
Порог `WARN` и лимит файла — **одно число**: 64 МиБ, выраженное там в
|
||||
страницах. Двумя константами они разъехались бы молча, сделав признак либо
|
||||
недостижимым, либо шумным.
|
||||
|
||||
### 4. `data_version` сравним только в пределах одного соединения — отсюда щуп
|
||||
|
||||
Два измерения, каждое из которых убивает наивную реализацию:
|
||||
|
||||
- **значения разных соединений одного пула несравнимы**: на одном и том же
|
||||
состоянии базы `c1=5`, `c2=3`, а любое свежее соединение отвечает `2`
|
||||
независимо от содержимого базы;
|
||||
- **своя запись значение не двигает**, чужая двигает.
|
||||
|
||||
Отсюда следствие, которое и есть главный риск варианта (в): если брать
|
||||
`data_version` из пула, два запроса на разных соединениях дают разные метки на
|
||||
неизменившейся витрине (ложная инвалидация — не страшно), но и **одинаковые
|
||||
метки на разных состояниях** (свежие соединения всегда отвечают `2` — вот это
|
||||
уже выдача устаревшего под видом свежего).
|
||||
|
||||
Поэтому версия читается с **одного закреплённого соединения-щупа**
|
||||
(`sql.Conn`), которое ничего больше не делает и потому никогда не двигает
|
||||
собственную версию. Щуп берётся по первому запросу версии; если он становится
|
||||
непригодным, он пересоздаётся, и вместе с ним меняется **поколение**.
|
||||
|
||||
**Что считать смертью щупа — измерено, а не предположено.** Первая редакция
|
||||
этого дизайна называла причиной отмену контекста; проверка на драйвере проекта
|
||||
показала обратное: запрос, оборванный отменой, возвращает `context.Canceled`, а
|
||||
следующий запрос на том же соединении проходит. Непригодным `sql.Conn`
|
||||
становится только после закрытия (`sql.ErrConnDone`). Различие не
|
||||
академическое: считай система смертью щупа любую ошибку — каждый клиент,
|
||||
оборвавший запрос по своему тайм-ауту, менял бы поколение, и все три
|
||||
потребителя получали бы полный ответ вместо `304`. Механизм схлопывался бы ровно
|
||||
под нагрузкой, ради которой заведён.
|
||||
|
||||
**Инвариант щупа записан одной строкой:** на нём выполняется ровно один вид
|
||||
запроса и только через `QueryRowContext(...).Scan(...)`; `QueryContext` и
|
||||
`BeginTx` не зовутся никогда. Незакрытые `Rows` на единственном долгоживущем
|
||||
соединении процесса удержали бы читающий снимок навсегда — чекпойнт перестал бы
|
||||
продвигаться, и метка убила бы обслуживание при полностью исправном
|
||||
обслуживании. Проверяется это оракулом: после серии снятий версии пассивный
|
||||
чекпойнт обязан перенести журнал целиком.
|
||||
|
||||
**Закрытие щупа входит в контракт, а не в реализацию.** Измерено: закреплённое
|
||||
соединение переживает `db.Close()` и продолжает отвечать. Значит без явного
|
||||
закрытия последнего соединения к базе не наступает вовсе — SQLite не делает
|
||||
финальный чекпойнт, рядом с базой остаётся `-wal`, и пересборка, переносящая
|
||||
один файл базы, теряет хвост записей молча. Отсюда же флаг «закрыто»: запрос
|
||||
версии, успевший в окно между закрытием щупа и закрытием пула, не имеет права
|
||||
открыть соединение заново.
|
||||
|
||||
Отвергнут **`FileControlDataVersion`** из `modernc.org/sqlite` (обёртка над
|
||||
`SQLITE_FCNTL_DATA_VERSION`): он отражает и коммиты собственного соединения,
|
||||
то есть снимает требование «щуп ничего не пишет». Цена — доступ через
|
||||
`(*sql.Conn).Raw` и приведение к интерфейсу драйвера в самом чувствительном
|
||||
месте ради инварианта, который держится одним небольшим файлом и проверяется
|
||||
тестом. Взято простое; если щуп когда-нибудь начнёт писать, замена — три
|
||||
строки.
|
||||
|
||||
Отвергнут **счётчик изменений со страницы 1** (`SQLITE_DBPAGE`) — по названной
|
||||
чужой причине: в режиме WAL он инкрементируется не на каждой транзакции, потому
|
||||
что страница 1 в журнал не попадает, если в ней самой ничего не поменялось.
|
||||
|
||||
### 5. Метка — пара «поколение + счётчик»
|
||||
|
||||
Счётчик `data_version` не переживает переоткрытия: свежее соединение всегда
|
||||
отвечает `2`. Значит после рестарта метка `2` означала бы совсем другое
|
||||
состояние, чем метка `2` до него, и клиент со старым `ETag` получил бы `304` на
|
||||
изменившиеся данные — единственный по-настоящему опасный исход всей задачи.
|
||||
Поэтому метка это `"<поколение>-<счётчик>"`, где поколение — ULID, выданный при
|
||||
получении соединения-щупа.
|
||||
|
||||
Побочная выгода названа вслух: поколение меняется и при выкатке новой версии
|
||||
бинаря, то есть смена **формы** ответа при неизменившихся данных тоже
|
||||
инвалидирует метку. Цена — один полный ответ каждому потребителю после
|
||||
рестарта.
|
||||
|
||||
### 6. Метка снимается до и после сборки ответа
|
||||
|
||||
Версия щупа снимается **дважды**: до открытия снимка и после его закрытия.
|
||||
Совпали — метка выставляется; разошлись — ответ уходит **без `ETag`**.
|
||||
|
||||
Причина ровно в обещании сильной метки. Если снять версию только до сборки, то
|
||||
коммит, случившийся во время сборки, даёт ответ более свежий, чем его метка, —
|
||||
и два ответа с одной меткой могут различаться байтами. Устаревания это не даёт
|
||||
(следующий запрос увидит другую версию и получит `200`), но обещание «равная
|
||||
метка ⟹ те же байты» перестаёт быть верным, а на нём держится весь смысл
|
||||
`304`.
|
||||
|
||||
Снимать версию **после** сборки и выставлять её нельзя категорически: это
|
||||
пометило бы старый снимок новой версией, то есть заперло бы клиента на
|
||||
устаревшем ответе навсегда — ровно тот единственный исход, которого нельзя
|
||||
допускать.
|
||||
|
||||
Цена ветки названа и измерена: под непрерывной свёрткой каталог перестаёт
|
||||
отдавать `ETag` вовсе — при коммите раз в 60 мс и сборке каталога 97 мс
|
||||
подписано 0 ответов из 15. То есть на время разбора задолженности (рестарт,
|
||||
широкий проход) условный запрос выключается сам. Это деградация в безопасную
|
||||
сторону — ровно сегодняшнее поведение, — и повтор сборки ради второй попытки не
|
||||
берётся: он удваивает самое дорогое чтение ровно в тот момент, когда база
|
||||
занята записью. На установившемся потоке цена мала: сборка 45 мс против
|
||||
доставки раз в пять минут.
|
||||
|
||||
След у этого состояния есть, хотя и не для владельца: каталог пишет `DEBUG`,
|
||||
когда ответ уходит неподписанным. Владельческий канал — `/stats`, и пункт про
|
||||
долю подписанных ответов внесён в его задачу тем же изменением.
|
||||
|
||||
### 7. Снимок каталога версией не подписывается изнутри хранилища
|
||||
|
||||
Напрашивалось снимать версию внутри той же читающей транзакции, что и снимок:
|
||||
там она равна версии снимка точно, без второй пробы. Отвергнуто ценой: это
|
||||
требует, чтобы снимок каталога шёл по тому же закреплённому соединению, то есть
|
||||
**все читающие запросы выстроились бы в очередь по одному**. Головная блокировка
|
||||
на 693 мс у одного потребителя означала бы ожидание у двух других, и это уже
|
||||
предел маршрута — то самое, что задача откладывает до Read API точек.
|
||||
|
||||
### 7а. Метка слабая (`W/`), и причина названа числом другого рода
|
||||
|
||||
Ответ каталога есть функция не только снимка, но и **горизонта измерения**
|
||||
(`Now() + час`), а горизонт едет вместе с часами. На нормальных данных это
|
||||
ничего не меняет: окно берёт самые свежие общие часы, и пока в витрине нет
|
||||
меток из будущего, ход часов ответ не двигает. Но метки из будущего в витрине
|
||||
возможны (сбитые часы телефона, чужое тело в приёме) — и тогда ответ меняется
|
||||
без единого коммита.
|
||||
|
||||
Поэтому метка **слабая**: `W/"<поколение>-<счётчик>"`. Это же предписывает
|
||||
общая практика для меток, построенных из состояния БД, а не из байтов ответа, и
|
||||
`If-None-Match` сравнивает метки слабо в любом случае — на `304` форма не
|
||||
влияет. Остаток был назван вслух — и оказался больше, чем звучал: враждебный проход
|
||||
построил и прогнал путь, где та же версия витрины даёт `cumulative` против
|
||||
`unknown` при нуле коммитов между. Слабая форма метки этого не лечит: смена
|
||||
измеренного рода — изменение семантическое. Поэтому горизонт вошёл в метку
|
||||
(решение 7в), и остаток закрыт, а не назван.
|
||||
|
||||
Побочное следствие, которое иначе было бы неверным: `WARN` о данных из будущего
|
||||
пишется при сборке ответа, а `304` сборки не делает. С горизонтом в метке полный
|
||||
ответ случается не реже раза в час на потребителя — значит и предупреждение
|
||||
тоже. Без горизонта потребитель на условном опросе гасил бы его насовсем.
|
||||
|
||||
### 7в. Горизонт входит в метку, огрублённый до часа
|
||||
|
||||
Метка ответа каталога — `версия витрины . час горизонта`. Огрубление точное, а
|
||||
не приблизительное: метки объектов лежат ровно на часах (проверено отдельно,
|
||||
включая зоны с неполночасовым смещением), поэтому отбор `hour_utc <= горизонт`
|
||||
меняется ровно при переходе горизонта через час. Цена — один полный ответ в час
|
||||
на потребителя при неизменившейся витрине; сборка стоит 45 мс.
|
||||
|
||||
Отсюда же следует, что версию ответа спрашивает **домен, а не хранилище**:
|
||||
`catalog.Service.Version` знает, что в ответ входит горизонт, а транспорт не
|
||||
знает и знать не должен. Read API точек ответит на том же месте своей версией,
|
||||
включающей канонизированную форму запроса.
|
||||
|
||||
### 7б. Читающий маршрут деградирует до полного ответа, а не до отказа
|
||||
|
||||
Версия не читается — ответ уходит `200` без метки. Это правило названо отдельно,
|
||||
потому что естественная реализация даёт обратное: `DataVersion` возвращает
|
||||
ошибку, транспорт переводит ошибку домена в `500`, и маршрут, работавший до
|
||||
задачи, перестаёт работать из-за машинерии, вся ценность которой — экономия.
|
||||
Отказ пробы поэтому глушится с комментарием: настоящий отказ базы всплывёт
|
||||
сборкой каталога, идущей следом, и будет назван ею один раз.
|
||||
|
||||
### 8. Условный запрос — помощник транспорта, а не свойство каталога
|
||||
|
||||
`If-None-Match` разбирается и метка сравнивается в `httpapi` одним помощником:
|
||||
точки и MCP получат его готовым. Сравнение слабое (`W/"x"` совпадает с `"x"`) —
|
||||
так предписывает HTTP для `If-None-Match`; `*` совпадает с любой существующей
|
||||
меткой.
|
||||
|
||||
Источник версии передаётся транспорту **функцией** (`store.StateVersion`) — той
|
||||
же формой, что и `worker.Notify` у приёма: транспорт не получает доступа к
|
||||
хранилищу целиком ради одного числа.
|
||||
|
||||
Три вещи, которые помощник обязан делать по HTTP и которые легко не сделать:
|
||||
неразбираемое условие даёт `200`, а не `400` (клиент, приславший мусор, получает
|
||||
данные); `*` совпадает с любой **существующей** меткой, а при её отсутствии
|
||||
условие не выполнено; `304` уходит без представленческих заголовков — так же,
|
||||
как их снимает `writeNotModified` в `net/http/fs.go`. Заголовок читается всеми
|
||||
строками (`Header.Values`), а не первой: `If-None-Match` клиент вправе прислать
|
||||
несколькими.
|
||||
|
||||
Ответы чтения помечаются `Cache-Control: private, no-cache`. До появления
|
||||
валидатора эвристическое кеширование посредником было маловероятным; с меткой
|
||||
ответ становится штатно кешируемым, а при выключенной проверке токенов в запросе
|
||||
нет и `Authorization`, на который опирается запрет для разделяемых кешей.
|
||||
|
||||
**`HEAD` маршрут не обслуживает, и это оставлено как было.** Роутер регистрирует
|
||||
только `GET`, так что `HEAD /api/v1/metrics` отвечает `405` — и отвечал им до
|
||||
задачи. Самый дешёвый способ спросить «изменилось ли» у клиента при этом есть:
|
||||
условный `GET`, который на совпавшей метке не собирает ответа вовсе. Заводить
|
||||
`HEAD` вместе с условным запросом значило бы расширять контракт маршрута
|
||||
мимоходом; вопрос принадлежит Read API точек, где маршрутов станет пять.
|
||||
|
||||
### 8а. Остановка: обе фоновые горутины ждутся вместе
|
||||
|
||||
Форма ожидания названа, потому что наивное добавление второго канала в
|
||||
существующий `select` закрыло бы базу по выходу **любой** из двух горутин — а
|
||||
закрытая из-под воркера база даёт `ERROR` по доставке, с которой всё в порядке.
|
||||
Обе горутины идут в один `sync.WaitGroup`, канал закрывается после `Wait`, и
|
||||
`select` против бюджета остановки остаётся один.
|
||||
|
||||
Цена названа: запись о превышении бюджета больше не обвиняет воркер свёртки
|
||||
поимённо — ждут двоих, и назвать виновным одного из них было бы догадкой.
|
||||
Практически это всё тот же воркер (чекпойнт выходит по отмене немедленно), но
|
||||
лог не должен утверждать того, чего не проверял.
|
||||
|
||||
Чекпойнт при этом идёт на контексте цикла, а не на отвязанном, — в отличие от
|
||||
свёртки. Причина в том, что терять ему нечего: перенос страниц идемпотентен,
|
||||
исхода разбора он не пишет, а следующий старт возьмёт журнал с того же места.
|
||||
Зато остановка не ждёт переноса полусотни мегабайт в бюджете, который делится с
|
||||
приёмом и воркером. Прерывание по отмене отказом не считается и в лог не идёт —
|
||||
иначе каждый `task restart` писал бы владельцу об отказе обслуживания.
|
||||
|
||||
### 9. Что где живёт
|
||||
|
||||
- `store.DataVersion(ctx)` — щуп, поколение, пересоздание. Хранилище владеет
|
||||
соединениями, и знание про `data_version` принадлежит ему.
|
||||
- `store.CheckpointWAL(ctx)` — один PRAGMA, тройка чисел наружу. Решение, что с
|
||||
ними делать, принимает не хранилище.
|
||||
- цикл чекпойнта — `cmd/healthlog`, рядом с запуском воркера: это забота
|
||||
жизненного цикла процесса, а не хранилища, и остановка у него общая с
|
||||
остальными горутинами. Асимметрия с воркером свёртки (тот живёт в
|
||||
`internal/replay`) названа вслух: у воркера есть доменный исход, у чекпойнта —
|
||||
только строка владельцу, а чтобы поселить цикл в `store`, пришлось бы внести
|
||||
туда логгер, первый в пакете. Интерпретация чисел при этом осталась в
|
||||
хранилище (`Checkpoint.Stuck`).
|
||||
- `Store.VersionedRead` — двойная проба вокруг чтения. В хранилище, а не в
|
||||
каталоге: правило «версией, снятой после чтения, не подписывать» обязано
|
||||
существовать в одном экземпляре, потому что нарушить его можно ровно одним
|
||||
способом, и точки с MCP заявлены потребителями той же машинерии.
|
||||
- `catalog.Service.Metrics` возвращает снимок вместе с версией — то есть
|
||||
каталог решает, чем подписан его ответ, но не как это делается.
|
||||
|
||||
Имена доменные, а не по PRAGMA: `StateVersion`, а не `DataVersion`. Метка это
|
||||
пара «поколение + счётчик», область её сравнимости задаёт хранилище, и читатель,
|
||||
знающий SQLite, не должен ждать от метода голого значения `data_version`.
|
||||
|
||||
### 10. Как это решают другие
|
||||
|
||||
Обе части задачи — общепринятая практика, и брались они готовыми.
|
||||
|
||||
**Чекпойнт.** Документация SQLite (`wal.html`, разделы 3.1, 3.2 и 6) называет
|
||||
ровно наш случай: при перекрывающихся читателях, среди которых всегда есть
|
||||
активный, «чекпойнты не смогут завершиться, и файл WAL будет расти без границы»;
|
||||
режим `PASSIVE` «делает столько, сколько может, не мешая другим соединениям, и
|
||||
может не дойти до конца»; чекпойнт «обычно не укорачивает файл, если не задан
|
||||
`journal_size_limit`». Механика признака взята из `wal_checkpoint_v2`:
|
||||
полнота — это `checkpointed == log`, а `busy` в пассивном режиме не значит
|
||||
ничего, потому что обработчик занятости в нём не зовётся вовсе. Это же
|
||||
объясняет измеренное `busy=0` при пяти перенесённых страницах из 6256.
|
||||
|
||||
**Litestream** ближе всех по форме: интервал чекпойнта — **минута**, режим
|
||||
`PASSIVE`, а блокирующий `TRUNCATE` — аварийный клапан по порогу размера, а не
|
||||
шаг расписания. Отсюда взят период. Не взят его же совет отключать
|
||||
`wal_autocheckpoint`: он продиктован тем, что Litestream владеет чекпойнтами
|
||||
целиком, а у нас автоматический чекпойнт — первая линия, таймер лишь страхует
|
||||
тишину. Не взят подход **rqlite** (всегда `TRUNCATE`, ожидание читателя до
|
||||
250 мс): он продиктован требованием нулевого WAL для снапшота Raft, которого у
|
||||
нас нет. Прикладные гайды по SQLite в проде (Django/`dj-lite`, «SQLite in
|
||||
production») из всего этого ставят одно — `journal_size_limit` порядка 26–64 МБ;
|
||||
взято 64 МиБ.
|
||||
|
||||
Отдельная чужая находка, объясняющая, ради чего признак вообще заводится: в Go
|
||||
самый частый источник вечного читателя — незакрытый `sql.Rows`, который держит
|
||||
читающую транзакцию до конца жизни процесса и останавливает чекпойнты навсегда.
|
||||
Это не гипотетический риск для нас: читающих запросов в проекте становится
|
||||
больше с каждой задачей Read API, и `WARN` про неразобранный журнал — ровно тот
|
||||
сигнал, который такую утечку показывает.
|
||||
|
||||
**Условный запрос.** Формулировка `pragma.html#pragma_data_version` взята
|
||||
дословно и определила конструкцию: значение «локальное свойство каждого
|
||||
соединения», сравнивать осмысленно «только значения одного соединения в разные
|
||||
моменты», и оно «не меняется для коммитов того же соединения». Рекомендация
|
||||
держать для наблюдения **отдельное соединение** взята из ответа сопровождающего
|
||||
SQLite на форуме; там же названа и наша проблема рестарта («версия, полученная
|
||||
следующим соединением, может быть несравнима»), которую и закрывает поколение.
|
||||
|
||||
Отвергнут **хеш файла базы** (так делает Datasette в неизменяемом режиме,
|
||||
отдавая кусок SHA-256 в URL и год кеша): наша база пишется непрерывно, и
|
||||
неизменяемого режима у неё не бывает. Взята оттуда одна мысль — маркер,
|
||||
посчитанный один раз при старте, законен, и именно ей является поколение.
|
||||
Отвергнут **собственный счётчик версии в таблице**: он переживает рестарт, но
|
||||
это второе производное состояние рядом с витриной и лишняя запись на каждый
|
||||
коммит — тот же довод, по которому в проекте не хранится измеренный род.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Соединение-щуп умерло, и версия перестала сравниваться** → пересоздание с
|
||||
новым поколением: клиенты получают по одному полному ответу, устаревшего не
|
||||
получает никто. Молчаливого варианта (переиспользовать поколение) не
|
||||
существует — это и был бы опасный исход.
|
||||
- **Чекпойнт конкурирует с приёмом** → `PASSIVE` не ждёт ни читателей, ни
|
||||
писателей: измерено при одновременной записи — `busy=0`, чекпойнт переносит
|
||||
то, что может, и выходит. Ошибка чекпойнта прохода не прекращает и цикл не
|
||||
убивает: логируется и ждётся следующий тик.
|
||||
- **Занятость чекпойнта, держащаяся тиками подряд, немая**: незамеренный тик
|
||||
ничего не говорит и ничего не меняет — правильно поодиночке, но серия таких
|
||||
тиков означает растущий журнал при полном молчании. Счётчик подряд идущих
|
||||
неизмеренных тиков не заводится: поток пачечный, писатель занимает блокировку
|
||||
чекпойнта секундами, а тик — минутный, так что серия маловероятна. Если
|
||||
окажется иначе, это увидит `/stats`.
|
||||
- **Порог 64 МиБ выбран без живого профиля** → он назван числом в одном месте,
|
||||
и признак сформулирован условием («страниц больше порога **и** перенесено
|
||||
меньше»), а не утверждением о нагрузке.
|
||||
- **Удерживающееся состояние даёт строку в минуту** → строка пишется при входе
|
||||
в состояние и повторяется, только когда журнал вырос вдвое; возврат к норме —
|
||||
отдельная строка. Иначе вечный читатель дал бы 1440 одинаковых `WARN` в
|
||||
сутки, и владелец перестал бы их читать раньше, чем кончится диск.
|
||||
- **Условный опрос гасит предупреждения каталога** (данные из будущего,
|
||||
противоречащий род): они пишутся при сборке ответа, а `304` сборки не делает.
|
||||
Названо в спеке каталога следствием, а не умолчано: состояние не исчезает —
|
||||
следующая доставка меняет версию, и ответ соберётся.
|
||||
- **`ETag` меняется чаще, чем меняется каталог**: `data_version` двигает любая
|
||||
запись в базу, включая учёт доставки, не менявшей витрину. Это ложная
|
||||
инвалидация, то есть безопасная сторона; обратной (метка та же, данные
|
||||
другие) конструкция не допускает по построению.
|
||||
- **Память маршрута остаётся без потолка** — вариант «а» отложен намеренно.
|
||||
Условный запрос снимает большую часть читающих транзакций, но враждебный
|
||||
первый запрос стоит столько же, сколько стоил. Это записано в задаче Read API
|
||||
точек, а не забыто.
|
||||
|
||||
## Open Questions
|
||||
|
||||
Нет: развилки закрыты решением по задаче и измерениями выше.
|
||||
@@ -0,0 +1,66 @@
|
||||
## Why
|
||||
|
||||
Первый читающий маршрут (`GET /api/v1/metrics`) обошёлся дороже, чем выглядел:
|
||||
два прохода ревью измерили 693 мс и +153 МиБ живой кучи на враждебном запросе,
|
||||
а непрерывная запись вместе с четырьмя читающими транзакциями внахлёст дала
|
||||
рост `-wal` около 7 МБ/с без верхней границы (40 МБ за пять секунд). Приём
|
||||
живёт в том же процессе, и обе цены платит он: OOM убивает приём, а доставка,
|
||||
не попавшая в архив, телефоном не переприсылается. Третье проявление той же
|
||||
причины — повтор: спека каталога уже требует побайтового совпадения двух
|
||||
ответов на неизменившейся витрине, то есть ресурс по построению пригоден для
|
||||
условного запроса, а `ETag` не выставляется вовсе.
|
||||
|
||||
Задача берётся **перед** Read API точек намеренно: тот строится поверх этой же
|
||||
машинерии, и решать один вопрос трижды (каталог, точки, MCP) нельзя.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Периодический чекпойнт WAL.** Рядом с воркером свёртки живёт горутина,
|
||||
которая раз в минуту выполняет `PRAGMA wal_checkpoint(PASSIVE)` и
|
||||
останавливается дренированием, как воркер. Автоматический чекпойнт SQLite
|
||||
срабатывает только по концу записи, поэтому WAL, раздутый всплеском, остаётся
|
||||
неразобранным до следующей доставки — а ночью телефон молчит часами.
|
||||
- **Наблюдаемость непродвинувшегося чекпойнта.** Пассивный чекпойнт не идёт
|
||||
дальше снимка самого старого активного читателя и **ошибки при этом не
|
||||
возвращает**: измерено — `busy=0`, `log=6256`, `checkpointed=5`. Значит
|
||||
единственный различимый признак — «страниц в журнале много, перенесено
|
||||
меньше», и именно он идёт в `WARN` владельцу.
|
||||
- **Названный предел файла журнала.** `journal_size_limit` в строке
|
||||
подключения: пассивный чекпойнт возвращает страницы в базу, но файл оставляет
|
||||
на пике (измерено: 51 МБ до и после успешного чекпойнта на 12502 страницы).
|
||||
Роста это не ограничивает — усечение делает первая запись после полного
|
||||
чекпойнта, — и так и сказано в спеке.
|
||||
- **Версия витрины и условный запрос.** Хранилище отдаёт версию витрины по
|
||||
`PRAGMA data_version`, каталог выставляет `ETag`, а на `If-None-Match` с
|
||||
непротухшей версией отвечает `304` **не открывая снимок вовсе**.
|
||||
- **Не делается** (отложено): предел размера ответа и собственный дедлайн
|
||||
маршрута — их проектирует Read API точек; потоковое измерение по метрике;
|
||||
кеш ответа; `HEAD` на маршруте каталога.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
Новых нет: обе части ложатся на существующие домены.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `storage`: добавляется **версия витрины** (признак изменения «в базу никто не
|
||||
коммитил»; монотонной она не является) и **обслуживание WAL** (чекпойнт по
|
||||
таймеру, признак непродвижения, остановка дренированием).
|
||||
- `catalog`: добавляется **условный запрос** — `ETag` на ответе каталога и
|
||||
`304` на `If-None-Match`, связанный с уже существующим требованием
|
||||
побайтового совпадения двух ответов на неизменившейся витрине.
|
||||
|
||||
## Impact
|
||||
|
||||
- `internal/store` — закреплённое соединение-щуп для `data_version`, метод
|
||||
чекпойнта WAL, `journal_size_limit` в DSN.
|
||||
- `internal/catalog` — снимок каталога уезжает вместе с версией витрины.
|
||||
- `internal/httpapi` — общий помощник условного запроса (им же будут
|
||||
пользоваться точки и MCP), `ETag`/`304` на маршруте каталога.
|
||||
- `cmd/healthlog/serve.go` — горутина чекпойнта и её дренирование в общем
|
||||
бюджете остановки.
|
||||
- Схема базы **не меняется**: миграции нет.
|
||||
- Контракт приёма не меняется. Контракт чтения расширяется совместимо: клиент,
|
||||
не присылающий `If-None-Match`, получает ровно то же, что и сегодня.
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### 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** предупреждение владельцу не пишется, потому что измерения не было
|
||||
+279
@@ -0,0 +1,279 @@
|
||||
## 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** база не закрывается, а запись лога называет этап, не указывая
|
||||
виновной горутины
|
||||
@@ -0,0 +1,66 @@
|
||||
## 1. Версия витрины в хранилище
|
||||
|
||||
- [x] 1.1 Соединение-щуп: `sql.Conn`, взятый по первому запросу версии,
|
||||
поколение (ULID через `internal/ident`), пересоздание с новым поколением
|
||||
только при `sql.ErrConnDone` — обстоятельства поколение не меняют
|
||||
- [x] 1.2 `Store.StateVersion(ctx)` — `PRAGMA data_version` со щупа, метка вида
|
||||
`<поколение>-<счётчик>`; `Store.VersionedRead` — двойная проба вокруг
|
||||
чтения; щуп закрывается раньше пула в `Close` и не воскресает после него
|
||||
- [x] 1.3 Тесты: неизменившаяся база даёт ту же версию; запись из пула её
|
||||
двигает; переоткрытие базы даёт другую версию; щуп пересоздаётся с новым
|
||||
поколением; после `Close` версия отказывает и `-wal` рядом не остаётся;
|
||||
щуп не удерживает читающий снимок
|
||||
|
||||
## 2. Обслуживание WAL
|
||||
|
||||
- [x] 2.1 `journal_size_limit` в DSN рабочего подключения, с причиной в
|
||||
комментарии (пассивный чекпойнт файл не укорачивает)
|
||||
- [x] 2.2 `Store.CheckpointWAL(ctx)` — `PRAGMA wal_checkpoint(PASSIVE)`,
|
||||
наружу тройка чисел (busy, log, checkpointed) без интерпретации
|
||||
- [x] 2.3 Цикл чекпойнта в `cmd/healthlog`: тик в минуту, `WARN` при
|
||||
«страниц больше порога и перенесено меньше», отказ не убивает цикл
|
||||
- [x] 2.4 Запуск и дренирование в `serve`: горутина ждётся в общем бюджете
|
||||
остановки, отдельного чекпойнта на выходе нет
|
||||
- [x] 2.5 Тесты: чекпойнт переносит страницы в тишине; удерживаемый читатель
|
||||
даёт `checkpointed < log` без ошибки; порог молчит на малом журнале;
|
||||
цикл выходит по отмене
|
||||
|
||||
## 3. Условный запрос в транспорте
|
||||
|
||||
- [x] 3.1 Помощник `httpapi`: разбор `If-None-Match` (список, `W/`, `*`),
|
||||
слабое сравнение, `304` без тела — общий для будущих читающих маршрутов
|
||||
- [x] 3.2 Источник версии передаётся транспорту функцией (как `worker.Notify`),
|
||||
проверка токена чтения остаётся раньше условия
|
||||
- [x] 3.3 Тесты помощника на формах заголовка: пусто, список, `W/`, `*`,
|
||||
мусор
|
||||
|
||||
## 4. Каталог отдаёт версию
|
||||
|
||||
- [x] 4.1 `catalog.Service.Metrics` возвращает снимок вместе с версией:
|
||||
проба до, сборка, проба после; расхождение — версии нет
|
||||
- [x] 4.2 `handleMetrics`: `ETag` из версии, `304` по `If-None-Match` без
|
||||
открытия снимка, ответ без `ETag` при расхождении проб
|
||||
- [x] 4.3 Тесты: два ответа подряд — одна метка и одинаковые байты; после
|
||||
свёртки метка другая; `304` не открывает снимок; `401` раньше `304`
|
||||
|
||||
## 5. Приёмочные критерии (рубрика ревью дизайна)
|
||||
|
||||
- [x] 5.1 Равная метка ⟹ побайтово равный ответ (кроме горизонта — назван в
|
||||
дизайне); обратное направление ошибок не допускается ни в одном тесте
|
||||
- [x] 5.2 Ни один новый лог не несёт значений точек, имён метрик без обрезки
|
||||
и секретов; уровень выбран по адресату
|
||||
- [x] 5.3 `task gate` зелёный; `task verify:archive` сходится (обслуживание
|
||||
WAL и версия не меняют витрину)
|
||||
- [x] 5.4 Поведенческая проверка на своём стенде из исходников (отдельный
|
||||
каталог данных, рабочий контейнер не трогаем): `curl` дважды даёт
|
||||
`304`, после доставки — `200`
|
||||
|
||||
## 6. Документация
|
||||
|
||||
- [x] 6.1 `docs/architecture.md`: версия витрины, условный запрос, обслуживание
|
||||
WAL, отвергнутые чужие решения с причинами
|
||||
- [x] 6.2 `README.md`: строка про условный запрос в примерах чтения
|
||||
- [x] 6.3 `docs/backlog`: задача снята, остаток (предел ответа, измеренная цена
|
||||
первого запроса, готовая машинерия условного запроса) перенесён в
|
||||
`read-api-tochki.md`; наблюдаемость — в `stats-nablyudaemost.md`, цена
|
||||
ветки исчерпанного бюджета — в `ostanovka-i-migraciya-sledy.md`
|
||||
Reference in New Issue
Block a user