Цена читающего маршрута: чекпойнт 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` не выполняет измерения и потому не пишет
предупреждений владельцу** (данные из будущего, противоречащий род). С условным
опросом они становятся функцией смены версии витрины, а не числа запросов;
состояние при этом не теряется — следующая доставка меняет версию, ответ
собирается, и предупреждение пишется.
### Свёртка и размер ответа
Запросов к метрике ровно два, и это один запрос с необязательным параметром:
-1
View File
@@ -21,7 +21,6 @@
## высокий
- [Тай-брейк при равной полноте точек](taj-brejk-pri-ravnoj-polnote.md) — Решено: брать бо́льшее значение. Порядок канонических форм берёт меньшее в 96% случаев — для накопительных это систематический недосчёт
- [Цена первого читающего маршрута: память, WAL и повторный опрос](cena-chitayushchego-marshruta.md) — Решено: чекпойнт по таймеру плюс ETag по data_version. Берётся перед Read API — тот строится поверх этой машинерии
- [Read API: точки, выбор слоя, свёртка по сетке](read-api-tochki.md) — Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может
- [OpenAPI-спека и Swagger UI](openapi-swagger.md) — Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате
- [MCP-сервер поверх Read API](mcp-server.md) — Агент-медик — первый заказчик проекта, а подключить его сейчас нечем
@@ -1,98 +0,0 @@
# Цена первого читающего маршрута: память, WAL и повторный опрос
**Приоритет:** высокий
**Решение принято 2026-08-02: вариант (г) плюс (в), именно в таком порядке.**
Разбирается без владельца: контракт хранения не меняется, данные не трогаются,
а обе части — общепринятая практика, а не собственный дизайн. Чекпойнт по
таймеру закрывает единственное проявление, которое ломает приём (диск), и стоит
одной горутины; `ETag` по `PRAGMA data_version` — один запрос к базе, снимает и
повтор, и большую часть читающих транзакций, не заводя кеша ответа.
Вариант (а) — предел и дедлайн маршрута — **не отвергнут, а отложен** до
[Read API точек](read-api-tochki.md): там предел размера ответа всё равно
проектируется, и делать его дважды не нужно. Вариант (б) не берём, пока
счётчик не заговорит. Вариант (д) — последним, если (в) окажется мало.
Задача берётся **перед** Read API: тот строится поверх этой машинерии.
Ниже — исходная постановка блокера, она же ТЗ.
## Что решить
Чем ограничить стоимость маршрута чтения, у которого нет ни предела ответа, ни
собственного дедлайна, ни условного запроса. Вопрос поднялся на каталоге
(`GET /api/v1/metrics`, change `2026-08-02-katalog-i-rod-agregacii`), но
принадлежит не ему: тот же ответ понадобится Read API точек и MCP, и решать его
трижды нельзя.
Три измеренных проявления одной причины.
**Память.** Снимок каталога держит разжатые точки окна по всем метрикам сразу,
хотя измерение идёт по одной метрике. Замер враждебного прохода ревью: 20 метрик
× 8 часов × 5000 точек — 693 мс и +153 МиБ живой кучи на один запрос.
Предварительный отбор по учётным колонкам (сделан) снял разжатие заведомо
непригодных часов, но множители «метрики × окно × точки × одновременные запросы»
остались без потолка. Приём живёт в том же процессе и уже даёт пик 768 МиБ на
теле 40 МиБ; OOM убивает приём, а доставка, не попавшая в архив, телефоном не
переприсылается.
**WAL.** Замер эксплуатационного прохода на копии с драйвером и PRAGMA проекта:
непрерывная запись плюс четыре читающих транзакции внахлёст дают рост `-wal`
около 7 МБ/с без верхней границы (40 МБ за пять секунд), тогда как тот же
писатель без читателей стабилизируется на 4 МБ. Пассивный чекпойнт SQLite не
продвигается дальше снимка самого старого активного читателя, и ошибки при этом
нет — виден только растущий файл. `PRAGMA wal_checkpoint` в проекте не
вызывается нигде.
**Повторный опрос.** Спека каталога требует побайтового совпадения двух ответов
на неизменившейся витрине — то есть ресурс по построению пригоден для условного
запроса, а `ETag`/`304` не выставляется. Потребителей трое (агент-медик, трекер,
игра), и самый частый их запрос — повтор неизменившегося.
## Варианты и цена
**а. Предел и дедлайн у маршрута.** Потолок числа метрик и точек в одном ответе,
собственный `context.WithTimeout`, честный отказ при превышении. Цена: клиент
обязан уметь читать частичный каталог, то есть появляется пагинация — контракт
чтения усложняется на первой же ручке.
**б. Измерение потоком по метрике внутри той же транзакции.** Точки метрики
освобождаются сразу после вердикта; требование «один снимок» не нарушается. Цена:
хранилище перестаёт возвращать снимок значением и начинает отдавать его
последовательно (итератор или колбэк) — то есть меняется форма границы
`store`/`catalog`, ради случая, которого живой поток пока не производит.
**в. Условный запрос: `ETag` по `PRAGMA data_version`.** Снимает и стоимость
повтора, и большую часть читающих транзакций разом: клиент с непротухшим `ETag`
получает `304`, и снимок не открывается вовсе. Цена: один лишний запрос к базе на
каждый вызов и обещание клиенту, что версия витрины меняется не чаще, чем данные.
**г. Периодический `wal_checkpoint(PASSIVE)` по таймеру рядом с воркером.**
Лечит только WAL, зато дёшево и без изменения контракта. Память и повтор
остаются.
**д. Кеш ответа на короткий TTL.** Закрывает всё сразу, но заводит третье
представление того же факта, и его инвалидация становится новым местом, где можно
ошибиться молча. Дизайн каталога отверг кеш именно поэтому.
## Что заблокировано
Ничего сегодня: на живом корпусе каталог собирается за 45 мс, потребителей у него
пока нет, а маршрут живёт в доверенной сети. Блокировано будущее — Read API
точек, где объёмы на порядок больше, и выкладка наружу, где опрос станет
непрерывным.
## Рекомендация
**г + в, именно в таком порядке.** Чекпойнт по таймеру закрывает единственное
проявление, которое ломает приём (диск), и стоит одной горутины без изменения
контракта. `ETag` по `data_version` — один запрос к базе, снимает и повтор, и
большую часть читающих транзакций, и делает это без кеша ответа.
Вариант «а» откладывать до Read API точек: там предел размера ответа всё равно
проектируется (`read-api-tochki.md`), и делать его дважды не нужно. Вариант «б»
не брать, пока счётчик не заговорит: он меняет форму границы ради случая,
которого поток не производит. Вариант «д» — последним, если «в» окажется мало.
Связано: `docs/architecture.md` → «Измерение рода агрегации», `read-api-tochki.md`,
`stats-nablyudaemost.md`.
+10 -2
View File
@@ -21,6 +21,14 @@
исчерпанного первого, и `BaseContext`, производный от контекста жизненного цикла,
чтобы долгий запрос об остановке узнавал.
**Цена этой ветки выросла** (change `cena-chitayushchego-marshruta`): база в ней
не закрывается, а значит не закрывается и закреплённое соединение версии
витрины — последнего соединения к базе не наступает, SQLite не делает финального
чекпойнта, и рядом с базой остаётся неразобранный `-wal` до 64 МиБ. Данные целы
(следующее открытие проиграет журнал), но файл базы в этом состоянии нельзя
переносить без его `-wal`. Обвинение в логе при этом стало честнее: этап
называется `background`, а не `fold-worker`, потому что ждут двоих.
**Миграция молчит и не прерывается штатной остановкой.** `store.migrate` не
пишет ни одной записи — ни «начал», ни «закончил», ни длительность, — а первая
строка в логе появляется уже после успешного открытия базы. Если миграция идёт
@@ -37,5 +45,5 @@
Готово, когда `WARN` о превышении бюджета называет виновный этап честно, а в логе
старта видно, что миграции накатывались и сколько это заняло.
Связано: `cmd/healthlog/serve.go`, `internal/store/store.go`,
`cena-chitayushchego-marshruta.md`.
Связано: `cmd/healthlog/serve.go`, `internal/store/store.go`, change
`2026-08-02-cena-chitayushchego-marshruta` (архив).
+20 -4
View File
@@ -36,10 +36,26 @@
величины выглядят как «0.5», означая разное; полярность придётся назвать вслух в
`architecture.md`, иначе через полгода два места кода поймут поле по-разному.
**Предел размера ответа тоже здесь.** У каталога его нет намеренно: правило
размера — общее для маршрутов чтения, и задавать его мимоходом на первой ручке
значило бы решить контракт до того, как известна форма тяжёлого ответа. Каталог
станет первым его потребителем.
**Предел размера ответа тоже здесь, и он унаследовал измеренную цену.** У
каталога предела нет намеренно: правило размера — общее для маршрутов чтения, и
задавать его мимоходом на первой ручке значило бы решить контракт до того, как
известна форма тяжёлого ответа. Каталог станет первым его потребителем.
Цена измерена на каталоге (задача «цена читающего маршрута», закрыта чекпойнтом
WAL и условным запросом): 693 мс и +153 МиБ живой кучи на враждебном запросе
(20 метрик × 8 часов × 5000 точек), при том что приём в том же процессе уже даёт
пик 768 МиБ на теле 40 МиБ. Условный запрос снял повтор, но первый запрос стоит
столько же, а множители «метрики × окно × точки × одновременные запросы»
по-прежнему без потолка. Сюда же уезжают отложенные варианты той задачи:
собственный дедлайн маршрута и потоковое измерение по метрике (второе — только
если счётчик заговорит).
**Машинерия условного запроса готова, и её надо взять, а не написать заново.**
`store.VersionedRead` держит правило «версией, снятой после чтения, не
подписывать»; `httpapi` — разбор `If-None-Match` и `304`. Метка обязана нести
**область действия**: у точек ответ есть функция параметров запроса, и
`etag(scope, version)` требует их канонизированную форму — иначе `304` ответит
на другой набор данных. Детали — `docs/architecture.md`, «Условный запрос».
**Форма провода наследуется от каталога, и это надо решить один раз.** Сегодня
типы `internal/catalog` сами несут json-теги, а транспорт владеет только
+9
View File
@@ -48,3 +48,12 @@
запроса, ни адреса клиента: жалобу потребителя не сопоставить с записью, а
выгрузку каталога посторонним — не отличить от планового опроса агента. У приёма
корреляция есть (`delivery_id`), у чтения аналога нет.
**Обслуживание журнала WAL тоже спрашивается здесь.** Признак «журнал не
разбирается» (чекпойнт по таймеру, change `cena-chitayushchego-marshruta`)
живёт одной строкой `WARN` в ротируемом docker-логе: состояние держится днями, а
сказано о нём один раз. Вопрос «журнал сейчас разбирается?» сегодня не имеет
ответа нигде, кроме `df`. В `/stats` просятся последний исход чекпойнта (когда,
сколько страниц лежит и сколько перенесено) и — тем же полем — доля ответов
чтения, которые удалось подписать `ETag`: механизм условного запроса может
перестать окупаться под плотным потоком, и снаружи это неотличимо от нормы.
+6
View File
@@ -161,3 +161,9 @@
архиве, и ответ «пересворачивать нечего» произносится с числом.** Утверждение
без числа не отличается от предположения, а цена ошибки здесь — необратимое
решение о судьбе тел.
- **Тест «в логе нет значения» проверяет запись без служебных полей, а не сырой
буфер.** Метка времени содержит доли секунды, поэтому искомая подстрока
находится в ней сама: проверка на «5.1» краснела примерно раз на сотню
прогонов от хода часов, а не от утечки. Разбираем запись, выбрасываем `time`
и ищем в остатке. Правило общее — таких тестов будет больше (токены, тела
запросов, координаты объектов).
+21
View File
@@ -110,3 +110,24 @@
стоит одной строки, а пропуск молчащий стоил семи находок и отдельной задачи
на их дозакрытие. Состав проходов и профилей при этом не трогаем: они
сработали ровно так, как задуманы, — их просто не позвали.
## 2026-08-02 — тест на утечку значений в лог краснел от хода часов
- **Где:** `internal/fold/log_test.go`, `TestFoldНесравнимыеНаборыДаютWarn`
- **Симптом:** гейт задачи про цену читающего маршрута покраснел на чужом
тесте: «в логе оказалось значение точки "5.1"». Значения в логе не было —
подстрока нашлась в метке времени записи (`…T20:23:35.193…` содержит `5.1`).
Повторный прогон зелёный.
- **Причина:** утверждение искало секрет в **сыром буфере** записи, а буфер
содержит служебное поле `time` с долями секунды. Вероятность совпадения для
двухсимвольного числа с точкой — около процента на прогон, то есть тест
флаки по построению, и краснеет он у того, кто мимо проходил.
- **Почему не поймали:** шаг `flaky` гейта гоняет набор дважды подряд —
вероятность поймать однопроцентную флаки за два прогона мала, а сам тест
выглядит образцовым: он проверяет ровно тот инвариант, который проекту
дороже всего («данные о здоровье чувствительнее токенов»). Ни один проход
ревью не смотрит на тесты чужих задач.
- **Что меняем:** правило в [conventions.md](conventions.md) — проверка «в логе
нет значения» разбирает запись и выбрасывает `time`, а не ищет в сыром
буфере. Гейт не трогаем: два прогона против однопроцентной флаки не помогут,
а десять стоили бы дороже самой находки.