- рядом с воркером свёртки живёт горутина, раз в минуту разбирающая журнал пассивным чекпойнтом; «журнал не разбирается» видно строкой владельцу, а не только по `df`. Признак — пара чисел, а не флаг занятости: тот молчит под удерживаемым читателем (`busy=0` при 6256 страницах и пяти перенесённых), а при занятой блокировке отдаёт `-1` вместо ответа, и `-1 >= -1` читалось бы как «разобрано целиком» - каталог отвечает `304` на `If-None-Match`, не открывая снимок витрины. Метка собрана из всего, от чего зависит ответ: версии витрины (`data_version` с закреплённого соединения плюс поколение — значение локально для соединения и не переживает переоткрытия), горизонта измерения и области действия ресурса. Версия снимается до и после сборки: снятая после пометила бы устаревший снимок свежим номером - предел и дедлайн ответа отложены в задачу Read API точек вместе с измеренной ценой первого запроса; попутно починен флаки-тест чужой задачи, искавший значение точки в сыром буфере записи лога
444 lines
42 KiB
Markdown
444 lines
42 KiB
Markdown
## 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
|
||
|
||
Нет: развилки закрыты решением по задаче и измерениями выше.
|