Files
healthlog/internal/store/wal.go
T
av 8db2ec7ff4 Цена читающего маршрута: чекпойнт WAL по таймеру и условный запрос
- рядом с воркером свёртки живёт горутина, раз в минуту разбирающая журнал
  пассивным чекпойнтом; «журнал не разбирается» видно строкой владельцу, а не
  только по `df`. Признак — пара чисел, а не флаг занятости: тот молчит под
  удерживаемым читателем (`busy=0` при 6256 страницах и пяти перенесённых), а
  при занятой блокировке отдаёт `-1` вместо ответа, и `-1 >= -1` читалось бы как
  «разобрано целиком»
- каталог отвечает `304` на `If-None-Match`, не открывая снимок витрины. Метка
  собрана из всего, от чего зависит ответ: версии витрины (`data_version` с
  закреплённого соединения плюс поколение — значение локально для соединения и
  не переживает переоткрытия), горизонта измерения и области действия ресурса.
  Версия снимается до и после сборки: снятая после пометила бы устаревший снимок
  свежим номером
- предел и дедлайн ответа отложены в задачу Read API точек вместе с измеренной
  ценой первого запроса; попутно починен флаки-тест чужой задачи, искавший
  значение точки в сыром буфере записи лога
2026-08-02 20:42:22 +03:00

113 lines
8.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}