Цена читающего маршрута: чекпойнт 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
+167
View File
@@ -519,6 +519,132 @@ HAE. Значит для него доставки не хвост журнал
пересборки старый период честно объявляет один слой вместо трёх, а не
притворяется, что ничего не изменилось.
### Версия витрины и обслуживание журнала
Два механизма живут рядом и держатся друг за друга: один говорит читателю «в
базу никто не коммитил», второй разбирает журнал, в который эти коммиты легли.
#### Версия витрины: пара «поколение + счётчик»
Читающие маршруты обязаны уметь отвечать «не изменилось» без сборки ответа —
самый частый запрос трёх потребителей это повтор неизменившегося. Признак
изменения берётся у SQLite: `PRAGMA data_version` меняется, когда в базу
закоммитило **другое** соединение.
Голым значением его брать нельзя, и обе причины измерены на стенде проекта:
- **счётчик несравним между соединениями.** На одном состоянии базы два
соединения пула отвечают разными числами, а любое свежее соединение отвечает
одним и тем же значением независимо от содержимого. Версия из пула давала бы
не только ложную инвалидацию (не страшно), но и **одинаковые метки на разных
состояниях** — то есть подтверждение неизменности на изменившихся данных.
- **счётчик не переживает переоткрытия.** После рестарта он начинается заново.
Поэтому версия читается с одного **закреплённого соединения-щупа**, а метка это
`поколение-счётчик`, где поколение — ULID, выданный соединению. Поколение
меняется при каждом пересоздании щупа и заодно при выкатке нового бинаря, то
есть смена **формы** ответа при неизменившихся данных тоже обнуляет метки.
Монотонной метка не является: сравнивать её можно только на равенство.
Три свойства щупа названы вслух, потому что каждое из них можно нарушить
незаметно:
- **щуп не пишет** — собственный коммит соединения его версию не двигает;
- **щуп не удерживает транзакцию**: только `QueryRowContext(...).Scan(...)`,
никаких `QueryContext` и `BeginTx`. Иначе единственное долгоживущее соединение
процесса становится вечным читателем — тем самым, из-за которого чекпойнт
перестаёт продвигаться;
- **щуп непригоден только после закрытия** (`sql.ErrConnDone`). Отмена запроса
клиентом соединение не убивает (измерено), и считать её смертью щупа значило
бы менять поколение на каждом оборванном запросе — механизм схлопывался бы под
той самой нагрузкой, ради которой заведён.
Закрытие хранилища освобождает щуп **раньше пула**: закреплённое соединение
переживает закрытие пула, а финальный чекпойнт SQLite делает при закрытии
последнего соединения. Забытый щуп оставил бы рядом с базой неразобранный
`-wal`, а пересборка, переносящая один файл `.db`, потеряла бы хвост записей
молча.
**Подписывается не ответ, а чтение целиком.** Версия снимается до и после
чтения, и метка выдаётся, только если обе пробы совпали. Порядок здесь не
стилистический: версия, снятая ПОСЛЕ чтения, пометила бы устаревший снимок
свежим номером и заперла бы клиента на нём навсегда; версия, снятая только ДО,
допускает два разных ответа под одной меткой. Правило живёт в одном месте
(`store.VersionedRead`), потому что Read API точек и MCP берут ту же машинерию,
а вторая реализация «по образцу» отличалась бы ровно на этот порядок.
Отказ пробы версией не является: читающий маршрут деградирует до полного
ответа, а не до отказа.
#### Обслуживание журнала WAL
`wal_autocheckpoint` включён по умолчанию и срабатывает **по концу записи**.
Отсюда дыра: всплеск, раздувший журнал, оставляет его неразобранным до следующей
доставки — а поток пачечный, ночью телефон молчит часами. Поэтому рядом с
воркером свёртки живёт горутина, раз в минуту делающая
`PRAGMA wal_checkpoint(PASSIVE)`.
Режим `PASSIVE`, и это тоже измерение: `TRUNCATE` двигает `data_version`, то
есть каждый тик обнулял бы условный запрос у всех потребителей, а вдобавок ждёт
читателей. `PASSIVE` не двигает версию даже перенося 12502 страницы.
**Признак беды — не флаг занятости.** Пассивный чекпойнт не идёт дальше снимка
самого старого активного читателя и ошибки при этом не возвращает: измерено
`busy=0` при 6256 страницах в журнале и пяти перенесённых. Признаком служит пара
чисел — страниц больше порога **и** перенесено меньше, чем лежало.
**Флаг занятости при этом означает не «не продвинулись», а «не измерено».** Не
взяв блокировку чекпойнта, SQLite отдаёт `busy=1` и `-1` вместо обоих чисел —
измерено, 1492 таких тика из 5502 при писателе и чекпойнте в цикле. Сравнивать
`-1` на шкале страниц нельзя буквально: `-1 >= -1` истинно, то есть
незамеренный тик читался бы как «журнал разобран целиком» — владельцу уходила бы
строка о выздоровлении посреди болезни, с числом, которого не бывает, а
подавитель повторов сбрасывался бы и давал пару строк в минуту вместо молчания.
Незамеренный тик поэтому не меняет ни объявленного состояния, ни накопленного о
нём. Размер страницы берётся у самой базы: он свойство файла, и чужое умолчание
сместило бы порог в разы.
Порог и `journal_size_limit` — одно число (64 МиБ), выраженное в двух видах:
предел возвращает файл, порог сообщает, что вернуть его не выходит. Двумя
константами они разъехались бы молча.
**Предела роста журнала это не даёт, и умалчивать об этом нельзя.** Измерено:
под удерживаемым читателем файл вырос до 51 МБ при лимите 8 МиБ — лимит
действует только после полного чекпойнта, усечение делает первая запись за ним.
Пока читатель держит снимок, журнал растёт, и единственный исход — `WARN`
владельцу. Аварийный клапан (блокирующий `TRUNCATE` по порогу размера, как у
Litestream) не взят по названной причине: он двигает версию витрины.
Строка о непродвижении пишется при входе в состояние и повторяется, только
когда журнал вырос вдвое; возврат к норме — отдельная строка. Признак заведён
ради состояния, которое само не проходит (в Go самый частый вечный читатель —
незакрытый `sql.Rows`), а строка в минуту дала бы 1440 одинаковых записей в
сутки.
#### Как это решают другие
- **Документация SQLite** (`wal.html`) называет наш случай дословно: при
перекрывающихся читателях, среди которых всегда есть активный, чекпойнты не
смогут завершиться, и файл журнала будет расти без границы. Оттуда же взято,
что `PASSIVE` «делает столько, сколько может» и может не дойти до конца, а
полнота проверяется равенством `checkpointed == log`.
- **Litestream** — интервал чекпойнта минута, режим `PASSIVE`, блокирующий
`TRUNCATE` только как клапан по порогу размера. Взят период и режим; не взят
его совет отключать `wal_autocheckpoint` (он владеет чекпойнтами целиком, у нас
автоматический — первая линия) и не взят клапан.
- **rqlite** всегда просит `TRUNCATE` и ждёт читателя до 250 мс — продиктовано
требованием нулевого журнала для снапшота Raft, которого у нас нет.
- **Гайды по SQLite в проде** (Django, `dj-lite`) из всего этого ставят одно —
`journal_size_limit` порядка 26–64 МБ. Взято 64 МиБ.
- **`PRAGMA data_version`**: рекомендация держать для наблюдения отдельное
соединение взята с форума SQLite. Отвергнуты: `FileControlDataVersion`
драйвера (снимает требование «щуп не пишет», но стоит доступа через
`(*sql.Conn).Raw` в самом чувствительном месте), счётчик изменений со
страницы 1 (`SQLITE_DBPAGE` — в режиме WAL инкрементируется не на каждой
транзакции), хеш файла базы (так делает Datasette в неизменяемом режиме —
наша база пишется непрерывно) и собственный счётчик версии в таблице (второе
производное состояние рядом с витриной и лишняя запись на каждый коммит).
### Устаревание нижнего слоя
Родной экспорт Apple Health точнее HAE (находка 34) и делается раз в 2–3
@@ -1258,6 +1384,47 @@ GET /healthz
слой, покрывающий весь запрошенный диапазон**. Молча переключать слой на
границе периода нельзя: ряд поедет незаметно для клиента.
### Условный запрос
Ресурсы чтения отвечают `304 Not Modified` на `If-None-Match` с непротухшей
меткой и **не открывают снимок витрины вовсе**.
**Метка собирается из всего, от чего зависит ответ.** У каталога это версия
витрины (см. «Версия витрины и обслуживание журнала») и **горизонт измерения**:
горизонт едет вместе с часами, и метка из будущего, лежащая в витрине, въезжает
в окно сама, без единого коммита. Путь построен враждебным проходом ревью и
прогнан: та же версия витрины, `cumulative` против `unknown`. Горизонт входит в
метку огрублённым до часа — огрубление точное, потому что метки объектов лежат
ровно на часах; цена — один полный ответ в час на потребителя.
Форма метки **слабая** (`W/"…"`): она выведена из состояния, а не из байтов
ответа — так предписывает общая практика для валидаторов такого рода. На исход
`304` это не влияет, `If-None-Match` сравнивается слабо в любом случае.
**Область действия метки — часть самой метки.** Она действительна в пределах
одного ресурса, поэтому маршрут, чей ответ есть функция параметров (точки), и
транспорт без адреса вовсе (MCP) кладут в неё канонизированную форму запроса.
Прозой это требовать бесполезно — прозу компилятор не проверяет, а забыть
область значит однажды ответить `304` на чужой набор данных; поэтому она
параметр помощника, а не забота вызывающего.
Три правила разбора, каждое из которых легко нарушить: неразбираемое условие
даёт `200`, а не `400`; `*` совпадает с любой **существующей** меткой, а при её
отсутствии условие не выполнено; `304` уходит без тела и без представленческих
заголовков. Токен чтения проверяется **раньше** условия: `304` без токена
подтверждал бы состояние витрины тому, кому она не открыта.
Ответы чтения помечаются `Cache-Control: private, no-cache`. До появления
валидатора эвристическое кеширование посредником было маловероятным; с меткой
ответ становится штатно кешируемым, а при выключенной проверке токенов в
запросе нет и `Authorization`.
Следствие названо вслух: **`304` не выполняет измерения и потому не пишет
предупреждений владельцу** (данные из будущего, противоречащий род). С условным
опросом они становятся функцией смены версии витрины, а не числа запросов;
состояние при этом не теряется — следующая доставка меняет версию, ответ
собирается, и предупреждение пишется.
### Свёртка и размер ответа
Запросов к метрике ровно два, и это один запрос с необязательным параметром: