Цена читающего маршрута: чекпойнт WAL по таймеру и условный запрос
- рядом с воркером свёртки живёт горутина, раз в минуту разбирающая журнал пассивным чекпойнтом; «журнал не разбирается» видно строкой владельцу, а не только по `df`. Признак — пара чисел, а не флаг занятости: тот молчит под удерживаемым читателем (`busy=0` при 6256 страницах и пяти перенесённых), а при занятой блокировке отдаёт `-1` вместо ответа, и `-1 >= -1` читалось бы как «разобрано целиком» - каталог отвечает `304` на `If-None-Match`, не открывая снимок витрины. Метка собрана из всего, от чего зависит ответ: версии витрины (`data_version` с закреплённого соединения плюс поколение — значение локально для соединения и не переживает переоткрытия), горизонта измерения и области действия ресурса. Версия снимается до и после сборки: снятая после пометила бы устаревший снимок свежим номером - предел и дедлайн ответа отложены в задачу Read API точек вместе с измеренной ценой первого запроса; попутно починен флаки-тест чужой задачи, искавший значение точки в сыром буфере записи лога
This commit is contained in:
@@ -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` не выполняет измерения и потому не пишет
|
||||
предупреждений владельцу** (данные из будущего, противоречащий род). С условным
|
||||
опросом они становятся функцией смены версии витрины, а не числа запросов;
|
||||
состояние при этом не теряется — следующая доставка меняет версию, ответ
|
||||
собирается, и предупреждение пишется.
|
||||
|
||||
### Свёртка и размер ответа
|
||||
|
||||
Запросов к метрике ровно два, и это один запрос с необязательным параметром:
|
||||
|
||||
Reference in New Issue
Block a user