Цена читающего маршрута: чекпойнт 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,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`, получает ровно то же, что и сегодня.