Цена читающего маршрута: чекпойнт 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
+112
View File
@@ -0,0 +1,112 @@
package store
import (
"context"
"fmt"
)
// checkpointQuery — пассивный чекпойнт журнала WAL.
//
// Режим PASSIVE, а не TRUNCATE или RESTART, и причина измерена: TRUNCATE
// двигает `data_version`, то есть каждый чекпойнт обнулял бы условный запрос у
// всех потребителей. PASSIVE не двигает его даже перенося 12502 страницы.
// Второй довод известнее: PASSIVE ничего не ждёт — ни читателей, ни писателей.
const checkpointQuery = `PRAGMA wal_checkpoint(PASSIVE)`
// pageSizeQuery — размер страницы базы. Свойство ФАЙЛА, зафиксированное при его
// создании, а не настройка соединения: журнал считается в страницах, а предел
// файла назван в байтах, и без этого числа их не связать.
const pageSizeQuery = `PRAGMA page_size`
// Checkpoint — исход одного чекпойнта: сколько страниц лежало в журнале и
// сколько из них перенесено в базу.
//
// Отдаётся без интерпретации: решение, считать ли это бедой, принимает не
// хранилище. Знать при этом надо обе величины, а не одну — признаком служит
// именно их расхождение.
type Checkpoint struct {
// Busy — SQLite не смог взять блокировку чекпойнта. Признаком беды флаг НЕ
// является: измерено `busy=0` при 6256 страницах в журнале и пяти
// перенесённых — обработчик занятости в пассивном режиме не зовётся, и
// «не продвинулись» флагом не выражается.
//
// Зато он выражает другое, и это обязано читаться: при `busy=1` SQLite
// отдаёт `log = checkpointed = -1`, то есть исход НЕ ИЗМЕРЕН. Воспроизведено
// шестью соединениями, чекпойнтящими один файл: 200 ответов `(1, -1, -1)`
// против 40 измеренных.
Busy bool
// Log — страниц в журнале, Checkpointed — из них перенесено в базу.
// Равенство означает, что журнал разобран целиком. Отрицательные значения
// означают «не измерено» (см. Busy) и на шкале страниц не сравниваются.
Log int
Checkpointed int
// PageSize — размер страницы этой базы в байтах. Ноль означает **«не
// измерено»** и нулём не является: без него страницы не перевести в байты,
// и признак беды молчит, а не гадает.
PageSize int
}
// Known отвечает, измерен ли исход вообще.
//
// SQLite отдаёт `-1` там, где ответа нет: чекпойнт не взял блокировку либо
// журнала не существует. Внутриполосный признак («-1 на шкале страниц») —
// ровно тот приём, который Effective Go называет неуклюжим, и он опасен
// буквально: `-1 >= -1` истинно, то есть незамеренный исход читался бы как
// «журнал разобран целиком», а владельцу уходила бы строка о выздоровлении
// посреди болезни, с числом, которого не бывает.
func (c Checkpoint) Known() bool { return !c.Busy && c.Log >= 0 && c.Checkpointed >= 0 }
// Complete отвечает, разобран ли журнал целиком. Неизмеренный исход
// разобранным не считается — «не знаем» и «разобран» разные ответы.
//
// Пассивный чекпойнт не идёт дальше снимка самого старого активного читателя и
// ошибки при этом не возвращает — растущий файл единственный след. Поэтому
// «журнал не разбирается» выражается здесь, а не флагом занятости.
func (c Checkpoint) Complete() bool { return c.Known() && c.Checkpointed >= c.Log }
// Stuck отвечает, перестал ли журнал разбираться: неразобранного накопилось
// больше, чем держит верхняя граница файла, и перенести это не вышло.
//
// Порог не своё число: это тот же `journal_size_limit`, только журнал считается
// в страницах, а предел назван в байтах. Одна величина в двух ролях (предел
// возвращает файл, порог сообщает, что вернуть его не выходит) — двумя
// константами они разъехались бы молча, сделав признак либо недостижимым, либо
// шумным.
//
// Размер страницы берётся у самой базы, а не предполагается: он фиксируется при
// создании файла, и база, созданная чужим инструментом с другим умолчанием,
// сместила бы порог в разы. Неизвестен — предикат молчит: гадать о пороге хуже,
// чем не сказать.
//
// Предикат живёт в хранилище, а не у вызывающего: семантика тройки
// `busy/log/checkpointed` принадлежит SQLite, и второй её экземпляр разошёлся
// бы с первым молча.
func (c Checkpoint) Stuck() bool {
if !c.Known() || c.PageSize <= 0 {
return false
}
return !c.Complete() && c.Log*c.PageSize >= journalSizeLimit
}
// CheckpointWAL переносит страницы журнала в базу и говорит, сколько удалось.
//
// Автоматический чекпойнт SQLite (`wal_autocheckpoint`, 1000 страниц) остаётся
// первой линией и отключать его незачем; этот вызов страхует случай, которого
// автоматический не закрывает по построению — запись прекратилась, а журнал
// остался неразобранным. Поток пачечный: ночью телефон молчит часами.
func (s *Store) CheckpointWAL(ctx context.Context) (Checkpoint, error) {
var busy, logPages, checkpointed int
if err := s.db.QueryRowContext(ctx, checkpointQuery).Scan(&busy, &logPages, &checkpointed); err != nil {
return Checkpoint{}, fmt.Errorf("wal checkpoint: %w", err)
}
// Отказ ЭТОГО запроса отказом чекпойнта не является: страницы уже
// перенесены, и объявить это провалом значило бы отправить владельца искать
// беду в чекпойнте. Неизвестный размер страницы предикат и так трактует как
// «не измерено» и молчит.
var pageSize int
if err := s.db.QueryRowContext(ctx, pageSizeQuery).Scan(&pageSize); err != nil {
pageSize = 0
}
return Checkpoint{Busy: busy != 0, Log: logPages, Checkpointed: checkpointed, PageSize: pageSize}, nil
}