From 8db2ec7ff436758e03e595799046fe854eaa1843 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Sun, 2 Aug 2026 20:42:22 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A6=D0=B5=D0=BD=D0=B0=20=D1=87=D0=B8=D1=82?= =?UTF-8?q?=D0=B0=D1=8E=D1=89=D0=B5=D0=B3=D0=BE=20=D0=BC=D0=B0=D1=80=D1=88?= =?UTF-8?q?=D1=80=D1=83=D1=82=D0=B0:=20=D1=87=D0=B5=D0=BA=D0=BF=D0=BE?= =?UTF-8?q?=D0=B9=D0=BD=D1=82=20WAL=20=D0=BF=D0=BE=20=D1=82=D0=B0=D0=B9?= =?UTF-8?q?=D0=BC=D0=B5=D1=80=D1=83=20=D0=B8=20=D1=83=D1=81=D0=BB=D0=BE?= =?UTF-8?q?=D0=B2=D0=BD=D1=8B=D0=B9=20=D0=B7=D0=B0=D0=BF=D1=80=D0=BE=D1=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - рядом с воркером свёртки живёт горутина, раз в минуту разбирающая журнал пассивным чекпойнтом; «журнал не разбирается» видно строкой владельцу, а не только по `df`. Признак — пара чисел, а не флаг занятости: тот молчит под удерживаемым читателем (`busy=0` при 6256 страницах и пяти перенесённых), а при занятой блокировке отдаёт `-1` вместо ответа, и `-1 >= -1` читалось бы как «разобрано целиком» - каталог отвечает `304` на `If-None-Match`, не открывая снимок витрины. Метка собрана из всего, от чего зависит ответ: версии витрины (`data_version` с закреплённого соединения плюс поколение — значение локально для соединения и не переживает переоткрытия), горизонта измерения и области действия ресурса. Версия снимается до и после сборки: снятая после пометила бы устаревший снимок свежим номером - предел и дедлайн ответа отложены в задачу Read API точек вместе с измеренной ценой первого запроса; попутно починен флаки-тест чужой задачи, искавший значение точки в сыром буфере записи лога --- README.md | 17 +- cmd/healthlog/checkpoint.go | 142 ++++++ cmd/healthlog/checkpoint_test.go | 170 +++++++ cmd/healthlog/serve.go | 68 ++- docs/architecture.md | 167 +++++++ docs/backlog/README.md | 1 - docs/backlog/cena-chitayushchego-marshruta.md | 98 ---- docs/backlog/ostanovka-i-migraciya-sledy.md | 12 +- docs/backlog/read-api-tochki.md | 24 +- docs/backlog/stats-nablyudaemost.md | 9 + docs/conventions.md | 6 + docs/review-journal.md | 21 + internal/catalog/catalog.go | 86 +++- internal/catalog/catalog_test.go | 50 +- internal/catalog/version_internal_test.go | 30 ++ internal/fold/log_test.go | 14 +- internal/httpapi/catalog.go | 36 +- internal/httpapi/conditional.go | 150 ++++++ internal/httpapi/conditional_route_test.go | 159 +++++++ internal/httpapi/conditional_test.go | 72 +++ internal/httpapi/httpapi_test.go | 52 +- internal/replay/archive_test.go | 9 +- internal/store/errors.go | 9 + internal/store/store.go | 27 ++ internal/store/version.go | 182 +++++++ internal/store/version_internal_test.go | 177 +++++++ internal/store/version_test.go | 222 +++++++++ internal/store/wal.go | 112 +++++ internal/store/wal_test.go | 198 ++++++++ .../.openspec.yaml | 2 + .../design.md | 443 ++++++++++++++++++ .../proposal.md | 66 +++ .../specs/catalog/spec.md | 139 ++++++ .../specs/storage/spec.md | 279 +++++++++++ .../tasks.md | 66 +++ openspec/specs/catalog/spec.md | 138 ++++++ openspec/specs/storage/spec.md | 278 +++++++++++ 37 files changed, 3575 insertions(+), 156 deletions(-) create mode 100644 cmd/healthlog/checkpoint.go create mode 100644 cmd/healthlog/checkpoint_test.go delete mode 100644 docs/backlog/cena-chitayushchego-marshruta.md create mode 100644 internal/catalog/version_internal_test.go create mode 100644 internal/httpapi/conditional.go create mode 100644 internal/httpapi/conditional_route_test.go create mode 100644 internal/httpapi/conditional_test.go create mode 100644 internal/store/version.go create mode 100644 internal/store/version_internal_test.go create mode 100644 internal/store/version_test.go create mode 100644 internal/store/wal.go create mode 100644 internal/store/wal_test.go create mode 100644 openspec/changes/archive/2026-08-02-cena-chitayushchego-marshruta/.openspec.yaml create mode 100644 openspec/changes/archive/2026-08-02-cena-chitayushchego-marshruta/design.md create mode 100644 openspec/changes/archive/2026-08-02-cena-chitayushchego-marshruta/proposal.md create mode 100644 openspec/changes/archive/2026-08-02-cena-chitayushchego-marshruta/specs/catalog/spec.md create mode 100644 openspec/changes/archive/2026-08-02-cena-chitayushchego-marshruta/specs/storage/spec.md create mode 100644 openspec/changes/archive/2026-08-02-cena-chitayushchego-marshruta/tasks.md diff --git a/README.md b/README.md index 9041d1c..d3e9dbd 100644 --- a/README.md +++ b/README.md @@ -58,15 +58,21 @@ iPhone ──HTTPS POST──► healthlog ──► журнал доставо В разработке. Готовы каркас и приём, большая часть разбора: сервис принимает пакеты, складывает их в сырой архив и **разбирает метрики в часовые объекты** — с выводом слоя из данных, канонизацией содержимого и слиянием точек по -полноте. Секции, которых разбор пока не покрывает (`workouts`, `stateOfMind` — -половина потока), принимаются, хранятся и честно помечаются как неразобранные. +полноте. Тренировки и записи со своим `id` (`workouts`, `stateOfMind`) тоже +разбираются; секции, которых разбор не покрывает, принимаются, хранятся и +честно помечаются как неразобранные. Есть и пересборка: `healthlog reindex` проигрывает журнал доставок в свежую витрину и сверяет её отпечаток с накопленной — на живом архиве из 116 тел пересборка воспроизводима и повторный прогон ничего не меняет. -Чего ещё нет: каталога метрик с измеренным родом агрегации и **read API** — -данные наружу пока не отдаются никак. План в [docs/plan.md](docs/plan.md). +Первый маршрут чтения открыт: **каталог разрезов** (`GET /api/v1/metrics`) под +токеном чтения отдаёт слои с диапазонами и измеренный род агрегации, а повтор +неизменившегося отвечает `304` по `ETag` — снимок витрины при этом не +открывается. Журнал WAL разбирается фоновым чекпойнтом по таймеру. + +Чего ещё нет: **read API точек**, тренировок и записей — сами данные наружу +пока не отдаются. План в [docs/plan.md](docs/plan.md). Разведка формата закончена: 50 находок на живом потоке, половина расходится с документацией Health Auto Export — [docs/local-research.md](docs/local-research.md). @@ -132,6 +138,9 @@ task run curl localhost:8080/healthz curl -X POST localhost:8080/api/v1/ingest -d '{"data":{"metrics":[]}}' curl localhost:8080/api/v1/metrics # каталог: слои, диапазоны, род агрегации + +# повтор неизменившегося не стоит ничего: метка из ответа возвращается условием +curl -i -H 'If-None-Match: W/"…"' localhost:8080/api/v1/metrics # 304 ``` ### Подключение телефона по локальной сети diff --git a/cmd/healthlog/checkpoint.go b/cmd/healthlog/checkpoint.go new file mode 100644 index 0000000..c862060 --- /dev/null +++ b/cmd/healthlog/checkpoint.go @@ -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 + } +} diff --git a/cmd/healthlog/checkpoint_test.go b/cmd/healthlog/checkpoint_test.go new file mode 100644 index 0000000..b477fe7 --- /dev/null +++ b/cmd/healthlog/checkpoint_test.go @@ -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 +} diff --git a/cmd/healthlog/serve.go b/cmd/healthlog/serve.go index 412d746..c9683bd 100644 --- a/cmd/healthlog/serve.go +++ b/cmd/healthlog/serve.go @@ -9,6 +9,7 @@ import ( "net" "net/http" "os/signal" + "sync" "syscall" "time" @@ -46,6 +47,31 @@ func runServe(args []string) error { 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 поднимает сервис и ведёт его до отмены контекста. // // Контекст параметром, а не подпиской на сигнал внутри: иначе весь жизненный @@ -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) } - workerCtx, stopWorker := context.WithCancel(context.Background()) - defer stopWorker() - workerDone := make(chan struct{}) - go func() { - defer close(workerDone) + // Обе фоновые горутины живут на одном контексте и ждутся вместе. Вместе — + // потому что база закрывается ПОСЛЕ выхода обеих: закрытая из-под воркера, + // она даёт ERROR по доставке, с которой всё в порядке, а из-под чекпойнта — + // отказ обслуживания на ровном месте. + 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) go func() { + // Параметры обслуживания журнала — в той же строке, а не отдельной: + // горутина, которую забыли запустить, иначе неотличима от здоровой + // ровно до того дня, когда журнал упрётся в диск. Ноль новых строк, обе + // константы проверяемы глазами. log.Info("server started", "addr", ln.Addr().String(), "db_path", cfg.Storage.DBPath, "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) { errCh <- fmt.Errorf("serve: %w", err) @@ -172,15 +216,9 @@ func serve(ctx context.Context, cfg *config.Config, log *slog.Logger, ready func } } - stopWorker() - select { - case <-workerDone: + stopBackground() + if waitBackground(shutdownCtx, backgroundDone, log) { closeStore() - case <-shutdownCtx.Done(): - // Воркер не вышел в бюджет. База не закрывается: её транзакцию свернёт - // выход процесса, и доставка останется `pending` — то есть будет - // подобрана следующим стартом. - log.Warn("shutdown budget exceeded", "stage", "fold-worker") } return serveErr } diff --git a/docs/architecture.md b/docs/architecture.md index 093937c..a2e8e73 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 @@ -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` не выполняет измерения и потому не пишет +предупреждений владельцу** (данные из будущего, противоречащий род). С условным +опросом они становятся функцией смены версии витрины, а не числа запросов; +состояние при этом не теряется — следующая доставка меняет версию, ответ +собирается, и предупреждение пишется. + ### Свёртка и размер ответа Запросов к метрике ровно два, и это один запрос с необязательным параметром: diff --git a/docs/backlog/README.md b/docs/backlog/README.md index fa42ff1..a047507 100644 --- a/docs/backlog/README.md +++ b/docs/backlog/README.md @@ -21,7 +21,6 @@ ## высокий - [Тай-брейк при равной полноте точек](taj-brejk-pri-ravnoj-polnote.md) — Решено: брать бо́льшее значение. Порядок канонических форм берёт меньшее в 96% случаев — для накопительных это систематический недосчёт -- [Цена первого читающего маршрута: память, WAL и повторный опрос](cena-chitayushchego-marshruta.md) — Решено: чекпойнт по таймеру плюс ETag по data_version. Берётся перед Read API — тот строится поверх этой машинерии - [Read API: точки, выбор слоя, свёртка по сетке](read-api-tochki.md) — Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может - [OpenAPI-спека и Swagger UI](openapi-swagger.md) — Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате - [MCP-сервер поверх Read API](mcp-server.md) — Агент-медик — первый заказчик проекта, а подключить его сейчас нечем diff --git a/docs/backlog/cena-chitayushchego-marshruta.md b/docs/backlog/cena-chitayushchego-marshruta.md deleted file mode 100644 index 354b7f6..0000000 --- a/docs/backlog/cena-chitayushchego-marshruta.md +++ /dev/null @@ -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`. diff --git a/docs/backlog/ostanovka-i-migraciya-sledy.md b/docs/backlog/ostanovka-i-migraciya-sledy.md index ef06d67..2aa4056 100644 --- a/docs/backlog/ostanovka-i-migraciya-sledy.md +++ b/docs/backlog/ostanovka-i-migraciya-sledy.md @@ -21,6 +21,14 @@ исчерпанного первого, и `BaseContext`, производный от контекста жизненного цикла, чтобы долгий запрос об остановке узнавал. +**Цена этой ветки выросла** (change `cena-chitayushchego-marshruta`): база в ней +не закрывается, а значит не закрывается и закреплённое соединение версии +витрины — последнего соединения к базе не наступает, SQLite не делает финального +чекпойнта, и рядом с базой остаётся неразобранный `-wal` до 64 МиБ. Данные целы +(следующее открытие проиграет журнал), но файл базы в этом состоянии нельзя +переносить без его `-wal`. Обвинение в логе при этом стало честнее: этап +называется `background`, а не `fold-worker`, потому что ждут двоих. + **Миграция молчит и не прерывается штатной остановкой.** `store.migrate` не пишет ни одной записи — ни «начал», ни «закончил», ни длительность, — а первая строка в логе появляется уже после успешного открытия базы. Если миграция идёт @@ -37,5 +45,5 @@ Готово, когда `WARN` о превышении бюджета называет виновный этап честно, а в логе старта видно, что миграции накатывались и сколько это заняло. -Связано: `cmd/healthlog/serve.go`, `internal/store/store.go`, -`cena-chitayushchego-marshruta.md`. +Связано: `cmd/healthlog/serve.go`, `internal/store/store.go`, change +`2026-08-02-cena-chitayushchego-marshruta` (архив). diff --git a/docs/backlog/read-api-tochki.md b/docs/backlog/read-api-tochki.md index 8dfd087..57b3bba 100644 --- a/docs/backlog/read-api-tochki.md +++ b/docs/backlog/read-api-tochki.md @@ -36,10 +36,26 @@ величины выглядят как «0.5», означая разное; полярность придётся назвать вслух в `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-теги, а транспорт владеет только diff --git a/docs/backlog/stats-nablyudaemost.md b/docs/backlog/stats-nablyudaemost.md index 2be6bd0..63b9034 100644 --- a/docs/backlog/stats-nablyudaemost.md +++ b/docs/backlog/stats-nablyudaemost.md @@ -48,3 +48,12 @@ запроса, ни адреса клиента: жалобу потребителя не сопоставить с записью, а выгрузку каталога посторонним — не отличить от планового опроса агента. У приёма корреляция есть (`delivery_id`), у чтения аналога нет. + +**Обслуживание журнала WAL тоже спрашивается здесь.** Признак «журнал не +разбирается» (чекпойнт по таймеру, change `cena-chitayushchego-marshruta`) +живёт одной строкой `WARN` в ротируемом docker-логе: состояние держится днями, а +сказано о нём один раз. Вопрос «журнал сейчас разбирается?» сегодня не имеет +ответа нигде, кроме `df`. В `/stats` просятся последний исход чекпойнта (когда, +сколько страниц лежит и сколько перенесено) и — тем же полем — доля ответов +чтения, которые удалось подписать `ETag`: механизм условного запроса может +перестать окупаться под плотным потоком, и снаружи это неотличимо от нормы. diff --git a/docs/conventions.md b/docs/conventions.md index 22bcee0..1ecd43d 100644 --- a/docs/conventions.md +++ b/docs/conventions.md @@ -161,3 +161,9 @@ архиве, и ответ «пересворачивать нечего» произносится с числом.** Утверждение без числа не отличается от предположения, а цена ошибки здесь — необратимое решение о судьбе тел. +- **Тест «в логе нет значения» проверяет запись без служебных полей, а не сырой + буфер.** Метка времени содержит доли секунды, поэтому искомая подстрока + находится в ней сама: проверка на «5.1» краснела примерно раз на сотню + прогонов от хода часов, а не от утечки. Разбираем запись, выбрасываем `time` + и ищем в остатке. Правило общее — таких тестов будет больше (токены, тела + запросов, координаты объектов). diff --git a/docs/review-journal.md b/docs/review-journal.md index 3a5c03e..69f02c7 100644 --- a/docs/review-journal.md +++ b/docs/review-journal.md @@ -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`, а не ищет в сыром + буфере. Гейт не трогаем: два прогона против однопроцентной флаки не помогут, + а десять стоили бы дороже самой находки. diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go index e381bfd..d90a440 100644 --- a/internal/catalog/catalog.go +++ b/internal/catalog/catalog.go @@ -204,6 +204,54 @@ type Metric struct { 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 собирает каталог по витрине. type Service struct { store *store.Store @@ -223,17 +271,26 @@ 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) - snap, err := s.store.ReadCatalog(ctx, store.CatalogWindow{ - Fine: string(hae.LayerMinute), - Coarse: string(hae.LayerHour), - Hours: Window, - Horizon: horizon, - CoarsePoints: coarsePoints, - MinFinePoints: minFinePoints, + + 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), + Coarse: string(hae.LayerHour), + Hours: Window, + Horizon: horizon, + CoarsePoints: coarsePoints, + MinFinePoints: minFinePoints, + }) + return err }) - if err != nil { + if err != nil { //nolint:nestif // ветка одна, вложенность даёт лог по адресату // Единственный логирующий чекпоинт исхода: транспорт переводит ошибку в // ответ и второй раз её не пишет. // @@ -247,7 +304,7 @@ func (s *Service) Metrics(ctx context.Context) ([]Metric, error) { } else { s.log.ErrorContext(ctx, "catalog failed", "capability", "query", "error", err) } - return nil, err + return Snapshot{}, err } out := make([]Metric, 0, len(snap.Layers)) @@ -286,7 +343,14 @@ func (s *Service) Metrics(ctx context.Context) ([]Metric, error) { 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 { diff --git a/internal/catalog/catalog_test.go b/internal/catalog/catalog_test.go index 42bed1e..af20dc5 100644 --- a/internal/catalog/catalog_test.go +++ b/internal/catalog/catalog_test.go @@ -129,7 +129,7 @@ func TestКаталогОтдаётРазрезыИИзмеренныйРод(t 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 { t.Fatalf("каталог: %v", err) } @@ -179,7 +179,7 @@ func TestКаталогНеИзмеряетПоНижнемуСлою(t *testing } merge(t, st, points) - metrics, err := service(t, st).Metrics(context.Background()) + metrics, err := metricsOf(context.Background(), service(t, st)) if err != nil { t.Fatalf("каталог: %v", err) } @@ -198,7 +198,7 @@ func TestКаталогОграничиваетОкно(t *testing.T) { st := openStore(t) 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 { t.Fatalf("каталог: %v", err) } @@ -227,7 +227,7 @@ func TestКаталогПоказываетРасхождениеЕдиниц(t 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 { t.Fatalf("каталог: %v", err) } @@ -249,7 +249,7 @@ func TestКаталогПоказываетРасхождениеЕдиниц(t func TestКаталогПустойВитриныПуст(t *testing.T) { t.Parallel() - metrics, err := service(t, openStore(t)).Metrics(context.Background()) + metrics, err := metricsOf(context.Background(), service(t, openStore(t))) if err != nil { t.Fatalf("каталог: %v", err) } @@ -268,7 +268,7 @@ func TestКаталогПересчитываетРодНаКаждыйЗапр ctx := context.Background() fill(t, st, "step_count", 2, true) - before, err := svc.Metrics(ctx) + before, err := metricsOf(ctx, svc) if err != nil { t.Fatalf("каталог: %v", err) } @@ -277,7 +277,7 @@ func TestКаталогПересчитываетРодНаКаждыйЗапр } fill(t, st, "step_count", 5, true) - after, err := svc.Metrics(ctx) + after, err := metricsOf(ctx, svc) if err != nil { t.Fatalf("каталог: %v", err) } @@ -310,7 +310,7 @@ func TestКаталогНеИзмеряетПоБудущимЧасам(t *testi merge(t, st, future) 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 { t.Fatalf("каталог: %v", err) } @@ -344,7 +344,7 @@ func TestКаталогНеСверяетСлоиРазныхЕдиниц(t *tes } merge(t, st, points) - metrics, err := service(t, st).Metrics(context.Background()) + metrics, err := metricsOf(context.Background(), service(t, st)) if err != nil { t.Fatalf("каталог: %v", err) } @@ -369,7 +369,7 @@ func TestКаталогПоказываетМетрикуСПустымИмен 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 { t.Fatalf("каталог: %v", err) } @@ -402,7 +402,7 @@ func TestКаталогПишетПредупреждениеОПротивор merge(t, st, points) 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 { t.Fatalf("каталог: %v", err) } @@ -439,7 +439,7 @@ func TestКаталогОграничиваетЧислоЕдиниц(t *testing } merge(t, st, points) - metrics, err := service(t, st).Metrics(context.Background()) + metrics, err := metricsOf(context.Background(), service(t, st)) if err != nil { t.Fatalf("каталог: %v", err) } @@ -463,7 +463,7 @@ func TestКаталогСообщаетОбОтказеХранилища(t *tes } 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("каталог на закрытой базе собрался") } rec, ok := handler.find("catalog failed") @@ -488,7 +488,7 @@ func TestКаталогНеПутаетОтменуСоСбоем(t *testing.T) cancel() 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("каталог собрался на отменённом контексте") } if _, ok := handler.find("catalog failed"); ok { @@ -498,3 +498,25 @@ func TestКаталогНеПутаетОтменуСоСбоем(t *testing.T) 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("каталог собран на стоящей витрине и остался без версии") + } +} diff --git a/internal/catalog/version_internal_test.go b/internal/catalog/version_internal_test.go new file mode 100644 index 0000000..d85f94f --- /dev/null +++ b/internal/catalog/version_internal_test.go @@ -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("пустая версия витрины подписана горизонтом — подписывать нечем") + } +} diff --git a/internal/fold/log_test.go b/internal/fold/log_test.go index fba437f..b912389 100644 --- a/internal/fold/log_test.go +++ b/internal/fold/log_test.go @@ -176,9 +176,19 @@ func TestFoldНесравнимыеНаборыДаютWarn(t *testing.T) { 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", "До еды"} { - if strings.Contains(buf.String(), secret) { - t.Errorf("в логе оказалось значение точки %q:\n%s", secret, buf.String()) + if strings.Contains(string(clean), secret) { + t.Errorf("в логе оказалось значение точки %q:\n%s", secret, clean) } } } diff --git a/internal/httpapi/catalog.go b/internal/httpapi/catalog.go index 07af266..61bdac0 100644 --- a/internal/httpapi/catalog.go +++ b/internal/httpapi/catalog.go @@ -21,8 +21,36 @@ type catalogResponse struct { // сравнивающий разобранные структуры, этого не увидел бы — потому приёмочная // проверка сравнивает байты ответа. Второй страховки здесь нет намеренно: // подстраховка поверх подстраховки прячет отказ первой. +// +// Условный запрос стоит ПОСЛЕ проверки токена (её ставит роутер) и ДО сборки +// снимка: в этом весь смысл — самый частый запрос потребителя есть повтор +// неизменившегося, и он не должен стоить ни снимка, ни разжатия точек. +// scopeMetrics — область действия метки каталога. Ответ маршрута не зависит от +// параметров запроса, поэтому область постоянна; у точек и MCP на её месте +// будет канонизированная форма запроса. +const scopeMetrics = "metrics" + 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 { // Исход операции логирует доменный слой, транспорт только переводит его // в ответ. Наружу уходит человекочитаемое сообщение, а не текст ошибки: @@ -30,5 +58,9 @@ func (a *api) handleMetrics(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "каталог не собрался") return } - writeJSON(w, http.StatusOK, catalogResponse{Metrics: metrics}) + // Версии может не быть — витрина изменилась, пока ответ собирался. Тогда + // ответ уходит без метки: это ровно поведение до появления условного + // запроса, то есть деградация в безопасную сторону. + setReadHeaders(w, etag(scopeMetrics, snap.Version)) + writeJSON(w, http.StatusOK, catalogResponse{Metrics: snap.Metrics}) } diff --git a/internal/httpapi/conditional.go b/internal/httpapi/conditional.go new file mode 100644 index 0000000..7d15f09 --- /dev/null +++ b/internal/httpapi/conditional.go @@ -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) + } +} diff --git a/internal/httpapi/conditional_route_test.go b/internal/httpapi/conditional_route_test.go new file mode 100644 index 0000000..ccd6bb2 --- /dev/null +++ b/internal/httpapi/conditional_route_test.go @@ -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("условный ответ собрал каталог: снимок открыт зря") + } +} diff --git a/internal/httpapi/conditional_test.go b/internal/httpapi/conditional_test.go new file mode 100644 index 0000000..77bac32 --- /dev/null +++ b/internal/httpapi/conditional_test.go @@ -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) + } +} diff --git a/internal/httpapi/httpapi_test.go b/internal/httpapi/httpapi_test.go index 38e9894..d30d2c4 100644 --- a/internal/httpapi/httpapi_test.go +++ b/internal/httpapi/httpapi_test.go @@ -10,6 +10,7 @@ import ( "net/http/httptest" "path/filepath" "strings" + "sync" "testing" "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) { 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() 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) } - log := slog.New(slog.DiscardHandler) + seen := &records{} + log := slog.New(seen) cat := catalog.New(st, log) h := httpapi.New(httpapi.Options{ Ingest: ingest.New(arch, st, nil, log), @@ -320,5 +332,41 @@ func newAPITokens(t *testing.T, writeTokens, readTokens []string) (http.Handler, // и это ровно тот транспорт, на котором приём обязан продолжать работать. 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 } diff --git a/internal/replay/archive_test.go b/internal/replay/archive_test.go index c79fe66..eb7c827 100644 --- a/internal/replay/archive_test.go +++ b/internal/replay/archive_test.go @@ -169,7 +169,7 @@ func measureStyles(t *testing.T, st *store.Store) { t.Helper() 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 { t.Fatalf("каталог: %v", err) } @@ -291,3 +291,10 @@ func gunzip(t *testing.T, body []byte) []byte { } return out } + +// metricsOf — список метрик каталога без версии витрины: версию проверяют +// отдельные тесты, остальным нужен только состав ответа. +func metricsOf(ctx context.Context, s *catalog.Service) ([]catalog.Metric, error) { + snap, err := s.Metrics(ctx) + return snap.Metrics, err +} diff --git a/internal/store/errors.go b/internal/store/errors.go index 4b7d3c1..b19922a 100644 --- a/internal/store/errors.go +++ b/internal/store/errors.go @@ -45,3 +45,12 @@ func Transient(err error) bool { } return errors.Is(err, context.Canceled) || errors.Is(err, ErrBusy) } + +// ErrClosed — хранилище закрыто, и заводить новые соединения к нему поздно. +// +// Отдельная ошибка, а не общая: запрос версии витрины и остановка сервиса идут +// в разных горутинах, и запрос, успевший в это окно, обязан получить отказ, а +// не открыть базу заново. Соединение, открытое после закрытия пула, оставило бы +// последним себя — SQLite не сделал бы финальный чекпойнт, и рядом с базой +// остался бы неразобранный `-wal`. +var ErrClosed = errors.New("хранилище закрыто") diff --git a/internal/store/store.go b/internal/store/store.go index 1f8047d..17f4e91 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -23,6 +23,10 @@ var migrationsFS embed.FS // Store — доступ к витрине. type Store struct { db *sqlx.DB + // probe — закреплённое соединение версии витрины, заводится по первому + // запросу версии. См. version.go: значение `data_version` локально для + // соединения, и брать его из пула нельзя. + probe versionProbe } // Open открывает БД по пути, сверяет версию схемы и накатывает миграции. @@ -152,12 +156,32 @@ func readSchemaVersion(ctx context.Context, db *sqlx.DB) (inDB, inBinary int64, // Close закрывает соединение с БД. func (s *Store) Close() error { + // Щуп версии закрывается сам и раньше пула: закреплённое соединение + // переживает `db.Close()` и продолжает отвечать на запросы — проверено. + s.closeProbe() if err := s.db.Close(); err != nil { return fmt.Errorf("close sqlite: %w", err) } 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 для параллельного чтения во время // записи, busy_timeout — чтобы конкурентная запись ждала, а не падала. // @@ -166,11 +190,14 @@ func (s *Store) Close() error { // запись после уже прочитанного снимка даёт SQLITE_BUSY_SNAPSHOT, которого // busy_timeout не покрывает. Измерено на восьми писателях: 242 успешных // слияния из 800 против 800 из 800. +// +// `journal_size_limit` — см. константу выше. func dsn(path string) string { q := url.Values{} q.Add("_pragma", "journal_mode(WAL)") q.Add("_pragma", "busy_timeout(5000)") q.Add("_pragma", "foreign_keys(on)") + q.Add("_pragma", fmt.Sprintf("journal_size_limit(%d)", journalSizeLimit)) q.Add("_txlock", "immediate") return "file:" + path + "?" + q.Encode() } diff --git a/internal/store/version.go b/internal/store/version.go new file mode 100644 index 0000000..7d27c8d --- /dev/null +++ b/internal/store/version.go @@ -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() +} diff --git a/internal/store/version_internal_test.go b/internal/store/version_internal_test.go new file mode 100644 index 0000000..0ec1a2e --- /dev/null +++ b/internal/store/version_internal_test.go @@ -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) + } +} diff --git a/internal/store/version_test.go b/internal/store/version_test.go new file mode 100644 index 0000000..c9a469a --- /dev/null +++ b/internal/store/version_test.go @@ -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) + } +} diff --git a/internal/store/wal.go b/internal/store/wal.go new file mode 100644 index 0000000..6bf6e31 --- /dev/null +++ b/internal/store/wal.go @@ -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 +} diff --git a/internal/store/wal_test.go b/internal/store/wal_test.go new file mode 100644 index 0000000..15c9b78 --- /dev/null +++ b/internal/store/wal_test.go @@ -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("чекпойнт на отменённом контексте прошёл успешно") + } +} diff --git a/openspec/changes/archive/2026-08-02-cena-chitayushchego-marshruta/.openspec.yaml b/openspec/changes/archive/2026-08-02-cena-chitayushchego-marshruta/.openspec.yaml new file mode 100644 index 0000000..d658936 --- /dev/null +++ b/openspec/changes/archive/2026-08-02-cena-chitayushchego-marshruta/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-02 diff --git a/openspec/changes/archive/2026-08-02-cena-chitayushchego-marshruta/design.md b/openspec/changes/archive/2026-08-02-cena-chitayushchego-marshruta/design.md new file mode 100644 index 0000000..ec20b4b --- /dev/null +++ b/openspec/changes/archive/2026-08-02-cena-chitayushchego-marshruta/design.md @@ -0,0 +1,443 @@ +## Context + +Каталог (`GET /api/v1/metrics`) — первый и пока единственный читающий маршрут. +Он живёт в одном процессе с приёмом и фоновой свёрткой, и три измерения ревью +показали, что цена чтения ложится на приём: + +- снимок каталога держит разжатые точки окна по всем метрикам сразу: 693 мс и + +153 МиБ живой кучи на враждебном запросе (20 метрик × 8 часов × 5000 точек); +- непрерывная запись плюс четыре читающих транзакции внахлёст дают рост `-wal` + около 7 МБ/с без верхней границы, тогда как тот же писатель без читателей + стабилизируется на 4 МБ; +- самый частый запрос трёх потребителей (агент-медик, трекер, игра) — повтор + неизменившегося, а условного запроса нет. + +Решение по задаче принято до её начала: **вариант (г) + вариант (в)**. Предел +размера ответа и собственный дедлайн маршрута (вариант «а») отложены до Read +API точек, где предел всё равно проектируется; потоковое измерение по метрике +(«б») не берётся, пока счётчик не заговорит; кеш ответа («д») — последним. + +Всё, что ниже, измерено на стенде этой машины тем же драйвером +(`modernc.org/sqlite`) и с тем же набором PRAGMA, что у сервиса. Числа приведены +там, где от них зависит решение. + +## Goals / Non-Goals + +**Goals:** + +- WAL перестаёт расти без верхней границы; когда он всё же растёт, это видно + владельцу строкой лога, а не только `df`. +- Повторный запрос неизменившегося каталога не открывает снимок витрины вовсе. +- `ETag` честен: **равная метка ⟹ тот же ответ**. Ответ есть функция снимка и + горизонта измерения, и в метку входят оба (решения 7а и 7в). +- Машинерия одна на все читающие маршруты: точки и MCP берут её готовой. +- Горутина чекпойнта дренируется осознанно, как воркер свёртки. + +**Non-Goals:** + +- Предел числа метрик и точек в ответе, пагинация, собственный дедлайн + маршрута — это Read API точек. +- Потоковое измерение рода по метрике: меняет форму границы `store`/`catalog` + ради случая, которого живой поток не производит. +- Кеш ответа: третье представление того же факта и новое место, где можно + ошибиться молча. +- Конфигурируемость периода чекпойнта и порогов: ни одного основания выбирать + их снаружи сегодня нет. + +## Decisions + +### 1. Чекпойнт по таймеру нужен не вместо автоматического, а рядом с ним + +`wal_autocheckpoint` включён по умолчанию (измерено: 1000 страниц) и запускает +пассивный чекпойнт **по концу записи**. Отсюда дыра: всплеск, раздувший WAL, +оставляет его неразобранным до следующей записи, а поток пачечный по природе — +ночью телефон молчит часами. Таймер закрывает ровно этот случай: страницы +возвращаются в базу вскоре после того, как читатели ушли, а не при следующей +доставке. + +Отвергнуто **«полагаться на автоматический чекпойнт»**: он не срабатывает без +записи, то есть именно в том состоянии, ради которого таймер и заводится. + +Отвергнут **`TRUNCATE`** — и по измеренной причине, а не по осторожности: он +двигает `data_version` (3 → 4 на пустом ходу), то есть каждый тик обнулял бы +`ETag` у всех потребителей. `PASSIVE` не двигает его даже при переносе 12502 +страниц (3 → 3). Это же измерение объясняет, почему две части задачи вообще +уживаются в одном процессе. + +**Период — минута**, тот же, что у тика воркера свёртки и у чекпойнта +Litestream. Он ничего не решает в +момент всплеска (пока читатели держат снимок, чекпойнт бессилен по построению), +и решает всё в тишине: минута против пяти неразличима по эффекту, но делает +признак «журнал не разбирается» своевременным. Холостой чекпойнт стоит одного +запроса на пустом журнале. + +### 2. Признак — не `busy`, а «перенесено меньше, чем лежит» + +Пассивный чекпойнт не идёт дальше снимка самого старого активного читателя, и +**ошибки при этом нет**. Измерено под удерживаемым читателем: `busy=0`, +`log=6256`, `checkpointed=5` — то есть флаг занятости молчит, а журнал растёт. +Значит признак строится на паре чисел: страниц в журнале больше порога **и** +перенесено меньше, чем лежало. + +Порог назван в байтах (64 МиБ) и переводится в страницы размером страницы самой +базы — он свойство файла, и чужое умолчание сместило бы признак в разы. +Величина взята из чужой практики (гайды по SQLite в проде ставят 26–64 МБ) и +собственного измерения: суточный поток даёт около 23 МБ архива, то есть журнал +такого размера означает не всплеск, а удерживаемый снимок. С пределом тела +приёма связи нет, хотя порядок и совпадает: журнал растёт от чтения, а не от +размера доставки. + +`WARN`, а не `ERROR`: адресат — владелец, событие «может стать проблемой» +(диск), лечится оно не кодом. + +**Частота строки решается отдельно от порога.** Признак заведён ради состояния, +которое само не проходит: вечный читатель (в Go чаще всего — незакрытый +`sql.Rows`) держит снимок до конца жизни процесса. Строка на каждый тик дала бы +1440 одинаковых записей в сутки — фон, а не сигнал. Поэтому строка пишется при +входе в состояние и повторяется, только когда журнал вырос вдвое; возврат к +норме — отдельная строка `INFO`, потому что тишина иначе неотличима от «сервис +перестал проверять». + +### 3. `journal_size_limit` — потому что пассивный чекпойнт файл не укорачивает + +Измерено: после успешного чекпойнта (`log=12502`, `checkpointed=12502`) файл +остаётся 51 МБ — страницы переиспользуются, но диск не возвращается. С +`journal_size_limit=64 МиБ` первая же запись после чекпойнта усекает файл до +предела (проверено: 51 МБ → 8 МиБ при лимите 8 МиБ), и `data_version` при этом +двигает сама запись, а не усечение. + +Это не второй механизм для одной цели: чекпойнт возвращает **страницы**, лимит +возвращает **файл**. Без первого второй никогда не срабатывает, без второго +пик, случившийся однажды, остаётся на диске навсегда. + +**Предела РОСТА это не даёт, и говорить иначе нельзя.** Измерено там же: под +удерживаемым читателем файл вырос до 51 МБ при лимите 8 МиБ — лимит действует +только после полного чекпойнта. То есть ровно в сценарии задачи +(перекрывающиеся читатели, 7 МБ/с) верхней границы у диска по-прежнему нет, и +единственный исход — `WARN` владельцу. Аварийный клапан, который на это ставит +Litestream (блокирующий `TRUNCATE` по порогу размера), не берётся по измеренной +причине: он двигает `data_version` и ждёт читателей. Понадобится — станет +отдельной задачей, и её ценой будет обнуление меток. + +Порог `WARN` и лимит файла — **одно число**: 64 МиБ, выраженное там в +страницах. Двумя константами они разъехались бы молча, сделав признак либо +недостижимым, либо шумным. + +### 4. `data_version` сравним только в пределах одного соединения — отсюда щуп + +Два измерения, каждое из которых убивает наивную реализацию: + +- **значения разных соединений одного пула несравнимы**: на одном и том же + состоянии базы `c1=5`, `c2=3`, а любое свежее соединение отвечает `2` + независимо от содержимого базы; +- **своя запись значение не двигает**, чужая двигает. + +Отсюда следствие, которое и есть главный риск варианта (в): если брать +`data_version` из пула, два запроса на разных соединениях дают разные метки на +неизменившейся витрине (ложная инвалидация — не страшно), но и **одинаковые +метки на разных состояниях** (свежие соединения всегда отвечают `2` — вот это +уже выдача устаревшего под видом свежего). + +Поэтому версия читается с **одного закреплённого соединения-щупа** +(`sql.Conn`), которое ничего больше не делает и потому никогда не двигает +собственную версию. Щуп берётся по первому запросу версии; если он становится +непригодным, он пересоздаётся, и вместе с ним меняется **поколение**. + +**Что считать смертью щупа — измерено, а не предположено.** Первая редакция +этого дизайна называла причиной отмену контекста; проверка на драйвере проекта +показала обратное: запрос, оборванный отменой, возвращает `context.Canceled`, а +следующий запрос на том же соединении проходит. Непригодным `sql.Conn` +становится только после закрытия (`sql.ErrConnDone`). Различие не +академическое: считай система смертью щупа любую ошибку — каждый клиент, +оборвавший запрос по своему тайм-ауту, менял бы поколение, и все три +потребителя получали бы полный ответ вместо `304`. Механизм схлопывался бы ровно +под нагрузкой, ради которой заведён. + +**Инвариант щупа записан одной строкой:** на нём выполняется ровно один вид +запроса и только через `QueryRowContext(...).Scan(...)`; `QueryContext` и +`BeginTx` не зовутся никогда. Незакрытые `Rows` на единственном долгоживущем +соединении процесса удержали бы читающий снимок навсегда — чекпойнт перестал бы +продвигаться, и метка убила бы обслуживание при полностью исправном +обслуживании. Проверяется это оракулом: после серии снятий версии пассивный +чекпойнт обязан перенести журнал целиком. + +**Закрытие щупа входит в контракт, а не в реализацию.** Измерено: закреплённое +соединение переживает `db.Close()` и продолжает отвечать. Значит без явного +закрытия последнего соединения к базе не наступает вовсе — SQLite не делает +финальный чекпойнт, рядом с базой остаётся `-wal`, и пересборка, переносящая +один файл базы, теряет хвост записей молча. Отсюда же флаг «закрыто»: запрос +версии, успевший в окно между закрытием щупа и закрытием пула, не имеет права +открыть соединение заново. + +Отвергнут **`FileControlDataVersion`** из `modernc.org/sqlite` (обёртка над +`SQLITE_FCNTL_DATA_VERSION`): он отражает и коммиты собственного соединения, +то есть снимает требование «щуп ничего не пишет». Цена — доступ через +`(*sql.Conn).Raw` и приведение к интерфейсу драйвера в самом чувствительном +месте ради инварианта, который держится одним небольшим файлом и проверяется +тестом. Взято простое; если щуп когда-нибудь начнёт писать, замена — три +строки. + +Отвергнут **счётчик изменений со страницы 1** (`SQLITE_DBPAGE`) — по названной +чужой причине: в режиме WAL он инкрементируется не на каждой транзакции, потому +что страница 1 в журнал не попадает, если в ней самой ничего не поменялось. + +### 5. Метка — пара «поколение + счётчик» + +Счётчик `data_version` не переживает переоткрытия: свежее соединение всегда +отвечает `2`. Значит после рестарта метка `2` означала бы совсем другое +состояние, чем метка `2` до него, и клиент со старым `ETag` получил бы `304` на +изменившиеся данные — единственный по-настоящему опасный исход всей задачи. +Поэтому метка это `"<поколение>-<счётчик>"`, где поколение — ULID, выданный при +получении соединения-щупа. + +Побочная выгода названа вслух: поколение меняется и при выкатке новой версии +бинаря, то есть смена **формы** ответа при неизменившихся данных тоже +инвалидирует метку. Цена — один полный ответ каждому потребителю после +рестарта. + +### 6. Метка снимается до и после сборки ответа + +Версия щупа снимается **дважды**: до открытия снимка и после его закрытия. +Совпали — метка выставляется; разошлись — ответ уходит **без `ETag`**. + +Причина ровно в обещании сильной метки. Если снять версию только до сборки, то +коммит, случившийся во время сборки, даёт ответ более свежий, чем его метка, — +и два ответа с одной меткой могут различаться байтами. Устаревания это не даёт +(следующий запрос увидит другую версию и получит `200`), но обещание «равная +метка ⟹ те же байты» перестаёт быть верным, а на нём держится весь смысл +`304`. + +Снимать версию **после** сборки и выставлять её нельзя категорически: это +пометило бы старый снимок новой версией, то есть заперло бы клиента на +устаревшем ответе навсегда — ровно тот единственный исход, которого нельзя +допускать. + +Цена ветки названа и измерена: под непрерывной свёрткой каталог перестаёт +отдавать `ETag` вовсе — при коммите раз в 60 мс и сборке каталога 97 мс +подписано 0 ответов из 15. То есть на время разбора задолженности (рестарт, +широкий проход) условный запрос выключается сам. Это деградация в безопасную +сторону — ровно сегодняшнее поведение, — и повтор сборки ради второй попытки не +берётся: он удваивает самое дорогое чтение ровно в тот момент, когда база +занята записью. На установившемся потоке цена мала: сборка 45 мс против +доставки раз в пять минут. + +След у этого состояния есть, хотя и не для владельца: каталог пишет `DEBUG`, +когда ответ уходит неподписанным. Владельческий канал — `/stats`, и пункт про +долю подписанных ответов внесён в его задачу тем же изменением. + +### 7. Снимок каталога версией не подписывается изнутри хранилища + +Напрашивалось снимать версию внутри той же читающей транзакции, что и снимок: +там она равна версии снимка точно, без второй пробы. Отвергнуто ценой: это +требует, чтобы снимок каталога шёл по тому же закреплённому соединению, то есть +**все читающие запросы выстроились бы в очередь по одному**. Головная блокировка +на 693 мс у одного потребителя означала бы ожидание у двух других, и это уже +предел маршрута — то самое, что задача откладывает до Read API точек. + +### 7а. Метка слабая (`W/`), и причина названа числом другого рода + +Ответ каталога есть функция не только снимка, но и **горизонта измерения** +(`Now() + час`), а горизонт едет вместе с часами. На нормальных данных это +ничего не меняет: окно берёт самые свежие общие часы, и пока в витрине нет +меток из будущего, ход часов ответ не двигает. Но метки из будущего в витрине +возможны (сбитые часы телефона, чужое тело в приёме) — и тогда ответ меняется +без единого коммита. + +Поэтому метка **слабая**: `W/"<поколение>-<счётчик>"`. Это же предписывает +общая практика для меток, построенных из состояния БД, а не из байтов ответа, и +`If-None-Match` сравнивает метки слабо в любом случае — на `304` форма не +влияет. Остаток был назван вслух — и оказался больше, чем звучал: враждебный проход +построил и прогнал путь, где та же версия витрины даёт `cumulative` против +`unknown` при нуле коммитов между. Слабая форма метки этого не лечит: смена +измеренного рода — изменение семантическое. Поэтому горизонт вошёл в метку +(решение 7в), и остаток закрыт, а не назван. + +Побочное следствие, которое иначе было бы неверным: `WARN` о данных из будущего +пишется при сборке ответа, а `304` сборки не делает. С горизонтом в метке полный +ответ случается не реже раза в час на потребителя — значит и предупреждение +тоже. Без горизонта потребитель на условном опросе гасил бы его насовсем. + +### 7в. Горизонт входит в метку, огрублённый до часа + +Метка ответа каталога — `версия витрины . час горизонта`. Огрубление точное, а +не приблизительное: метки объектов лежат ровно на часах (проверено отдельно, +включая зоны с неполночасовым смещением), поэтому отбор `hour_utc <= горизонт` +меняется ровно при переходе горизонта через час. Цена — один полный ответ в час +на потребителя при неизменившейся витрине; сборка стоит 45 мс. + +Отсюда же следует, что версию ответа спрашивает **домен, а не хранилище**: +`catalog.Service.Version` знает, что в ответ входит горизонт, а транспорт не +знает и знать не должен. Read API точек ответит на том же месте своей версией, +включающей канонизированную форму запроса. + +### 7б. Читающий маршрут деградирует до полного ответа, а не до отказа + +Версия не читается — ответ уходит `200` без метки. Это правило названо отдельно, +потому что естественная реализация даёт обратное: `DataVersion` возвращает +ошибку, транспорт переводит ошибку домена в `500`, и маршрут, работавший до +задачи, перестаёт работать из-за машинерии, вся ценность которой — экономия. +Отказ пробы поэтому глушится с комментарием: настоящий отказ базы всплывёт +сборкой каталога, идущей следом, и будет назван ею один раз. + +### 8. Условный запрос — помощник транспорта, а не свойство каталога + +`If-None-Match` разбирается и метка сравнивается в `httpapi` одним помощником: +точки и MCP получат его готовым. Сравнение слабое (`W/"x"` совпадает с `"x"`) — +так предписывает HTTP для `If-None-Match`; `*` совпадает с любой существующей +меткой. + +Источник версии передаётся транспорту **функцией** (`store.StateVersion`) — той +же формой, что и `worker.Notify` у приёма: транспорт не получает доступа к +хранилищу целиком ради одного числа. + +Три вещи, которые помощник обязан делать по HTTP и которые легко не сделать: +неразбираемое условие даёт `200`, а не `400` (клиент, приславший мусор, получает +данные); `*` совпадает с любой **существующей** меткой, а при её отсутствии +условие не выполнено; `304` уходит без представленческих заголовков — так же, +как их снимает `writeNotModified` в `net/http/fs.go`. Заголовок читается всеми +строками (`Header.Values`), а не первой: `If-None-Match` клиент вправе прислать +несколькими. + +Ответы чтения помечаются `Cache-Control: private, no-cache`. До появления +валидатора эвристическое кеширование посредником было маловероятным; с меткой +ответ становится штатно кешируемым, а при выключенной проверке токенов в запросе +нет и `Authorization`, на который опирается запрет для разделяемых кешей. + +**`HEAD` маршрут не обслуживает, и это оставлено как было.** Роутер регистрирует +только `GET`, так что `HEAD /api/v1/metrics` отвечает `405` — и отвечал им до +задачи. Самый дешёвый способ спросить «изменилось ли» у клиента при этом есть: +условный `GET`, который на совпавшей метке не собирает ответа вовсе. Заводить +`HEAD` вместе с условным запросом значило бы расширять контракт маршрута +мимоходом; вопрос принадлежит Read API точек, где маршрутов станет пять. + +### 8а. Остановка: обе фоновые горутины ждутся вместе + +Форма ожидания названа, потому что наивное добавление второго канала в +существующий `select` закрыло бы базу по выходу **любой** из двух горутин — а +закрытая из-под воркера база даёт `ERROR` по доставке, с которой всё в порядке. +Обе горутины идут в один `sync.WaitGroup`, канал закрывается после `Wait`, и +`select` против бюджета остановки остаётся один. + +Цена названа: запись о превышении бюджета больше не обвиняет воркер свёртки +поимённо — ждут двоих, и назвать виновным одного из них было бы догадкой. +Практически это всё тот же воркер (чекпойнт выходит по отмене немедленно), но +лог не должен утверждать того, чего не проверял. + +Чекпойнт при этом идёт на контексте цикла, а не на отвязанном, — в отличие от +свёртки. Причина в том, что терять ему нечего: перенос страниц идемпотентен, +исхода разбора он не пишет, а следующий старт возьмёт журнал с того же места. +Зато остановка не ждёт переноса полусотни мегабайт в бюджете, который делится с +приёмом и воркером. Прерывание по отмене отказом не считается и в лог не идёт — +иначе каждый `task restart` писал бы владельцу об отказе обслуживания. + +### 9. Что где живёт + +- `store.DataVersion(ctx)` — щуп, поколение, пересоздание. Хранилище владеет + соединениями, и знание про `data_version` принадлежит ему. +- `store.CheckpointWAL(ctx)` — один PRAGMA, тройка чисел наружу. Решение, что с + ними делать, принимает не хранилище. +- цикл чекпойнта — `cmd/healthlog`, рядом с запуском воркера: это забота + жизненного цикла процесса, а не хранилища, и остановка у него общая с + остальными горутинами. Асимметрия с воркером свёртки (тот живёт в + `internal/replay`) названа вслух: у воркера есть доменный исход, у чекпойнта — + только строка владельцу, а чтобы поселить цикл в `store`, пришлось бы внести + туда логгер, первый в пакете. Интерпретация чисел при этом осталась в + хранилище (`Checkpoint.Stuck`). +- `Store.VersionedRead` — двойная проба вокруг чтения. В хранилище, а не в + каталоге: правило «версией, снятой после чтения, не подписывать» обязано + существовать в одном экземпляре, потому что нарушить его можно ровно одним + способом, и точки с MCP заявлены потребителями той же машинерии. +- `catalog.Service.Metrics` возвращает снимок вместе с версией — то есть + каталог решает, чем подписан его ответ, но не как это делается. + +Имена доменные, а не по PRAGMA: `StateVersion`, а не `DataVersion`. Метка это +пара «поколение + счётчик», область её сравнимости задаёт хранилище, и читатель, +знающий SQLite, не должен ждать от метода голого значения `data_version`. + +### 10. Как это решают другие + +Обе части задачи — общепринятая практика, и брались они готовыми. + +**Чекпойнт.** Документация SQLite (`wal.html`, разделы 3.1, 3.2 и 6) называет +ровно наш случай: при перекрывающихся читателях, среди которых всегда есть +активный, «чекпойнты не смогут завершиться, и файл WAL будет расти без границы»; +режим `PASSIVE` «делает столько, сколько может, не мешая другим соединениям, и +может не дойти до конца»; чекпойнт «обычно не укорачивает файл, если не задан +`journal_size_limit`». Механика признака взята из `wal_checkpoint_v2`: +полнота — это `checkpointed == log`, а `busy` в пассивном режиме не значит +ничего, потому что обработчик занятости в нём не зовётся вовсе. Это же +объясняет измеренное `busy=0` при пяти перенесённых страницах из 6256. + +**Litestream** ближе всех по форме: интервал чекпойнта — **минута**, режим +`PASSIVE`, а блокирующий `TRUNCATE` — аварийный клапан по порогу размера, а не +шаг расписания. Отсюда взят период. Не взят его же совет отключать +`wal_autocheckpoint`: он продиктован тем, что Litestream владеет чекпойнтами +целиком, а у нас автоматический чекпойнт — первая линия, таймер лишь страхует +тишину. Не взят подход **rqlite** (всегда `TRUNCATE`, ожидание читателя до +250 мс): он продиктован требованием нулевого WAL для снапшота Raft, которого у +нас нет. Прикладные гайды по SQLite в проде (Django/`dj-lite`, «SQLite in +production») из всего этого ставят одно — `journal_size_limit` порядка 26–64 МБ; +взято 64 МиБ. + +Отдельная чужая находка, объясняющая, ради чего признак вообще заводится: в Go +самый частый источник вечного читателя — незакрытый `sql.Rows`, который держит +читающую транзакцию до конца жизни процесса и останавливает чекпойнты навсегда. +Это не гипотетический риск для нас: читающих запросов в проекте становится +больше с каждой задачей Read API, и `WARN` про неразобранный журнал — ровно тот +сигнал, который такую утечку показывает. + +**Условный запрос.** Формулировка `pragma.html#pragma_data_version` взята +дословно и определила конструкцию: значение «локальное свойство каждого +соединения», сравнивать осмысленно «только значения одного соединения в разные +моменты», и оно «не меняется для коммитов того же соединения». Рекомендация +держать для наблюдения **отдельное соединение** взята из ответа сопровождающего +SQLite на форуме; там же названа и наша проблема рестарта («версия, полученная +следующим соединением, может быть несравнима»), которую и закрывает поколение. + +Отвергнут **хеш файла базы** (так делает Datasette в неизменяемом режиме, +отдавая кусок SHA-256 в URL и год кеша): наша база пишется непрерывно, и +неизменяемого режима у неё не бывает. Взята оттуда одна мысль — маркер, +посчитанный один раз при старте, законен, и именно ей является поколение. +Отвергнут **собственный счётчик версии в таблице**: он переживает рестарт, но +это второе производное состояние рядом с витриной и лишняя запись на каждый +коммит — тот же довод, по которому в проекте не хранится измеренный род. + +## Risks / Trade-offs + +- **Соединение-щуп умерло, и версия перестала сравниваться** → пересоздание с + новым поколением: клиенты получают по одному полному ответу, устаревшего не + получает никто. Молчаливого варианта (переиспользовать поколение) не + существует — это и был бы опасный исход. +- **Чекпойнт конкурирует с приёмом** → `PASSIVE` не ждёт ни читателей, ни + писателей: измерено при одновременной записи — `busy=0`, чекпойнт переносит + то, что может, и выходит. Ошибка чекпойнта прохода не прекращает и цикл не + убивает: логируется и ждётся следующий тик. +- **Занятость чекпойнта, держащаяся тиками подряд, немая**: незамеренный тик + ничего не говорит и ничего не меняет — правильно поодиночке, но серия таких + тиков означает растущий журнал при полном молчании. Счётчик подряд идущих + неизмеренных тиков не заводится: поток пачечный, писатель занимает блокировку + чекпойнта секундами, а тик — минутный, так что серия маловероятна. Если + окажется иначе, это увидит `/stats`. +- **Порог 64 МиБ выбран без живого профиля** → он назван числом в одном месте, + и признак сформулирован условием («страниц больше порога **и** перенесено + меньше»), а не утверждением о нагрузке. +- **Удерживающееся состояние даёт строку в минуту** → строка пишется при входе + в состояние и повторяется, только когда журнал вырос вдвое; возврат к норме — + отдельная строка. Иначе вечный читатель дал бы 1440 одинаковых `WARN` в + сутки, и владелец перестал бы их читать раньше, чем кончится диск. +- **Условный опрос гасит предупреждения каталога** (данные из будущего, + противоречащий род): они пишутся при сборке ответа, а `304` сборки не делает. + Названо в спеке каталога следствием, а не умолчано: состояние не исчезает — + следующая доставка меняет версию, и ответ соберётся. +- **`ETag` меняется чаще, чем меняется каталог**: `data_version` двигает любая + запись в базу, включая учёт доставки, не менявшей витрину. Это ложная + инвалидация, то есть безопасная сторона; обратной (метка та же, данные + другие) конструкция не допускает по построению. +- **Память маршрута остаётся без потолка** — вариант «а» отложен намеренно. + Условный запрос снимает большую часть читающих транзакций, но враждебный + первый запрос стоит столько же, сколько стоил. Это записано в задаче Read API + точек, а не забыто. + +## Open Questions + +Нет: развилки закрыты решением по задаче и измерениями выше. diff --git a/openspec/changes/archive/2026-08-02-cena-chitayushchego-marshruta/proposal.md b/openspec/changes/archive/2026-08-02-cena-chitayushchego-marshruta/proposal.md new file mode 100644 index 0000000..de0aca8 --- /dev/null +++ b/openspec/changes/archive/2026-08-02-cena-chitayushchego-marshruta/proposal.md @@ -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`, получает ровно то же, что и сегодня. diff --git a/openspec/changes/archive/2026-08-02-cena-chitayushchego-marshruta/specs/catalog/spec.md b/openspec/changes/archive/2026-08-02-cena-chitayushchego-marshruta/specs/catalog/spec.md new file mode 100644 index 0000000..849e974 --- /dev/null +++ b/openspec/changes/archive/2026-08-02-cena-chitayushchego-marshruta/specs/catalog/spec.md @@ -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** предупреждение владельцу не пишется, потому что измерения не было diff --git a/openspec/changes/archive/2026-08-02-cena-chitayushchego-marshruta/specs/storage/spec.md b/openspec/changes/archive/2026-08-02-cena-chitayushchego-marshruta/specs/storage/spec.md new file mode 100644 index 0000000..a631a8c --- /dev/null +++ b/openspec/changes/archive/2026-08-02-cena-chitayushchego-marshruta/specs/storage/spec.md @@ -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** база не закрывается, а запись лога называет этап, не указывая + виновной горутины diff --git a/openspec/changes/archive/2026-08-02-cena-chitayushchego-marshruta/tasks.md b/openspec/changes/archive/2026-08-02-cena-chitayushchego-marshruta/tasks.md new file mode 100644 index 0000000..fcf8aea --- /dev/null +++ b/openspec/changes/archive/2026-08-02-cena-chitayushchego-marshruta/tasks.md @@ -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` diff --git a/openspec/specs/catalog/spec.md b/openspec/specs/catalog/spec.md index b3f4cb6..94b694d 100644 --- a/openspec/specs/catalog/spec.md +++ b/openspec/specs/catalog/spec.md @@ -524,3 +524,141 @@ Read API MUST опираться на фактические объекты за - **WHEN** доставка учтена - **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** предупреждение владельцу не пишется, потому что измерения не было + diff --git a/openspec/specs/storage/spec.md b/openspec/specs/storage/spec.md index ddf7d48..f72092f 100644 --- a/openspec/specs/storage/spec.md +++ b/openspec/specs/storage/spec.md @@ -1057,3 +1057,281 @@ SHALL: сегодня ровно этот случай даёт ноль и мо - **WHEN** выполняется выборка - **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** база не закрывается, а запись лога называет этап, не указывая + виновной горутины +