Цена читающего маршрута: чекпойнт 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
+13 -4
View File
@@ -58,15 +58,21 @@ iPhone ──HTTPS POST──► healthlog ──► журнал доставо
В разработке. Готовы каркас и приём, большая часть разбора: сервис принимает В разработке. Готовы каркас и приём, большая часть разбора: сервис принимает
пакеты, складывает их в сырой архив и **разбирает метрики в часовые объекты** пакеты, складывает их в сырой архив и **разбирает метрики в часовые объекты**
— с выводом слоя из данных, канонизацией содержимого и слиянием точек по — с выводом слоя из данных, канонизацией содержимого и слиянием точек по
полноте. Секции, которых разбор пока не покрывает (`workouts`, `stateOfMind` полноте. Тренировки и записи со своим `id` (`workouts`, `stateOfMind`) тоже
половина потока), принимаются, хранятся и честно помечаются как неразобранные. разбираются; секции, которых разбор не покрывает, принимаются, хранятся и
честно помечаются как неразобранные.
Есть и пересборка: `healthlog reindex` проигрывает журнал доставок в свежую Есть и пересборка: `healthlog reindex` проигрывает журнал доставок в свежую
витрину и сверяет её отпечаток с накопленной — на живом архиве из 116 тел витрину и сверяет её отпечаток с накопленной — на живом архиве из 116 тел
пересборка воспроизводима и повторный прогон ничего не меняет. пересборка воспроизводима и повторный прогон ничего не меняет.
Чего ещё нет: каталога метрик с измеренным родом агрегации и **read API** Первый маршрут чтения открыт: **каталог разрезов** (`GET /api/v1/metrics`) под
данные наружу пока не отдаются никак. План в [docs/plan.md](docs/plan.md). токеном чтения отдаёт слои с диапазонами и измеренный род агрегации, а повтор
неизменившегося отвечает `304` по `ETag` — снимок витрины при этом не
открывается. Журнал WAL разбирается фоновым чекпойнтом по таймеру.
Чего ещё нет: **read API точек**, тренировок и записей — сами данные наружу
пока не отдаются. План в [docs/plan.md](docs/plan.md).
Разведка формата закончена: 50 находок на живом потоке, половина расходится с Разведка формата закончена: 50 находок на живом потоке, половина расходится с
документацией Health Auto Export — [docs/local-research.md](docs/local-research.md). документацией Health Auto Export — [docs/local-research.md](docs/local-research.md).
@@ -132,6 +138,9 @@ task run
curl localhost:8080/healthz curl localhost:8080/healthz
curl -X POST localhost:8080/api/v1/ingest -d '{"data":{"metrics":[]}}' curl -X POST localhost:8080/api/v1/ingest -d '{"data":{"metrics":[]}}'
curl localhost:8080/api/v1/metrics # каталог: слои, диапазоны, род агрегации curl localhost:8080/api/v1/metrics # каталог: слои, диапазоны, род агрегации
# повтор неизменившегося не стоит ничего: метка из ответа возвращается условием
curl -i -H 'If-None-Match: W/"…"' localhost:8080/api/v1/metrics # 304
``` ```
### Подключение телефона по локальной сети ### Подключение телефона по локальной сети
+142
View File
@@ -0,0 +1,142 @@
package main
import (
"context"
"errors"
"log/slog"
"time"
"git.vakhrushev.me/av/healthlog/internal/store"
)
// checkpointInterval — как часто разбирается журнал WAL.
//
// Автоматический чекпойнт SQLite остаётся первой линией и срабатывает по концу
// записи; этот тик закрывает случай, которого тот не закрывает по построению —
// запись прекратилась, а журнал остался неразобранным. Поток пачечный, ночью
// телефон молчит часами, поэтому минута против пяти неразличима по эффекту;
// минута взята потому, что с ней своевременен признак «журнал не разбирается»,
// и потому, что это тот же ритм, что у тика воркера свёртки. Тот же период
// берёт Litestream, у которого задача ровно та же.
const checkpointInterval = time.Minute
// walGrowth — во сколько раз обязан вырасти неразобранный журнал, чтобы о нём
// сказали второй раз.
//
// Признак заводится ради состояния, которое САМО НЕ ПРОХОДИТ: вечный читатель
// (в Go чаще всего — незакрытый `sql.Rows`) держит снимок до конца жизни
// процесса. Строка на каждый тик дала бы 1440 одинаковых `WARN` в сутки, и
// владелец перестал бы их читать раньше, чем кончится диск. Поэтому вторая
// строка пишется, только когда стало вдвое хуже.
const walGrowth = 2
// keepWAL разбирает журнал WAL, пока сервис работает.
//
// Живёт в бинаре, а не в хранилище, и это осознанная асимметрия с воркером
// свёртки: у воркера есть доменный исход (доставка свёрнута), а здесь только
// жизненный цикл процесса и строка владельцу. Чтобы завести цикл в `store`,
// пришлось бы внести туда логгер — первый в пакете, который сегодня не логирует
// вовсе и все исходы отдаёт возвратом. Интерпретация чисел при этом осталась в
// хранилище (`Checkpoint.Stuck`): семантика тройки `busy/log/checkpointed`
// принадлежит SQLite, а не тому, кто её печатает.
//
// Период параметром, а не константой внутри: тот же шов, что `Worker.Pass` у
// свёртки, и по той же причине — иначе проверка «цикл переживает отказ» ждала
// бы по минуте на тик. Конфигурируемостью это не является: вызов один, и он
// называет константу.
//
// Контекст один, и работа идёт на нём же — в отличие от свёртки, которая
// сворачивает на отвязанном. Прерванный чекпойнт ничего не теряет: перенос
// страниц идемпотентен, исхода разбора он не пишет, а следующий старт возьмёт
// журнал с того же места. Зато остановка не ждёт переноса полусотни мегабайт в
// бюджете, который делится с приёмом и воркером.
func keepWAL(ctx context.Context, st *store.Store, log *slog.Logger, every time.Duration) {
log = log.With("capability", "wal")
ticker := time.NewTicker(every)
defer ticker.Stop()
var watch walWatch
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
}
ck, err := st.CheckpointWAL(ctx)
if err != nil {
if errors.Is(err, context.Canceled) {
// Штатная остановка не отказ: чекпойнт прерван ею же. ERROR о
// ней обесценил бы уровень, по которому вмешиваются, — и делал
// бы это на каждом `task restart`.
//
// Различаем по САМОЙ ошибке, а не по `ctx.Err()`: настоящий
// отказ базы, случившийся в тот же тик, что и сигнал остановки,
// иначе подавлялся бы как штатный — то есть терялся бы ровно
// тогда, когда владелец смотрит в логи.
return
}
// Отказ не прекращает цикл: обслуживание, умершее от временного
// отказа базы, молча перестало бы разбирать журнал до конца жизни
// процесса — а видно это было бы только по свободному месту.
log.ErrorContext(ctx, "wal checkpoint failed", "error", err)
continue
}
switch watch.see(ck) {
case walStuck:
// Адресат — владелец, событие «может стать проблемой»: журнал
// растёт, и лечится это не кодом. Значений из данных в записи нет —
// только счётчики страниц.
log.WarnContext(ctx, "wal checkpoint did not advance",
"log_pages", ck.Log,
"checkpointed_pages", ck.Checkpointed)
case walRecovered:
// Возврат к норме — событие, и сказать о нём надо: молчание иначе
// неотличимо от «сервис перестал проверять».
log.InfoContext(ctx, "wal checkpoint caught up", "log_pages", ck.Log)
}
}
}
// walSay — что сказать владельцу по исходу очередного чекпойнта.
type walSay int
const (
walSilent walSay = iota
walStuck
walRecovered
)
// walWatch решает, когда о неразобранном журнале говорить. Отдельно от цикла,
// потому что это единственная его часть, у которой есть исход: решение зависит
// от предыдущих тиков, а проверять его ожиданием минут нельзя.
type walWatch struct {
// warnedAt — размер журнала, о котором уже сказано. Ноль означает
// «состояние нормальное». Свойство разговора с владельцем, а не базы,
// поэтому живёт здесь, а не в хранилище.
warnedAt int
}
func (w *walWatch) see(ck store.Checkpoint) walSay {
switch {
case !ck.Known():
// Исход не измерен (чекпойнт не взял блокировку). Молчим и НЕ трогаем
// накопленное: иначе занятый тик посреди беды прочитался бы как
// выздоровление, сбросил бы подавитель и вернул те самые 1440 строк в
// сутки, против которых он заведён.
return walSilent
case ck.Stuck() && (w.warnedAt == 0 || ck.Log >= w.warnedAt*walGrowth):
w.warnedAt = ck.Log
return walStuck
case ck.Complete() && w.warnedAt != 0:
// Именно `Complete`, а не «порог перестал срабатывать»: журнал, упавший
// ниже порога, но так и не перенесённый, — это всё ещё удерживаемый
// снимок. Строка «догнали» при нуле перенесённых страниц утверждала бы
// то, чего никто не проверял.
w.warnedAt = 0
return walRecovered
default:
return walSilent
}
}
+170
View File
@@ -0,0 +1,170 @@
package main
import (
"context"
"log/slog"
"path/filepath"
"sync"
"testing"
"time"
"git.vakhrushev.me/av/healthlog/internal/store"
)
// Решение «сказать ли владельцу» проверяется таблицей, а не ожиданием минут:
// состояние копится по тикам, и без отдельной точки его пришлось бы проверять
// прогоном цикла.
func TestКогдаГоворитьОНеразобранномЖурнале(t *testing.T) {
t.Parallel()
const over = 100000 // заведомо больше порога, выраженного в страницах
stuck := store.Checkpoint{Log: over, Checkpointed: 0, PageSize: 4096}
worse := store.Checkpoint{Log: over * 4, Checkpointed: 0, PageSize: 4096}
slightlyWorse := store.Checkpoint{Log: over + 1, Checkpointed: 0, PageSize: 4096}
fine := store.Checkpoint{Log: 12, Checkpointed: 12, PageSize: 4096}
// Занятый чекпойнт: исход не измерен, `-1` вместо чисел.
unknown := store.Checkpoint{Busy: true, Log: -1, Checkpointed: -1, PageSize: 4096}
var w walWatch
cases := []struct {
name string
in store.Checkpoint
want walSay
}{
{"первый застрявший чекпойнт", stuck, walStuck},
{"то же состояние — молчим", stuck, walSilent},
{"чуть хуже — всё ещё молчим", slightlyWorse, walSilent},
{"занятый тик посреди беды молчит", unknown, walSilent},
{"и не сбрасывает накопленное", stuck, walSilent},
{"стало заметно хуже", worse, walStuck},
{"разобрался — говорим о возврате", fine, walRecovered},
{"норма держится — молчим", fine, walSilent},
{"застрял снова", stuck, walStuck},
}
for _, c := range cases {
if got := w.see(c.in); got != c.want {
t.Errorf("%s: сказано %v, ждали %v", c.name, got, c.want)
}
}
}
// Цикл обязан пережить отказ базы: обслуживание, умершее от временного отказа,
// молча перестало бы разбирать журнал до конца жизни процесса.
func TestЦиклЧекпойнтаПереживаетОтказ(t *testing.T) {
t.Parallel()
st, err := store.Open(filepath.Join(t.TempDir(), "healthlog.db"))
if err != nil {
t.Fatalf("открытие базы: %v", err)
}
// Закрытая база — самый простой источник устойчивого отказа чекпойнта.
if err := st.Close(); err != nil {
t.Fatalf("закрытие базы: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
seen := &lines{}
done := make(chan struct{})
go func() {
defer close(done)
keepWAL(ctx, st, slog.New(seen), time.Millisecond)
}()
// Даём циклу натолкнуться на отказ много раз подряд.
time.Sleep(50 * time.Millisecond)
select {
case <-done:
t.Fatal("цикл вышел сам, не дождавшись отмены")
default:
}
// Отказ обязан быть виден: молча не разбирающийся журнал обнаруживается
// только по свободному месту.
if !seen.has("wal checkpoint failed") {
t.Error("отказ чекпойнта не оставил записи владельцу")
}
cancel()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("цикл не вышел по отмене")
}
}
// Отмена — единственный законный повод выйти, и выйти надо сразу: горутина
// ждётся в общем бюджете остановки вместе с воркером свёртки.
func TestЦиклЧекпойнтаВыходитПоОтмене(t *testing.T) {
t.Parallel()
st, err := store.Open(filepath.Join(t.TempDir(), "healthlog.db"))
if err != nil {
t.Fatalf("открытие базы: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
defer close(done)
keepWAL(ctx, st, slog.New(slog.DiscardHandler), time.Millisecond)
}()
cancel()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("цикл не вышел по отмене")
}
}
// Ветка «фоновые горутины не уложились в бюджет» — последняя защита инварианта
// «доставка либо свёрнута целиком, либо остаётся pending». Прогоном сервиса её
// не проверить: бюджет тридцать секунд, а заставить воркер зависнуть нечем.
func TestОжиданиеФоновыхГорутин(t *testing.T) {
t.Parallel()
closed := make(chan struct{})
close(closed)
if !waitBackground(context.Background(), closed, slog.New(slog.DiscardHandler)) {
t.Error("вышедшие горутины не дождались")
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
seen := &lines{}
if waitBackground(ctx, make(chan struct{}), slog.New(seen)) {
t.Error("зависшие горутины объявлены вышедшими — база закрылась бы из-под них")
}
if !seen.has("shutdown budget exceeded") {
t.Error("превышение бюджета осталось без строки владельцу")
}
}
// lines — slog.Handler, копящий сообщения: проверяется факт записи, не данные.
type lines struct {
mu sync.Mutex
msg []string
}
func (l *lines) Enabled(context.Context, slog.Level) bool { return true }
func (l *lines) Handle(_ context.Context, rec slog.Record) error {
l.mu.Lock()
defer l.mu.Unlock()
l.msg = append(l.msg, rec.Message)
return nil
}
func (l *lines) WithAttrs([]slog.Attr) slog.Handler { return l }
func (l *lines) WithGroup(string) slog.Handler { return l }
func (l *lines) has(msg string) bool {
l.mu.Lock()
defer l.mu.Unlock()
for _, m := range l.msg {
if m == msg {
return true
}
}
return false
}
+53 -15
View File
@@ -9,6 +9,7 @@ import (
"net" "net"
"net/http" "net/http"
"os/signal" "os/signal"
"sync"
"syscall" "syscall"
"time" "time"
@@ -46,6 +47,31 @@ func runServe(args []string) error {
return serve(ctx, cfg, logging.New(cfg.Log.Level, cfg.Log.Format), nil) return serve(ctx, cfg, logging.New(cfg.Log.Level, cfg.Log.Format), nil)
} }
// waitBackground ждёт выхода фоновых горутин и говорит, дождался ли.
//
// Отдельной функцией потому, что это единственная ветка остановки, у которой
// есть исход, и проверить её прогоном сервиса нельзя: бюджет — тридцать секунд,
// а заставить воркер зависнуть по требованию нечем.
//
// Не дождались — база НЕ закрывается: её транзакцию свернёт выход процесса, и
// доставка останется `pending`, то есть будет подобрана следующим стартом.
// Закрытая из-под воркера, она дала бы ERROR по доставке, с которой всё в
// порядке.
//
// Этап в записи называется общим именем, а не воркером свёртки: ждём мы двоих,
// и назвать виновным одного из них значило бы угадать. Чекпойнт при этом
// выходит по отмене немедленно, так что практически это всё тот же воркер, — но
// лог не должен утверждать того, чего не проверял.
func waitBackground(shutdownCtx context.Context, done <-chan struct{}, log *slog.Logger) bool {
select {
case <-done:
return true
case <-shutdownCtx.Done():
log.Warn("shutdown budget exceeded", "stage", "background")
return false
}
}
// serve поднимает сервис и ведёт его до отмены контекста. // serve поднимает сервис и ведёт его до отмены контекста.
// //
// Контекст параметром, а не подпиской на сигнал внутри: иначе весь жизненный // Контекст параметром, а не подпиской на сигнал внутри: иначе весь жизненный
@@ -119,23 +145,41 @@ func serve(ctx context.Context, cfg *config.Config, log *slog.Logger, ready func
return fmt.Errorf("listen %q: %w", cfg.Server.Addr, err) return fmt.Errorf("listen %q: %w", cfg.Server.Addr, err)
} }
workerCtx, stopWorker := context.WithCancel(context.Background()) // Обе фоновые горутины живут на одном контексте и ждутся вместе. Вместе —
defer stopWorker() // потому что база закрывается ПОСЛЕ выхода обеих: закрытая из-под воркера,
workerDone := make(chan struct{}) // она даёт ERROR по доставке, с которой всё в порядке, а из-под чекпойнта —
go func() { // отказ обслуживания на ровном месте.
defer close(workerDone) bgCtx, stopBackground := context.WithCancel(context.Background())
defer stopBackground()
var bg sync.WaitGroup
bg.Go(func() {
// Первый проход воркера и есть подбор неразобранного при старте: // Первый проход воркера и есть подбор неразобранного при старте:
// отдельного кода для него нет намеренно. // отдельного кода для него нет намеренно.
worker.Run(workerCtx) worker.Run(bgCtx)
})
bg.Go(func() {
keepWAL(bgCtx, st, log, checkpointInterval)
})
backgroundDone := make(chan struct{})
go func() {
bg.Wait()
close(backgroundDone)
}() }()
errCh := make(chan error, 1) errCh := make(chan error, 1)
go func() { go func() {
// Параметры обслуживания журнала — в той же строке, а не отдельной:
// горутина, которую забыли запустить, иначе неотличима от здоровой
// ровно до того дня, когда журнал упрётся в диск. Ноль новых строк, обе
// константы проверяемы глазами.
log.Info("server started", log.Info("server started",
"addr", ln.Addr().String(), "addr", ln.Addr().String(),
"db_path", cfg.Storage.DBPath, "db_path", cfg.Storage.DBPath,
"archive_dir", arch.Root(), "archive_dir", arch.Root(),
"max_body_mb", cfg.Ingest.MaxBodyMB) "max_body_mb", cfg.Ingest.MaxBodyMB,
"wal_checkpoint_sec", int64(checkpointInterval.Seconds()),
"wal_limit_mb", store.JournalSizeLimitMB)
if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) { if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- fmt.Errorf("serve: %w", err) errCh <- fmt.Errorf("serve: %w", err)
@@ -172,15 +216,9 @@ func serve(ctx context.Context, cfg *config.Config, log *slog.Logger, ready func
} }
} }
stopWorker() stopBackground()
select { if waitBackground(shutdownCtx, backgroundDone, log) {
case <-workerDone:
closeStore() closeStore()
case <-shutdownCtx.Done():
// Воркер не вышел в бюджет. База не закрывается: её транзакцию свернёт
// выход процесса, и доставка останется `pending` — то есть будет
// подобрана следующим стартом.
log.Warn("shutdown budget exceeded", "stage", "fold-worker")
} }
return serveErr return serveErr
} }
+167
View File
@@ -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 Родной экспорт 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` не выполняет измерения и потому не пишет
предупреждений владельцу** (данные из будущего, противоречащий род). С условным
опросом они становятся функцией смены версии витрины, а не числа запросов;
состояние при этом не теряется — следующая доставка меняет версию, ответ
собирается, и предупреждение пишется.
### Свёртка и размер ответа ### Свёртка и размер ответа
Запросов к метрике ровно два, и это один запрос с необязательным параметром: Запросов к метрике ровно два, и это один запрос с необязательным параметром:
-1
View File
@@ -21,7 +21,6 @@
## высокий ## высокий
- [Тай-брейк при равной полноте точек](taj-brejk-pri-ravnoj-polnote.md) — Решено: брать бо́льшее значение. Порядок канонических форм берёт меньшее в 96% случаев — для накопительных это систематический недосчёт - [Тай-брейк при равной полноте точек](taj-brejk-pri-ravnoj-polnote.md) — Решено: брать бо́льшее значение. Порядок канонических форм берёт меньшее в 96% случаев — для накопительных это систематический недосчёт
- [Цена первого читающего маршрута: память, WAL и повторный опрос](cena-chitayushchego-marshruta.md) — Решено: чекпойнт по таймеру плюс ETag по data_version. Берётся перед Read API — тот строится поверх этой машинерии
- [Read API: точки, выбор слоя, свёртка по сетке](read-api-tochki.md) — Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может - [Read API: точки, выбор слоя, свёртка по сетке](read-api-tochki.md) — Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может
- [OpenAPI-спека и Swagger UI](openapi-swagger.md) — Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате - [OpenAPI-спека и Swagger UI](openapi-swagger.md) — Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате
- [MCP-сервер поверх Read API](mcp-server.md) — Агент-медик — первый заказчик проекта, а подключить его сейчас нечем - [MCP-сервер поверх Read API](mcp-server.md) — Агент-медик — первый заказчик проекта, а подключить его сейчас нечем
@@ -1,98 +0,0 @@
# Цена первого читающего маршрута: память, WAL и повторный опрос
**Приоритет:** высокий
**Решение принято 2026-08-02: вариант (г) плюс (в), именно в таком порядке.**
Разбирается без владельца: контракт хранения не меняется, данные не трогаются,
а обе части — общепринятая практика, а не собственный дизайн. Чекпойнт по
таймеру закрывает единственное проявление, которое ломает приём (диск), и стоит
одной горутины; `ETag` по `PRAGMA data_version` — один запрос к базе, снимает и
повтор, и большую часть читающих транзакций, не заводя кеша ответа.
Вариант (а) — предел и дедлайн маршрута — **не отвергнут, а отложен** до
[Read API точек](read-api-tochki.md): там предел размера ответа всё равно
проектируется, и делать его дважды не нужно. Вариант (б) не берём, пока
счётчик не заговорит. Вариант (д) — последним, если (в) окажется мало.
Задача берётся **перед** Read API: тот строится поверх этой машинерии.
Ниже — исходная постановка блокера, она же ТЗ.
## Что решить
Чем ограничить стоимость маршрута чтения, у которого нет ни предела ответа, ни
собственного дедлайна, ни условного запроса. Вопрос поднялся на каталоге
(`GET /api/v1/metrics`, change `2026-08-02-katalog-i-rod-agregacii`), но
принадлежит не ему: тот же ответ понадобится Read API точек и MCP, и решать его
трижды нельзя.
Три измеренных проявления одной причины.
**Память.** Снимок каталога держит разжатые точки окна по всем метрикам сразу,
хотя измерение идёт по одной метрике. Замер враждебного прохода ревью: 20 метрик
× 8 часов × 5000 точек — 693 мс и +153 МиБ живой кучи на один запрос.
Предварительный отбор по учётным колонкам (сделан) снял разжатие заведомо
непригодных часов, но множители «метрики × окно × точки × одновременные запросы»
остались без потолка. Приём живёт в том же процессе и уже даёт пик 768 МиБ на
теле 40 МиБ; OOM убивает приём, а доставка, не попавшая в архив, телефоном не
переприсылается.
**WAL.** Замер эксплуатационного прохода на копии с драйвером и PRAGMA проекта:
непрерывная запись плюс четыре читающих транзакции внахлёст дают рост `-wal`
около 7 МБ/с без верхней границы (40 МБ за пять секунд), тогда как тот же
писатель без читателей стабилизируется на 4 МБ. Пассивный чекпойнт SQLite не
продвигается дальше снимка самого старого активного читателя, и ошибки при этом
нет — виден только растущий файл. `PRAGMA wal_checkpoint` в проекте не
вызывается нигде.
**Повторный опрос.** Спека каталога требует побайтового совпадения двух ответов
на неизменившейся витрине — то есть ресурс по построению пригоден для условного
запроса, а `ETag`/`304` не выставляется. Потребителей трое (агент-медик, трекер,
игра), и самый частый их запрос — повтор неизменившегося.
## Варианты и цена
**а. Предел и дедлайн у маршрута.** Потолок числа метрик и точек в одном ответе,
собственный `context.WithTimeout`, честный отказ при превышении. Цена: клиент
обязан уметь читать частичный каталог, то есть появляется пагинация — контракт
чтения усложняется на первой же ручке.
**б. Измерение потоком по метрике внутри той же транзакции.** Точки метрики
освобождаются сразу после вердикта; требование «один снимок» не нарушается. Цена:
хранилище перестаёт возвращать снимок значением и начинает отдавать его
последовательно (итератор или колбэк) — то есть меняется форма границы
`store`/`catalog`, ради случая, которого живой поток пока не производит.
**в. Условный запрос: `ETag` по `PRAGMA data_version`.** Снимает и стоимость
повтора, и большую часть читающих транзакций разом: клиент с непротухшим `ETag`
получает `304`, и снимок не открывается вовсе. Цена: один лишний запрос к базе на
каждый вызов и обещание клиенту, что версия витрины меняется не чаще, чем данные.
**г. Периодический `wal_checkpoint(PASSIVE)` по таймеру рядом с воркером.**
Лечит только WAL, зато дёшево и без изменения контракта. Память и повтор
остаются.
**д. Кеш ответа на короткий TTL.** Закрывает всё сразу, но заводит третье
представление того же факта, и его инвалидация становится новым местом, где можно
ошибиться молча. Дизайн каталога отверг кеш именно поэтому.
## Что заблокировано
Ничего сегодня: на живом корпусе каталог собирается за 45 мс, потребителей у него
пока нет, а маршрут живёт в доверенной сети. Блокировано будущее — Read API
точек, где объёмы на порядок больше, и выкладка наружу, где опрос станет
непрерывным.
## Рекомендация
**г + в, именно в таком порядке.** Чекпойнт по таймеру закрывает единственное
проявление, которое ломает приём (диск), и стоит одной горутины без изменения
контракта. `ETag` по `data_version` — один запрос к базе, снимает и повтор, и
большую часть читающих транзакций, и делает это без кеша ответа.
Вариант «а» откладывать до Read API точек: там предел размера ответа всё равно
проектируется (`read-api-tochki.md`), и делать его дважды не нужно. Вариант «б»
не брать, пока счётчик не заговорит: он меняет форму границы ради случая,
которого поток не производит. Вариант «д» — последним, если «в» окажется мало.
Связано: `docs/architecture.md` → «Измерение рода агрегации», `read-api-tochki.md`,
`stats-nablyudaemost.md`.
+10 -2
View File
@@ -21,6 +21,14 @@
исчерпанного первого, и `BaseContext`, производный от контекста жизненного цикла, исчерпанного первого, и `BaseContext`, производный от контекста жизненного цикла,
чтобы долгий запрос об остановке узнавал. чтобы долгий запрос об остановке узнавал.
**Цена этой ветки выросла** (change `cena-chitayushchego-marshruta`): база в ней
не закрывается, а значит не закрывается и закреплённое соединение версии
витрины — последнего соединения к базе не наступает, SQLite не делает финального
чекпойнта, и рядом с базой остаётся неразобранный `-wal` до 64 МиБ. Данные целы
(следующее открытие проиграет журнал), но файл базы в этом состоянии нельзя
переносить без его `-wal`. Обвинение в логе при этом стало честнее: этап
называется `background`, а не `fold-worker`, потому что ждут двоих.
**Миграция молчит и не прерывается штатной остановкой.** `store.migrate` не **Миграция молчит и не прерывается штатной остановкой.** `store.migrate` не
пишет ни одной записи — ни «начал», ни «закончил», ни длительность, — а первая пишет ни одной записи — ни «начал», ни «закончил», ни длительность, — а первая
строка в логе появляется уже после успешного открытия базы. Если миграция идёт строка в логе появляется уже после успешного открытия базы. Если миграция идёт
@@ -37,5 +45,5 @@
Готово, когда `WARN` о превышении бюджета называет виновный этап честно, а в логе Готово, когда `WARN` о превышении бюджета называет виновный этап честно, а в логе
старта видно, что миграции накатывались и сколько это заняло. старта видно, что миграции накатывались и сколько это заняло.
Связано: `cmd/healthlog/serve.go`, `internal/store/store.go`, Связано: `cmd/healthlog/serve.go`, `internal/store/store.go`, change
`cena-chitayushchego-marshruta.md`. `2026-08-02-cena-chitayushchego-marshruta` (архив).
+20 -4
View File
@@ -36,10 +36,26 @@
величины выглядят как «0.5», означая разное; полярность придётся назвать вслух в величины выглядят как «0.5», означая разное; полярность придётся назвать вслух в
`architecture.md`, иначе через полгода два места кода поймут поле по-разному. `architecture.md`, иначе через полгода два места кода поймут поле по-разному.
**Предел размера ответа тоже здесь.** У каталога его нет намеренно: правило **Предел размера ответа тоже здесь, и он унаследовал измеренную цену.** У
размера — общее для маршрутов чтения, и задавать его мимоходом на первой ручке каталога предела нет намеренно: правило размера — общее для маршрутов чтения, и
значило бы решить контракт до того, как известна форма тяжёлого ответа. Каталог задавать его мимоходом на первой ручке значило бы решить контракт до того, как
станет первым его потребителем. известна форма тяжёлого ответа. Каталог станет первым его потребителем.
Цена измерена на каталоге (задача «цена читающего маршрута», закрыта чекпойнтом
WAL и условным запросом): 693 мс и +153 МиБ живой кучи на враждебном запросе
(20 метрик × 8 часов × 5000 точек), при том что приём в том же процессе уже даёт
пик 768 МиБ на теле 40 МиБ. Условный запрос снял повтор, но первый запрос стоит
столько же, а множители «метрики × окно × точки × одновременные запросы»
по-прежнему без потолка. Сюда же уезжают отложенные варианты той задачи:
собственный дедлайн маршрута и потоковое измерение по метрике (второе — только
если счётчик заговорит).
**Машинерия условного запроса готова, и её надо взять, а не написать заново.**
`store.VersionedRead` держит правило «версией, снятой после чтения, не
подписывать»; `httpapi` — разбор `If-None-Match` и `304`. Метка обязана нести
**область действия**: у точек ответ есть функция параметров запроса, и
`etag(scope, version)` требует их канонизированную форму — иначе `304` ответит
на другой набор данных. Детали — `docs/architecture.md`, «Условный запрос».
**Форма провода наследуется от каталога, и это надо решить один раз.** Сегодня **Форма провода наследуется от каталога, и это надо решить один раз.** Сегодня
типы `internal/catalog` сами несут json-теги, а транспорт владеет только типы `internal/catalog` сами несут json-теги, а транспорт владеет только
+9
View File
@@ -48,3 +48,12 @@
запроса, ни адреса клиента: жалобу потребителя не сопоставить с записью, а запроса, ни адреса клиента: жалобу потребителя не сопоставить с записью, а
выгрузку каталога посторонним — не отличить от планового опроса агента. У приёма выгрузку каталога посторонним — не отличить от планового опроса агента. У приёма
корреляция есть (`delivery_id`), у чтения аналога нет. корреляция есть (`delivery_id`), у чтения аналога нет.
**Обслуживание журнала WAL тоже спрашивается здесь.** Признак «журнал не
разбирается» (чекпойнт по таймеру, change `cena-chitayushchego-marshruta`)
живёт одной строкой `WARN` в ротируемом docker-логе: состояние держится днями, а
сказано о нём один раз. Вопрос «журнал сейчас разбирается?» сегодня не имеет
ответа нигде, кроме `df`. В `/stats` просятся последний исход чекпойнта (когда,
сколько страниц лежит и сколько перенесено) и — тем же полем — доля ответов
чтения, которые удалось подписать `ETag`: механизм условного запроса может
перестать окупаться под плотным потоком, и снаружи это неотличимо от нормы.
+6
View File
@@ -161,3 +161,9 @@
архиве, и ответ «пересворачивать нечего» произносится с числом.** Утверждение архиве, и ответ «пересворачивать нечего» произносится с числом.** Утверждение
без числа не отличается от предположения, а цена ошибки здесь — необратимое без числа не отличается от предположения, а цена ошибки здесь — необратимое
решение о судьбе тел. решение о судьбе тел.
- **Тест «в логе нет значения» проверяет запись без служебных полей, а не сырой
буфер.** Метка времени содержит доли секунды, поэтому искомая подстрока
находится в ней сама: проверка на «5.1» краснела примерно раз на сотню
прогонов от хода часов, а не от утечки. Разбираем запись, выбрасываем `time`
и ищем в остатке. Правило общее — таких тестов будет больше (токены, тела
запросов, координаты объектов).
+21
View File
@@ -110,3 +110,24 @@
стоит одной строки, а пропуск молчащий стоил семи находок и отдельной задачи стоит одной строки, а пропуск молчащий стоил семи находок и отдельной задачи
на их дозакрытие. Состав проходов и профилей при этом не трогаем: они на их дозакрытие. Состав проходов и профилей при этом не трогаем: они
сработали ровно так, как задуманы, — их просто не позвали. сработали ровно так, как задуманы, — их просто не позвали.
## 2026-08-02 — тест на утечку значений в лог краснел от хода часов
- **Где:** `internal/fold/log_test.go`, `TestFoldНесравнимыеНаборыДаютWarn`
- **Симптом:** гейт задачи про цену читающего маршрута покраснел на чужом
тесте: «в логе оказалось значение точки "5.1"». Значения в логе не было —
подстрока нашлась в метке времени записи (`…T20:23:35.193…` содержит `5.1`).
Повторный прогон зелёный.
- **Причина:** утверждение искало секрет в **сыром буфере** записи, а буфер
содержит служебное поле `time` с долями секунды. Вероятность совпадения для
двухсимвольного числа с точкой — около процента на прогон, то есть тест
флаки по построению, и краснеет он у того, кто мимо проходил.
- **Почему не поймали:** шаг `flaky` гейта гоняет набор дважды подряд —
вероятность поймать однопроцентную флаки за два прогона мала, а сам тест
выглядит образцовым: он проверяет ровно тот инвариант, который проекту
дороже всего («данные о здоровье чувствительнее токенов»). Ни один проход
ревью не смотрит на тесты чужих задач.
- **Что меняем:** правило в [conventions.md](conventions.md) — проверка «в логе
нет значения» разбирает запись и выбрасывает `time`, а не ищет в сыром
буфере. Гейт не трогаем: два прогона против однопроцентной флаки не помогут,
а десять стоили бы дороже самой находки.
+69 -5
View File
@@ -204,6 +204,54 @@ type Metric struct {
Layers []LayerRange `json:"layers"` Layers []LayerRange `json:"layers"`
} }
// Snapshot — каталог вместе с версией ответа.
//
// Версия пустая, когда подписать ответ нечем: витрина изменилась, пока он
// собирался, или прочитать её версию не удалось. Это не отказ — ответ уходит
// целиком, просто без условной метки, ровно как до появления условного запроса.
//
// Имя перекликается с `store.CatalogSnapshot` намеренно и означает другое: тот
// снимок — вход измерения (объекты и разрезы), этот — готовый ответ.
type Snapshot struct {
Version string
Metrics []Metric
}
// Version — версия ОТВЕТА каталога: версия витрины плюс горизонт измерения.
//
// Горизонт входит в неё, потому что ответ есть функция обоих. Час объекта
// сравнивается с `Now() + horizonSlack`, и с ходом часов состав окна меняется
// без единого коммита: метка из будущего, лежащая в витрине (сбитые часы
// телефона — состояние, о котором рядом пишется WARN), въезжает в окно сама и
// способна перевернуть измеренный род. Построено и прогнано: та же версия
// витрины, `cumulative` против `unknown`, ноль коммитов между.
//
// Огрубление до часа не приблизительное, а точное: `hour_utc` объектов лежит
// ровно на часах, поэтому отбор `hour_utc <= горизонт` меняется ровно при
// переходе горизонта через час. Цена — один полный ответ в час на потребителя
// при неизменившейся витрине; сборка каталога на живом корпусе стоит 45 мс.
func (s *Service) Version(ctx context.Context) (string, error) {
version, err := s.store.StateVersion(ctx)
if err != nil {
// Отказ пробы отказом маршрута не является — но и молчать о нём нельзя:
// без метки условный запрос выключается для всех потребителей, а
// снаружи это неотличимо от нормы. DEBUG, потому что адресат здесь
// разработчик: владельцу об этом скажет `/stats`, когда появится.
s.log.DebugContext(ctx, "state version unavailable", "capability", "query", "error", err)
return "", err
}
return stamp(version, store.Now().Add(horizonSlack)), nil
}
// stamp склеивает версию витрины с горизонтом. Пустая версия остаётся пустой:
// подписывать нечем — значит нечем, и горизонт этого не меняет.
func stamp(version string, horizon time.Time) string {
if version == "" {
return ""
}
return version + "." + horizon.Truncate(time.Hour).Format("2006010215")
}
// Service собирает каталог по витрине. // Service собирает каталог по витрине.
type Service struct { type Service struct {
store *store.Store store *store.Store
@@ -223,9 +271,16 @@ func New(st *store.Store, log *slog.Logger) *Service {
// и объяснять, на каком составе данных оно снято; устаревшее при этом выглядит // и объяснять, на каком составе данных оно снято; устаревшее при этом выглядит
// ровно как свежее. Вычисленный на запрос род есть функция витрины, а витрина — // ровно как свежее. Вычисленный на запрос род есть функция витрины, а витрина —
// функция журнала, и устаревать в нём нечему. // функция журнала, и устаревать в нём нечему.
func (s *Service) Metrics(ctx context.Context) ([]Metric, error) { // Ответ подписывается версией витрины: она нужна условному запросу, и снимает
// её хранилище — двумя пробами вокруг чтения. Порядок проб там же и объяснён:
// версия, снятая после чтения, пометила бы устаревший снимок свежей меткой.
func (s *Service) Metrics(ctx context.Context) (Snapshot, error) {
horizon := store.Now().Add(horizonSlack) horizon := store.Now().Add(horizonSlack)
snap, err := s.store.ReadCatalog(ctx, store.CatalogWindow{
var snap store.CatalogSnapshot
version, err := s.store.VersionedRead(ctx, func(ctx context.Context) error {
var err error
snap, err = s.store.ReadCatalog(ctx, store.CatalogWindow{
Fine: string(hae.LayerMinute), Fine: string(hae.LayerMinute),
Coarse: string(hae.LayerHour), Coarse: string(hae.LayerHour),
Hours: Window, Hours: Window,
@@ -233,7 +288,9 @@ func (s *Service) Metrics(ctx context.Context) ([]Metric, error) {
CoarsePoints: coarsePoints, CoarsePoints: coarsePoints,
MinFinePoints: minFinePoints, MinFinePoints: minFinePoints,
}) })
if err != nil { return err
})
if err != nil { //nolint:nestif // ветка одна, вложенность даёт лог по адресату
// Единственный логирующий чекпоинт исхода: транспорт переводит ошибку в // Единственный логирующий чекпоинт исхода: транспорт переводит ошибку в
// ответ и второй раз её не пишет. // ответ и второй раз её не пишет.
// //
@@ -247,7 +304,7 @@ func (s *Service) Metrics(ctx context.Context) ([]Metric, error) {
} else { } else {
s.log.ErrorContext(ctx, "catalog failed", "capability", "query", "error", err) s.log.ErrorContext(ctx, "catalog failed", "capability", "query", "error", err)
} }
return nil, err return Snapshot{}, err
} }
out := make([]Metric, 0, len(snap.Layers)) out := make([]Metric, 0, len(snap.Layers))
@@ -286,7 +343,14 @@ func (s *Service) Metrics(ctx context.Context) ([]Metric, error) {
Layers: group.layers, Layers: group.layers,
}) })
} }
return out, nil if version == "" {
// Витрина изменилась, пока ответ собирался (или версию не прочитать).
// Ответ уйдёт без метки — это безопасная сторона, но след нужен: под
// плотным потоком доставок так может уходить каждый ответ, и тогда
// механизм не окупается вовсе.
s.log.DebugContext(ctx, "catalog unsigned", "capability", "query")
}
return Snapshot{Version: stamp(version, horizon), Metrics: out}, nil
} }
type metricGroup struct { type metricGroup struct {
+36 -14
View File
@@ -129,7 +129,7 @@ func TestКаталогОтдаётРазрезыИИзмеренныйРод(t
incoming("sleep_analysis", "raw", "hr", time.Date(2026, 6, 1, 3, 7, 0, 0, time.UTC), 1), incoming("sleep_analysis", "raw", "hr", time.Date(2026, 6, 1, 3, 7, 0, 0, time.UTC), 1),
}) })
metrics, err := service(t, st).Metrics(context.Background()) metrics, err := metricsOf(context.Background(), service(t, st))
if err != nil { if err != nil {
t.Fatalf("каталог: %v", err) t.Fatalf("каталог: %v", err)
} }
@@ -179,7 +179,7 @@ func TestКаталогНеИзмеряетПоНижнемуСлою(t *testing
} }
merge(t, st, points) merge(t, st, points)
metrics, err := service(t, st).Metrics(context.Background()) metrics, err := metricsOf(context.Background(), service(t, st))
if err != nil { if err != nil {
t.Fatalf("каталог: %v", err) t.Fatalf("каталог: %v", err)
} }
@@ -198,7 +198,7 @@ func TestКаталогОграничиваетОкно(t *testing.T) {
st := openStore(t) st := openStore(t)
fill(t, st, "step_count", catalog.Window+7, true) fill(t, st, "step_count", catalog.Window+7, true)
metrics, err := service(t, st).Metrics(context.Background()) metrics, err := metricsOf(context.Background(), service(t, st))
if err != nil { if err != nil {
t.Fatalf("каталог: %v", err) t.Fatalf("каталог: %v", err)
} }
@@ -227,7 +227,7 @@ func TestКаталогПоказываетРасхождениеЕдиниц(t
incoming("walking_running_distance", "minute", "m", base.Add(2*time.Hour), 2), incoming("walking_running_distance", "minute", "m", base.Add(2*time.Hour), 2),
}) })
metrics, err := service(t, st).Metrics(context.Background()) metrics, err := metricsOf(context.Background(), service(t, st))
if err != nil { if err != nil {
t.Fatalf("каталог: %v", err) t.Fatalf("каталог: %v", err)
} }
@@ -249,7 +249,7 @@ func TestКаталогПоказываетРасхождениеЕдиниц(t
func TestКаталогПустойВитриныПуст(t *testing.T) { func TestКаталогПустойВитриныПуст(t *testing.T) {
t.Parallel() t.Parallel()
metrics, err := service(t, openStore(t)).Metrics(context.Background()) metrics, err := metricsOf(context.Background(), service(t, openStore(t)))
if err != nil { if err != nil {
t.Fatalf("каталог: %v", err) t.Fatalf("каталог: %v", err)
} }
@@ -268,7 +268,7 @@ func TestКаталогПересчитываетРодНаКаждыйЗапр
ctx := context.Background() ctx := context.Background()
fill(t, st, "step_count", 2, true) fill(t, st, "step_count", 2, true)
before, err := svc.Metrics(ctx) before, err := metricsOf(ctx, svc)
if err != nil { if err != nil {
t.Fatalf("каталог: %v", err) t.Fatalf("каталог: %v", err)
} }
@@ -277,7 +277,7 @@ func TestКаталогПересчитываетРодНаКаждыйЗапр
} }
fill(t, st, "step_count", 5, true) fill(t, st, "step_count", 5, true)
after, err := svc.Metrics(ctx) after, err := metricsOf(ctx, svc)
if err != nil { if err != nil {
t.Fatalf("каталог: %v", err) t.Fatalf("каталог: %v", err)
} }
@@ -310,7 +310,7 @@ func TestКаталогНеИзмеряетПоБудущимЧасам(t *testi
merge(t, st, future) merge(t, st, future)
handler := &logged{} handler := &logged{}
metrics, err := catalog.New(st, slog.New(handler)).Metrics(context.Background()) metrics, err := metricsOf(context.Background(), catalog.New(st, slog.New(handler)))
if err != nil { if err != nil {
t.Fatalf("каталог: %v", err) t.Fatalf("каталог: %v", err)
} }
@@ -344,7 +344,7 @@ func TestКаталогНеСверяетСлоиРазныхЕдиниц(t *tes
} }
merge(t, st, points) merge(t, st, points)
metrics, err := service(t, st).Metrics(context.Background()) metrics, err := metricsOf(context.Background(), service(t, st))
if err != nil { if err != nil {
t.Fatalf("каталог: %v", err) t.Fatalf("каталог: %v", err)
} }
@@ -369,7 +369,7 @@ func TestКаталогПоказываетМетрикуСПустымИмен
incoming("step_count", "minute", "count", base, 2), incoming("step_count", "minute", "count", base, 2),
}) })
metrics, err := service(t, st).Metrics(context.Background()) metrics, err := metricsOf(context.Background(), service(t, st))
if err != nil { if err != nil {
t.Fatalf("каталог: %v", err) t.Fatalf("каталог: %v", err)
} }
@@ -402,7 +402,7 @@ func TestКаталогПишетПредупреждениеОПротивор
merge(t, st, points) merge(t, st, points)
handler := &logged{} handler := &logged{}
metrics, err := catalog.New(st, slog.New(handler)).Metrics(context.Background()) metrics, err := metricsOf(context.Background(), catalog.New(st, slog.New(handler)))
if err != nil { if err != nil {
t.Fatalf("каталог: %v", err) t.Fatalf("каталог: %v", err)
} }
@@ -439,7 +439,7 @@ func TestКаталогОграничиваетЧислоЕдиниц(t *testing
} }
merge(t, st, points) merge(t, st, points)
metrics, err := service(t, st).Metrics(context.Background()) metrics, err := metricsOf(context.Background(), service(t, st))
if err != nil { if err != nil {
t.Fatalf("каталог: %v", err) t.Fatalf("каталог: %v", err)
} }
@@ -463,7 +463,7 @@ func TestКаталогСообщаетОбОтказеХранилища(t *tes
} }
handler := &logged{} handler := &logged{}
if _, err := catalog.New(st, slog.New(handler)).Metrics(context.Background()); err == nil { if _, err := metricsOf(context.Background(), catalog.New(st, slog.New(handler))); err == nil {
t.Fatal("каталог на закрытой базе собрался") t.Fatal("каталог на закрытой базе собрался")
} }
rec, ok := handler.find("catalog failed") rec, ok := handler.find("catalog failed")
@@ -488,7 +488,7 @@ func TestКаталогНеПутаетОтменуСоСбоем(t *testing.T)
cancel() cancel()
handler := &logged{} handler := &logged{}
if _, err := catalog.New(st, slog.New(handler)).Metrics(ctx); err == nil { if _, err := metricsOf(ctx, catalog.New(st, slog.New(handler))); err == nil {
t.Fatal("каталог собрался на отменённом контексте") t.Fatal("каталог собрался на отменённом контексте")
} }
if _, ok := handler.find("catalog failed"); ok { if _, ok := handler.find("catalog failed"); ok {
@@ -498,3 +498,25 @@ func TestКаталогНеПутаетОтменуСоСбоем(t *testing.T)
t.Error("отмена не отмечена вовсе — исход операции обязан быть виден") t.Error("отмена не отмечена вовсе — исход операции обязан быть виден")
} }
} }
// metricsOf — список метрик каталога без версии витрины: версию проверяют
// отдельные тесты, остальным нужен только состав ответа.
func metricsOf(ctx context.Context, s *catalog.Service) ([]catalog.Metric, error) {
snap, err := s.Metrics(ctx)
return snap.Metrics, err
}
// Каталог подписывает свой ответ версией витрины: без неё транспорту нечего
// поставить в `ETag`, и условный запрос не работает вовсе.
func TestКаталогОтдаётсяСВерсиейВитрины(t *testing.T) {
t.Parallel()
st := openStore(t)
snap, err := service(t, st).Metrics(context.Background())
if err != nil {
t.Fatalf("каталог: %v", err)
}
if snap.Version == "" {
t.Error("каталог собран на стоящей витрине и остался без версии")
}
}
+30
View File
@@ -0,0 +1,30 @@
package catalog
import (
"testing"
"time"
)
// Ответ каталога есть функция снимка И горизонта измерения: метка из будущего,
// лежащая в витрине, въезжает в окно сама, с ходом часов и без единого коммита.
// Построено враждебным проходом: та же версия витрины, `cumulative` против
// `unknown`. Значит горизонт обязан входить в метку — иначе клиент с
// `If-None-Match` получит `304` на изменившийся ответ.
func TestГоризонтВходитВВерсиюОтвета(t *testing.T) {
t.Parallel()
at := time.Date(2026, 6, 1, 10, 30, 0, 0, time.UTC)
if stamp("v", at) == stamp("v", at.Add(2*time.Hour)) {
t.Error("версия не изменилась при сдвиге горизонта на два часа")
}
// Огрубление до часа точное, а не приблизительное: `hour_utc` объектов лежит
// ровно на часах, поэтому отбор меняется ровно при переходе через час.
// Внутри часа метка обязана стоять — иначе она дребезжала бы ежесекундно.
if stamp("v", at) != stamp("v", at.Add(20*time.Minute)) {
t.Error("версия сдвинулась внутри одного часа — метка дребезжит на месте")
}
if stamp("", at) != "" {
t.Error("пустая версия витрины подписана горизонтом — подписывать нечем")
}
}
+12 -2
View File
@@ -176,9 +176,19 @@ func TestFoldНесравнимыеНаборыДаютWarn(t *testing.T) {
t.Errorf("координаты объекта в записи не те: %q", at) t.Errorf("координаты объекта в записи не те: %q", at)
} }
// И ни одного значения точки: данные о здоровье чувствительнее токенов. // И ни одного значения точки: данные о здоровье чувствительнее токенов.
//
// Метка времени из проверки исключается, и это не поблажка: она содержит
// доли секунды, поэтому подстрока вроде "5.1" находится в ней примерно раз
// на сотню прогонов — тест краснел от хода часов, а не от утечки. Проверять
// надо запись без служебного поля, которое значений нести не может.
delete(rec, "time")
clean, err := json.Marshal(rec)
if err != nil {
t.Fatalf("запись лога не сериализуется: %v", err)
}
for _, secret := range []string{"5.1", "До еды"} { for _, secret := range []string{"5.1", "До еды"} {
if strings.Contains(buf.String(), secret) { if strings.Contains(string(clean), secret) {
t.Errorf("в логе оказалось значение точки %q:\n%s", secret, buf.String()) t.Errorf("в логе оказалось значение точки %q:\n%s", secret, clean)
} }
} }
} }
+34 -2
View File
@@ -21,8 +21,36 @@ type catalogResponse struct {
// сравнивающий разобранные структуры, этого не увидел бы — потому приёмочная // сравнивающий разобранные структуры, этого не увидел бы — потому приёмочная
// проверка сравнивает байты ответа. Второй страховки здесь нет намеренно: // проверка сравнивает байты ответа. Второй страховки здесь нет намеренно:
// подстраховка поверх подстраховки прячет отказ первой. // подстраховка поверх подстраховки прячет отказ первой.
//
// Условный запрос стоит ПОСЛЕ проверки токена (её ставит роутер) и ДО сборки
// снимка: в этом весь смысл — самый частый запрос потребителя есть повтор
// неизменившегося, и он не должен стоить ни снимка, ни разжатия точек.
// scopeMetrics — область действия метки каталога. Ответ маршрута не зависит от
// параметров запроса, поэтому область постоянна; у точек и MCP на её месте
// будет канонизированная форма запроса.
const scopeMetrics = "metrics"
func (a *api) handleMetrics(w http.ResponseWriter, r *http.Request) { func (a *api) handleMetrics(w http.ResponseWriter, r *http.Request) {
metrics, err := a.catalog.Metrics(r.Context()) // Values, а не Get: `If-None-Match` клиент вправе прислать несколькими
// строками, и `Get` увидел бы только первую — часть меток осталась бы
// нерассмотренной.
if cond := r.Header.Values("If-None-Match"); len(cond) > 0 {
// Отказ пробы глушится намеренно: он означает лишь, что условного
// ответа не будет, — а настоящий отказ базы всплывёт сборкой каталога
// строкой ниже и будет назван ею один раз.
// Версию спрашиваем У КАТАЛОГА, а не у хранилища: ответ есть функция не
// только состояния витрины, и что ещё в него входит, знает домен. Read
// API точек ответит здесь же своей версией, включающей параметры
// запроса, — транспорту эти правила знать незачем.
if version, err := a.catalog.Version(r.Context()); err == nil {
if tag := etag(scopeMetrics, version); notModified(cond, tag) {
writeNotModified(w, tag)
return
}
}
}
snap, err := a.catalog.Metrics(r.Context())
if err != nil { if err != nil {
// Исход операции логирует доменный слой, транспорт только переводит его // Исход операции логирует доменный слой, транспорт только переводит его
// в ответ. Наружу уходит человекочитаемое сообщение, а не текст ошибки: // в ответ. Наружу уходит человекочитаемое сообщение, а не текст ошибки:
@@ -30,5 +58,9 @@ func (a *api) handleMetrics(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, "каталог не собрался") writeError(w, http.StatusInternalServerError, "каталог не собрался")
return return
} }
writeJSON(w, http.StatusOK, catalogResponse{Metrics: metrics}) // Версии может не быть — витрина изменилась, пока ответ собирался. Тогда
// ответ уходит без метки: это ровно поведение до появления условного
// запроса, то есть деградация в безопасную сторону.
setReadHeaders(w, etag(scopeMetrics, snap.Version))
writeJSON(w, http.StatusOK, catalogResponse{Metrics: snap.Metrics})
} }
+150
View File
@@ -0,0 +1,150 @@
package httpapi
import (
"net/http"
"net/textproto"
"strings"
)
// Условный запрос читающих маршрутов. Помощник общий намеренно: тот же ответ
// понадобится точкам и MCP, а протокол здесь ровно такой, каким его описывает
// HTTP, — второй его экземпляр разошёлся бы с первым в мелочи вроде слабого
// сравнения.
//
// ГРАНИЦА, которую обязан знать следующий потребитель: метка действительна
// только в пределах одного адреса ресурса. У каталога ответ зависит лишь от
// состояния витрины, поэтому версии достаточно; у Read API точек ответ есть
// функция параметров запроса, а у MCP адреса нет вовсе — там в метку обязана
// входить канонизированная форма запроса, иначе «не изменилось» ответит на
// другой набор данных.
// etag собирает метку HTTP из области действия и версии ответа.
//
// Область — параметр, а не забота вызывающего: она и есть то, что помощник
// обязан не дать забыть. Метка действительна в пределах ОДНОГО ресурса, и
// маршрут, чей ответ зависит от параметров запроса (Read API точек) или у
// которого адреса нет вовсе (MCP), кладёт сюда их канонизированную форму.
// Прозой это уже было написано — и прозу компилятор не проверяет: `etag(v)` у
// второго маршрута собрался бы и отдал `304` на чужой набор данных.
//
// Метка СЛАБАЯ, и это не осторожность. Ответ есть функция не только снимка, но
// и горизонта измерения (`Now()` плюс час), а горизонт едет вместе с часами:
// пока в витрине нет меток из будущего, ход часов ответ не двигает, но метки из
// будущего в ней возможны — сбитые часы телефона, чужое тело в приёме. Слабая
// метка это допускает, сильная обещала бы побайтовое равенство, которого в этом
// случае нет. На исход `304` форма не влияет: `If-None-Match` сравнивается
// слабо в любом случае.
func etag(scope, version string) string {
if version == "" {
return ""
}
return `W/"` + scope + "." + version + `"`
}
// notModified отвечает, покрывает ли условие запроса текущую метку.
//
// Сравнение слабое: `W/"x"` и `"x"` — одна и та же метка, так предписывает
// HTTP для `If-None-Match`. Звёздочка совпадает с любой существующей меткой.
//
// Неразбираемое значение условия — не отказ, а невыполненное условие: клиент,
// приславший мусор, получает данные, а не `400`. Пустая метка (подписать ответ
// нечем) не совпадает ни с чем, включая звёздочку: подтверждать неизменность
// нечем.
//
// Алгоритм повторяет `net/http/fs.go` (`scanETag`, `etagWeakMatch`,
// `checkIfNoneMatch`): там он есть, но неэкспортирован, и копия дешевле
// зависимости. Копия ПОЛНАЯ, включая сканер: резать список по запятой нельзя —
// запятая законный символ внутри метки, а в метку читающего маршрута once
// попадёт канонизированная форма запроса, где запятая естественна. Тогда `304`
// перестал бы срабатывать вообще, и симптом («условный запрос не экономит»)
// увёл бы отладку в маршрут, а не в помощника.
func notModified(header []string, tag string) bool {
if tag == "" {
return false
}
for _, line := range header {
for {
line = textproto.TrimString(line)
if line == "" {
break
}
if line[0] == ',' {
line = line[1:]
continue
}
if line[0] == '*' {
// Звёздочка совпадает с ЛЮБОЙ существующей меткой, а не с любым
// состоянием: метки нет — условие не выполнено, и клиент со
// звёздочкой получает данные, а не вечный `304`.
return true
}
candidate, remain := scanETag(line)
if candidate == "" {
// Нечитаемая метка прекращает разбор строки: так делает stdlib,
// и это не отказ — условие просто не выполнено.
break
}
if strings.TrimPrefix(candidate, `W/`) == strings.TrimPrefix(tag, `W/`) {
return true
}
line = remain
}
}
return false
}
// scanETag откусывает метку от начала строки и отдаёт остаток. Копия
// `net/http/fs.go`; диапазоны символов — `etagc` из RFC 9110, в них ВХОДИТ
// запятая.
func scanETag(s string) (tag, remain string) {
s = textproto.TrimString(s)
start := 0
if strings.HasPrefix(s, "W/") {
start = 2
}
if len(s[start:]) < 2 || s[start] != '"' {
return "", ""
}
for i := start + 1; i < len(s); i++ {
c := s[i]
switch {
case c == 0x21 || c >= 0x23 && c <= 0x7E || c >= 0x80:
// шум внутри метки — законные символы
case c == '"':
return s[:i+1], s[i+1:]
default:
return "", ""
}
}
return "", ""
}
// writeNotModified отвечает `304`: та же метка, никакого тела.
//
// Представленческие заголовки снимаются — так делает и stdlib
// (`net/http/fs.go`, `writeNotModified`) со ссылкой на RFC 9110: у ответа без
// тела нечего описывать, а `Content-Length`, доживший до `304`, вводит в
// заблуждение любой кеш. Тело рантайм и так не пропустит
// (`bodyAllowedForStatus`), но полагаться на это значило бы получать
// проглоченную ошибку записи вместо ответа.
func writeNotModified(w http.ResponseWriter, tag string) {
h := w.Header()
h.Del("Content-Type")
h.Del("Content-Length")
h.Del("Content-Encoding")
setReadHeaders(w, tag)
w.WriteHeader(http.StatusNotModified)
}
// setReadHeaders вешает на ответ чтения метку и правило кеширования.
//
// `private, no-cache` означает «кешируй, но каждый раз спрашивай»: ровно то,
// ради чего заведена метка. Заголовок обязателен, а не желателен, — без него
// промежуточный кеш вправе решить по эвристике, что выгрузку истории здоровья
// можно подержать у себя.
func setReadHeaders(w http.ResponseWriter, tag string) {
w.Header().Set("Cache-Control", "private, no-cache")
if tag != "" {
w.Header().Set("ETag", tag)
}
}
+159
View File
@@ -0,0 +1,159 @@
package httpapi_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"git.vakhrushev.me/av/healthlog/internal/store"
)
func conditionalGet(t *testing.T, h http.Handler, auth, cond string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/api/v1/metrics", nil)
if auth != "" {
req.Header.Set("Authorization", auth)
}
if cond != "" {
req.Header.Set("If-None-Match", cond)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
func point(t *testing.T, st *store.Store, metric string, at time.Time) {
t.Helper()
_, err := st.Merge(context.Background(), store.Incoming{Points: []store.IncomingPoint{{
Metric: metric, Layer: "minute", Units: "count",
Point: store.Point{Start: at, End: at, Raw: json.RawMessage(`{"qty":1}`)},
}}}, store.DeliveryRef{ID: "d"})
if err != nil {
t.Fatalf("слияние: %v", err)
}
}
// Обещание клиенту целиком: на неизменившейся витрине метка та же и байты те
// же, а повтор с этой меткой не собирает снимка вовсе.
func TestКаталогПовторНаНеизменившейсяВитрине(t *testing.T) {
h, st, _ := newAPITokens(t, nil, nil)
point(t, st, "step_count", time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC))
first := getCatalog(t, h, "")
tag := first.Header().Get("ETag")
if tag == "" {
t.Fatal("ответ ушёл без метки")
}
if got := first.Header().Get("Cache-Control"); got != "private, no-cache" {
t.Errorf("Cache-Control %q — выгрузку здоровья вправе сохранить любой посредник", got)
}
second := getCatalog(t, h, "")
if got := second.Header().Get("ETag"); got != tag {
t.Errorf("метка сдвинулась на неизменившейся витрине: %q → %q", tag, got)
}
if first.Body.String() != second.Body.String() {
t.Error("два ответа на неизменившейся витрине разошлись байтами")
}
cond := conditionalGet(t, h, "", tag)
if cond.Code != http.StatusNotModified {
t.Fatalf("статус %d, ждали 304: %s", cond.Code, cond.Body.String())
}
if cond.Body.Len() != 0 {
t.Errorf("у 304 есть тело: %q", cond.Body.String())
}
if got := cond.Header().Get("ETag"); got != tag {
t.Errorf("метка на 304 — %q, ждали %q", got, tag)
}
if got := cond.Header().Get("Content-Type"); got != "" {
t.Errorf("у 304 остались представленческие заголовки: Content-Type=%q", got)
}
}
// Свёртка записала объект — метка обязана смениться, иначе клиент останется на
// устаревшем ответе навсегда.
func TestКаталогМенялсяПослеЗаписи(t *testing.T) {
h, st, _ := newAPITokens(t, nil, nil)
at := time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC)
point(t, st, "step_count", at)
tag := getCatalog(t, h, "").Header().Get("ETag")
point(t, st, "heart_rate", at)
rec := conditionalGet(t, h, "", tag)
if rec.Code != http.StatusOK {
t.Fatalf("статус %d, ждали 200 — витрина изменилась", rec.Code)
}
if got := rec.Header().Get("ETag"); got == tag {
t.Error("метка не изменилась после записи в витрину")
}
}
// Звёздочка совпадает с любой существующей меткой; мусор условия не выполняет
// и отказом не является.
func TestКаталогФормыУсловия(t *testing.T) {
h, st, _ := newAPITokens(t, nil, nil)
point(t, st, "step_count", time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC))
cases := []struct {
name string
cond string
want int
}{
{"звёздочка", "*", http.StatusNotModified},
{"мусор", "не-метка", http.StatusOK},
{"чужая метка", `W/"gen-нет"`, http.StatusOK},
}
for _, c := range cases {
if got := conditionalGet(t, h, "", c.cond).Code; got != c.want {
t.Errorf("%s: статус %d, ждали %d", c.name, got, c.want)
}
}
}
// Токен проверяется РАНЬШЕ условия: `304` без токена подтверждал бы состояние
// витрины тому, кому она не открыта.
func TestУсловныйЗапросБезТокена(t *testing.T) {
read := []string{"read-token"}
h, st, _ := newAPITokens(t, nil, read)
point(t, st, "step_count", time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC))
tag := conditionalGet(t, h, "Bearer read-token", "").Header().Get("ETag")
if tag == "" {
t.Fatal("ответ ушёл без метки")
}
for _, cond := range []string{tag, "*"} {
if got := conditionalGet(t, h, "", cond).Code; got != http.StatusUnauthorized {
t.Errorf("условие %q без токена дало статус %d, ждали 401", cond, got)
}
}
}
// Смысл условного запроса — в том, что снимок не открывается вовсе. Оракул
// внешний: каталог при сборке пишет владельцу предупреждение о данных из
// будущего, и его отсутствие означает, что сборки не было.
func TestУсловныйОтветНеСобираетКаталог(t *testing.T) {
h, st, _, seen := newAPILogged(t, nil, nil)
// Метка из будущего: при каждой сборке каталога она даёт `WARN`.
point(t, st, "step_count", time.Now().UTC().Add(48*time.Hour))
tag := getCatalog(t, h, "").Header().Get("ETag")
if !seen.has("future data") {
t.Fatal("сборка каталога не дала ожидаемого предупреждения — оракул непригоден")
}
seen.reset()
if got := conditionalGet(t, h, "", tag).Code; got != http.StatusNotModified {
t.Fatalf("статус %d, ждали 304", got)
}
if seen.has("future data") {
t.Error("условный ответ собрал каталог: снимок открыт зря")
}
}
+72
View File
@@ -0,0 +1,72 @@
package httpapi
import (
"net/http/httptest"
"testing"
)
// Сравнение меток слабое, звёздочка совпадает с любой существующей меткой, а
// мусор условия не выполняет — и это не отказ: клиент, приславший кривой
// заголовок, получает данные, а не `400`.
func TestУсловиеЗапроса(t *testing.T) {
t.Parallel()
const tag = `W/"metrics.gen-7"`
cases := []struct {
name string
header []string
tag string
want bool
}{
{"заголовка нет", nil, tag, false},
{"пустая строка", []string{""}, tag, false},
{"та же метка", []string{tag}, tag, true},
{"та же метка без W/", []string{`"metrics.gen-7"`}, tag, true},
{"чужая метка", []string{`W/"metrics.gen-8"`}, tag, false},
{"метка другого ресурса", []string{`W/"points.gen-7"`}, tag, false},
{"список, метка вторая", []string{`W/"metrics.gen-1", W/"metrics.gen-7"`}, tag, true},
{"две строки заголовка", []string{`W/"metrics.gen-1"`, `W/"metrics.gen-7"`}, tag, true},
{"запятая внутри метки", []string{`W/"points.a,b-7"`}, `W/"points.a,b-7"`, true},
{"метка без закрывающей кавычки", []string{`W/"metrics.gen-7`}, tag, false},
{"звёздочка", []string{"*"}, tag, true},
{"звёздочка без метки", []string{"*"}, "", false},
{"мусор", []string{"metrics.gen-7"}, tag, false},
{"мусор со звёздочкой внутри", []string{`"*"`}, tag, false},
{"метки нет", []string{tag}, "", false},
}
for _, c := range cases {
if got := notModified(c.header, c.tag); got != c.want {
t.Errorf("%s: %v, ждали %v", c.name, got, c.want)
}
}
}
// Пустая версия метки не даёт: подписать ответ нечем, и притворяться нельзя.
func TestМеткаИзВерсии(t *testing.T) {
t.Parallel()
if got := etag("metrics", ""); got != "" {
t.Errorf("пустая версия дала метку %q", got)
}
if got := etag("metrics", "gen-7"); got != `W/"metrics.gen-7"` {
t.Errorf("метка %q, ждали слабую с областью", got)
}
if etag("metrics", "gen-7") == etag("points", "gen-7") {
t.Error("метки разных ресурсов совпали — 304 отдал бы чужие данные")
}
}
// Подписать нечем — заголовка нет вовсе. Пустой `ETag:` синтаксически невалиден,
// и что с ним сделает посредник, не определено ничем.
func TestОтветБезВерсииНеНесётМетки(t *testing.T) {
t.Parallel()
rec := httptest.NewRecorder()
setReadHeaders(rec, "")
if _, ok := rec.Header()["Etag"]; ok {
t.Errorf("ответ без версии несёт метку %q", rec.Header().Get("ETag"))
}
if got := rec.Header().Get("Cache-Control"); got != "private, no-cache" {
t.Errorf("правило кеширования %q — оно не зависит от наличия метки", got)
}
}
+50 -2
View File
@@ -10,6 +10,7 @@ import (
"net/http/httptest" "net/http/httptest"
"path/filepath" "path/filepath"
"strings" "strings"
"sync"
"testing" "testing"
"time" "time"
@@ -294,6 +295,16 @@ func newAPI(t *testing.T, writeTokens []string) (http.Handler, *store.Store) {
// переписывать два десятка вызовов ради одного параметра. // переписывать два десятка вызовов ради одного параметра.
func newAPITokens(t *testing.T, writeTokens, readTokens []string) (http.Handler, *store.Store, *catalog.Service) { func newAPITokens(t *testing.T, writeTokens, readTokens []string) (http.Handler, *store.Store, *catalog.Service) {
t.Helper() t.Helper()
h, st, cat, _ := newAPILogged(t, writeTokens, readTokens)
return h, st, cat
}
// newAPILogged отдаёт ещё и записи лога. Нужен там, где лог служит ОРАКУЛОМ, а
// не наблюдением: единственный внешний признак того, что каталог собирался, —
// его предупреждения владельцу.
func newAPILogged(t *testing.T, writeTokens, readTokens []string) (http.Handler, *store.Store, *catalog.Service, *records) {
t.Helper()
dir := t.TempDir() dir := t.TempDir()
st, err := store.Open(filepath.Join(dir, "healthlog.db")) st, err := store.Open(filepath.Join(dir, "healthlog.db"))
@@ -307,7 +318,8 @@ func newAPITokens(t *testing.T, writeTokens, readTokens []string) (http.Handler,
t.Fatalf("archive.New: %v", err) t.Fatalf("archive.New: %v", err)
} }
log := slog.New(slog.DiscardHandler) seen := &records{}
log := slog.New(seen)
cat := catalog.New(st, log) cat := catalog.New(st, log)
h := httpapi.New(httpapi.Options{ h := httpapi.New(httpapi.Options{
Ingest: ingest.New(arch, st, nil, log), Ingest: ingest.New(arch, st, nil, log),
@@ -320,5 +332,41 @@ func newAPITokens(t *testing.T, writeTokens, readTokens []string) (http.Handler,
// и это ровно тот транспорт, на котором приём обязан продолжать работать. // и это ровно тот транспорт, на котором приём обязан продолжать работать.
IngestWriteBudget: time.Minute, IngestWriteBudget: time.Minute,
}) })
return h, st, cat return h, st, cat, seen
}
// records — slog.Handler, копящий сообщения. Значений атрибутов не хранит:
// проверяется факт записи, а данные о здоровье в тесты тащить незачем.
type records struct {
mu sync.Mutex
msg []string
}
func (r *records) Enabled(context.Context, slog.Level) bool { return true }
func (r *records) Handle(_ context.Context, rec slog.Record) error {
r.mu.Lock()
defer r.mu.Unlock()
r.msg = append(r.msg, rec.Message)
return nil
}
func (r *records) WithAttrs([]slog.Attr) slog.Handler { return r }
func (r *records) WithGroup(string) slog.Handler { return r }
func (r *records) has(msg string) bool {
r.mu.Lock()
defer r.mu.Unlock()
for _, m := range r.msg {
if m == msg {
return true
}
}
return false
}
func (r *records) reset() {
r.mu.Lock()
defer r.mu.Unlock()
r.msg = nil
} }
+8 -1
View File
@@ -169,7 +169,7 @@ func measureStyles(t *testing.T, st *store.Store) {
t.Helper() t.Helper()
started := time.Now() started := time.Now()
metrics, err := catalog.New(st, slog.New(slog.DiscardHandler)).Metrics(context.Background()) metrics, err := metricsOf(context.Background(), catalog.New(st, slog.New(slog.DiscardHandler)))
if err != nil { if err != nil {
t.Fatalf("каталог: %v", err) t.Fatalf("каталог: %v", err)
} }
@@ -291,3 +291,10 @@ func gunzip(t *testing.T, body []byte) []byte {
} }
return out return out
} }
// metricsOf — список метрик каталога без версии витрины: версию проверяют
// отдельные тесты, остальным нужен только состав ответа.
func metricsOf(ctx context.Context, s *catalog.Service) ([]catalog.Metric, error) {
snap, err := s.Metrics(ctx)
return snap.Metrics, err
}
+9
View File
@@ -45,3 +45,12 @@ func Transient(err error) bool {
} }
return errors.Is(err, context.Canceled) || errors.Is(err, ErrBusy) return errors.Is(err, context.Canceled) || errors.Is(err, ErrBusy)
} }
// ErrClosed — хранилище закрыто, и заводить новые соединения к нему поздно.
//
// Отдельная ошибка, а не общая: запрос версии витрины и остановка сервиса идут
// в разных горутинах, и запрос, успевший в это окно, обязан получить отказ, а
// не открыть базу заново. Соединение, открытое после закрытия пула, оставило бы
// последним себя — SQLite не сделал бы финальный чекпойнт, и рядом с базой
// остался бы неразобранный `-wal`.
var ErrClosed = errors.New("хранилище закрыто")
+27
View File
@@ -23,6 +23,10 @@ var migrationsFS embed.FS
// Store — доступ к витрине. // Store — доступ к витрине.
type Store struct { type Store struct {
db *sqlx.DB db *sqlx.DB
// probe — закреплённое соединение версии витрины, заводится по первому
// запросу версии. См. version.go: значение `data_version` локально для
// соединения, и брать его из пула нельзя.
probe versionProbe
} }
// Open открывает БД по пути, сверяет версию схемы и накатывает миграции. // Open открывает БД по пути, сверяет версию схемы и накатывает миграции.
@@ -152,12 +156,32 @@ func readSchemaVersion(ctx context.Context, db *sqlx.DB) (inDB, inBinary int64,
// Close закрывает соединение с БД. // Close закрывает соединение с БД.
func (s *Store) Close() error { func (s *Store) Close() error {
// Щуп версии закрывается сам и раньше пула: закреплённое соединение
// переживает `db.Close()` и продолжает отвечать на запросы — проверено.
s.closeProbe()
if err := s.db.Close(); err != nil { if err := s.db.Close(); err != nil {
return fmt.Errorf("close sqlite: %w", err) return fmt.Errorf("close sqlite: %w", err)
} }
return nil return nil
} }
// `journal_size_limit` держит верхнюю границу файла журнала. Чекпойнт
// возвращает страницы в базу, но файл оставляет на пике: измерено — 51 МБ до и
// после успешного переноса 12502 страниц. С лимитом первая же следующая запись
// усекает файл до предела.
//
// Величина взята из чужой практики (гайды по SQLite в проде ставят 26–64 МБ) и
// собственного измерения: суточный поток даёт около 23 МБ архива, то есть
// журнал такого размера означает не всплеск, а удерживаемый снимок. С пределом
// тела приёма она НЕ связана, хотя и совпадает по порядку: журнал растёт от
// чтения, а не от размера доставки, и менять её вслед за `ingest.max_body_mb`
// незачем.
const journalSizeLimit = 64 << 20
// JournalSizeLimitMB — тот же предел для строки старта: владелец обязан видеть
// в логе, с какими параметрами обслуживается журнал, а число живёт здесь.
const JournalSizeLimitMB = journalSizeLimit >> 20
// dsn собирает строку подключения: WAL для параллельного чтения во время // dsn собирает строку подключения: WAL для параллельного чтения во время
// записи, busy_timeout — чтобы конкурентная запись ждала, а не падала. // записи, busy_timeout — чтобы конкурентная запись ждала, а не падала.
// //
@@ -166,11 +190,14 @@ func (s *Store) Close() error {
// запись после уже прочитанного снимка даёт SQLITE_BUSY_SNAPSHOT, которого // запись после уже прочитанного снимка даёт SQLITE_BUSY_SNAPSHOT, которого
// busy_timeout не покрывает. Измерено на восьми писателях: 242 успешных // busy_timeout не покрывает. Измерено на восьми писателях: 242 успешных
// слияния из 800 против 800 из 800. // слияния из 800 против 800 из 800.
//
// `journal_size_limit` — см. константу выше.
func dsn(path string) string { func dsn(path string) string {
q := url.Values{} q := url.Values{}
q.Add("_pragma", "journal_mode(WAL)") q.Add("_pragma", "journal_mode(WAL)")
q.Add("_pragma", "busy_timeout(5000)") q.Add("_pragma", "busy_timeout(5000)")
q.Add("_pragma", "foreign_keys(on)") q.Add("_pragma", "foreign_keys(on)")
q.Add("_pragma", fmt.Sprintf("journal_size_limit(%d)", journalSizeLimit))
q.Add("_txlock", "immediate") q.Add("_txlock", "immediate")
return "file:" + path + "?" + q.Encode() return "file:" + path + "?" + q.Encode()
} }
+182
View File
@@ -0,0 +1,182 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"strconv"
"sync"
"git.vakhrushev.me/av/healthlog/internal/ident"
)
// dataVersionQuery — счётчик коммитов, видимых соединению.
//
// SQLite меняет его, когда изменения в базу закоммитило ДРУГОЕ соединение, и
// оставляет неизменным для коммитов самого соединения. Отсюда требование к
// щупу: он ничего не пишет.
const dataVersionQuery = `PRAGMA data_version`
// versionProbe — закреплённое соединение, с которого читается версия витрины.
//
// Соединение своё, а не из пула, по измеренной причине: значение `data_version`
// локально для соединения. На одном и том же состоянии базы два соединения
// одного пула отвечают разными числами, а любое СВЕЖЕЕ соединение отвечает
// одним и тем же значением независимо от содержимого базы. Версия из пула
// поэтому давала бы не только ложную инвалидацию (разные метки на
// неизменившейся витрине — не страшно), но и одинаковые метки на разных
// состояниях — то есть `304` на изменившиеся данные.
//
// Поколение выдаётся соединению и меняется вместе с ним. Без него метка не
// переживала бы рестарт: счётчик после переоткрытия начинается заново, и одно и
// то же значение до и после означало бы разные состояния витрины. Побочная
// выгода: поколение меняется и при выкатке нового бинаря, так что смена ФОРМЫ
// ответа при неизменившихся данных тоже обнуляет метки клиентов.
//
// Мьютекс здесь не только ради поля: `sql.Conn` не предназначен для
// одновременного использования из нескольких горутин, а запросов чтения бывает
// сколько угодно. Запрос при этом мгновенный — очереди на нём не образуется.
// На щупе выполняется РОВНО ОДИН вид запроса и только через
// `QueryRowContext(...).Scan(...)`. `QueryContext` и `BeginTx` на нём не
// зовутся никогда: незакрытые `Rows` или открытая транзакция удержали бы
// читающий снимок до конца жизни процесса — а щуп единственное долгоживущее
// соединение процесса. Тогда пассивный чекпойнт перестал бы продвигаться
// вовсе, и вторая половина задачи убила бы первую при полностью исправном
// обслуживании. Заодно повисли бы все читающие запросы: щуп у них общий.
type versionProbe struct {
mu sync.Mutex
conn *sql.Conn
generation string
// closed — хранилище закрыто, щупа больше не будет. Без флага гонка
// «запрос версии против Close» воскресила бы соединение уже после закрытия
// пула, и последнее соединение к базе осталось бы открытым: SQLite не
// сделал бы финальный чекпойнт, а рядом с базой остался бы `-wal`.
closed bool
}
// StateVersion отдаёт версию витрины: метку, которая меняется при любом
// коммите в базу и не меняется, пока коммитов не было.
//
// Имя не называет PRAGMA намеренно: метка это пара «поколение + счётчик», а не
// голое значение `data_version`, и область её сравнимости задаёт хранилище, а
// не SQLite. Со «версией схемы» (ErrSchemaMismatch) она не пересекается ничем.
//
// Равные метки означают, что между их снятием в базу никто ничего не записал, —
// на этом и держится условный запрос читающих маршрутов. Обратное неверно:
// метка меняется от любой записи, включая учёт доставки, витрину не менявшей.
// Это ложная инвалидация, то есть безопасная сторона.
func (s *Store) StateVersion(ctx context.Context) (string, error) {
s.probe.mu.Lock()
defer s.probe.mu.Unlock()
// Две попытки, а не цикл: единственная восстановимая беда — умершее
// соединение, и лечится она ровно одним пересозданием. Повторять дальше
// значило бы ходить по кругу за отказом, который не в соединении.
var lastErr error
for range 2 {
conn, generation, err := s.probe.acquire(ctx, s.db.DB)
if err != nil {
return "", err
}
var counter int64
err = conn.QueryRowContext(ctx, dataVersionQuery).Scan(&counter)
if err == nil {
return generation + "-" + strconv.FormatInt(counter, 10), nil
}
lastErr = err
// Непригодность соединения — это ТОЛЬКО `ErrConnDone`, и список узок
// намеренно. Измерено на этом драйвере: отмена контекста запроса щуп не
// убивает — следующий запрос на нём проходит; непригодным соединение
// становится после явного закрытия. Считать смертью щупа любую ошибку
// нельзя: занятость базы и обрыв запроса клиентом (обычные события, для
// которых в проекте заведён `Transient`) меняли бы поколение, и все
// потребители получали бы полный ответ вместо `304` — то есть механизм
// схлопывался бы ровно под нагрузкой, ради которой заведён.
if !errors.Is(err, sql.ErrConnDone) {
break
}
// Вместе с соединением выбрасывается и поколение: переиспользовать его
// нельзя — счётчик у нового соединения начнётся заново, и старая метка
// совпала бы с новой на другом состоянии.
s.probe.release()
}
return "", fmt.Errorf("read data version: %w", lastErr)
}
// VersionedRead выполняет чтение и отдаёт версию витрины, которой это чтение
// подписано. Пустая версия означает «подписать нечем» — не отказ.
//
// Правило живёт здесь, в одном экземпляре, потому что нарушить его можно ровно
// одним способом и этот способ опасен: версия, снятая ПОСЛЕ чтения, пометила бы
// устаревший снимок свежей меткой и заперла бы клиента на нём навсегда. Версия,
// снятая только ДО, допускает два разных ответа под одной меткой. Поэтому проба
// делается дважды, а метка выдаётся, только если между пробами в базу никто не
// коммитил.
//
// Read API точек и MCP заявлены потребителями той же машинерии: вторая её
// реализация «по образцу» отличалась бы от первой ровно на этот порядок, и ни
// один тест каталога этого не увидел бы.
//
// Отказ пробы версией не является и запрос не роняет: читающий маршрут
// деградирует до полного ответа, а не до отказа. Настоящий отказ базы всплывёт
// самим чтением, которое идёт следом, и будет назван один раз им.
func (s *Store) VersionedRead(ctx context.Context, read func(context.Context) error) (string, error) {
before, probeErr := s.StateVersion(ctx)
if err := read(ctx); err != nil {
return "", err
}
if probeErr != nil {
return "", nil
}
after, err := s.StateVersion(ctx)
if err != nil || after != before {
return "", nil
}
return before, nil
}
// acquire отдаёт закреплённое соединение, заводя его при первом обращении.
// Вызывается под мьютексом.
//
// Лениво, а не при открытии базы: щуп нужен читающим маршрутам, а `reindex` и
// утилиты учёта открывают ту же базу и версию не спрашивают ни разу.
func (p *versionProbe) acquire(ctx context.Context, db *sql.DB) (*sql.Conn, string, error) {
if p.conn != nil {
return p.conn, p.generation, nil
}
if p.closed {
return nil, "", ErrClosed
}
conn, err := db.Conn(ctx)
if err != nil {
return nil, "", fmt.Errorf("pin version probe connection: %w", err)
}
p.conn = conn
p.generation = ident.NewID()
return p.conn, p.generation, nil
}
// release закрывает закреплённое соединение и забывает поколение.
// Вызывается под мьютексом.
func (p *versionProbe) release() {
if p.conn == nil {
return
}
// Ошибка закрытия непригодного соединения ничего не меняет: следующий
// заход возьмёт новое.
_ = p.conn.Close()
p.conn = nil
p.generation = ""
}
// closeProbe закрывает щуп. Отдельно от пула и ДО него: закреплённое
// соединение переживает `db.Close()` и продолжает отвечать на запросы
// (проверено), то есть само по себе не закрывается ничем.
func (s *Store) closeProbe() {
s.probe.mu.Lock()
defer s.probe.mu.Unlock()
s.probe.closed = true
s.probe.release()
}
+177
View File
@@ -0,0 +1,177 @@
package store
import (
"context"
"errors"
"path/filepath"
"strings"
"testing"
)
// Смерть щупа — не гипотеза: `sql.Conn`, закрытый кем угодно, отвечает
// `ErrConnDone` навсегда. Без пересоздания читающий контур остался бы без
// условного запроса до конца жизни процесса; с пересозданием обязано смениться
// и поколение — счётчик у нового соединения начинается заново, и старая метка
// совпала бы с новой на другом состоянии витрины.
func TestЩупПересоздаётсяСНовымПоколением(t *testing.T) {
t.Parallel()
st, err := Open(filepath.Join(t.TempDir(), "healthlog.db"))
if err != nil {
t.Fatalf("открытие базы: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
before, err := st.StateVersion(t.Context())
if err != nil {
t.Fatalf("версия витрины: %v", err)
}
// Ровно то, что делает непригодным настоящее соединение: явное закрытие.
st.probe.mu.Lock()
_ = st.probe.conn.Close()
st.probe.mu.Unlock()
after, err := st.StateVersion(t.Context())
if err != nil {
t.Fatalf("версия после смерти щупа: %v", err)
}
if generationOf(before) == generationOf(after) {
t.Errorf("поколение переиспользовано: %q → %q", before, after)
}
}
func generationOf(version string) string {
return strings.SplitN(version, "-", 2)[0]
}
// Отказ пробы — не отказ чтения: маршрут обязан деградировать до полного
// ответа, а не до `500`. Ветка исполняется только когда проба не удалась, а
// чтение прошло, и другого способа туда попасть нет.
func TestVersionedReadПриОтказеПробыОтдаётЧтениеБезВерсии(t *testing.T) {
t.Parallel()
st, err := Open(filepath.Join(t.TempDir(), "healthlog.db"))
if err != nil {
t.Fatalf("открытие базы: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
// Щуп закрыт, а база — нет: проба отказывает, чтение проходит.
st.closeProbe()
read := false
version, err := st.VersionedRead(t.Context(), func(context.Context) error {
read = true
return nil
})
if err != nil {
t.Fatalf("чтение подменено отказом пробы: %v", err)
}
if !read {
t.Error("чтение не выполнено")
}
if version != "" {
t.Errorf("ответ подписан версией %q, хотя проба отказала", version)
}
}
// Пул закрыт, а флаг «закрыто» не взведён: щупа нет и завести его нечем.
// Отдельная ветка от ErrClosed, и она обязана быть отказом, а не пустой
// версией — иначе отказ базы выглядел бы как «версии нет».
func TestВерсияОтказываетКогдаПулЗакрыт(t *testing.T) {
t.Parallel()
st, err := Open(filepath.Join(t.TempDir(), "healthlog.db"))
if err != nil {
t.Fatalf("открытие базы: %v", err)
}
if err := st.db.Close(); err != nil {
t.Fatalf("закрытие пула: %v", err)
}
if _, err := st.StateVersion(t.Context()); err == nil {
t.Error("версия снята с закрытого пула")
}
}
// Запрос версии против закрытия хранилища: ровно та гонка, ради которой заведён
// флаг «закрыто». Проверяется под `-race`; исход законен любой, кроме
// воскресшего соединения — его ловит требование «после Close версия отказывает».
func TestВерсияПротивЗакрытия(t *testing.T) {
t.Parallel()
st, err := Open(filepath.Join(t.TempDir(), "healthlog.db"))
if err != nil {
t.Fatalf("открытие базы: %v", err)
}
const n = 8
done := make(chan struct{}, n)
for range n {
go func() {
_, _ = st.StateVersion(context.Background())
done <- struct{}{}
}()
}
_ = st.Close()
for range n {
<-done
}
if _, err := st.StateVersion(context.Background()); !errors.Is(err, ErrClosed) {
t.Errorf("после закрытия версия отвечает %v, ждали %v", err, ErrClosed)
}
}
// Оборванный клиентом запрос — обычное событие, и смертью щупа он быть не
// имеет права: иначе каждый такой обрыв менял бы поколение и обнулял метки всех
// потребителей, то есть механизм схлопывался бы под нагрузкой.
func TestОборванныйЗапросНеМеняетПоколение(t *testing.T) {
t.Parallel()
st, err := Open(filepath.Join(t.TempDir(), "healthlog.db"))
if err != nil {
t.Fatalf("открытие базы: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
before, err := st.StateVersion(t.Context())
if err != nil {
t.Fatalf("версия витрины: %v", err)
}
dead, cancel := context.WithCancel(context.Background())
cancel()
if _, err := st.StateVersion(dead); err == nil {
t.Fatal("запрос на отменённом контексте прошёл — тест проверяет не то")
}
after, err := st.StateVersion(t.Context())
if err != nil {
t.Fatalf("версия после обрыва: %v", err)
}
if generationOf(before) != generationOf(after) {
t.Errorf("обрыв запроса сменил поколение: %q → %q", before, after)
}
}
// Предел файла журнала — строка в DSN, и её пропажу не заметит ни один тест
// поведения: журнал просто останется на пике навсегда.
func TestПределЖурналаЗадан(t *testing.T) {
t.Parallel()
st, err := Open(filepath.Join(t.TempDir(), "healthlog.db"))
if err != nil {
t.Fatalf("открытие базы: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
var limit int64
if err := st.db.QueryRowContext(t.Context(), "PRAGMA journal_size_limit").Scan(&limit); err != nil {
t.Fatalf("предел журнала: %v", err)
}
if limit != journalSizeLimit {
t.Errorf("предел журнала %d, ждали %d", limit, journalSizeLimit)
}
}
+222
View File
@@ -0,0 +1,222 @@
package store_test
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.vakhrushev.me/av/healthlog/internal/store"
)
func version(t *testing.T, st *store.Store) string {
t.Helper()
v, err := st.StateVersion(t.Context())
if err != nil {
t.Fatalf("версия витрины: %v", err)
}
if v == "" {
t.Fatal("версия витрины пуста")
}
return v
}
// Точка в витрину, чтобы у версии было от чего измениться.
func writePoint(t *testing.T, st *store.Store, metric string, at time.Time) {
t.Helper()
_, err := st.Merge(context.Background(), store.Incoming{Points: []store.IncomingPoint{{
Metric: metric, Layer: "minute", Units: "count",
Point: store.Point{Start: at, End: at, Raw: json.RawMessage(`{"qty":1}`)},
}}}, store.DeliveryRef{ID: "d"})
if err != nil {
t.Fatalf("слияние: %v", err)
}
}
// Главное свойство: без коммитов метка не двигается. На нём держится `304`.
func TestВерсияНеМеняетсяБезЗаписи(t *testing.T) {
t.Parallel()
st := open(t)
first := version(t, st)
// Читающие запросы версию двигать не имеют права: иначе условный запрос не
// сработал бы ни разу — каждый ответ каталога сам бы себя и обесценивал.
if _, err := st.ReadCatalog(t.Context(), window(48, time.Now())); err != nil {
t.Fatalf("каталог: %v", err)
}
if got := version(t, st); got != first {
t.Errorf("версия сдвинулась без записи: %q → %q", first, got)
}
}
// Запись из пула — то есть с ЧУЖОГО для щупа соединения — обязана быть видна.
// Это ровно случай фоновой свёртки: она пишет, пока читающий маршрут отвечает.
func TestВерсияМеняетсяПослеЗаписи(t *testing.T) {
t.Parallel()
st := open(t)
before := version(t, st)
writePoint(t, st, "step_count", ts(t, "2026-06-01T10:00:00Z"))
after := version(t, st)
if after == before {
t.Errorf("версия не изменилась после записи: %q", before)
}
if strings.SplitN(before, "-", 2)[0] != strings.SplitN(after, "-", 2)[0] {
t.Errorf("поколение сменилось без пересоздания щупа: %q → %q", before, after)
}
}
// Счётчик `data_version` после переоткрытия базы начинается заново, и одно и то
// же значение до и после означало бы разные состояния витрины. Метку от этого
// спасает поколение — проверяем на одном и том же файле.
func TestВерсияНеПовторяетсяПослеПереоткрытия(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "healthlog.db")
first, err := store.Open(path)
if err != nil {
t.Fatalf("открытие базы: %v", err)
}
before := version(t, first)
writePoint(t, first, "step_count", ts(t, "2026-06-01T10:00:00Z"))
if err := first.Close(); err != nil {
t.Fatalf("закрытие базы: %v", err)
}
second, err := store.Open(path)
if err != nil {
t.Fatalf("повторное открытие базы: %v", err)
}
t.Cleanup(func() { _ = second.Close() })
if after := version(t, second); after == before {
t.Errorf("версия совпала через переоткрытие: %q — клиент получил бы 304 на изменившиеся данные", before)
}
}
// Версия читается конкурентно: `sql.Conn` одновременного использования не
// допускает, и без защиты это гонка, а не редкий отказ.
func TestВерсияЧитаетсяКонкурентно(t *testing.T) {
t.Parallel()
st := open(t)
const n = 16
errs := make(chan error, n)
for range n {
go func() {
_, err := st.StateVersion(context.Background())
errs <- err
}()
}
for range n {
if err := <-errs; err != nil {
t.Fatalf("версия витрины: %v", err)
}
}
}
// Подпись ответа: пока витрина стоит, чтение подписывается версией.
func TestVersionedReadПодписываетТихоеЧтение(t *testing.T) {
t.Parallel()
st := open(t)
called := false
v, err := st.VersionedRead(t.Context(), func(context.Context) error {
called = true
return nil
})
if err != nil {
t.Fatalf("чтение с версией: %v", err)
}
if !called {
t.Fatal("чтение не выполнено")
}
if v == "" {
t.Error("тихое чтение осталось без версии")
}
}
// Единственная ветка, ради которой проба делается дважды: витрина изменилась,
// пока ответ собирался. Метки быть не должно — иначе два разных ответа уехали
// бы под одной, а подписать снимок версией, снятой ПОСЛЕ него, значило бы
// запереть клиента на устаревшем ответе навсегда.
func TestVersionedReadНеПодписываетИзменившеесяЧтение(t *testing.T) {
t.Parallel()
st := open(t)
v, err := st.VersionedRead(t.Context(), func(ctx context.Context) error {
writePoint(t, st, "step_count", ts(t, "2026-06-01T10:00:00Z"))
return nil
})
if err != nil {
t.Fatalf("чтение с версией: %v", err)
}
if v != "" {
t.Errorf("ответ подписан версией %q, хотя витрина изменилась при сборке", v)
}
}
// Отказ самого чтения версией не подменяется: это отказ операции, и он обязан
// дойти до вызывающего.
func TestVersionedReadВозвращаетОтказЧтения(t *testing.T) {
t.Parallel()
st := open(t)
want := errors.New("чтение не вышло")
if _, err := st.VersionedRead(t.Context(), func(context.Context) error { return want }); !errors.Is(err, want) {
t.Errorf("ошибка %v, ждали %v", err, want)
}
}
// После закрытия хранилища щуп не воскресает: соединение, открытое позже
// закрытия пула, осталось бы последним, и SQLite не сделал бы финальный
// чекпойнт.
func TestВерсияПослеЗакрытияОтказывает(t *testing.T) {
t.Parallel()
st, err := store.Open(filepath.Join(t.TempDir(), "healthlog.db"))
if err != nil {
t.Fatalf("открытие базы: %v", err)
}
if err := st.Close(); err != nil {
t.Fatalf("закрытие базы: %v", err)
}
if _, err := st.StateVersion(context.Background()); !errors.Is(err, store.ErrClosed) {
t.Errorf("ошибка %v, ждали %v", err, store.ErrClosed)
}
}
// Закрытие хранилища обязано оставить базу без журнала: финальный чекпойнт
// делает SQLite при закрытии ПОСЛЕДНЕГО соединения, а щуп его переживает.
// Незакрытый щуп означал бы `-wal` рядом с базой — и подмену базы пересборкой
// без хвоста записей.
func TestЗакрытиеНеОставляетЖурнала(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "healthlog.db")
st, err := store.Open(path)
if err != nil {
t.Fatalf("открытие базы: %v", err)
}
if _, err := st.StateVersion(t.Context()); err != nil {
t.Fatalf("версия витрины: %v", err)
}
writePoint(t, st, "step_count", ts(t, "2026-06-01T10:00:00Z"))
if err := st.Close(); err != nil {
t.Fatalf("закрытие базы: %v", err)
}
if _, err := os.Stat(path + "-wal"); !os.IsNotExist(err) {
t.Errorf("рядом с базой остался журнал: %v", err)
}
}
+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
}
+198
View File
@@ -0,0 +1,198 @@
package store_test
import (
"context"
"database/sql"
"path/filepath"
"testing"
"time"
_ "modernc.org/sqlite" // второе подключение к тому же файлу мимо store
"git.vakhrushev.me/av/healthlog/internal/store"
)
func checkpoint(t *testing.T, st *store.Store) store.Checkpoint {
t.Helper()
ck, err := st.CheckpointWAL(t.Context())
if err != nil {
t.Fatalf("чекпойнт: %v", err)
}
return ck
}
// Штатный случай: писателей нет, читателей нет — журнал разбирается целиком.
// Это и есть работа, которой автоматический чекпойнт не делает: он срабатывает
// по концу записи, а ночью телефон молчит часами.
func TestЧекпойнтРазбираетЖурналВТишине(t *testing.T) {
t.Parallel()
st := open(t)
base := ts(t, "2026-06-01T00:00:00Z")
for i := range 20 {
writePoint(t, st, "step_count", base.Add(time.Duration(i)*time.Minute))
}
ck := checkpoint(t, st)
if !ck.Complete() {
t.Errorf("журнал разобран не целиком: log=%d checkpointed=%d", ck.Log, ck.Checkpointed)
}
if ck.Stuck() {
t.Errorf("разобранный журнал объявлен застрявшим: %+v", ck)
}
}
// Щуп версии — единственное долгоживущее соединение процесса, и он ходит в базу
// дважды на каждый читающий запрос. Останься за ним открытая читающая
// транзакция — пассивный чекпойнт перестал бы продвигаться навсегда, и вторая
// половина задачи убила бы первую при полностью исправном обслуживании.
func TestЩупНеУдерживаетЧитающийСнимок(t *testing.T) {
t.Parallel()
st := open(t)
base := ts(t, "2026-06-01T00:00:00Z")
for i := range 10 {
if _, err := st.StateVersion(t.Context()); err != nil {
t.Fatalf("версия витрины: %v", err)
}
writePoint(t, st, "step_count", base.Add(time.Duration(i)*time.Minute))
}
ck := checkpoint(t, st)
if !ck.Complete() {
t.Errorf("щуп удерживает снимок: log=%d checkpointed=%d", ck.Log, ck.Checkpointed)
}
}
// Удерживаемый читатель — ровно тот случай, ради которого признак и заведён:
// пассивный чекпойнт не идёт дальше его снимка и ОШИБКИ ПРИ ЭТОМ НЕ ВОЗВРАЩАЕТ.
// Проверяем, что признаком служит расхождение чисел, а не флаг занятости.
func TestЧекпойнтПодЧитателемНеПродвигаетсяБезОшибки(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "healthlog.db")
st, err := store.Open(path)
if err != nil {
t.Fatalf("открытие базы: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
base := ts(t, "2026-06-01T00:00:00Z")
writePoint(t, st, "step_count", base)
// Читающая транзакция мимо store: она моделирует не наш код, а любого
// читателя, задержавшегося на снимке, — включая забытый `rows.Close()`.
db, err := sql.Open("sqlite", "file:"+path+"?_pragma=busy_timeout(5000)")
if err != nil {
t.Fatalf("открытие второго подключения: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
tx, err := db.BeginTx(t.Context(), &sql.TxOptions{ReadOnly: true})
if err != nil {
t.Fatalf("читающая транзакция: %v", err)
}
var n int
if err := tx.QueryRowContext(t.Context(), "SELECT count(*) FROM bucket").Scan(&n); err != nil {
t.Fatalf("чтение снимка: %v", err)
}
for i := 1; i < 40; i++ {
writePoint(t, st, "step_count", base.Add(time.Duration(i)*time.Minute))
}
ck := checkpoint(t, st)
_ = tx.Rollback()
if ck.Busy {
t.Errorf("флаг занятости взведён — признак строится не на нём: %+v", ck)
}
if ck.Complete() {
// Не Skip: на платформе, разбирающей журнал под удерживаемым читателем,
// ломается предпосылка всей задачи, а пропущенный тест выглядит зелёным
// — и вместе с ним молча исчезает единственная защита режима PASSIVE.
t.Fatalf("журнал разобран под удерживаемым читателем — предпосылка задачи не воспроизводится: %+v", ck)
}
if ck.Log <= ck.Checkpointed {
t.Errorf("перенесено не меньше, чем лежит: %+v", ck)
}
}
// Порог молчит на журнале обычного размера: иначе `WARN` шёл бы каждую минуту
// на здоровом сервисе, и уровень, по которому вмешиваются, перестал бы значить
// что-либо.
func TestНебольшойНеразобранныйЖурналНеЗастрял(t *testing.T) {
t.Parallel()
ck := store.Checkpoint{Log: 10, Checkpointed: 0, PageSize: 4096}
if ck.Stuck() {
t.Errorf("десять неразобранных страниц объявлены бедой: %+v", ck)
}
if ck.Complete() {
t.Errorf("неразобранный журнал объявлен разобранным: %+v", ck)
}
}
// Занятый чекпойнт отдаёт `busy=1` и `-1` вместо чисел: исход НЕ ИЗМЕРЕН.
// Внутриполосный `-1` опасен буквально — `-1 >= -1` истинно, то есть
// незамеренный тик читался бы как «журнал разобран целиком», и владельцу ушла
// бы строка о выздоровлении посреди болезни.
func TestЗанятыйЧекпойнтНеИзмерен(t *testing.T) {
t.Parallel()
ck := store.Checkpoint{Busy: true, Log: -1, Checkpointed: -1, PageSize: 4096}
if ck.Known() {
t.Errorf("занятый чекпойнт объявлен измеренным: %+v", ck)
}
if ck.Complete() {
t.Errorf("незамеренный исход объявлен разобранным журналом: %+v", ck)
}
if ck.Stuck() {
t.Errorf("незамеренный исход объявлен бедой: %+v", ck)
}
}
// Журнала нет вовсе — SQLite отвечает теми же `-1`. Исход тот же: молчим.
func TestОтсутствующийЖурналНеИзмерен(t *testing.T) {
t.Parallel()
ck := store.Checkpoint{Log: -1, Checkpointed: -1, PageSize: 4096}
if ck.Known() || ck.Complete() || ck.Stuck() {
t.Errorf("исход без журнала прочитан как измеренный: %+v", ck)
}
}
// Размер страницы — свойство файла, и без него порог не выразить. Неизвестен —
// признак молчит: сместившийся в разы порог хуже, чем его отсутствие.
func TestБезРазмераСтраницыПризнакМолчит(t *testing.T) {
t.Parallel()
ck := store.Checkpoint{Log: 1 << 20, Checkpointed: 0}
if ck.Stuck() {
t.Errorf("порог сработал при неизвестном размере страницы: %+v", ck)
}
}
// Размер страницы приходит из базы, а не предполагается кодом.
func TestЧекпойнтНазываетРазмерСтраницы(t *testing.T) {
t.Parallel()
if got := checkpoint(t, open(t)).PageSize; got <= 0 {
t.Errorf("размер страницы %d — порог выразить нечем", got)
}
}
// Отмена контекста не должна превращаться в отказ обслуживания: цикл проверяет
// её сам, а вызов обязан вернуть ошибку, а не молчаливый нулевой исход.
func TestЧекпойнтНаОтменённомКонтексте(t *testing.T) {
t.Parallel()
st := open(t)
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := st.CheckpointWAL(ctx); err == nil {
t.Error("чекпойнт на отменённом контексте прошёл успешно")
}
}
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-02
@@ -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` порядка 2664 МБ;
взято 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
Нет: развилки закрыты решением по задаче и измерениями выше.
@@ -0,0 +1,66 @@
## Why
Первый читающий маршрут (`GET /api/v1/metrics`) обошёлся дороже, чем выглядел:
два прохода ревью измерили 693 мс и +153 МиБ живой кучи на враждебном запросе,
а непрерывная запись вместе с четырьмя читающими транзакциями внахлёст дала
рост `-wal` около 7 МБ/с без верхней границы (40 МБ за пять секунд). Приём
живёт в том же процессе, и обе цены платит он: OOM убивает приём, а доставка,
не попавшая в архив, телефоном не переприсылается. Третье проявление той же
причины — повтор: спека каталога уже требует побайтового совпадения двух
ответов на неизменившейся витрине, то есть ресурс по построению пригоден для
условного запроса, а `ETag` не выставляется вовсе.
Задача берётся **перед** Read API точек намеренно: тот строится поверх этой же
машинерии, и решать один вопрос трижды (каталог, точки, MCP) нельзя.
## What Changes
- **Периодический чекпойнт WAL.** Рядом с воркером свёртки живёт горутина,
которая раз в минуту выполняет `PRAGMA wal_checkpoint(PASSIVE)` и
останавливается дренированием, как воркер. Автоматический чекпойнт SQLite
срабатывает только по концу записи, поэтому WAL, раздутый всплеском, остаётся
неразобранным до следующей доставки — а ночью телефон молчит часами.
- **Наблюдаемость непродвинувшегося чекпойнта.** Пассивный чекпойнт не идёт
дальше снимка самого старого активного читателя и **ошибки при этом не
возвращает**: измерено — `busy=0`, `log=6256`, `checkpointed=5`. Значит
единственный различимый признак — «страниц в журнале много, перенесено
меньше», и именно он идёт в `WARN` владельцу.
- **Названный предел файла журнала.** `journal_size_limit` в строке
подключения: пассивный чекпойнт возвращает страницы в базу, но файл оставляет
на пике (измерено: 51 МБ до и после успешного чекпойнта на 12502 страницы).
Роста это не ограничивает — усечение делает первая запись после полного
чекпойнта, — и так и сказано в спеке.
- **Версия витрины и условный запрос.** Хранилище отдаёт версию витрины по
`PRAGMA data_version`, каталог выставляет `ETag`, а на `If-None-Match` с
непротухшей версией отвечает `304` **не открывая снимок вовсе**.
- **Не делается** (отложено): предел размера ответа и собственный дедлайн
маршрута — их проектирует Read API точек; потоковое измерение по метрике;
кеш ответа; `HEAD` на маршруте каталога.
## Capabilities
### New Capabilities
Новых нет: обе части ложатся на существующие домены.
### Modified Capabilities
- `storage`: добавляется **версия витрины** (признак изменения «в базу никто не
коммитил»; монотонной она не является) и **обслуживание WAL** (чекпойнт по
таймеру, признак непродвижения, остановка дренированием).
- `catalog`: добавляется **условный запрос**`ETag` на ответе каталога и
`304` на `If-None-Match`, связанный с уже существующим требованием
побайтового совпадения двух ответов на неизменившейся витрине.
## Impact
- `internal/store` — закреплённое соединение-щуп для `data_version`, метод
чекпойнта WAL, `journal_size_limit` в DSN.
- `internal/catalog` — снимок каталога уезжает вместе с версией витрины.
- `internal/httpapi` — общий помощник условного запроса (им же будут
пользоваться точки и MCP), `ETag`/`304` на маршруте каталога.
- `cmd/healthlog/serve.go` — горутина чекпойнта и её дренирование в общем
бюджете остановки.
- Схема базы **не меняется**: миграции нет.
- Контракт приёма не меняется. Контракт чтения расширяется совместимо: клиент,
не присылающий `If-None-Match`, получает ровно то же, что и сегодня.
@@ -0,0 +1,139 @@
## ADDED Requirements
### Requirement: Каталог отвечает на условный запрос
Система SHALL выставлять на ответе каталога заголовок `ETag` и SHALL отвечать
`304 Not Modified` на запрос с `If-None-Match`, чья метка совпадает с текущей
версией витрины. При совпадении снимок витрины открываться MUST NOT: смысл
условного запроса в том, что самый частый запрос потребителя — повтор
неизменившегося — не стоит ничего.
Метка MUST строиться из **всего, от чего зависит ответ**: версии витрины и
горизонта измерения. Горизонт едет вместе с часами, и метка из будущего,
лежащая в витрине, въезжает в окно сама — без единого коммита. Путь построен и
прогнан: та же версия витрины, `cumulative` против `unknown`. Значит версии
витрины для метки НЕ ДОСТАТОЧНО, и слабая форма метки этого не лечит: смена
измеренного рода — изменение семантическое, на нём Read API строит арифметику
года.
Горизонт входит в метку огрублённым до часа, и огрубление точное, а не
приблизительное: метки объектов лежат ровно на часах, поэтому отбор по горизонту
меняется ровно при переходе через час. Цена названа: один полный ответ в час на
потребителя при неизменившейся витрине.
Форма метки MUST оставаться слабой (`W/"…"`): она выведена из состояния, а не из
байтов ответа. На исход `304` это не влияет — `If-None-Match` сравнивается слабо
в любом случае.
Метка MUST выставляться, только если за всё время сборки ответа в базу никто не
коммитил; правило снятия версии принадлежит хранилищу и здесь не повторяется.
Ответ без метки — законный исход, а не отказ: клиент просто не сможет спросить
условно в следующий раз.
**Метка действительна только в пределах одного ресурса, и область действия
MUST входить в саму метку.** Маршрут, чей ответ есть функция параметров запроса
(Read API точек), и транспорт, у которого адреса нет вовсе (MCP), обязаны
подмешивать в неё канонизированную форму запроса — иначе «не изменилось»
ответит на другой набор данных. Требовать этого прозой недостаточно: правило
MUST быть выражено формой вызова, потому что забыть его — единственный путь всей
задачи, ведущий к выдаче не тех данных.
Ответ `304` MUST нести ту же метку и MUST NOT нести тела и представленческих
заголовков. Клиент, не приславший `If-None-Match`, MUST получать ровно то же,
что и до появления условного запроса.
Ответы каталога MUST быть помечены непригодными для разделяемого кеша
(`Cache-Control: private, no-cache`). До появления валидатора эвристическое
кеширование посредником было маловероятным; с меткой ответ становится штатно
кешируемым, а при выключенной проверке токенов (законная конфигурация) в
запросе нет и `Authorization` — тогда выгрузку истории здоровья вправе
сохранить любой прокси на пути.
Проверка токена чтения MUST предшествовать условному запросу: `304` без токена
подтверждал бы состояние витрины тому, кому она не открыта.
Разбор условия MUST следовать HTTP и MUST NOT превращать кривой заголовок в
отказ:
- звёздочка (`*`) совпадает с любой **существующей** меткой; метки нет —
условие не выполнено, и клиент со звёздочкой получает данные, а не вечный
`304`;
- неразбираемое значение условия не выполняет и даёт `200`, а не `400`.
**Следствие названо вслух: `304` не выполняет измерения и потому не пишет
предупреждений владельцу.** Предупреждения каталога (данные из будущего,
противоречащий род агрегации) привязаны к сборке ответа; с условным опросом они
становятся функцией смены версии витрины, а не числа запросов. Состояние при
этом не исчезает: следующая доставка меняет версию, ответ собирается, и
предупреждение пишется — а пока витрина стоит, повторять его на каждый опрос
трёх потребителей значило бы обесценить уровень.
#### Scenario: Повтор на неизменившейся витрине
- **GIVEN** клиент получил каталог и запомнил его `ETag`
- **WHEN** он повторяет запрос с `If-None-Match` этой метки, а витрина не
менялась
- **THEN** ответ — `304` без тела, с той же меткой
#### Scenario: Витрина изменилась
- **GIVEN** клиент получил каталог и запомнил его `ETag`
- **WHEN** свёртка записала объект и клиент повторяет запрос с прежней меткой
- **THEN** ответ — `200` с полным каталогом и новой меткой
#### Scenario: Горизонт сдвинулся
- **GIVEN** витрина не менялась
- **WHEN** горизонт измерения перешёл через час
- **THEN** метка отличается от прежней
#### Scenario: Метка другого ресурса
- **GIVEN** клиент присылает метку, выданную другим читающим маршрутом
- **WHEN** совпадает версия витрины
- **THEN** условие не выполнено, и ответ — `200`
#### Scenario: Клиент не спрашивает условно
- **WHEN** каталог запрашивается без `If-None-Match`
- **THEN** ответ — `200` с полным каталогом, меткой и правилом кеширования
#### Scenario: Две метки на неизменившейся витрине совпадают
- **GIVEN** витрина не менялась между двумя запросами
- **WHEN** каталог запрошен дважды
- **THEN** метки совпадают, и тела ответов совпадают побайтово
#### Scenario: Звёздочка в условии
- **WHEN** каталог запрашивается с `If-None-Match: *`
- **THEN** ответ — `304` с текущей меткой
#### Scenario: Условие нечитаемо
- **WHEN** каталог запрашивается с `If-None-Match`, который меткой не является
- **THEN** ответ — `200` с полным каталогом, а не `400` и не `304`
#### Scenario: Условный запрос без токена чтения
- **GIVEN** список токенов чтения непуст
- **WHEN** каталог запрашивается с `If-None-Match`, но без токена
- **THEN** ответ — `401`, а не `304`
#### Scenario: Витрина изменилась во время сборки ответа
- **GIVEN** между снятием версии до и после сборки в базу был коммит
- **WHEN** ответ сформирован
- **THEN** он уходит с полным телом и без заголовка `ETag`
#### Scenario: Версия витрины недоступна
- **GIVEN** версию витрины прочитать не удалось
- **WHEN** каталог запрашивается, в том числе с `If-None-Match`
- **THEN** ответ — `200` с полным каталогом и без метки, а не `500` и не `304`
#### Scenario: Условный ответ не собирает каталог
- **GIVEN** в витрине лежат данные, помеченные будущим
- **WHEN** каталог отвечает `304` по совпавшей метке
- **THEN** предупреждение владельцу не пишется, потому что измерения не было
@@ -0,0 +1,279 @@
## ADDED Requirements
### Requirement: Хранилище отдаёт версию витрины
Система SHALL отдавать **версию витрины** — метку, которая MUST меняться при
любом коммите в базу и MUST NOT меняться, пока коммитов не было. Метка
предназначена условному запросу читающих маршрутов: равные метки означают, что
между их снятием в базу никто ничего не записал.
Метка MUST быть парой «поколение + счётчик». Счётчик — `PRAGMA data_version`,
поколение — идентификатор, выданный тому соединению, с которого счётчик
читается. Монотонной метка не является и сравнению на «новее» не подлежит:
гарантируется только неравенство.
Обе части обязательны, и каждая закрывает измеренный отказ:
- **счётчик несравним между соединениями.** На одном и том же состоянии базы
два соединения одного пула отвечают разными числами, а любое свежее
соединение отвечает одним и тем же значением независимо от содержимого базы.
Поэтому счётчик MUST читаться с одного закреплённого соединения, которое
ничем другим не занято: собственная запись соединения его версию не двигает,
и щуп, участвующий в записи, молчал бы о собственных изменениях.
- **счётчик не переживает переоткрытия.** После рестарта он начинается заново,
поэтому одно и то же значение до и после означает разные состояния витрины.
Без поколения клиент со старой меткой получал бы «не изменилось» на
изменившиеся данные — единственный по-настоящему опасный исход условного
запроса.
Соединение-щуп MUST NOT удерживать открытую читающую транзакцию между снятиями
версии: каждое снятие завершается до возврата. Иначе щуп — единственное
долгоживущее соединение процесса — становится тем самым вечным читателем,
против которого заведён чекпойнт, и версия витрины отменяет обслуживание
журнала при полностью исправном обслуживании.
Поколение MUST меняться всякий раз, когда соединение-щуп создаётся заново.
Переиспользовать поколение MUST NOT: это ровно тот случай, ради которого оно
заведено.
**Непригодность щупа — узкий класс, а не любая ошибка.** Пересоздание
допускается только при отказе, означающем закрытое соединение; отмена запроса
клиентом, занятость базы и прочие обстоятельства (то, что проект уже отличает
предикатом «не сделано» против «не выходит») поколение менять MUST NOT.
Измерено: отмена контекста запроса щуп не убивает — следующий запрос на нём
проходит. Считай система смертью щупа любую ошибку, каждый оборванный клиентом
запрос обнулял бы метки всех потребителей, то есть механизм схлопывался бы под
той самой нагрузкой, ради которой заведён.
Закрытие хранилища MUST освобождать щуп раньше пула и MUST исключать его
пересоздание после закрытия. Закреплённое соединение переживает закрытие пула
(измерено), а SQLite делает финальный чекпойнт только при закрытии последнего
соединения: забытый щуп оставляет рядом с базой неразобранный `-wal`, и
пересборка, переносящая один файл базы, теряет хвост записей молча.
#### Scenario: Витрина не менялась
- **GIVEN** после первого запроса версии в базу никто не писал
- **WHEN** версия запрашивается второй раз
- **THEN** обе версии совпадают
#### Scenario: Свёртка записала объект
- **GIVEN** версия витрины снята
- **WHEN** фоновая свёртка закоммитила изменения в витрину
- **THEN** следующая снятая версия отличается от прежней
#### Scenario: База переоткрыта
- **GIVEN** версия витрины снята, база закрыта и открыта заново
- **WHEN** версия снимается снова на том же файле
- **THEN** она отличается от снятой до переоткрытия
#### Scenario: Соединение-щуп стало непригодным
- **GIVEN** соединение, с которого читается счётчик, закрыто
- **WHEN** версия запрашивается снова
- **THEN** запрос отвечает версией НОВОГО поколения, а не отказом
#### Scenario: Запрос версии оборван клиентом
- **GIVEN** запрос версии отменён контекстом
- **WHEN** версия запрашивается следующим запросом
- **THEN** поколение остаётся прежним
#### Scenario: Щуп не мешает разбирать журнал
- **GIVEN** версия снималась много раз подряд
- **WHEN** выполняется пассивный чекпойнт и других читателей нет
- **THEN** журнал перенесён целиком
#### Scenario: Хранилище закрыто
- **GIVEN** хранилище закрыто
- **WHEN** запрашивается версия витрины
- **THEN** запрос отказывает и нового соединения к базе не открывает, а рядом с
базой не остаётся файла журнала
### Requirement: Чтение подписывается версией только целиком
Система SHALL снимать версию витрины **до и после** чтения, которое ею
подписывается, и SHALL отдавать версию, только если обе пробы совпали. При
расхождении версии нет, и это не отказ: ответ уходит полным, просто без метки.
Версия, снятая ПОСЛЕ чтения, MUST NOT выставляться на его результате: она
пометила бы устаревший снимок свежим номером и заперла бы клиента на нём
навсегда — единственный по-настоящему опасный исход всей конструкции. Версия,
снятая только ДО, допускает два разных ответа под одной меткой.
Правило MUST существовать в одном экземпляре: Read API точек и MCP заявлены
потребителями той же машинерии, и вторая её реализация «по образцу»
отличалась бы от первой ровно на этот порядок — а тест первой этого не
увидел бы.
Отказ пробы версией не является и чтение не отменяет: маршрут деградирует до
полного ответа, а не до отказа.
#### Scenario: Витрина стояла всё время чтения
- **WHEN** чтение выполнено и обе пробы дали одну версию
- **THEN** версия отдана
#### Scenario: Витрина изменилась во время чтения
- **GIVEN** между пробами в базу закоммитили
- **THEN** версии нет, а результат чтения отдан целиком
#### Scenario: Само чтение отказало
- **WHEN** чтение вернуло ошибку
- **THEN** ошибка отдана вызывающему, а не подменена отсутствием версии
### Requirement: Журнал WAL разбирается по таймеру, и его непродвижение видно
Система SHALL выполнять `PRAGMA wal_checkpoint(PASSIVE)` **раз в минуту**, пока
сервис работает. Автоматический чекпойнт SQLite MUST NOT считаться достаточным:
он срабатывает по концу записи, а поток пачечный — журнал, раздутый всплеском,
иначе остаётся неразобранным до следующей доставки, и ночью это часы.
Режим MUST быть `PASSIVE`. `TRUNCATE` и `RESTART` применять MUST NOT: они
двигают счётчик версии витрины, то есть каждый тик обнулял бы условный запрос у
всех потребителей, а `TRUNCATE` вдобавок ждёт читателей.
Пассивный чекпойнт не идёт дальше снимка самого старого активного читателя и
**ошибки при этом не возвращает**: измерено `busy=0` при 6256 страницах в
журнале и 5 перенесённых. Поэтому система SHALL считать признаком беды пару
чисел — страниц в журнале больше **16384** (64 МиБ при странице в 4 КиБ) **и**
перенесено меньше, чем лежало, — и MUST сообщать об этом владельцу уровнем
`WARN`. Флаг занятости признаком «не продвинулись» служить MUST NOT: он молчит
ровно в измеренном случае удерживаемого читателя.
**Зато флаг занятости выражает другое, и это MUST читаться: исход не измерен.**
Не взяв блокировку чекпойнта, SQLite отдаёт `busy=1` и **`-1` вместо обоих
чисел** — измерено, 1492 таких тика из 5502 при писателе и чекпойнте в цикле.
Сравнивать `-1` на шкале страниц MUST NOT: `-1 >= -1` истинно, то есть
незамеренный тик читался бы как «журнал разобран целиком», владельцу уходила бы
строка о выздоровлении посреди болезни, а подавитель повторов сбрасывался бы —
и вместо задуманного молчания получалась бы пара строк в минуту. Незамеренный
исход MUST не менять ни объявленного состояния, ни накопленного о нём.
Размер страницы MUST браться у самой базы, а не предполагаться: он фиксируется
при создании файла, и база, созданная чужим инструментом, сместила бы порог в
разы. Неизвестен — признак молчит.
Порог MUST быть выражен через ту же величину, что и предел файла журнала: это
одно число в двух ролях (предел возвращает файл, порог сообщает, что вернуть
его не выходит), и двумя разошедшимися константами признак стал бы либо
недостижимым, либо шумным — молча.
**Строка о непродвижении не пишется на каждый тик.** Признак заведён ради
состояния, которое само не проходит (вечный читатель живёт до конца процесса), а
строка в минуту дала бы 1440 одинаковых записей в сутки. Система SHALL сообщать
о входе в состояние и повторять, только когда журнал заметно вырос; возврат к
норме MUST быть отдельным событием — молчание иначе неотличимо от «сервис
перестал проверять».
Отказ чекпойнта MUST NOT прекращать цикл и MUST быть виден записью лога:
обслуживание, умершее от временного отказа базы, молча перестало бы разбирать
журнал до конца жизни процесса. Отмена контекста отказом при этом не является.
Файл журнала MUST иметь названный предел (`journal_size_limit`, 64 МиБ).
Предел **роста этим не даётся, и это сказано вслух**: измерено — под
удерживаемым читателем файл вырос до 51 МБ при пределе 8 МиБ, и успешный
чекпойнт его не укоротил; усечение делает первая запись после полного
чекпойнта. Пока читатель держит снимок, журнал растёт, и единственный исход —
`WARN` владельцу.
#### Scenario: Журнал разбирается в тишине
- **GIVEN** доставок нет, а в журнале остались неразобранные страницы
- **WHEN** проходит период чекпойнта
- **THEN** страницы перенесены в базу без единой новой записи
#### Scenario: Читатель держит снимок
- **GIVEN** идёт запись, и читающая транзакция удерживает старый снимок
- **WHEN** выполняется пассивный чекпойнт
- **THEN** он завершается без ошибки, переносит меньше, чем лежит в журнале, и
флаг занятости остаётся снятым
#### Scenario: Чекпойнт не взял блокировку
- **GIVEN** о непродвижении журнала уже сказано
- **WHEN** очередной чекпойнт возвращает признак занятости и `-1` вместо чисел
- **THEN** ни строки о выздоровлении, ни строки о беде не пишется, а
накопленное состояние не меняется
#### Scenario: Журнал невелик
- **GIVEN** в журнале меньше страниц, чем названный порог
- **WHEN** чекпойнт не смог перенести всё
- **THEN** строка `WARN` не пишется
#### Scenario: Состояние держится
- **GIVEN** о непродвижении журнала уже сказано
- **WHEN** следующий чекпойнт застаёт журнал того же размера
- **THEN** строка не повторяется
#### Scenario: Журнал разобрался
- **GIVEN** о непродвижении журнала было сказано
- **WHEN** очередной чекпойнт переносит журнал ЦЕЛИКОМ
- **THEN** о возврате к норме сказано один раз
#### Scenario: Журнал стал мал, но не перенесён
- **GIVEN** о непродвижении журнала было сказано
- **WHEN** очередной чекпойнт переносит не всё, а журнал при этом ниже порога
- **THEN** о возврате к норме не сообщается: перенос — это то, что проверено, а
размер ниже порога — нет
#### Scenario: Чекпойнт отказал
- **GIVEN** очередной чекпойнт вернул ошибку
- **WHEN** наступает следующий период
- **THEN** отказ виден строкой лога, а чекпойнт выполняется снова
### Requirement: Обслуживание журнала останавливается дренированием
Система SHALL останавливать периодический чекпойнт осознанно: горутина MUST
получать отмену и MUST быть дождана вместе с воркером свёртки — база
закрывается только после выхода **обеих**. Обрывать её выходом процесса
MUST NOT, а закрывать базу по выходу одной из двух MUST NOT: закрытая из-под
воркера, она даёт `ERROR` по доставке, с которой всё в порядке.
Идущий чекпойнт при отмене прерывается, и терять ему нечего: перенос страниц
идемпотентен, исхода разбора чекпойнт не пишет, а следующий старт берёт журнал с
того же места. Прерывание по отмене отказом MUST NOT считаться.
Исчерпание бюджета остановки MUST называть этап, не утверждая большего, чем
проверено: ждут двоих, и назвать виновным одного из них — догадка.
Отдельного чекпойнта на остановке система выполнять MUST NOT: закрытие
последнего соединения к базе SQLite делает его само. Условие названо в
требовании о версии витрины — щуп обязан быть закрыт раньше пула, иначе
последнего соединения не наступает вовсе.
**Остаток назван вслух: в ветке исчерпанного бюджета база не закрывается, а
значит финального чекпойнта не наступает и рядом с ней остаётся `-wal`.**
Данные при этом целы — следующее открытие проиграет журнал, — но файл базы в
этом состоянии переносить без его `-wal` нельзя. Процедура подмены при
пересборке этого и требует: она удаляет `-wal` старой базы вместе с ней самой.
#### Scenario: Сервис останавливается
- **WHEN** сервис получает сигнал остановки
- **THEN** горутина чекпойнта завершается до закрытия базы
#### Scenario: Чекпойнт идёт в момент остановки
- **GIVEN** чекпойнт выполняется, когда пришла отмена
- **WHEN** он прерывается
- **THEN** отказ не пишется, новый цикл не начинается, и горутина выходит
#### Scenario: Бюджет остановки исчерпан
- **GIVEN** фоновые горутины не вышли в бюджет
- **WHEN** сервис завершается
- **THEN** база не закрывается, а запись лога называет этап, не указывая
виновной горутины
@@ -0,0 +1,66 @@
## 1. Версия витрины в хранилище
- [x] 1.1 Соединение-щуп: `sql.Conn`, взятый по первому запросу версии,
поколение (ULID через `internal/ident`), пересоздание с новым поколением
только при `sql.ErrConnDone` — обстоятельства поколение не меняют
- [x] 1.2 `Store.StateVersion(ctx)``PRAGMA data_version` со щупа, метка вида
`<поколение>-<счётчик>`; `Store.VersionedRead` — двойная проба вокруг
чтения; щуп закрывается раньше пула в `Close` и не воскресает после него
- [x] 1.3 Тесты: неизменившаяся база даёт ту же версию; запись из пула её
двигает; переоткрытие базы даёт другую версию; щуп пересоздаётся с новым
поколением; после `Close` версия отказывает и `-wal` рядом не остаётся;
щуп не удерживает читающий снимок
## 2. Обслуживание WAL
- [x] 2.1 `journal_size_limit` в DSN рабочего подключения, с причиной в
комментарии (пассивный чекпойнт файл не укорачивает)
- [x] 2.2 `Store.CheckpointWAL(ctx)``PRAGMA wal_checkpoint(PASSIVE)`,
наружу тройка чисел (busy, log, checkpointed) без интерпретации
- [x] 2.3 Цикл чекпойнта в `cmd/healthlog`: тик в минуту, `WARN` при
«страниц больше порога и перенесено меньше», отказ не убивает цикл
- [x] 2.4 Запуск и дренирование в `serve`: горутина ждётся в общем бюджете
остановки, отдельного чекпойнта на выходе нет
- [x] 2.5 Тесты: чекпойнт переносит страницы в тишине; удерживаемый читатель
даёт `checkpointed < log` без ошибки; порог молчит на малом журнале;
цикл выходит по отмене
## 3. Условный запрос в транспорте
- [x] 3.1 Помощник `httpapi`: разбор `If-None-Match` (список, `W/`, `*`),
слабое сравнение, `304` без тела — общий для будущих читающих маршрутов
- [x] 3.2 Источник версии передаётся транспорту функцией (как `worker.Notify`),
проверка токена чтения остаётся раньше условия
- [x] 3.3 Тесты помощника на формах заголовка: пусто, список, `W/`, `*`,
мусор
## 4. Каталог отдаёт версию
- [x] 4.1 `catalog.Service.Metrics` возвращает снимок вместе с версией:
проба до, сборка, проба после; расхождение — версии нет
- [x] 4.2 `handleMetrics`: `ETag` из версии, `304` по `If-None-Match` без
открытия снимка, ответ без `ETag` при расхождении проб
- [x] 4.3 Тесты: два ответа подряд — одна метка и одинаковые байты; после
свёртки метка другая; `304` не открывает снимок; `401` раньше `304`
## 5. Приёмочные критерии (рубрика ревью дизайна)
- [x] 5.1 Равная метка ⟹ побайтово равный ответ (кроме горизонта — назван в
дизайне); обратное направление ошибок не допускается ни в одном тесте
- [x] 5.2 Ни один новый лог не несёт значений точек, имён метрик без обрезки
и секретов; уровень выбран по адресату
- [x] 5.3 `task gate` зелёный; `task verify:archive` сходится (обслуживание
WAL и версия не меняют витрину)
- [x] 5.4 Поведенческая проверка на своём стенде из исходников (отдельный
каталог данных, рабочий контейнер не трогаем): `curl` дважды даёт
`304`, после доставки — `200`
## 6. Документация
- [x] 6.1 `docs/architecture.md`: версия витрины, условный запрос, обслуживание
WAL, отвергнутые чужие решения с причинами
- [x] 6.2 `README.md`: строка про условный запрос в примерах чтения
- [x] 6.3 `docs/backlog`: задача снята, остаток (предел ответа, измеренная цена
первого запроса, готовая машинерия условного запроса) перенесён в
`read-api-tochki.md`; наблюдаемость — в `stats-nablyudaemost.md`, цена
ветки исчерпанного бюджета — в `ostanovka-i-migraciya-sledy.md`
+138
View File
@@ -524,3 +524,141 @@ Read API MUST опираться на фактические объекты за
- **WHEN** доставка учтена - **WHEN** доставка учтена
- **THEN** в сохранённых заголовках вместо значения стоит пометка о сокрытии - **THEN** в сохранённых заголовках вместо значения стоит пометка о сокрытии
### Requirement: Каталог отвечает на условный запрос
Система SHALL выставлять на ответе каталога заголовок `ETag` и SHALL отвечать
`304 Not Modified` на запрос с `If-None-Match`, чья метка совпадает с текущей
версией витрины. При совпадении снимок витрины открываться MUST NOT: смысл
условного запроса в том, что самый частый запрос потребителя — повтор
неизменившегося — не стоит ничего.
Метка MUST строиться из **всего, от чего зависит ответ**: версии витрины и
горизонта измерения. Горизонт едет вместе с часами, и метка из будущего,
лежащая в витрине, въезжает в окно сама — без единого коммита. Путь построен и
прогнан: та же версия витрины, `cumulative` против `unknown`. Значит версии
витрины для метки НЕ ДОСТАТОЧНО, и слабая форма метки этого не лечит: смена
измеренного рода — изменение семантическое, на нём Read API строит арифметику
года.
Горизонт входит в метку огрублённым до часа, и огрубление точное, а не
приблизительное: метки объектов лежат ровно на часах, поэтому отбор по горизонту
меняется ровно при переходе через час. Цена названа: один полный ответ в час на
потребителя при неизменившейся витрине.
Форма метки MUST оставаться слабой (`W/"…"`): она выведена из состояния, а не из
байтов ответа. На исход `304` это не влияет — `If-None-Match` сравнивается слабо
в любом случае.
Метка MUST выставляться, только если за всё время сборки ответа в базу никто не
коммитил; правило снятия версии принадлежит хранилищу и здесь не повторяется.
Ответ без метки — законный исход, а не отказ: клиент просто не сможет спросить
условно в следующий раз.
**Метка действительна только в пределах одного ресурса, и область действия
MUST входить в саму метку.** Маршрут, чей ответ есть функция параметров запроса
(Read API точек), и транспорт, у которого адреса нет вовсе (MCP), обязаны
подмешивать в неё канонизированную форму запроса — иначе «не изменилось»
ответит на другой набор данных. Требовать этого прозой недостаточно: правило
MUST быть выражено формой вызова, потому что забыть его — единственный путь всей
задачи, ведущий к выдаче не тех данных.
Ответ `304` MUST нести ту же метку и MUST NOT нести тела и представленческих
заголовков. Клиент, не приславший `If-None-Match`, MUST получать ровно то же,
что и до появления условного запроса.
Ответы каталога MUST быть помечены непригодными для разделяемого кеша
(`Cache-Control: private, no-cache`). До появления валидатора эвристическое
кеширование посредником было маловероятным; с меткой ответ становится штатно
кешируемым, а при выключенной проверке токенов (законная конфигурация) в
запросе нет и `Authorization` — тогда выгрузку истории здоровья вправе
сохранить любой прокси на пути.
Проверка токена чтения MUST предшествовать условному запросу: `304` без токена
подтверждал бы состояние витрины тому, кому она не открыта.
Разбор условия MUST следовать HTTP и MUST NOT превращать кривой заголовок в
отказ:
- звёздочка (`*`) совпадает с любой **существующей** меткой; метки нет —
условие не выполнено, и клиент со звёздочкой получает данные, а не вечный
`304`;
- неразбираемое значение условия не выполняет и даёт `200`, а не `400`.
**Следствие названо вслух: `304` не выполняет измерения и потому не пишет
предупреждений владельцу.** Предупреждения каталога (данные из будущего,
противоречащий род агрегации) привязаны к сборке ответа; с условным опросом они
становятся функцией смены версии витрины, а не числа запросов. Состояние при
этом не исчезает: следующая доставка меняет версию, ответ собирается, и
предупреждение пишется — а пока витрина стоит, повторять его на каждый опрос
трёх потребителей значило бы обесценить уровень.
#### Scenario: Повтор на неизменившейся витрине
- **GIVEN** клиент получил каталог и запомнил его `ETag`
- **WHEN** он повторяет запрос с `If-None-Match` этой метки, а витрина не
менялась
- **THEN** ответ — `304` без тела, с той же меткой
#### Scenario: Витрина изменилась
- **GIVEN** клиент получил каталог и запомнил его `ETag`
- **WHEN** свёртка записала объект и клиент повторяет запрос с прежней меткой
- **THEN** ответ — `200` с полным каталогом и новой меткой
#### Scenario: Горизонт сдвинулся
- **GIVEN** витрина не менялась
- **WHEN** горизонт измерения перешёл через час
- **THEN** метка отличается от прежней
#### Scenario: Метка другого ресурса
- **GIVEN** клиент присылает метку, выданную другим читающим маршрутом
- **WHEN** совпадает версия витрины
- **THEN** условие не выполнено, и ответ — `200`
#### Scenario: Клиент не спрашивает условно
- **WHEN** каталог запрашивается без `If-None-Match`
- **THEN** ответ — `200` с полным каталогом, меткой и правилом кеширования
#### Scenario: Две метки на неизменившейся витрине совпадают
- **GIVEN** витрина не менялась между двумя запросами
- **WHEN** каталог запрошен дважды
- **THEN** метки совпадают, и тела ответов совпадают побайтово
#### Scenario: Звёздочка в условии
- **WHEN** каталог запрашивается с `If-None-Match: *`
- **THEN** ответ — `304` с текущей меткой
#### Scenario: Условие нечитаемо
- **WHEN** каталог запрашивается с `If-None-Match`, который меткой не является
- **THEN** ответ — `200` с полным каталогом, а не `400` и не `304`
#### Scenario: Условный запрос без токена чтения
- **GIVEN** список токенов чтения непуст
- **WHEN** каталог запрашивается с `If-None-Match`, но без токена
- **THEN** ответ — `401`, а не `304`
#### Scenario: Витрина изменилась во время сборки ответа
- **GIVEN** между снятием версии до и после сборки в базу был коммит
- **WHEN** ответ сформирован
- **THEN** он уходит с полным телом и без заголовка `ETag`
#### Scenario: Версия витрины недоступна
- **GIVEN** версию витрины прочитать не удалось
- **WHEN** каталог запрашивается, в том числе с `If-None-Match`
- **THEN** ответ — `200` с полным каталогом и без метки, а не `500` и не `304`
#### Scenario: Условный ответ не собирает каталог
- **GIVEN** в витрине лежат данные, помеченные будущим
- **WHEN** каталог отвечает `304` по совпавшей метке
- **THEN** предупреждение владельцу не пишется, потому что измерения не было
+278
View File
@@ -1057,3 +1057,281 @@ SHALL: сегодня ровно этот случай даёт ноль и мо
- **WHEN** выполняется выборка - **WHEN** выполняется выборка
- **THEN** число обращений к базе не зависит от числа часов - **THEN** число обращений к базе не зависит от числа часов
### Requirement: Хранилище отдаёт версию витрины
Система SHALL отдавать **версию витрины** — метку, которая MUST меняться при
любом коммите в базу и MUST NOT меняться, пока коммитов не было. Метка
предназначена условному запросу читающих маршрутов: равные метки означают, что
между их снятием в базу никто ничего не записал.
Метка MUST быть парой «поколение + счётчик». Счётчик — `PRAGMA data_version`,
поколение — идентификатор, выданный тому соединению, с которого счётчик
читается. Монотонной метка не является и сравнению на «новее» не подлежит:
гарантируется только неравенство.
Обе части обязательны, и каждая закрывает измеренный отказ:
- **счётчик несравним между соединениями.** На одном и том же состоянии базы
два соединения одного пула отвечают разными числами, а любое свежее
соединение отвечает одним и тем же значением независимо от содержимого базы.
Поэтому счётчик MUST читаться с одного закреплённого соединения, которое
ничем другим не занято: собственная запись соединения его версию не двигает,
и щуп, участвующий в записи, молчал бы о собственных изменениях.
- **счётчик не переживает переоткрытия.** После рестарта он начинается заново,
поэтому одно и то же значение до и после означает разные состояния витрины.
Без поколения клиент со старой меткой получал бы «не изменилось» на
изменившиеся данные — единственный по-настоящему опасный исход условного
запроса.
Соединение-щуп MUST NOT удерживать открытую читающую транзакцию между снятиями
версии: каждое снятие завершается до возврата. Иначе щуп — единственное
долгоживущее соединение процесса — становится тем самым вечным читателем,
против которого заведён чекпойнт, и версия витрины отменяет обслуживание
журнала при полностью исправном обслуживании.
Поколение MUST меняться всякий раз, когда соединение-щуп создаётся заново.
Переиспользовать поколение MUST NOT: это ровно тот случай, ради которого оно
заведено.
**Непригодность щупа — узкий класс, а не любая ошибка.** Пересоздание
допускается только при отказе, означающем закрытое соединение; отмена запроса
клиентом, занятость базы и прочие обстоятельства (то, что проект уже отличает
предикатом «не сделано» против «не выходит») поколение менять MUST NOT.
Измерено: отмена контекста запроса щуп не убивает — следующий запрос на нём
проходит. Считай система смертью щупа любую ошибку, каждый оборванный клиентом
запрос обнулял бы метки всех потребителей, то есть механизм схлопывался бы под
той самой нагрузкой, ради которой заведён.
Закрытие хранилища MUST освобождать щуп раньше пула и MUST исключать его
пересоздание после закрытия. Закреплённое соединение переживает закрытие пула
(измерено), а SQLite делает финальный чекпойнт только при закрытии последнего
соединения: забытый щуп оставляет рядом с базой неразобранный `-wal`, и
пересборка, переносящая один файл базы, теряет хвост записей молча.
#### Scenario: Витрина не менялась
- **GIVEN** после первого запроса версии в базу никто не писал
- **WHEN** версия запрашивается второй раз
- **THEN** обе версии совпадают
#### Scenario: Свёртка записала объект
- **GIVEN** версия витрины снята
- **WHEN** фоновая свёртка закоммитила изменения в витрину
- **THEN** следующая снятая версия отличается от прежней
#### Scenario: База переоткрыта
- **GIVEN** версия витрины снята, база закрыта и открыта заново
- **WHEN** версия снимается снова на том же файле
- **THEN** она отличается от снятой до переоткрытия
#### Scenario: Соединение-щуп стало непригодным
- **GIVEN** соединение, с которого читается счётчик, закрыто
- **WHEN** версия запрашивается снова
- **THEN** запрос отвечает версией НОВОГО поколения, а не отказом
#### Scenario: Запрос версии оборван клиентом
- **GIVEN** запрос версии отменён контекстом
- **WHEN** версия запрашивается следующим запросом
- **THEN** поколение остаётся прежним
#### Scenario: Щуп не мешает разбирать журнал
- **GIVEN** версия снималась много раз подряд
- **WHEN** выполняется пассивный чекпойнт и других читателей нет
- **THEN** журнал перенесён целиком
#### Scenario: Хранилище закрыто
- **GIVEN** хранилище закрыто
- **WHEN** запрашивается версия витрины
- **THEN** запрос отказывает и нового соединения к базе не открывает, а рядом с
базой не остаётся файла журнала
### Requirement: Чтение подписывается версией только целиком
Система SHALL снимать версию витрины **до и после** чтения, которое ею
подписывается, и SHALL отдавать версию, только если обе пробы совпали. При
расхождении версии нет, и это не отказ: ответ уходит полным, просто без метки.
Версия, снятая ПОСЛЕ чтения, MUST NOT выставляться на его результате: она
пометила бы устаревший снимок свежим номером и заперла бы клиента на нём
навсегда — единственный по-настоящему опасный исход всей конструкции. Версия,
снятая только ДО, допускает два разных ответа под одной меткой.
Правило MUST существовать в одном экземпляре: Read API точек и MCP заявлены
потребителями той же машинерии, и вторая её реализация «по образцу»
отличалась бы от первой ровно на этот порядок — а тест первой этого не
увидел бы.
Отказ пробы версией не является и чтение не отменяет: маршрут деградирует до
полного ответа, а не до отказа.
#### Scenario: Витрина стояла всё время чтения
- **WHEN** чтение выполнено и обе пробы дали одну версию
- **THEN** версия отдана
#### Scenario: Витрина изменилась во время чтения
- **GIVEN** между пробами в базу закоммитили
- **THEN** версии нет, а результат чтения отдан целиком
#### Scenario: Само чтение отказало
- **WHEN** чтение вернуло ошибку
- **THEN** ошибка отдана вызывающему, а не подменена отсутствием версии
### Requirement: Журнал WAL разбирается по таймеру, и его непродвижение видно
Система SHALL выполнять `PRAGMA wal_checkpoint(PASSIVE)` **раз в минуту**, пока
сервис работает. Автоматический чекпойнт SQLite MUST NOT считаться достаточным:
он срабатывает по концу записи, а поток пачечный — журнал, раздутый всплеском,
иначе остаётся неразобранным до следующей доставки, и ночью это часы.
Режим MUST быть `PASSIVE`. `TRUNCATE` и `RESTART` применять MUST NOT: они
двигают счётчик версии витрины, то есть каждый тик обнулял бы условный запрос у
всех потребителей, а `TRUNCATE` вдобавок ждёт читателей.
Пассивный чекпойнт не идёт дальше снимка самого старого активного читателя и
**ошибки при этом не возвращает**: измерено `busy=0` при 6256 страницах в
журнале и 5 перенесённых. Поэтому система SHALL считать признаком беды пару
чисел — страниц в журнале больше **16384** (64 МиБ при странице в 4 КиБ) **и**
перенесено меньше, чем лежало, — и MUST сообщать об этом владельцу уровнем
`WARN`. Флаг занятости признаком «не продвинулись» служить MUST NOT: он молчит
ровно в измеренном случае удерживаемого читателя.
**Зато флаг занятости выражает другое, и это MUST читаться: исход не измерен.**
Не взяв блокировку чекпойнта, SQLite отдаёт `busy=1` и **`-1` вместо обоих
чисел** — измерено, 1492 таких тика из 5502 при писателе и чекпойнте в цикле.
Сравнивать `-1` на шкале страниц MUST NOT: `-1 >= -1` истинно, то есть
незамеренный тик читался бы как «журнал разобран целиком», владельцу уходила бы
строка о выздоровлении посреди болезни, а подавитель повторов сбрасывался бы —
и вместо задуманного молчания получалась бы пара строк в минуту. Незамеренный
исход MUST не менять ни объявленного состояния, ни накопленного о нём.
Размер страницы MUST браться у самой базы, а не предполагаться: он фиксируется
при создании файла, и база, созданная чужим инструментом, сместила бы порог в
разы. Неизвестен — признак молчит.
Порог MUST быть выражен через ту же величину, что и предел файла журнала: это
одно число в двух ролях (предел возвращает файл, порог сообщает, что вернуть
его не выходит), и двумя разошедшимися константами признак стал бы либо
недостижимым, либо шумным — молча.
**Строка о непродвижении не пишется на каждый тик.** Признак заведён ради
состояния, которое само не проходит (вечный читатель живёт до конца процесса), а
строка в минуту дала бы 1440 одинаковых записей в сутки. Система SHALL сообщать
о входе в состояние и повторять, только когда журнал заметно вырос; возврат к
норме MUST быть отдельным событием — молчание иначе неотличимо от «сервис
перестал проверять».
Отказ чекпойнта MUST NOT прекращать цикл и MUST быть виден записью лога:
обслуживание, умершее от временного отказа базы, молча перестало бы разбирать
журнал до конца жизни процесса. Отмена контекста отказом при этом не является.
Файл журнала MUST иметь названный предел (`journal_size_limit`, 64 МиБ).
Предел **роста этим не даётся, и это сказано вслух**: измерено — под
удерживаемым читателем файл вырос до 51 МБ при пределе 8 МиБ, и успешный
чекпойнт его не укоротил; усечение делает первая запись после полного
чекпойнта. Пока читатель держит снимок, журнал растёт, и единственный исход —
`WARN` владельцу.
#### Scenario: Журнал разбирается в тишине
- **GIVEN** доставок нет, а в журнале остались неразобранные страницы
- **WHEN** проходит период чекпойнта
- **THEN** страницы перенесены в базу без единой новой записи
#### Scenario: Читатель держит снимок
- **GIVEN** идёт запись, и читающая транзакция удерживает старый снимок
- **WHEN** выполняется пассивный чекпойнт
- **THEN** он завершается без ошибки, переносит меньше, чем лежит в журнале, и
флаг занятости остаётся снятым
#### Scenario: Чекпойнт не взял блокировку
- **GIVEN** о непродвижении журнала уже сказано
- **WHEN** очередной чекпойнт возвращает признак занятости и `-1` вместо чисел
- **THEN** ни строки о выздоровлении, ни строки о беде не пишется, а
накопленное состояние не меняется
#### Scenario: Журнал невелик
- **GIVEN** в журнале меньше страниц, чем названный порог
- **WHEN** чекпойнт не смог перенести всё
- **THEN** строка `WARN` не пишется
#### Scenario: Состояние держится
- **GIVEN** о непродвижении журнала уже сказано
- **WHEN** следующий чекпойнт застаёт журнал того же размера
- **THEN** строка не повторяется
#### Scenario: Журнал разобрался
- **GIVEN** о непродвижении журнала было сказано
- **WHEN** очередной чекпойнт переносит журнал ЦЕЛИКОМ
- **THEN** о возврате к норме сказано один раз
#### Scenario: Журнал стал мал, но не перенесён
- **GIVEN** о непродвижении журнала было сказано
- **WHEN** очередной чекпойнт переносит не всё, а журнал при этом ниже порога
- **THEN** о возврате к норме не сообщается: перенос — это то, что проверено, а
размер ниже порога — нет
#### Scenario: Чекпойнт отказал
- **GIVEN** очередной чекпойнт вернул ошибку
- **WHEN** наступает следующий период
- **THEN** отказ виден строкой лога, а чекпойнт выполняется снова
### Requirement: Обслуживание журнала останавливается дренированием
Система SHALL останавливать периодический чекпойнт осознанно: горутина MUST
получать отмену и MUST быть дождана вместе с воркером свёртки — база
закрывается только после выхода **обеих**. Обрывать её выходом процесса
MUST NOT, а закрывать базу по выходу одной из двух MUST NOT: закрытая из-под
воркера, она даёт `ERROR` по доставке, с которой всё в порядке.
Идущий чекпойнт при отмене прерывается, и терять ему нечего: перенос страниц
идемпотентен, исхода разбора чекпойнт не пишет, а следующий старт берёт журнал с
того же места. Прерывание по отмене отказом MUST NOT считаться.
Исчерпание бюджета остановки MUST называть этап, не утверждая большего, чем
проверено: ждут двоих, и назвать виновным одного из них — догадка.
Отдельного чекпойнта на остановке система выполнять MUST NOT: закрытие
последнего соединения к базе SQLite делает его само. Условие названо в
требовании о версии витрины — щуп обязан быть закрыт раньше пула, иначе
последнего соединения не наступает вовсе.
**Остаток назван вслух: в ветке исчерпанного бюджета база не закрывается, а
значит финального чекпойнта не наступает и рядом с ней остаётся `-wal`.**
Данные при этом целы — следующее открытие проиграет журнал, — но файл базы в
этом состоянии переносить без его `-wal` нельзя. Процедура подмены при
пересборке этого и требует: она удаляет `-wal` старой базы вместе с ней самой.
#### Scenario: Сервис останавливается
- **WHEN** сервис получает сигнал остановки
- **THEN** горутина чекпойнта завершается до закрытия базы
#### Scenario: Чекпойнт идёт в момент остановки
- **GIVEN** чекпойнт выполняется, когда пришла отмена
- **WHEN** он прерывается
- **THEN** отказ не пишется, новый цикл не начинается, и горутина выходит
#### Scenario: Бюджет остановки исчерпан
- **GIVEN** фоновые горутины не вышли в бюджет
- **WHEN** сервис завершается
- **THEN** база не закрывается, а запись лога называет этап, не указывая
виновной горутины