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