Цена читающего маршрута: чекпойнт 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
@@ -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` порядка 2664 МБ;
взято 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
Нет: развилки закрыты решением по задаче и измерениями выше.