Цена читающего маршрута: чекпойнт WAL по таймеру и условный запрос
- рядом с воркером свёртки живёт горутина, раз в минуту разбирающая журнал пассивным чекпойнтом; «журнал не разбирается» видно строкой владельцу, а не только по `df`. Признак — пара чисел, а не флаг занятости: тот молчит под удерживаемым читателем (`busy=0` при 6256 страницах и пяти перенесённых), а при занятой блокировке отдаёт `-1` вместо ответа, и `-1 >= -1` читалось бы как «разобрано целиком» - каталог отвечает `304` на `If-None-Match`, не открывая снимок витрины. Метка собрана из всего, от чего зависит ответ: версии витрины (`data_version` с закреплённого соединения плюс поколение — значение локально для соединения и не переживает переоткрытия), горизонта измерения и области действия ресурса. Версия снимается до и после сборки: снятая после пометила бы устаревший снимок свежим номером - предел и дедлайн ответа отложены в задачу Read API точек вместе с измеренной ценой первого запроса; попутно починен флаки-тест чужой задачи, искавший значение точки в сыром буфере записи лога
This commit is contained in:
@@ -58,15 +58,21 @@ iPhone ──HTTPS POST──► healthlog ──► журнал доставо
|
|||||||
В разработке. Готовы каркас и приём, большая часть разбора: сервис принимает
|
В разработке. Готовы каркас и приём, большая часть разбора: сервис принимает
|
||||||
пакеты, складывает их в сырой архив и **разбирает метрики в часовые объекты**
|
пакеты, складывает их в сырой архив и **разбирает метрики в часовые объекты**
|
||||||
— с выводом слоя из данных, канонизацией содержимого и слиянием точек по
|
— с выводом слоя из данных, канонизацией содержимого и слиянием точек по
|
||||||
полноте. Секции, которых разбор пока не покрывает (`workouts`, `stateOfMind` —
|
полноте. Тренировки и записи со своим `id` (`workouts`, `stateOfMind`) тоже
|
||||||
половина потока), принимаются, хранятся и честно помечаются как неразобранные.
|
разбираются; секции, которых разбор не покрывает, принимаются, хранятся и
|
||||||
|
честно помечаются как неразобранные.
|
||||||
|
|
||||||
Есть и пересборка: `healthlog reindex` проигрывает журнал доставок в свежую
|
Есть и пересборка: `healthlog reindex` проигрывает журнал доставок в свежую
|
||||||
витрину и сверяет её отпечаток с накопленной — на живом архиве из 116 тел
|
витрину и сверяет её отпечаток с накопленной — на живом архиве из 116 тел
|
||||||
пересборка воспроизводима и повторный прогон ничего не меняет.
|
пересборка воспроизводима и повторный прогон ничего не меняет.
|
||||||
|
|
||||||
Чего ещё нет: каталога метрик с измеренным родом агрегации и **read API** —
|
Первый маршрут чтения открыт: **каталог разрезов** (`GET /api/v1/metrics`) под
|
||||||
данные наружу пока не отдаются никак. План в [docs/plan.md](docs/plan.md).
|
токеном чтения отдаёт слои с диапазонами и измеренный род агрегации, а повтор
|
||||||
|
неизменившегося отвечает `304` по `ETag` — снимок витрины при этом не
|
||||||
|
открывается. Журнал WAL разбирается фоновым чекпойнтом по таймеру.
|
||||||
|
|
||||||
|
Чего ещё нет: **read API точек**, тренировок и записей — сами данные наружу
|
||||||
|
пока не отдаются. План в [docs/plan.md](docs/plan.md).
|
||||||
|
|
||||||
Разведка формата закончена: 50 находок на живом потоке, половина расходится с
|
Разведка формата закончена: 50 находок на живом потоке, половина расходится с
|
||||||
документацией Health Auto Export — [docs/local-research.md](docs/local-research.md).
|
документацией Health Auto Export — [docs/local-research.md](docs/local-research.md).
|
||||||
@@ -132,6 +138,9 @@ task run
|
|||||||
curl localhost:8080/healthz
|
curl localhost:8080/healthz
|
||||||
curl -X POST localhost:8080/api/v1/ingest -d '{"data":{"metrics":[]}}'
|
curl -X POST localhost:8080/api/v1/ingest -d '{"data":{"metrics":[]}}'
|
||||||
curl localhost:8080/api/v1/metrics # каталог: слои, диапазоны, род агрегации
|
curl localhost:8080/api/v1/metrics # каталог: слои, диапазоны, род агрегации
|
||||||
|
|
||||||
|
# повтор неизменившегося не стоит ничего: метка из ответа возвращается условием
|
||||||
|
curl -i -H 'If-None-Match: W/"…"' localhost:8080/api/v1/metrics # 304
|
||||||
```
|
```
|
||||||
|
|
||||||
### Подключение телефона по локальной сети
|
### Подключение телефона по локальной сети
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/healthlog/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Решение «сказать ли владельцу» проверяется таблицей, а не ожиданием минут:
|
||||||
|
// состояние копится по тикам, и без отдельной точки его пришлось бы проверять
|
||||||
|
// прогоном цикла.
|
||||||
|
func TestКогдаГоворитьОНеразобранномЖурнале(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const over = 100000 // заведомо больше порога, выраженного в страницах
|
||||||
|
stuck := store.Checkpoint{Log: over, Checkpointed: 0, PageSize: 4096}
|
||||||
|
worse := store.Checkpoint{Log: over * 4, Checkpointed: 0, PageSize: 4096}
|
||||||
|
slightlyWorse := store.Checkpoint{Log: over + 1, Checkpointed: 0, PageSize: 4096}
|
||||||
|
fine := store.Checkpoint{Log: 12, Checkpointed: 12, PageSize: 4096}
|
||||||
|
// Занятый чекпойнт: исход не измерен, `-1` вместо чисел.
|
||||||
|
unknown := store.Checkpoint{Busy: true, Log: -1, Checkpointed: -1, PageSize: 4096}
|
||||||
|
|
||||||
|
var w walWatch
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
in store.Checkpoint
|
||||||
|
want walSay
|
||||||
|
}{
|
||||||
|
{"первый застрявший чекпойнт", stuck, walStuck},
|
||||||
|
{"то же состояние — молчим", stuck, walSilent},
|
||||||
|
{"чуть хуже — всё ещё молчим", slightlyWorse, walSilent},
|
||||||
|
{"занятый тик посреди беды молчит", unknown, walSilent},
|
||||||
|
{"и не сбрасывает накопленное", stuck, walSilent},
|
||||||
|
{"стало заметно хуже", worse, walStuck},
|
||||||
|
{"разобрался — говорим о возврате", fine, walRecovered},
|
||||||
|
{"норма держится — молчим", fine, walSilent},
|
||||||
|
{"застрял снова", stuck, walStuck},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := w.see(c.in); got != c.want {
|
||||||
|
t.Errorf("%s: сказано %v, ждали %v", c.name, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Цикл обязан пережить отказ базы: обслуживание, умершее от временного отказа,
|
||||||
|
// молча перестало бы разбирать журнал до конца жизни процесса.
|
||||||
|
func TestЦиклЧекпойнтаПереживаетОтказ(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
st, err := store.Open(filepath.Join(t.TempDir(), "healthlog.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("открытие базы: %v", err)
|
||||||
|
}
|
||||||
|
// Закрытая база — самый простой источник устойчивого отказа чекпойнта.
|
||||||
|
if err := st.Close(); err != nil {
|
||||||
|
t.Fatalf("закрытие базы: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
seen := &lines{}
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
keepWAL(ctx, st, slog.New(seen), time.Millisecond)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Даём циклу натолкнуться на отказ много раз подряд.
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
t.Fatal("цикл вышел сам, не дождавшись отмены")
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
// Отказ обязан быть виден: молча не разбирающийся журнал обнаруживается
|
||||||
|
// только по свободному месту.
|
||||||
|
if !seen.has("wal checkpoint failed") {
|
||||||
|
t.Error("отказ чекпойнта не оставил записи владельцу")
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("цикл не вышел по отмене")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отмена — единственный законный повод выйти, и выйти надо сразу: горутина
|
||||||
|
// ждётся в общем бюджете остановки вместе с воркером свёртки.
|
||||||
|
func TestЦиклЧекпойнтаВыходитПоОтмене(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
st, err := store.Open(filepath.Join(t.TempDir(), "healthlog.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("открытие базы: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = st.Close() })
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
keepWAL(ctx, st, slog.New(slog.DiscardHandler), time.Millisecond)
|
||||||
|
}()
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("цикл не вышел по отмене")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ветка «фоновые горутины не уложились в бюджет» — последняя защита инварианта
|
||||||
|
// «доставка либо свёрнута целиком, либо остаётся pending». Прогоном сервиса её
|
||||||
|
// не проверить: бюджет тридцать секунд, а заставить воркер зависнуть нечем.
|
||||||
|
func TestОжиданиеФоновыхГорутин(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
closed := make(chan struct{})
|
||||||
|
close(closed)
|
||||||
|
if !waitBackground(context.Background(), closed, slog.New(slog.DiscardHandler)) {
|
||||||
|
t.Error("вышедшие горутины не дождались")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
seen := &lines{}
|
||||||
|
if waitBackground(ctx, make(chan struct{}), slog.New(seen)) {
|
||||||
|
t.Error("зависшие горутины объявлены вышедшими — база закрылась бы из-под них")
|
||||||
|
}
|
||||||
|
if !seen.has("shutdown budget exceeded") {
|
||||||
|
t.Error("превышение бюджета осталось без строки владельцу")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// lines — slog.Handler, копящий сообщения: проверяется факт записи, не данные.
|
||||||
|
type lines struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
msg []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *lines) Enabled(context.Context, slog.Level) bool { return true }
|
||||||
|
|
||||||
|
func (l *lines) Handle(_ context.Context, rec slog.Record) error {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
l.msg = append(l.msg, rec.Message)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *lines) WithAttrs([]slog.Attr) slog.Handler { return l }
|
||||||
|
func (l *lines) WithGroup(string) slog.Handler { return l }
|
||||||
|
|
||||||
|
func (l *lines) has(msg string) bool {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
for _, m := range l.msg {
|
||||||
|
if m == msg {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
+53
-15
@@ -9,6 +9,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -46,6 +47,31 @@ func runServe(args []string) error {
|
|||||||
return serve(ctx, cfg, logging.New(cfg.Log.Level, cfg.Log.Format), nil)
|
return serve(ctx, cfg, logging.New(cfg.Log.Level, cfg.Log.Format), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// waitBackground ждёт выхода фоновых горутин и говорит, дождался ли.
|
||||||
|
//
|
||||||
|
// Отдельной функцией потому, что это единственная ветка остановки, у которой
|
||||||
|
// есть исход, и проверить её прогоном сервиса нельзя: бюджет — тридцать секунд,
|
||||||
|
// а заставить воркер зависнуть по требованию нечем.
|
||||||
|
//
|
||||||
|
// Не дождались — база НЕ закрывается: её транзакцию свернёт выход процесса, и
|
||||||
|
// доставка останется `pending`, то есть будет подобрана следующим стартом.
|
||||||
|
// Закрытая из-под воркера, она дала бы ERROR по доставке, с которой всё в
|
||||||
|
// порядке.
|
||||||
|
//
|
||||||
|
// Этап в записи называется общим именем, а не воркером свёртки: ждём мы двоих,
|
||||||
|
// и назвать виновным одного из них значило бы угадать. Чекпойнт при этом
|
||||||
|
// выходит по отмене немедленно, так что практически это всё тот же воркер, — но
|
||||||
|
// лог не должен утверждать того, чего не проверял.
|
||||||
|
func waitBackground(shutdownCtx context.Context, done <-chan struct{}, log *slog.Logger) bool {
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
return true
|
||||||
|
case <-shutdownCtx.Done():
|
||||||
|
log.Warn("shutdown budget exceeded", "stage", "background")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// serve поднимает сервис и ведёт его до отмены контекста.
|
// serve поднимает сервис и ведёт его до отмены контекста.
|
||||||
//
|
//
|
||||||
// Контекст параметром, а не подпиской на сигнал внутри: иначе весь жизненный
|
// Контекст параметром, а не подпиской на сигнал внутри: иначе весь жизненный
|
||||||
@@ -119,23 +145,41 @@ func serve(ctx context.Context, cfg *config.Config, log *slog.Logger, ready func
|
|||||||
return fmt.Errorf("listen %q: %w", cfg.Server.Addr, err)
|
return fmt.Errorf("listen %q: %w", cfg.Server.Addr, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
workerCtx, stopWorker := context.WithCancel(context.Background())
|
// Обе фоновые горутины живут на одном контексте и ждутся вместе. Вместе —
|
||||||
defer stopWorker()
|
// потому что база закрывается ПОСЛЕ выхода обеих: закрытая из-под воркера,
|
||||||
workerDone := make(chan struct{})
|
// она даёт ERROR по доставке, с которой всё в порядке, а из-под чекпойнта —
|
||||||
go func() {
|
// отказ обслуживания на ровном месте.
|
||||||
defer close(workerDone)
|
bgCtx, stopBackground := context.WithCancel(context.Background())
|
||||||
|
defer stopBackground()
|
||||||
|
|
||||||
|
var bg sync.WaitGroup
|
||||||
|
bg.Go(func() {
|
||||||
// Первый проход воркера и есть подбор неразобранного при старте:
|
// Первый проход воркера и есть подбор неразобранного при старте:
|
||||||
// отдельного кода для него нет намеренно.
|
// отдельного кода для него нет намеренно.
|
||||||
worker.Run(workerCtx)
|
worker.Run(bgCtx)
|
||||||
|
})
|
||||||
|
bg.Go(func() {
|
||||||
|
keepWAL(bgCtx, st, log, checkpointInterval)
|
||||||
|
})
|
||||||
|
backgroundDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
bg.Wait()
|
||||||
|
close(backgroundDone)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
errCh := make(chan error, 1)
|
errCh := make(chan error, 1)
|
||||||
go func() {
|
go func() {
|
||||||
|
// Параметры обслуживания журнала — в той же строке, а не отдельной:
|
||||||
|
// горутина, которую забыли запустить, иначе неотличима от здоровой
|
||||||
|
// ровно до того дня, когда журнал упрётся в диск. Ноль новых строк, обе
|
||||||
|
// константы проверяемы глазами.
|
||||||
log.Info("server started",
|
log.Info("server started",
|
||||||
"addr", ln.Addr().String(),
|
"addr", ln.Addr().String(),
|
||||||
"db_path", cfg.Storage.DBPath,
|
"db_path", cfg.Storage.DBPath,
|
||||||
"archive_dir", arch.Root(),
|
"archive_dir", arch.Root(),
|
||||||
"max_body_mb", cfg.Ingest.MaxBodyMB)
|
"max_body_mb", cfg.Ingest.MaxBodyMB,
|
||||||
|
"wal_checkpoint_sec", int64(checkpointInterval.Seconds()),
|
||||||
|
"wal_limit_mb", store.JournalSizeLimitMB)
|
||||||
|
|
||||||
if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
errCh <- fmt.Errorf("serve: %w", err)
|
errCh <- fmt.Errorf("serve: %w", err)
|
||||||
@@ -172,15 +216,9 @@ func serve(ctx context.Context, cfg *config.Config, log *slog.Logger, ready func
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stopWorker()
|
stopBackground()
|
||||||
select {
|
if waitBackground(shutdownCtx, backgroundDone, log) {
|
||||||
case <-workerDone:
|
|
||||||
closeStore()
|
closeStore()
|
||||||
case <-shutdownCtx.Done():
|
|
||||||
// Воркер не вышел в бюджет. База не закрывается: её транзакцию свернёт
|
|
||||||
// выход процесса, и доставка останется `pending` — то есть будет
|
|
||||||
// подобрана следующим стартом.
|
|
||||||
log.Warn("shutdown budget exceeded", "stage", "fold-worker")
|
|
||||||
}
|
}
|
||||||
return serveErr
|
return serveErr
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -519,6 +519,132 @@ HAE. Значит для него доставки не хвост журнал
|
|||||||
пересборки старый период честно объявляет один слой вместо трёх, а не
|
пересборки старый период честно объявляет один слой вместо трёх, а не
|
||||||
притворяется, что ничего не изменилось.
|
притворяется, что ничего не изменилось.
|
||||||
|
|
||||||
|
### Версия витрины и обслуживание журнала
|
||||||
|
|
||||||
|
Два механизма живут рядом и держатся друг за друга: один говорит читателю «в
|
||||||
|
базу никто не коммитил», второй разбирает журнал, в который эти коммиты легли.
|
||||||
|
|
||||||
|
#### Версия витрины: пара «поколение + счётчик»
|
||||||
|
|
||||||
|
Читающие маршруты обязаны уметь отвечать «не изменилось» без сборки ответа —
|
||||||
|
самый частый запрос трёх потребителей это повтор неизменившегося. Признак
|
||||||
|
изменения берётся у SQLite: `PRAGMA data_version` меняется, когда в базу
|
||||||
|
закоммитило **другое** соединение.
|
||||||
|
|
||||||
|
Голым значением его брать нельзя, и обе причины измерены на стенде проекта:
|
||||||
|
|
||||||
|
- **счётчик несравним между соединениями.** На одном состоянии базы два
|
||||||
|
соединения пула отвечают разными числами, а любое свежее соединение отвечает
|
||||||
|
одним и тем же значением независимо от содержимого. Версия из пула давала бы
|
||||||
|
не только ложную инвалидацию (не страшно), но и **одинаковые метки на разных
|
||||||
|
состояниях** — то есть подтверждение неизменности на изменившихся данных.
|
||||||
|
- **счётчик не переживает переоткрытия.** После рестарта он начинается заново.
|
||||||
|
|
||||||
|
Поэтому версия читается с одного **закреплённого соединения-щупа**, а метка это
|
||||||
|
`поколение-счётчик`, где поколение — ULID, выданный соединению. Поколение
|
||||||
|
меняется при каждом пересоздании щупа и заодно при выкатке нового бинаря, то
|
||||||
|
есть смена **формы** ответа при неизменившихся данных тоже обнуляет метки.
|
||||||
|
Монотонной метка не является: сравнивать её можно только на равенство.
|
||||||
|
|
||||||
|
Три свойства щупа названы вслух, потому что каждое из них можно нарушить
|
||||||
|
незаметно:
|
||||||
|
|
||||||
|
- **щуп не пишет** — собственный коммит соединения его версию не двигает;
|
||||||
|
- **щуп не удерживает транзакцию**: только `QueryRowContext(...).Scan(...)`,
|
||||||
|
никаких `QueryContext` и `BeginTx`. Иначе единственное долгоживущее соединение
|
||||||
|
процесса становится вечным читателем — тем самым, из-за которого чекпойнт
|
||||||
|
перестаёт продвигаться;
|
||||||
|
- **щуп непригоден только после закрытия** (`sql.ErrConnDone`). Отмена запроса
|
||||||
|
клиентом соединение не убивает (измерено), и считать её смертью щупа значило
|
||||||
|
бы менять поколение на каждом оборванном запросе — механизм схлопывался бы под
|
||||||
|
той самой нагрузкой, ради которой заведён.
|
||||||
|
|
||||||
|
Закрытие хранилища освобождает щуп **раньше пула**: закреплённое соединение
|
||||||
|
переживает закрытие пула, а финальный чекпойнт SQLite делает при закрытии
|
||||||
|
последнего соединения. Забытый щуп оставил бы рядом с базой неразобранный
|
||||||
|
`-wal`, а пересборка, переносящая один файл `.db`, потеряла бы хвост записей
|
||||||
|
молча.
|
||||||
|
|
||||||
|
**Подписывается не ответ, а чтение целиком.** Версия снимается до и после
|
||||||
|
чтения, и метка выдаётся, только если обе пробы совпали. Порядок здесь не
|
||||||
|
стилистический: версия, снятая ПОСЛЕ чтения, пометила бы устаревший снимок
|
||||||
|
свежим номером и заперла бы клиента на нём навсегда; версия, снятая только ДО,
|
||||||
|
допускает два разных ответа под одной меткой. Правило живёт в одном месте
|
||||||
|
(`store.VersionedRead`), потому что Read API точек и MCP берут ту же машинерию,
|
||||||
|
а вторая реализация «по образцу» отличалась бы ровно на этот порядок.
|
||||||
|
|
||||||
|
Отказ пробы версией не является: читающий маршрут деградирует до полного
|
||||||
|
ответа, а не до отказа.
|
||||||
|
|
||||||
|
#### Обслуживание журнала WAL
|
||||||
|
|
||||||
|
`wal_autocheckpoint` включён по умолчанию и срабатывает **по концу записи**.
|
||||||
|
Отсюда дыра: всплеск, раздувший журнал, оставляет его неразобранным до следующей
|
||||||
|
доставки — а поток пачечный, ночью телефон молчит часами. Поэтому рядом с
|
||||||
|
воркером свёртки живёт горутина, раз в минуту делающая
|
||||||
|
`PRAGMA wal_checkpoint(PASSIVE)`.
|
||||||
|
|
||||||
|
Режим `PASSIVE`, и это тоже измерение: `TRUNCATE` двигает `data_version`, то
|
||||||
|
есть каждый тик обнулял бы условный запрос у всех потребителей, а вдобавок ждёт
|
||||||
|
читателей. `PASSIVE` не двигает версию даже перенося 12502 страницы.
|
||||||
|
|
||||||
|
**Признак беды — не флаг занятости.** Пассивный чекпойнт не идёт дальше снимка
|
||||||
|
самого старого активного читателя и ошибки при этом не возвращает: измерено
|
||||||
|
`busy=0` при 6256 страницах в журнале и пяти перенесённых. Признаком служит пара
|
||||||
|
чисел — страниц больше порога **и** перенесено меньше, чем лежало.
|
||||||
|
|
||||||
|
**Флаг занятости при этом означает не «не продвинулись», а «не измерено».** Не
|
||||||
|
взяв блокировку чекпойнта, SQLite отдаёт `busy=1` и `-1` вместо обоих чисел —
|
||||||
|
измерено, 1492 таких тика из 5502 при писателе и чекпойнте в цикле. Сравнивать
|
||||||
|
`-1` на шкале страниц нельзя буквально: `-1 >= -1` истинно, то есть
|
||||||
|
незамеренный тик читался бы как «журнал разобран целиком» — владельцу уходила бы
|
||||||
|
строка о выздоровлении посреди болезни, с числом, которого не бывает, а
|
||||||
|
подавитель повторов сбрасывался бы и давал пару строк в минуту вместо молчания.
|
||||||
|
Незамеренный тик поэтому не меняет ни объявленного состояния, ни накопленного о
|
||||||
|
нём. Размер страницы берётся у самой базы: он свойство файла, и чужое умолчание
|
||||||
|
сместило бы порог в разы.
|
||||||
|
|
||||||
|
Порог и `journal_size_limit` — одно число (64 МиБ), выраженное в двух видах:
|
||||||
|
предел возвращает файл, порог сообщает, что вернуть его не выходит. Двумя
|
||||||
|
константами они разъехались бы молча.
|
||||||
|
|
||||||
|
**Предела роста журнала это не даёт, и умалчивать об этом нельзя.** Измерено:
|
||||||
|
под удерживаемым читателем файл вырос до 51 МБ при лимите 8 МиБ — лимит
|
||||||
|
действует только после полного чекпойнта, усечение делает первая запись за ним.
|
||||||
|
Пока читатель держит снимок, журнал растёт, и единственный исход — `WARN`
|
||||||
|
владельцу. Аварийный клапан (блокирующий `TRUNCATE` по порогу размера, как у
|
||||||
|
Litestream) не взят по названной причине: он двигает версию витрины.
|
||||||
|
|
||||||
|
Строка о непродвижении пишется при входе в состояние и повторяется, только
|
||||||
|
когда журнал вырос вдвое; возврат к норме — отдельная строка. Признак заведён
|
||||||
|
ради состояния, которое само не проходит (в Go самый частый вечный читатель —
|
||||||
|
незакрытый `sql.Rows`), а строка в минуту дала бы 1440 одинаковых записей в
|
||||||
|
сутки.
|
||||||
|
|
||||||
|
#### Как это решают другие
|
||||||
|
|
||||||
|
- **Документация SQLite** (`wal.html`) называет наш случай дословно: при
|
||||||
|
перекрывающихся читателях, среди которых всегда есть активный, чекпойнты не
|
||||||
|
смогут завершиться, и файл журнала будет расти без границы. Оттуда же взято,
|
||||||
|
что `PASSIVE` «делает столько, сколько может» и может не дойти до конца, а
|
||||||
|
полнота проверяется равенством `checkpointed == log`.
|
||||||
|
- **Litestream** — интервал чекпойнта минута, режим `PASSIVE`, блокирующий
|
||||||
|
`TRUNCATE` только как клапан по порогу размера. Взят период и режим; не взят
|
||||||
|
его совет отключать `wal_autocheckpoint` (он владеет чекпойнтами целиком, у нас
|
||||||
|
автоматический — первая линия) и не взят клапан.
|
||||||
|
- **rqlite** всегда просит `TRUNCATE` и ждёт читателя до 250 мс — продиктовано
|
||||||
|
требованием нулевого журнала для снапшота Raft, которого у нас нет.
|
||||||
|
- **Гайды по SQLite в проде** (Django, `dj-lite`) из всего этого ставят одно —
|
||||||
|
`journal_size_limit` порядка 26–64 МБ. Взято 64 МиБ.
|
||||||
|
- **`PRAGMA data_version`**: рекомендация держать для наблюдения отдельное
|
||||||
|
соединение взята с форума SQLite. Отвергнуты: `FileControlDataVersion`
|
||||||
|
драйвера (снимает требование «щуп не пишет», но стоит доступа через
|
||||||
|
`(*sql.Conn).Raw` в самом чувствительном месте), счётчик изменений со
|
||||||
|
страницы 1 (`SQLITE_DBPAGE` — в режиме WAL инкрементируется не на каждой
|
||||||
|
транзакции), хеш файла базы (так делает Datasette в неизменяемом режиме —
|
||||||
|
наша база пишется непрерывно) и собственный счётчик версии в таблице (второе
|
||||||
|
производное состояние рядом с витриной и лишняя запись на каждый коммит).
|
||||||
|
|
||||||
### Устаревание нижнего слоя
|
### Устаревание нижнего слоя
|
||||||
|
|
||||||
Родной экспорт Apple Health точнее HAE (находка 34) и делается раз в 2–3
|
Родной экспорт Apple Health точнее HAE (находка 34) и делается раз в 2–3
|
||||||
@@ -1258,6 +1384,47 @@ GET /healthz
|
|||||||
слой, покрывающий весь запрошенный диапазон**. Молча переключать слой на
|
слой, покрывающий весь запрошенный диапазон**. Молча переключать слой на
|
||||||
границе периода нельзя: ряд поедет незаметно для клиента.
|
границе периода нельзя: ряд поедет незаметно для клиента.
|
||||||
|
|
||||||
|
### Условный запрос
|
||||||
|
|
||||||
|
Ресурсы чтения отвечают `304 Not Modified` на `If-None-Match` с непротухшей
|
||||||
|
меткой и **не открывают снимок витрины вовсе**.
|
||||||
|
|
||||||
|
**Метка собирается из всего, от чего зависит ответ.** У каталога это версия
|
||||||
|
витрины (см. «Версия витрины и обслуживание журнала») и **горизонт измерения**:
|
||||||
|
горизонт едет вместе с часами, и метка из будущего, лежащая в витрине, въезжает
|
||||||
|
в окно сама, без единого коммита. Путь построен враждебным проходом ревью и
|
||||||
|
прогнан: та же версия витрины, `cumulative` против `unknown`. Горизонт входит в
|
||||||
|
метку огрублённым до часа — огрубление точное, потому что метки объектов лежат
|
||||||
|
ровно на часах; цена — один полный ответ в час на потребителя.
|
||||||
|
|
||||||
|
Форма метки **слабая** (`W/"…"`): она выведена из состояния, а не из байтов
|
||||||
|
ответа — так предписывает общая практика для валидаторов такого рода. На исход
|
||||||
|
`304` это не влияет, `If-None-Match` сравнивается слабо в любом случае.
|
||||||
|
|
||||||
|
**Область действия метки — часть самой метки.** Она действительна в пределах
|
||||||
|
одного ресурса, поэтому маршрут, чей ответ есть функция параметров (точки), и
|
||||||
|
транспорт без адреса вовсе (MCP) кладут в неё канонизированную форму запроса.
|
||||||
|
Прозой это требовать бесполезно — прозу компилятор не проверяет, а забыть
|
||||||
|
область значит однажды ответить `304` на чужой набор данных; поэтому она
|
||||||
|
параметр помощника, а не забота вызывающего.
|
||||||
|
|
||||||
|
Три правила разбора, каждое из которых легко нарушить: неразбираемое условие
|
||||||
|
даёт `200`, а не `400`; `*` совпадает с любой **существующей** меткой, а при её
|
||||||
|
отсутствии условие не выполнено; `304` уходит без тела и без представленческих
|
||||||
|
заголовков. Токен чтения проверяется **раньше** условия: `304` без токена
|
||||||
|
подтверждал бы состояние витрины тому, кому она не открыта.
|
||||||
|
|
||||||
|
Ответы чтения помечаются `Cache-Control: private, no-cache`. До появления
|
||||||
|
валидатора эвристическое кеширование посредником было маловероятным; с меткой
|
||||||
|
ответ становится штатно кешируемым, а при выключенной проверке токенов в
|
||||||
|
запросе нет и `Authorization`.
|
||||||
|
|
||||||
|
Следствие названо вслух: **`304` не выполняет измерения и потому не пишет
|
||||||
|
предупреждений владельцу** (данные из будущего, противоречащий род). С условным
|
||||||
|
опросом они становятся функцией смены версии витрины, а не числа запросов;
|
||||||
|
состояние при этом не теряется — следующая доставка меняет версию, ответ
|
||||||
|
собирается, и предупреждение пишется.
|
||||||
|
|
||||||
### Свёртка и размер ответа
|
### Свёртка и размер ответа
|
||||||
|
|
||||||
Запросов к метрике ровно два, и это один запрос с необязательным параметром:
|
Запросов к метрике ровно два, и это один запрос с необязательным параметром:
|
||||||
|
|||||||
@@ -21,7 +21,6 @@
|
|||||||
|
|
||||||
## высокий
|
## высокий
|
||||||
- [Тай-брейк при равной полноте точек](taj-brejk-pri-ravnoj-polnote.md) — Решено: брать бо́льшее значение. Порядок канонических форм берёт меньшее в 96% случаев — для накопительных это систематический недосчёт
|
- [Тай-брейк при равной полноте точек](taj-brejk-pri-ravnoj-polnote.md) — Решено: брать бо́льшее значение. Порядок канонических форм берёт меньшее в 96% случаев — для накопительных это систематический недосчёт
|
||||||
- [Цена первого читающего маршрута: память, WAL и повторный опрос](cena-chitayushchego-marshruta.md) — Решено: чекпойнт по таймеру плюс ETag по data_version. Берётся перед Read API — тот строится поверх этой машинерии
|
|
||||||
- [Read API: точки, выбор слоя, свёртка по сетке](read-api-tochki.md) — Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может
|
- [Read API: точки, выбор слоя, свёртка по сетке](read-api-tochki.md) — Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может
|
||||||
- [OpenAPI-спека и Swagger UI](openapi-swagger.md) — Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате
|
- [OpenAPI-спека и Swagger UI](openapi-swagger.md) — Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате
|
||||||
- [MCP-сервер поверх Read API](mcp-server.md) — Агент-медик — первый заказчик проекта, а подключить его сейчас нечем
|
- [MCP-сервер поверх Read API](mcp-server.md) — Агент-медик — первый заказчик проекта, а подключить его сейчас нечем
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
# Цена первого читающего маршрута: память, WAL и повторный опрос
|
|
||||||
|
|
||||||
**Приоритет:** высокий
|
|
||||||
|
|
||||||
**Решение принято 2026-08-02: вариант (г) плюс (в), именно в таком порядке.**
|
|
||||||
Разбирается без владельца: контракт хранения не меняется, данные не трогаются,
|
|
||||||
а обе части — общепринятая практика, а не собственный дизайн. Чекпойнт по
|
|
||||||
таймеру закрывает единственное проявление, которое ломает приём (диск), и стоит
|
|
||||||
одной горутины; `ETag` по `PRAGMA data_version` — один запрос к базе, снимает и
|
|
||||||
повтор, и большую часть читающих транзакций, не заводя кеша ответа.
|
|
||||||
|
|
||||||
Вариант (а) — предел и дедлайн маршрута — **не отвергнут, а отложен** до
|
|
||||||
[Read API точек](read-api-tochki.md): там предел размера ответа всё равно
|
|
||||||
проектируется, и делать его дважды не нужно. Вариант (б) не берём, пока
|
|
||||||
счётчик не заговорит. Вариант (д) — последним, если (в) окажется мало.
|
|
||||||
|
|
||||||
Задача берётся **перед** Read API: тот строится поверх этой машинерии.
|
|
||||||
Ниже — исходная постановка блокера, она же ТЗ.
|
|
||||||
|
|
||||||
## Что решить
|
|
||||||
|
|
||||||
Чем ограничить стоимость маршрута чтения, у которого нет ни предела ответа, ни
|
|
||||||
собственного дедлайна, ни условного запроса. Вопрос поднялся на каталоге
|
|
||||||
(`GET /api/v1/metrics`, change `2026-08-02-katalog-i-rod-agregacii`), но
|
|
||||||
принадлежит не ему: тот же ответ понадобится Read API точек и MCP, и решать его
|
|
||||||
трижды нельзя.
|
|
||||||
|
|
||||||
Три измеренных проявления одной причины.
|
|
||||||
|
|
||||||
**Память.** Снимок каталога держит разжатые точки окна по всем метрикам сразу,
|
|
||||||
хотя измерение идёт по одной метрике. Замер враждебного прохода ревью: 20 метрик
|
|
||||||
× 8 часов × 5000 точек — 693 мс и +153 МиБ живой кучи на один запрос.
|
|
||||||
Предварительный отбор по учётным колонкам (сделан) снял разжатие заведомо
|
|
||||||
непригодных часов, но множители «метрики × окно × точки × одновременные запросы»
|
|
||||||
остались без потолка. Приём живёт в том же процессе и уже даёт пик 768 МиБ на
|
|
||||||
теле 40 МиБ; OOM убивает приём, а доставка, не попавшая в архив, телефоном не
|
|
||||||
переприсылается.
|
|
||||||
|
|
||||||
**WAL.** Замер эксплуатационного прохода на копии с драйвером и PRAGMA проекта:
|
|
||||||
непрерывная запись плюс четыре читающих транзакции внахлёст дают рост `-wal`
|
|
||||||
около 7 МБ/с без верхней границы (40 МБ за пять секунд), тогда как тот же
|
|
||||||
писатель без читателей стабилизируется на 4 МБ. Пассивный чекпойнт SQLite не
|
|
||||||
продвигается дальше снимка самого старого активного читателя, и ошибки при этом
|
|
||||||
нет — виден только растущий файл. `PRAGMA wal_checkpoint` в проекте не
|
|
||||||
вызывается нигде.
|
|
||||||
|
|
||||||
**Повторный опрос.** Спека каталога требует побайтового совпадения двух ответов
|
|
||||||
на неизменившейся витрине — то есть ресурс по построению пригоден для условного
|
|
||||||
запроса, а `ETag`/`304` не выставляется. Потребителей трое (агент-медик, трекер,
|
|
||||||
игра), и самый частый их запрос — повтор неизменившегося.
|
|
||||||
|
|
||||||
## Варианты и цена
|
|
||||||
|
|
||||||
**а. Предел и дедлайн у маршрута.** Потолок числа метрик и точек в одном ответе,
|
|
||||||
собственный `context.WithTimeout`, честный отказ при превышении. Цена: клиент
|
|
||||||
обязан уметь читать частичный каталог, то есть появляется пагинация — контракт
|
|
||||||
чтения усложняется на первой же ручке.
|
|
||||||
|
|
||||||
**б. Измерение потоком по метрике внутри той же транзакции.** Точки метрики
|
|
||||||
освобождаются сразу после вердикта; требование «один снимок» не нарушается. Цена:
|
|
||||||
хранилище перестаёт возвращать снимок значением и начинает отдавать его
|
|
||||||
последовательно (итератор или колбэк) — то есть меняется форма границы
|
|
||||||
`store`/`catalog`, ради случая, которого живой поток пока не производит.
|
|
||||||
|
|
||||||
**в. Условный запрос: `ETag` по `PRAGMA data_version`.** Снимает и стоимость
|
|
||||||
повтора, и большую часть читающих транзакций разом: клиент с непротухшим `ETag`
|
|
||||||
получает `304`, и снимок не открывается вовсе. Цена: один лишний запрос к базе на
|
|
||||||
каждый вызов и обещание клиенту, что версия витрины меняется не чаще, чем данные.
|
|
||||||
|
|
||||||
**г. Периодический `wal_checkpoint(PASSIVE)` по таймеру рядом с воркером.**
|
|
||||||
Лечит только WAL, зато дёшево и без изменения контракта. Память и повтор
|
|
||||||
остаются.
|
|
||||||
|
|
||||||
**д. Кеш ответа на короткий TTL.** Закрывает всё сразу, но заводит третье
|
|
||||||
представление того же факта, и его инвалидация становится новым местом, где можно
|
|
||||||
ошибиться молча. Дизайн каталога отверг кеш именно поэтому.
|
|
||||||
|
|
||||||
## Что заблокировано
|
|
||||||
|
|
||||||
Ничего сегодня: на живом корпусе каталог собирается за 45 мс, потребителей у него
|
|
||||||
пока нет, а маршрут живёт в доверенной сети. Блокировано будущее — Read API
|
|
||||||
точек, где объёмы на порядок больше, и выкладка наружу, где опрос станет
|
|
||||||
непрерывным.
|
|
||||||
|
|
||||||
## Рекомендация
|
|
||||||
|
|
||||||
**г + в, именно в таком порядке.** Чекпойнт по таймеру закрывает единственное
|
|
||||||
проявление, которое ломает приём (диск), и стоит одной горутины без изменения
|
|
||||||
контракта. `ETag` по `data_version` — один запрос к базе, снимает и повтор, и
|
|
||||||
большую часть читающих транзакций, и делает это без кеша ответа.
|
|
||||||
|
|
||||||
Вариант «а» откладывать до Read API точек: там предел размера ответа всё равно
|
|
||||||
проектируется (`read-api-tochki.md`), и делать его дважды не нужно. Вариант «б»
|
|
||||||
не брать, пока счётчик не заговорит: он меняет форму границы ради случая,
|
|
||||||
которого поток не производит. Вариант «д» — последним, если «в» окажется мало.
|
|
||||||
|
|
||||||
Связано: `docs/architecture.md` → «Измерение рода агрегации», `read-api-tochki.md`,
|
|
||||||
`stats-nablyudaemost.md`.
|
|
||||||
@@ -21,6 +21,14 @@
|
|||||||
исчерпанного первого, и `BaseContext`, производный от контекста жизненного цикла,
|
исчерпанного первого, и `BaseContext`, производный от контекста жизненного цикла,
|
||||||
чтобы долгий запрос об остановке узнавал.
|
чтобы долгий запрос об остановке узнавал.
|
||||||
|
|
||||||
|
**Цена этой ветки выросла** (change `cena-chitayushchego-marshruta`): база в ней
|
||||||
|
не закрывается, а значит не закрывается и закреплённое соединение версии
|
||||||
|
витрины — последнего соединения к базе не наступает, SQLite не делает финального
|
||||||
|
чекпойнта, и рядом с базой остаётся неразобранный `-wal` до 64 МиБ. Данные целы
|
||||||
|
(следующее открытие проиграет журнал), но файл базы в этом состоянии нельзя
|
||||||
|
переносить без его `-wal`. Обвинение в логе при этом стало честнее: этап
|
||||||
|
называется `background`, а не `fold-worker`, потому что ждут двоих.
|
||||||
|
|
||||||
**Миграция молчит и не прерывается штатной остановкой.** `store.migrate` не
|
**Миграция молчит и не прерывается штатной остановкой.** `store.migrate` не
|
||||||
пишет ни одной записи — ни «начал», ни «закончил», ни длительность, — а первая
|
пишет ни одной записи — ни «начал», ни «закончил», ни длительность, — а первая
|
||||||
строка в логе появляется уже после успешного открытия базы. Если миграция идёт
|
строка в логе появляется уже после успешного открытия базы. Если миграция идёт
|
||||||
@@ -37,5 +45,5 @@
|
|||||||
Готово, когда `WARN` о превышении бюджета называет виновный этап честно, а в логе
|
Готово, когда `WARN` о превышении бюджета называет виновный этап честно, а в логе
|
||||||
старта видно, что миграции накатывались и сколько это заняло.
|
старта видно, что миграции накатывались и сколько это заняло.
|
||||||
|
|
||||||
Связано: `cmd/healthlog/serve.go`, `internal/store/store.go`,
|
Связано: `cmd/healthlog/serve.go`, `internal/store/store.go`, change
|
||||||
`cena-chitayushchego-marshruta.md`.
|
`2026-08-02-cena-chitayushchego-marshruta` (архив).
|
||||||
|
|||||||
@@ -36,10 +36,26 @@
|
|||||||
величины выглядят как «0.5», означая разное; полярность придётся назвать вслух в
|
величины выглядят как «0.5», означая разное; полярность придётся назвать вслух в
|
||||||
`architecture.md`, иначе через полгода два места кода поймут поле по-разному.
|
`architecture.md`, иначе через полгода два места кода поймут поле по-разному.
|
||||||
|
|
||||||
**Предел размера ответа тоже здесь.** У каталога его нет намеренно: правило
|
**Предел размера ответа тоже здесь, и он унаследовал измеренную цену.** У
|
||||||
размера — общее для маршрутов чтения, и задавать его мимоходом на первой ручке
|
каталога предела нет намеренно: правило размера — общее для маршрутов чтения, и
|
||||||
значило бы решить контракт до того, как известна форма тяжёлого ответа. Каталог
|
задавать его мимоходом на первой ручке значило бы решить контракт до того, как
|
||||||
станет первым его потребителем.
|
известна форма тяжёлого ответа. Каталог станет первым его потребителем.
|
||||||
|
|
||||||
|
Цена измерена на каталоге (задача «цена читающего маршрута», закрыта чекпойнтом
|
||||||
|
WAL и условным запросом): 693 мс и +153 МиБ живой кучи на враждебном запросе
|
||||||
|
(20 метрик × 8 часов × 5000 точек), при том что приём в том же процессе уже даёт
|
||||||
|
пик 768 МиБ на теле 40 МиБ. Условный запрос снял повтор, но первый запрос стоит
|
||||||
|
столько же, а множители «метрики × окно × точки × одновременные запросы»
|
||||||
|
по-прежнему без потолка. Сюда же уезжают отложенные варианты той задачи:
|
||||||
|
собственный дедлайн маршрута и потоковое измерение по метрике (второе — только
|
||||||
|
если счётчик заговорит).
|
||||||
|
|
||||||
|
**Машинерия условного запроса готова, и её надо взять, а не написать заново.**
|
||||||
|
`store.VersionedRead` держит правило «версией, снятой после чтения, не
|
||||||
|
подписывать»; `httpapi` — разбор `If-None-Match` и `304`. Метка обязана нести
|
||||||
|
**область действия**: у точек ответ есть функция параметров запроса, и
|
||||||
|
`etag(scope, version)` требует их канонизированную форму — иначе `304` ответит
|
||||||
|
на другой набор данных. Детали — `docs/architecture.md`, «Условный запрос».
|
||||||
|
|
||||||
**Форма провода наследуется от каталога, и это надо решить один раз.** Сегодня
|
**Форма провода наследуется от каталога, и это надо решить один раз.** Сегодня
|
||||||
типы `internal/catalog` сами несут json-теги, а транспорт владеет только
|
типы `internal/catalog` сами несут json-теги, а транспорт владеет только
|
||||||
|
|||||||
@@ -48,3 +48,12 @@
|
|||||||
запроса, ни адреса клиента: жалобу потребителя не сопоставить с записью, а
|
запроса, ни адреса клиента: жалобу потребителя не сопоставить с записью, а
|
||||||
выгрузку каталога посторонним — не отличить от планового опроса агента. У приёма
|
выгрузку каталога посторонним — не отличить от планового опроса агента. У приёма
|
||||||
корреляция есть (`delivery_id`), у чтения аналога нет.
|
корреляция есть (`delivery_id`), у чтения аналога нет.
|
||||||
|
|
||||||
|
**Обслуживание журнала WAL тоже спрашивается здесь.** Признак «журнал не
|
||||||
|
разбирается» (чекпойнт по таймеру, change `cena-chitayushchego-marshruta`)
|
||||||
|
живёт одной строкой `WARN` в ротируемом docker-логе: состояние держится днями, а
|
||||||
|
сказано о нём один раз. Вопрос «журнал сейчас разбирается?» сегодня не имеет
|
||||||
|
ответа нигде, кроме `df`. В `/stats` просятся последний исход чекпойнта (когда,
|
||||||
|
сколько страниц лежит и сколько перенесено) и — тем же полем — доля ответов
|
||||||
|
чтения, которые удалось подписать `ETag`: механизм условного запроса может
|
||||||
|
перестать окупаться под плотным потоком, и снаружи это неотличимо от нормы.
|
||||||
|
|||||||
@@ -161,3 +161,9 @@
|
|||||||
архиве, и ответ «пересворачивать нечего» произносится с числом.** Утверждение
|
архиве, и ответ «пересворачивать нечего» произносится с числом.** Утверждение
|
||||||
без числа не отличается от предположения, а цена ошибки здесь — необратимое
|
без числа не отличается от предположения, а цена ошибки здесь — необратимое
|
||||||
решение о судьбе тел.
|
решение о судьбе тел.
|
||||||
|
- **Тест «в логе нет значения» проверяет запись без служебных полей, а не сырой
|
||||||
|
буфер.** Метка времени содержит доли секунды, поэтому искомая подстрока
|
||||||
|
находится в ней сама: проверка на «5.1» краснела примерно раз на сотню
|
||||||
|
прогонов от хода часов, а не от утечки. Разбираем запись, выбрасываем `time`
|
||||||
|
и ищем в остатке. Правило общее — таких тестов будет больше (токены, тела
|
||||||
|
запросов, координаты объектов).
|
||||||
|
|||||||
@@ -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`, а не ищет в сыром
|
||||||
|
буфере. Гейт не трогаем: два прогона против однопроцентной флаки не помогут,
|
||||||
|
а десять стоили бы дороже самой находки.
|
||||||
|
|||||||
+75
-11
@@ -204,6 +204,54 @@ type Metric struct {
|
|||||||
Layers []LayerRange `json:"layers"`
|
Layers []LayerRange `json:"layers"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Snapshot — каталог вместе с версией ответа.
|
||||||
|
//
|
||||||
|
// Версия пустая, когда подписать ответ нечем: витрина изменилась, пока он
|
||||||
|
// собирался, или прочитать её версию не удалось. Это не отказ — ответ уходит
|
||||||
|
// целиком, просто без условной метки, ровно как до появления условного запроса.
|
||||||
|
//
|
||||||
|
// Имя перекликается с `store.CatalogSnapshot` намеренно и означает другое: тот
|
||||||
|
// снимок — вход измерения (объекты и разрезы), этот — готовый ответ.
|
||||||
|
type Snapshot struct {
|
||||||
|
Version string
|
||||||
|
Metrics []Metric
|
||||||
|
}
|
||||||
|
|
||||||
|
// Version — версия ОТВЕТА каталога: версия витрины плюс горизонт измерения.
|
||||||
|
//
|
||||||
|
// Горизонт входит в неё, потому что ответ есть функция обоих. Час объекта
|
||||||
|
// сравнивается с `Now() + horizonSlack`, и с ходом часов состав окна меняется
|
||||||
|
// без единого коммита: метка из будущего, лежащая в витрине (сбитые часы
|
||||||
|
// телефона — состояние, о котором рядом пишется WARN), въезжает в окно сама и
|
||||||
|
// способна перевернуть измеренный род. Построено и прогнано: та же версия
|
||||||
|
// витрины, `cumulative` против `unknown`, ноль коммитов между.
|
||||||
|
//
|
||||||
|
// Огрубление до часа не приблизительное, а точное: `hour_utc` объектов лежит
|
||||||
|
// ровно на часах, поэтому отбор `hour_utc <= горизонт` меняется ровно при
|
||||||
|
// переходе горизонта через час. Цена — один полный ответ в час на потребителя
|
||||||
|
// при неизменившейся витрине; сборка каталога на живом корпусе стоит 45 мс.
|
||||||
|
func (s *Service) Version(ctx context.Context) (string, error) {
|
||||||
|
version, err := s.store.StateVersion(ctx)
|
||||||
|
if err != nil {
|
||||||
|
// Отказ пробы отказом маршрута не является — но и молчать о нём нельзя:
|
||||||
|
// без метки условный запрос выключается для всех потребителей, а
|
||||||
|
// снаружи это неотличимо от нормы. DEBUG, потому что адресат здесь
|
||||||
|
// разработчик: владельцу об этом скажет `/stats`, когда появится.
|
||||||
|
s.log.DebugContext(ctx, "state version unavailable", "capability", "query", "error", err)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return stamp(version, store.Now().Add(horizonSlack)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// stamp склеивает версию витрины с горизонтом. Пустая версия остаётся пустой:
|
||||||
|
// подписывать нечем — значит нечем, и горизонт этого не меняет.
|
||||||
|
func stamp(version string, horizon time.Time) string {
|
||||||
|
if version == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return version + "." + horizon.Truncate(time.Hour).Format("2006010215")
|
||||||
|
}
|
||||||
|
|
||||||
// Service собирает каталог по витрине.
|
// Service собирает каталог по витрине.
|
||||||
type Service struct {
|
type Service struct {
|
||||||
store *store.Store
|
store *store.Store
|
||||||
@@ -223,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)
|
horizon := store.Now().Add(horizonSlack)
|
||||||
snap, err := s.store.ReadCatalog(ctx, store.CatalogWindow{
|
|
||||||
Fine: string(hae.LayerMinute),
|
var snap store.CatalogSnapshot
|
||||||
Coarse: string(hae.LayerHour),
|
version, err := s.store.VersionedRead(ctx, func(ctx context.Context) error {
|
||||||
Hours: Window,
|
var err error
|
||||||
Horizon: horizon,
|
snap, err = s.store.ReadCatalog(ctx, store.CatalogWindow{
|
||||||
CoarsePoints: coarsePoints,
|
Fine: string(hae.LayerMinute),
|
||||||
MinFinePoints: minFinePoints,
|
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 {
|
} else {
|
||||||
s.log.ErrorContext(ctx, "catalog failed", "capability", "query", "error", err)
|
s.log.ErrorContext(ctx, "catalog failed", "capability", "query", "error", err)
|
||||||
}
|
}
|
||||||
return nil, err
|
return Snapshot{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
out := make([]Metric, 0, len(snap.Layers))
|
out := make([]Metric, 0, len(snap.Layers))
|
||||||
@@ -286,7 +343,14 @@ func (s *Service) Metrics(ctx context.Context) ([]Metric, error) {
|
|||||||
Layers: group.layers,
|
Layers: group.layers,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return out, nil
|
if version == "" {
|
||||||
|
// Витрина изменилась, пока ответ собирался (или версию не прочитать).
|
||||||
|
// Ответ уйдёт без метки — это безопасная сторона, но след нужен: под
|
||||||
|
// плотным потоком доставок так может уходить каждый ответ, и тогда
|
||||||
|
// механизм не окупается вовсе.
|
||||||
|
s.log.DebugContext(ctx, "catalog unsigned", "capability", "query")
|
||||||
|
}
|
||||||
|
return Snapshot{Version: stamp(version, horizon), Metrics: out}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type metricGroup struct {
|
type metricGroup struct {
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ func TestКаталогОтдаётРазрезыИИзмеренныйРод(t
|
|||||||
incoming("sleep_analysis", "raw", "hr", time.Date(2026, 6, 1, 3, 7, 0, 0, time.UTC), 1),
|
incoming("sleep_analysis", "raw", "hr", time.Date(2026, 6, 1, 3, 7, 0, 0, time.UTC), 1),
|
||||||
})
|
})
|
||||||
|
|
||||||
metrics, err := service(t, st).Metrics(context.Background())
|
metrics, err := metricsOf(context.Background(), service(t, st))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("каталог: %v", err)
|
t.Fatalf("каталог: %v", err)
|
||||||
}
|
}
|
||||||
@@ -179,7 +179,7 @@ func TestКаталогНеИзмеряетПоНижнемуСлою(t *testing
|
|||||||
}
|
}
|
||||||
merge(t, st, points)
|
merge(t, st, points)
|
||||||
|
|
||||||
metrics, err := service(t, st).Metrics(context.Background())
|
metrics, err := metricsOf(context.Background(), service(t, st))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("каталог: %v", err)
|
t.Fatalf("каталог: %v", err)
|
||||||
}
|
}
|
||||||
@@ -198,7 +198,7 @@ func TestКаталогОграничиваетОкно(t *testing.T) {
|
|||||||
st := openStore(t)
|
st := openStore(t)
|
||||||
fill(t, st, "step_count", catalog.Window+7, true)
|
fill(t, st, "step_count", catalog.Window+7, true)
|
||||||
|
|
||||||
metrics, err := service(t, st).Metrics(context.Background())
|
metrics, err := metricsOf(context.Background(), service(t, st))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("каталог: %v", err)
|
t.Fatalf("каталог: %v", err)
|
||||||
}
|
}
|
||||||
@@ -227,7 +227,7 @@ func TestКаталогПоказываетРасхождениеЕдиниц(t
|
|||||||
incoming("walking_running_distance", "minute", "m", base.Add(2*time.Hour), 2),
|
incoming("walking_running_distance", "minute", "m", base.Add(2*time.Hour), 2),
|
||||||
})
|
})
|
||||||
|
|
||||||
metrics, err := service(t, st).Metrics(context.Background())
|
metrics, err := metricsOf(context.Background(), service(t, st))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("каталог: %v", err)
|
t.Fatalf("каталог: %v", err)
|
||||||
}
|
}
|
||||||
@@ -249,7 +249,7 @@ func TestКаталогПоказываетРасхождениеЕдиниц(t
|
|||||||
func TestКаталогПустойВитриныПуст(t *testing.T) {
|
func TestКаталогПустойВитриныПуст(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
metrics, err := service(t, openStore(t)).Metrics(context.Background())
|
metrics, err := metricsOf(context.Background(), service(t, openStore(t)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("каталог: %v", err)
|
t.Fatalf("каталог: %v", err)
|
||||||
}
|
}
|
||||||
@@ -268,7 +268,7 @@ func TestКаталогПересчитываетРодНаКаждыйЗапр
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
fill(t, st, "step_count", 2, true)
|
fill(t, st, "step_count", 2, true)
|
||||||
before, err := svc.Metrics(ctx)
|
before, err := metricsOf(ctx, svc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("каталог: %v", err)
|
t.Fatalf("каталог: %v", err)
|
||||||
}
|
}
|
||||||
@@ -277,7 +277,7 @@ func TestКаталогПересчитываетРодНаКаждыйЗапр
|
|||||||
}
|
}
|
||||||
|
|
||||||
fill(t, st, "step_count", 5, true)
|
fill(t, st, "step_count", 5, true)
|
||||||
after, err := svc.Metrics(ctx)
|
after, err := metricsOf(ctx, svc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("каталог: %v", err)
|
t.Fatalf("каталог: %v", err)
|
||||||
}
|
}
|
||||||
@@ -310,7 +310,7 @@ func TestКаталогНеИзмеряетПоБудущимЧасам(t *testi
|
|||||||
merge(t, st, future)
|
merge(t, st, future)
|
||||||
|
|
||||||
handler := &logged{}
|
handler := &logged{}
|
||||||
metrics, err := catalog.New(st, slog.New(handler)).Metrics(context.Background())
|
metrics, err := metricsOf(context.Background(), catalog.New(st, slog.New(handler)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("каталог: %v", err)
|
t.Fatalf("каталог: %v", err)
|
||||||
}
|
}
|
||||||
@@ -344,7 +344,7 @@ func TestКаталогНеСверяетСлоиРазныхЕдиниц(t *tes
|
|||||||
}
|
}
|
||||||
merge(t, st, points)
|
merge(t, st, points)
|
||||||
|
|
||||||
metrics, err := service(t, st).Metrics(context.Background())
|
metrics, err := metricsOf(context.Background(), service(t, st))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("каталог: %v", err)
|
t.Fatalf("каталог: %v", err)
|
||||||
}
|
}
|
||||||
@@ -369,7 +369,7 @@ func TestКаталогПоказываетМетрикуСПустымИмен
|
|||||||
incoming("step_count", "minute", "count", base, 2),
|
incoming("step_count", "minute", "count", base, 2),
|
||||||
})
|
})
|
||||||
|
|
||||||
metrics, err := service(t, st).Metrics(context.Background())
|
metrics, err := metricsOf(context.Background(), service(t, st))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("каталог: %v", err)
|
t.Fatalf("каталог: %v", err)
|
||||||
}
|
}
|
||||||
@@ -402,7 +402,7 @@ func TestКаталогПишетПредупреждениеОПротивор
|
|||||||
merge(t, st, points)
|
merge(t, st, points)
|
||||||
|
|
||||||
handler := &logged{}
|
handler := &logged{}
|
||||||
metrics, err := catalog.New(st, slog.New(handler)).Metrics(context.Background())
|
metrics, err := metricsOf(context.Background(), catalog.New(st, slog.New(handler)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("каталог: %v", err)
|
t.Fatalf("каталог: %v", err)
|
||||||
}
|
}
|
||||||
@@ -439,7 +439,7 @@ func TestКаталогОграничиваетЧислоЕдиниц(t *testing
|
|||||||
}
|
}
|
||||||
merge(t, st, points)
|
merge(t, st, points)
|
||||||
|
|
||||||
metrics, err := service(t, st).Metrics(context.Background())
|
metrics, err := metricsOf(context.Background(), service(t, st))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("каталог: %v", err)
|
t.Fatalf("каталог: %v", err)
|
||||||
}
|
}
|
||||||
@@ -463,7 +463,7 @@ func TestКаталогСообщаетОбОтказеХранилища(t *tes
|
|||||||
}
|
}
|
||||||
|
|
||||||
handler := &logged{}
|
handler := &logged{}
|
||||||
if _, err := catalog.New(st, slog.New(handler)).Metrics(context.Background()); err == nil {
|
if _, err := metricsOf(context.Background(), catalog.New(st, slog.New(handler))); err == nil {
|
||||||
t.Fatal("каталог на закрытой базе собрался")
|
t.Fatal("каталог на закрытой базе собрался")
|
||||||
}
|
}
|
||||||
rec, ok := handler.find("catalog failed")
|
rec, ok := handler.find("catalog failed")
|
||||||
@@ -488,7 +488,7 @@ func TestКаталогНеПутаетОтменуСоСбоем(t *testing.T)
|
|||||||
cancel()
|
cancel()
|
||||||
|
|
||||||
handler := &logged{}
|
handler := &logged{}
|
||||||
if _, err := catalog.New(st, slog.New(handler)).Metrics(ctx); err == nil {
|
if _, err := metricsOf(ctx, catalog.New(st, slog.New(handler))); err == nil {
|
||||||
t.Fatal("каталог собрался на отменённом контексте")
|
t.Fatal("каталог собрался на отменённом контексте")
|
||||||
}
|
}
|
||||||
if _, ok := handler.find("catalog failed"); ok {
|
if _, ok := handler.find("catalog failed"); ok {
|
||||||
@@ -498,3 +498,25 @@ func TestКаталогНеПутаетОтменуСоСбоем(t *testing.T)
|
|||||||
t.Error("отмена не отмечена вовсе — исход операции обязан быть виден")
|
t.Error("отмена не отмечена вовсе — исход операции обязан быть виден")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// metricsOf — список метрик каталога без версии витрины: версию проверяют
|
||||||
|
// отдельные тесты, остальным нужен только состав ответа.
|
||||||
|
func metricsOf(ctx context.Context, s *catalog.Service) ([]catalog.Metric, error) {
|
||||||
|
snap, err := s.Metrics(ctx)
|
||||||
|
return snap.Metrics, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Каталог подписывает свой ответ версией витрины: без неё транспорту нечего
|
||||||
|
// поставить в `ETag`, и условный запрос не работает вовсе.
|
||||||
|
func TestКаталогОтдаётсяСВерсиейВитрины(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
st := openStore(t)
|
||||||
|
snap, err := service(t, st).Metrics(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("каталог: %v", err)
|
||||||
|
}
|
||||||
|
if snap.Version == "" {
|
||||||
|
t.Error("каталог собран на стоящей витрине и остался без версии")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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("пустая версия витрины подписана горизонтом — подписывать нечем")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -176,9 +176,19 @@ func TestFoldНесравнимыеНаборыДаютWarn(t *testing.T) {
|
|||||||
t.Errorf("координаты объекта в записи не те: %q", at)
|
t.Errorf("координаты объекта в записи не те: %q", at)
|
||||||
}
|
}
|
||||||
// И ни одного значения точки: данные о здоровье чувствительнее токенов.
|
// И ни одного значения точки: данные о здоровье чувствительнее токенов.
|
||||||
|
//
|
||||||
|
// Метка времени из проверки исключается, и это не поблажка: она содержит
|
||||||
|
// доли секунды, поэтому подстрока вроде "5.1" находится в ней примерно раз
|
||||||
|
// на сотню прогонов — тест краснел от хода часов, а не от утечки. Проверять
|
||||||
|
// надо запись без служебного поля, которое значений нести не может.
|
||||||
|
delete(rec, "time")
|
||||||
|
clean, err := json.Marshal(rec)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("запись лога не сериализуется: %v", err)
|
||||||
|
}
|
||||||
for _, secret := range []string{"5.1", "До еды"} {
|
for _, secret := range []string{"5.1", "До еды"} {
|
||||||
if strings.Contains(buf.String(), secret) {
|
if strings.Contains(string(clean), secret) {
|
||||||
t.Errorf("в логе оказалось значение точки %q:\n%s", secret, buf.String())
|
t.Errorf("в логе оказалось значение точки %q:\n%s", secret, clean)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,8 +21,36 @@ type catalogResponse struct {
|
|||||||
// сравнивающий разобранные структуры, этого не увидел бы — потому приёмочная
|
// сравнивающий разобранные структуры, этого не увидел бы — потому приёмочная
|
||||||
// проверка сравнивает байты ответа. Второй страховки здесь нет намеренно:
|
// проверка сравнивает байты ответа. Второй страховки здесь нет намеренно:
|
||||||
// подстраховка поверх подстраховки прячет отказ первой.
|
// подстраховка поверх подстраховки прячет отказ первой.
|
||||||
|
//
|
||||||
|
// Условный запрос стоит ПОСЛЕ проверки токена (её ставит роутер) и ДО сборки
|
||||||
|
// снимка: в этом весь смысл — самый частый запрос потребителя есть повтор
|
||||||
|
// неизменившегося, и он не должен стоить ни снимка, ни разжатия точек.
|
||||||
|
// scopeMetrics — область действия метки каталога. Ответ маршрута не зависит от
|
||||||
|
// параметров запроса, поэтому область постоянна; у точек и MCP на её месте
|
||||||
|
// будет канонизированная форма запроса.
|
||||||
|
const scopeMetrics = "metrics"
|
||||||
|
|
||||||
func (a *api) handleMetrics(w http.ResponseWriter, r *http.Request) {
|
func (a *api) handleMetrics(w http.ResponseWriter, r *http.Request) {
|
||||||
metrics, err := a.catalog.Metrics(r.Context())
|
// Values, а не Get: `If-None-Match` клиент вправе прислать несколькими
|
||||||
|
// строками, и `Get` увидел бы только первую — часть меток осталась бы
|
||||||
|
// нерассмотренной.
|
||||||
|
if cond := r.Header.Values("If-None-Match"); len(cond) > 0 {
|
||||||
|
// Отказ пробы глушится намеренно: он означает лишь, что условного
|
||||||
|
// ответа не будет, — а настоящий отказ базы всплывёт сборкой каталога
|
||||||
|
// строкой ниже и будет назван ею один раз.
|
||||||
|
// Версию спрашиваем У КАТАЛОГА, а не у хранилища: ответ есть функция не
|
||||||
|
// только состояния витрины, и что ещё в него входит, знает домен. Read
|
||||||
|
// API точек ответит здесь же своей версией, включающей параметры
|
||||||
|
// запроса, — транспорту эти правила знать незачем.
|
||||||
|
if version, err := a.catalog.Version(r.Context()); err == nil {
|
||||||
|
if tag := etag(scopeMetrics, version); notModified(cond, tag) {
|
||||||
|
writeNotModified(w, tag)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
snap, err := a.catalog.Metrics(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Исход операции логирует доменный слой, транспорт только переводит его
|
// Исход операции логирует доменный слой, транспорт только переводит его
|
||||||
// в ответ. Наружу уходит человекочитаемое сообщение, а не текст ошибки:
|
// в ответ. Наружу уходит человекочитаемое сообщение, а не текст ошибки:
|
||||||
@@ -30,5 +58,9 @@ func (a *api) handleMetrics(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusInternalServerError, "каталог не собрался")
|
writeError(w, http.StatusInternalServerError, "каталог не собрался")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, catalogResponse{Metrics: metrics})
|
// Версии может не быть — витрина изменилась, пока ответ собирался. Тогда
|
||||||
|
// ответ уходит без метки: это ровно поведение до появления условного
|
||||||
|
// запроса, то есть деградация в безопасную сторону.
|
||||||
|
setReadHeaders(w, etag(scopeMetrics, snap.Version))
|
||||||
|
writeJSON(w, http.StatusOK, catalogResponse{Metrics: snap.Metrics})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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("условный ответ собрал каталог: снимок открыт зря")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -294,6 +295,16 @@ func newAPI(t *testing.T, writeTokens []string) (http.Handler, *store.Store) {
|
|||||||
// переписывать два десятка вызовов ради одного параметра.
|
// переписывать два десятка вызовов ради одного параметра.
|
||||||
func newAPITokens(t *testing.T, writeTokens, readTokens []string) (http.Handler, *store.Store, *catalog.Service) {
|
func newAPITokens(t *testing.T, writeTokens, readTokens []string) (http.Handler, *store.Store, *catalog.Service) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
h, st, cat, _ := newAPILogged(t, writeTokens, readTokens)
|
||||||
|
return h, st, cat
|
||||||
|
}
|
||||||
|
|
||||||
|
// newAPILogged отдаёт ещё и записи лога. Нужен там, где лог служит ОРАКУЛОМ, а
|
||||||
|
// не наблюдением: единственный внешний признак того, что каталог собирался, —
|
||||||
|
// его предупреждения владельцу.
|
||||||
|
func newAPILogged(t *testing.T, writeTokens, readTokens []string) (http.Handler, *store.Store, *catalog.Service, *records) {
|
||||||
|
t.Helper()
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
|
|
||||||
st, err := store.Open(filepath.Join(dir, "healthlog.db"))
|
st, err := store.Open(filepath.Join(dir, "healthlog.db"))
|
||||||
@@ -307,7 +318,8 @@ func newAPITokens(t *testing.T, writeTokens, readTokens []string) (http.Handler,
|
|||||||
t.Fatalf("archive.New: %v", err)
|
t.Fatalf("archive.New: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
log := slog.New(slog.DiscardHandler)
|
seen := &records{}
|
||||||
|
log := slog.New(seen)
|
||||||
cat := catalog.New(st, log)
|
cat := catalog.New(st, log)
|
||||||
h := httpapi.New(httpapi.Options{
|
h := httpapi.New(httpapi.Options{
|
||||||
Ingest: ingest.New(arch, st, nil, log),
|
Ingest: ingest.New(arch, st, nil, log),
|
||||||
@@ -320,5 +332,41 @@ func newAPITokens(t *testing.T, writeTokens, readTokens []string) (http.Handler,
|
|||||||
// и это ровно тот транспорт, на котором приём обязан продолжать работать.
|
// и это ровно тот транспорт, на котором приём обязан продолжать работать.
|
||||||
IngestWriteBudget: time.Minute,
|
IngestWriteBudget: time.Minute,
|
||||||
})
|
})
|
||||||
return h, st, cat
|
return h, st, cat, seen
|
||||||
|
}
|
||||||
|
|
||||||
|
// records — slog.Handler, копящий сообщения. Значений атрибутов не хранит:
|
||||||
|
// проверяется факт записи, а данные о здоровье в тесты тащить незачем.
|
||||||
|
type records struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
msg []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *records) Enabled(context.Context, slog.Level) bool { return true }
|
||||||
|
|
||||||
|
func (r *records) Handle(_ context.Context, rec slog.Record) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.msg = append(r.msg, rec.Message)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *records) WithAttrs([]slog.Attr) slog.Handler { return r }
|
||||||
|
func (r *records) WithGroup(string) slog.Handler { return r }
|
||||||
|
|
||||||
|
func (r *records) has(msg string) bool {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
for _, m := range r.msg {
|
||||||
|
if m == msg {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *records) reset() {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.msg = nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ func measureStyles(t *testing.T, st *store.Store) {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
started := time.Now()
|
started := time.Now()
|
||||||
metrics, err := catalog.New(st, slog.New(slog.DiscardHandler)).Metrics(context.Background())
|
metrics, err := metricsOf(context.Background(), catalog.New(st, slog.New(slog.DiscardHandler)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("каталог: %v", err)
|
t.Fatalf("каталог: %v", err)
|
||||||
}
|
}
|
||||||
@@ -291,3 +291,10 @@ func gunzip(t *testing.T, body []byte) []byte {
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// metricsOf — список метрик каталога без версии витрины: версию проверяют
|
||||||
|
// отдельные тесты, остальным нужен только состав ответа.
|
||||||
|
func metricsOf(ctx context.Context, s *catalog.Service) ([]catalog.Metric, error) {
|
||||||
|
snap, err := s.Metrics(ctx)
|
||||||
|
return snap.Metrics, err
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,3 +45,12 @@ func Transient(err error) bool {
|
|||||||
}
|
}
|
||||||
return errors.Is(err, context.Canceled) || errors.Is(err, ErrBusy)
|
return errors.Is(err, context.Canceled) || errors.Is(err, ErrBusy)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ErrClosed — хранилище закрыто, и заводить новые соединения к нему поздно.
|
||||||
|
//
|
||||||
|
// Отдельная ошибка, а не общая: запрос версии витрины и остановка сервиса идут
|
||||||
|
// в разных горутинах, и запрос, успевший в это окно, обязан получить отказ, а
|
||||||
|
// не открыть базу заново. Соединение, открытое после закрытия пула, оставило бы
|
||||||
|
// последним себя — SQLite не сделал бы финальный чекпойнт, и рядом с базой
|
||||||
|
// остался бы неразобранный `-wal`.
|
||||||
|
var ErrClosed = errors.New("хранилище закрыто")
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ var migrationsFS embed.FS
|
|||||||
// Store — доступ к витрине.
|
// Store — доступ к витрине.
|
||||||
type Store struct {
|
type Store struct {
|
||||||
db *sqlx.DB
|
db *sqlx.DB
|
||||||
|
// probe — закреплённое соединение версии витрины, заводится по первому
|
||||||
|
// запросу версии. См. version.go: значение `data_version` локально для
|
||||||
|
// соединения, и брать его из пула нельзя.
|
||||||
|
probe versionProbe
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open открывает БД по пути, сверяет версию схемы и накатывает миграции.
|
// Open открывает БД по пути, сверяет версию схемы и накатывает миграции.
|
||||||
@@ -152,12 +156,32 @@ func readSchemaVersion(ctx context.Context, db *sqlx.DB) (inDB, inBinary int64,
|
|||||||
|
|
||||||
// Close закрывает соединение с БД.
|
// Close закрывает соединение с БД.
|
||||||
func (s *Store) Close() error {
|
func (s *Store) Close() error {
|
||||||
|
// Щуп версии закрывается сам и раньше пула: закреплённое соединение
|
||||||
|
// переживает `db.Close()` и продолжает отвечать на запросы — проверено.
|
||||||
|
s.closeProbe()
|
||||||
if err := s.db.Close(); err != nil {
|
if err := s.db.Close(); err != nil {
|
||||||
return fmt.Errorf("close sqlite: %w", err)
|
return fmt.Errorf("close sqlite: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `journal_size_limit` держит верхнюю границу файла журнала. Чекпойнт
|
||||||
|
// возвращает страницы в базу, но файл оставляет на пике: измерено — 51 МБ до и
|
||||||
|
// после успешного переноса 12502 страниц. С лимитом первая же следующая запись
|
||||||
|
// усекает файл до предела.
|
||||||
|
//
|
||||||
|
// Величина взята из чужой практики (гайды по SQLite в проде ставят 26–64 МБ) и
|
||||||
|
// собственного измерения: суточный поток даёт около 23 МБ архива, то есть
|
||||||
|
// журнал такого размера означает не всплеск, а удерживаемый снимок. С пределом
|
||||||
|
// тела приёма она НЕ связана, хотя и совпадает по порядку: журнал растёт от
|
||||||
|
// чтения, а не от размера доставки, и менять её вслед за `ingest.max_body_mb`
|
||||||
|
// незачем.
|
||||||
|
const journalSizeLimit = 64 << 20
|
||||||
|
|
||||||
|
// JournalSizeLimitMB — тот же предел для строки старта: владелец обязан видеть
|
||||||
|
// в логе, с какими параметрами обслуживается журнал, а число живёт здесь.
|
||||||
|
const JournalSizeLimitMB = journalSizeLimit >> 20
|
||||||
|
|
||||||
// dsn собирает строку подключения: WAL для параллельного чтения во время
|
// dsn собирает строку подключения: WAL для параллельного чтения во время
|
||||||
// записи, busy_timeout — чтобы конкурентная запись ждала, а не падала.
|
// записи, busy_timeout — чтобы конкурентная запись ждала, а не падала.
|
||||||
//
|
//
|
||||||
@@ -166,11 +190,14 @@ func (s *Store) Close() error {
|
|||||||
// запись после уже прочитанного снимка даёт SQLITE_BUSY_SNAPSHOT, которого
|
// запись после уже прочитанного снимка даёт SQLITE_BUSY_SNAPSHOT, которого
|
||||||
// busy_timeout не покрывает. Измерено на восьми писателях: 242 успешных
|
// busy_timeout не покрывает. Измерено на восьми писателях: 242 успешных
|
||||||
// слияния из 800 против 800 из 800.
|
// слияния из 800 против 800 из 800.
|
||||||
|
//
|
||||||
|
// `journal_size_limit` — см. константу выше.
|
||||||
func dsn(path string) string {
|
func dsn(path string) string {
|
||||||
q := url.Values{}
|
q := url.Values{}
|
||||||
q.Add("_pragma", "journal_mode(WAL)")
|
q.Add("_pragma", "journal_mode(WAL)")
|
||||||
q.Add("_pragma", "busy_timeout(5000)")
|
q.Add("_pragma", "busy_timeout(5000)")
|
||||||
q.Add("_pragma", "foreign_keys(on)")
|
q.Add("_pragma", "foreign_keys(on)")
|
||||||
|
q.Add("_pragma", fmt.Sprintf("journal_size_limit(%d)", journalSizeLimit))
|
||||||
q.Add("_txlock", "immediate")
|
q.Add("_txlock", "immediate")
|
||||||
return "file:" + path + "?" + q.Encode()
|
return "file:" + path + "?" + q.Encode()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
package store_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite" // второе подключение к тому же файлу мимо store
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/healthlog/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func checkpoint(t *testing.T, st *store.Store) store.Checkpoint {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ck, err := st.CheckpointWAL(t.Context())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("чекпойнт: %v", err)
|
||||||
|
}
|
||||||
|
return ck
|
||||||
|
}
|
||||||
|
|
||||||
|
// Штатный случай: писателей нет, читателей нет — журнал разбирается целиком.
|
||||||
|
// Это и есть работа, которой автоматический чекпойнт не делает: он срабатывает
|
||||||
|
// по концу записи, а ночью телефон молчит часами.
|
||||||
|
func TestЧекпойнтРазбираетЖурналВТишине(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
st := open(t)
|
||||||
|
base := ts(t, "2026-06-01T00:00:00Z")
|
||||||
|
for i := range 20 {
|
||||||
|
writePoint(t, st, "step_count", base.Add(time.Duration(i)*time.Minute))
|
||||||
|
}
|
||||||
|
|
||||||
|
ck := checkpoint(t, st)
|
||||||
|
if !ck.Complete() {
|
||||||
|
t.Errorf("журнал разобран не целиком: log=%d checkpointed=%d", ck.Log, ck.Checkpointed)
|
||||||
|
}
|
||||||
|
if ck.Stuck() {
|
||||||
|
t.Errorf("разобранный журнал объявлен застрявшим: %+v", ck)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Щуп версии — единственное долгоживущее соединение процесса, и он ходит в базу
|
||||||
|
// дважды на каждый читающий запрос. Останься за ним открытая читающая
|
||||||
|
// транзакция — пассивный чекпойнт перестал бы продвигаться навсегда, и вторая
|
||||||
|
// половина задачи убила бы первую при полностью исправном обслуживании.
|
||||||
|
func TestЩупНеУдерживаетЧитающийСнимок(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
st := open(t)
|
||||||
|
base := ts(t, "2026-06-01T00:00:00Z")
|
||||||
|
for i := range 10 {
|
||||||
|
if _, err := st.StateVersion(t.Context()); err != nil {
|
||||||
|
t.Fatalf("версия витрины: %v", err)
|
||||||
|
}
|
||||||
|
writePoint(t, st, "step_count", base.Add(time.Duration(i)*time.Minute))
|
||||||
|
}
|
||||||
|
|
||||||
|
ck := checkpoint(t, st)
|
||||||
|
if !ck.Complete() {
|
||||||
|
t.Errorf("щуп удерживает снимок: log=%d checkpointed=%d", ck.Log, ck.Checkpointed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удерживаемый читатель — ровно тот случай, ради которого признак и заведён:
|
||||||
|
// пассивный чекпойнт не идёт дальше его снимка и ОШИБКИ ПРИ ЭТОМ НЕ ВОЗВРАЩАЕТ.
|
||||||
|
// Проверяем, что признаком служит расхождение чисел, а не флаг занятости.
|
||||||
|
func TestЧекпойнтПодЧитателемНеПродвигаетсяБезОшибки(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "healthlog.db")
|
||||||
|
st, err := store.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("открытие базы: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = st.Close() })
|
||||||
|
|
||||||
|
base := ts(t, "2026-06-01T00:00:00Z")
|
||||||
|
writePoint(t, st, "step_count", base)
|
||||||
|
|
||||||
|
// Читающая транзакция мимо store: она моделирует не наш код, а любого
|
||||||
|
// читателя, задержавшегося на снимке, — включая забытый `rows.Close()`.
|
||||||
|
db, err := sql.Open("sqlite", "file:"+path+"?_pragma=busy_timeout(5000)")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("открытие второго подключения: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
|
||||||
|
tx, err := db.BeginTx(t.Context(), &sql.TxOptions{ReadOnly: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("читающая транзакция: %v", err)
|
||||||
|
}
|
||||||
|
var n int
|
||||||
|
if err := tx.QueryRowContext(t.Context(), "SELECT count(*) FROM bucket").Scan(&n); err != nil {
|
||||||
|
t.Fatalf("чтение снимка: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 1; i < 40; i++ {
|
||||||
|
writePoint(t, st, "step_count", base.Add(time.Duration(i)*time.Minute))
|
||||||
|
}
|
||||||
|
|
||||||
|
ck := checkpoint(t, st)
|
||||||
|
_ = tx.Rollback()
|
||||||
|
|
||||||
|
if ck.Busy {
|
||||||
|
t.Errorf("флаг занятости взведён — признак строится не на нём: %+v", ck)
|
||||||
|
}
|
||||||
|
if ck.Complete() {
|
||||||
|
// Не Skip: на платформе, разбирающей журнал под удерживаемым читателем,
|
||||||
|
// ломается предпосылка всей задачи, а пропущенный тест выглядит зелёным
|
||||||
|
// — и вместе с ним молча исчезает единственная защита режима PASSIVE.
|
||||||
|
t.Fatalf("журнал разобран под удерживаемым читателем — предпосылка задачи не воспроизводится: %+v", ck)
|
||||||
|
}
|
||||||
|
if ck.Log <= ck.Checkpointed {
|
||||||
|
t.Errorf("перенесено не меньше, чем лежит: %+v", ck)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Порог молчит на журнале обычного размера: иначе `WARN` шёл бы каждую минуту
|
||||||
|
// на здоровом сервисе, и уровень, по которому вмешиваются, перестал бы значить
|
||||||
|
// что-либо.
|
||||||
|
func TestНебольшойНеразобранныйЖурналНеЗастрял(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ck := store.Checkpoint{Log: 10, Checkpointed: 0, PageSize: 4096}
|
||||||
|
if ck.Stuck() {
|
||||||
|
t.Errorf("десять неразобранных страниц объявлены бедой: %+v", ck)
|
||||||
|
}
|
||||||
|
if ck.Complete() {
|
||||||
|
t.Errorf("неразобранный журнал объявлен разобранным: %+v", ck)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Занятый чекпойнт отдаёт `busy=1` и `-1` вместо чисел: исход НЕ ИЗМЕРЕН.
|
||||||
|
// Внутриполосный `-1` опасен буквально — `-1 >= -1` истинно, то есть
|
||||||
|
// незамеренный тик читался бы как «журнал разобран целиком», и владельцу ушла
|
||||||
|
// бы строка о выздоровлении посреди болезни.
|
||||||
|
func TestЗанятыйЧекпойнтНеИзмерен(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ck := store.Checkpoint{Busy: true, Log: -1, Checkpointed: -1, PageSize: 4096}
|
||||||
|
if ck.Known() {
|
||||||
|
t.Errorf("занятый чекпойнт объявлен измеренным: %+v", ck)
|
||||||
|
}
|
||||||
|
if ck.Complete() {
|
||||||
|
t.Errorf("незамеренный исход объявлен разобранным журналом: %+v", ck)
|
||||||
|
}
|
||||||
|
if ck.Stuck() {
|
||||||
|
t.Errorf("незамеренный исход объявлен бедой: %+v", ck)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Журнала нет вовсе — SQLite отвечает теми же `-1`. Исход тот же: молчим.
|
||||||
|
func TestОтсутствующийЖурналНеИзмерен(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ck := store.Checkpoint{Log: -1, Checkpointed: -1, PageSize: 4096}
|
||||||
|
if ck.Known() || ck.Complete() || ck.Stuck() {
|
||||||
|
t.Errorf("исход без журнала прочитан как измеренный: %+v", ck)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Размер страницы — свойство файла, и без него порог не выразить. Неизвестен —
|
||||||
|
// признак молчит: сместившийся в разы порог хуже, чем его отсутствие.
|
||||||
|
func TestБезРазмераСтраницыПризнакМолчит(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ck := store.Checkpoint{Log: 1 << 20, Checkpointed: 0}
|
||||||
|
if ck.Stuck() {
|
||||||
|
t.Errorf("порог сработал при неизвестном размере страницы: %+v", ck)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Размер страницы приходит из базы, а не предполагается кодом.
|
||||||
|
func TestЧекпойнтНазываетРазмерСтраницы(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
if got := checkpoint(t, open(t)).PageSize; got <= 0 {
|
||||||
|
t.Errorf("размер страницы %d — порог выразить нечем", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отмена контекста не должна превращаться в отказ обслуживания: цикл проверяет
|
||||||
|
// её сам, а вызов обязан вернуть ошибку, а не молчаливый нулевой исход.
|
||||||
|
func TestЧекпойнтНаОтменённомКонтексте(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
st := open(t)
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
if _, err := st.CheckpointWAL(ctx); err == nil {
|
||||||
|
t.Error("чекпойнт на отменённом контексте прошёл успешно")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-08-02
|
||||||
@@ -0,0 +1,443 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
Каталог (`GET /api/v1/metrics`) — первый и пока единственный читающий маршрут.
|
||||||
|
Он живёт в одном процессе с приёмом и фоновой свёрткой, и три измерения ревью
|
||||||
|
показали, что цена чтения ложится на приём:
|
||||||
|
|
||||||
|
- снимок каталога держит разжатые точки окна по всем метрикам сразу: 693 мс и
|
||||||
|
+153 МиБ живой кучи на враждебном запросе (20 метрик × 8 часов × 5000 точек);
|
||||||
|
- непрерывная запись плюс четыре читающих транзакции внахлёст дают рост `-wal`
|
||||||
|
около 7 МБ/с без верхней границы, тогда как тот же писатель без читателей
|
||||||
|
стабилизируется на 4 МБ;
|
||||||
|
- самый частый запрос трёх потребителей (агент-медик, трекер, игра) — повтор
|
||||||
|
неизменившегося, а условного запроса нет.
|
||||||
|
|
||||||
|
Решение по задаче принято до её начала: **вариант (г) + вариант (в)**. Предел
|
||||||
|
размера ответа и собственный дедлайн маршрута (вариант «а») отложены до Read
|
||||||
|
API точек, где предел всё равно проектируется; потоковое измерение по метрике
|
||||||
|
(«б») не берётся, пока счётчик не заговорит; кеш ответа («д») — последним.
|
||||||
|
|
||||||
|
Всё, что ниже, измерено на стенде этой машины тем же драйвером
|
||||||
|
(`modernc.org/sqlite`) и с тем же набором PRAGMA, что у сервиса. Числа приведены
|
||||||
|
там, где от них зависит решение.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
|
||||||
|
- WAL перестаёт расти без верхней границы; когда он всё же растёт, это видно
|
||||||
|
владельцу строкой лога, а не только `df`.
|
||||||
|
- Повторный запрос неизменившегося каталога не открывает снимок витрины вовсе.
|
||||||
|
- `ETag` честен: **равная метка ⟹ тот же ответ**. Ответ есть функция снимка и
|
||||||
|
горизонта измерения, и в метку входят оба (решения 7а и 7в).
|
||||||
|
- Машинерия одна на все читающие маршруты: точки и MCP берут её готовой.
|
||||||
|
- Горутина чекпойнта дренируется осознанно, как воркер свёртки.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
|
||||||
|
- Предел числа метрик и точек в ответе, пагинация, собственный дедлайн
|
||||||
|
маршрута — это Read API точек.
|
||||||
|
- Потоковое измерение рода по метрике: меняет форму границы `store`/`catalog`
|
||||||
|
ради случая, которого живой поток не производит.
|
||||||
|
- Кеш ответа: третье представление того же факта и новое место, где можно
|
||||||
|
ошибиться молча.
|
||||||
|
- Конфигурируемость периода чекпойнта и порогов: ни одного основания выбирать
|
||||||
|
их снаружи сегодня нет.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### 1. Чекпойнт по таймеру нужен не вместо автоматического, а рядом с ним
|
||||||
|
|
||||||
|
`wal_autocheckpoint` включён по умолчанию (измерено: 1000 страниц) и запускает
|
||||||
|
пассивный чекпойнт **по концу записи**. Отсюда дыра: всплеск, раздувший WAL,
|
||||||
|
оставляет его неразобранным до следующей записи, а поток пачечный по природе —
|
||||||
|
ночью телефон молчит часами. Таймер закрывает ровно этот случай: страницы
|
||||||
|
возвращаются в базу вскоре после того, как читатели ушли, а не при следующей
|
||||||
|
доставке.
|
||||||
|
|
||||||
|
Отвергнуто **«полагаться на автоматический чекпойнт»**: он не срабатывает без
|
||||||
|
записи, то есть именно в том состоянии, ради которого таймер и заводится.
|
||||||
|
|
||||||
|
Отвергнут **`TRUNCATE`** — и по измеренной причине, а не по осторожности: он
|
||||||
|
двигает `data_version` (3 → 4 на пустом ходу), то есть каждый тик обнулял бы
|
||||||
|
`ETag` у всех потребителей. `PASSIVE` не двигает его даже при переносе 12502
|
||||||
|
страниц (3 → 3). Это же измерение объясняет, почему две части задачи вообще
|
||||||
|
уживаются в одном процессе.
|
||||||
|
|
||||||
|
**Период — минута**, тот же, что у тика воркера свёртки и у чекпойнта
|
||||||
|
Litestream. Он ничего не решает в
|
||||||
|
момент всплеска (пока читатели держат снимок, чекпойнт бессилен по построению),
|
||||||
|
и решает всё в тишине: минута против пяти неразличима по эффекту, но делает
|
||||||
|
признак «журнал не разбирается» своевременным. Холостой чекпойнт стоит одного
|
||||||
|
запроса на пустом журнале.
|
||||||
|
|
||||||
|
### 2. Признак — не `busy`, а «перенесено меньше, чем лежит»
|
||||||
|
|
||||||
|
Пассивный чекпойнт не идёт дальше снимка самого старого активного читателя, и
|
||||||
|
**ошибки при этом нет**. Измерено под удерживаемым читателем: `busy=0`,
|
||||||
|
`log=6256`, `checkpointed=5` — то есть флаг занятости молчит, а журнал растёт.
|
||||||
|
Значит признак строится на паре чисел: страниц в журнале больше порога **и**
|
||||||
|
перенесено меньше, чем лежало.
|
||||||
|
|
||||||
|
Порог назван в байтах (64 МиБ) и переводится в страницы размером страницы самой
|
||||||
|
базы — он свойство файла, и чужое умолчание сместило бы признак в разы.
|
||||||
|
Величина взята из чужой практики (гайды по SQLite в проде ставят 26–64 МБ) и
|
||||||
|
собственного измерения: суточный поток даёт около 23 МБ архива, то есть журнал
|
||||||
|
такого размера означает не всплеск, а удерживаемый снимок. С пределом тела
|
||||||
|
приёма связи нет, хотя порядок и совпадает: журнал растёт от чтения, а не от
|
||||||
|
размера доставки.
|
||||||
|
|
||||||
|
`WARN`, а не `ERROR`: адресат — владелец, событие «может стать проблемой»
|
||||||
|
(диск), лечится оно не кодом.
|
||||||
|
|
||||||
|
**Частота строки решается отдельно от порога.** Признак заведён ради состояния,
|
||||||
|
которое само не проходит: вечный читатель (в Go чаще всего — незакрытый
|
||||||
|
`sql.Rows`) держит снимок до конца жизни процесса. Строка на каждый тик дала бы
|
||||||
|
1440 одинаковых записей в сутки — фон, а не сигнал. Поэтому строка пишется при
|
||||||
|
входе в состояние и повторяется, только когда журнал вырос вдвое; возврат к
|
||||||
|
норме — отдельная строка `INFO`, потому что тишина иначе неотличима от «сервис
|
||||||
|
перестал проверять».
|
||||||
|
|
||||||
|
### 3. `journal_size_limit` — потому что пассивный чекпойнт файл не укорачивает
|
||||||
|
|
||||||
|
Измерено: после успешного чекпойнта (`log=12502`, `checkpointed=12502`) файл
|
||||||
|
остаётся 51 МБ — страницы переиспользуются, но диск не возвращается. С
|
||||||
|
`journal_size_limit=64 МиБ` первая же запись после чекпойнта усекает файл до
|
||||||
|
предела (проверено: 51 МБ → 8 МиБ при лимите 8 МиБ), и `data_version` при этом
|
||||||
|
двигает сама запись, а не усечение.
|
||||||
|
|
||||||
|
Это не второй механизм для одной цели: чекпойнт возвращает **страницы**, лимит
|
||||||
|
возвращает **файл**. Без первого второй никогда не срабатывает, без второго
|
||||||
|
пик, случившийся однажды, остаётся на диске навсегда.
|
||||||
|
|
||||||
|
**Предела РОСТА это не даёт, и говорить иначе нельзя.** Измерено там же: под
|
||||||
|
удерживаемым читателем файл вырос до 51 МБ при лимите 8 МиБ — лимит действует
|
||||||
|
только после полного чекпойнта. То есть ровно в сценарии задачи
|
||||||
|
(перекрывающиеся читатели, 7 МБ/с) верхней границы у диска по-прежнему нет, и
|
||||||
|
единственный исход — `WARN` владельцу. Аварийный клапан, который на это ставит
|
||||||
|
Litestream (блокирующий `TRUNCATE` по порогу размера), не берётся по измеренной
|
||||||
|
причине: он двигает `data_version` и ждёт читателей. Понадобится — станет
|
||||||
|
отдельной задачей, и её ценой будет обнуление меток.
|
||||||
|
|
||||||
|
Порог `WARN` и лимит файла — **одно число**: 64 МиБ, выраженное там в
|
||||||
|
страницах. Двумя константами они разъехались бы молча, сделав признак либо
|
||||||
|
недостижимым, либо шумным.
|
||||||
|
|
||||||
|
### 4. `data_version` сравним только в пределах одного соединения — отсюда щуп
|
||||||
|
|
||||||
|
Два измерения, каждое из которых убивает наивную реализацию:
|
||||||
|
|
||||||
|
- **значения разных соединений одного пула несравнимы**: на одном и том же
|
||||||
|
состоянии базы `c1=5`, `c2=3`, а любое свежее соединение отвечает `2`
|
||||||
|
независимо от содержимого базы;
|
||||||
|
- **своя запись значение не двигает**, чужая двигает.
|
||||||
|
|
||||||
|
Отсюда следствие, которое и есть главный риск варианта (в): если брать
|
||||||
|
`data_version` из пула, два запроса на разных соединениях дают разные метки на
|
||||||
|
неизменившейся витрине (ложная инвалидация — не страшно), но и **одинаковые
|
||||||
|
метки на разных состояниях** (свежие соединения всегда отвечают `2` — вот это
|
||||||
|
уже выдача устаревшего под видом свежего).
|
||||||
|
|
||||||
|
Поэтому версия читается с **одного закреплённого соединения-щупа**
|
||||||
|
(`sql.Conn`), которое ничего больше не делает и потому никогда не двигает
|
||||||
|
собственную версию. Щуп берётся по первому запросу версии; если он становится
|
||||||
|
непригодным, он пересоздаётся, и вместе с ним меняется **поколение**.
|
||||||
|
|
||||||
|
**Что считать смертью щупа — измерено, а не предположено.** Первая редакция
|
||||||
|
этого дизайна называла причиной отмену контекста; проверка на драйвере проекта
|
||||||
|
показала обратное: запрос, оборванный отменой, возвращает `context.Canceled`, а
|
||||||
|
следующий запрос на том же соединении проходит. Непригодным `sql.Conn`
|
||||||
|
становится только после закрытия (`sql.ErrConnDone`). Различие не
|
||||||
|
академическое: считай система смертью щупа любую ошибку — каждый клиент,
|
||||||
|
оборвавший запрос по своему тайм-ауту, менял бы поколение, и все три
|
||||||
|
потребителя получали бы полный ответ вместо `304`. Механизм схлопывался бы ровно
|
||||||
|
под нагрузкой, ради которой заведён.
|
||||||
|
|
||||||
|
**Инвариант щупа записан одной строкой:** на нём выполняется ровно один вид
|
||||||
|
запроса и только через `QueryRowContext(...).Scan(...)`; `QueryContext` и
|
||||||
|
`BeginTx` не зовутся никогда. Незакрытые `Rows` на единственном долгоживущем
|
||||||
|
соединении процесса удержали бы читающий снимок навсегда — чекпойнт перестал бы
|
||||||
|
продвигаться, и метка убила бы обслуживание при полностью исправном
|
||||||
|
обслуживании. Проверяется это оракулом: после серии снятий версии пассивный
|
||||||
|
чекпойнт обязан перенести журнал целиком.
|
||||||
|
|
||||||
|
**Закрытие щупа входит в контракт, а не в реализацию.** Измерено: закреплённое
|
||||||
|
соединение переживает `db.Close()` и продолжает отвечать. Значит без явного
|
||||||
|
закрытия последнего соединения к базе не наступает вовсе — SQLite не делает
|
||||||
|
финальный чекпойнт, рядом с базой остаётся `-wal`, и пересборка, переносящая
|
||||||
|
один файл базы, теряет хвост записей молча. Отсюда же флаг «закрыто»: запрос
|
||||||
|
версии, успевший в окно между закрытием щупа и закрытием пула, не имеет права
|
||||||
|
открыть соединение заново.
|
||||||
|
|
||||||
|
Отвергнут **`FileControlDataVersion`** из `modernc.org/sqlite` (обёртка над
|
||||||
|
`SQLITE_FCNTL_DATA_VERSION`): он отражает и коммиты собственного соединения,
|
||||||
|
то есть снимает требование «щуп ничего не пишет». Цена — доступ через
|
||||||
|
`(*sql.Conn).Raw` и приведение к интерфейсу драйвера в самом чувствительном
|
||||||
|
месте ради инварианта, который держится одним небольшим файлом и проверяется
|
||||||
|
тестом. Взято простое; если щуп когда-нибудь начнёт писать, замена — три
|
||||||
|
строки.
|
||||||
|
|
||||||
|
Отвергнут **счётчик изменений со страницы 1** (`SQLITE_DBPAGE`) — по названной
|
||||||
|
чужой причине: в режиме WAL он инкрементируется не на каждой транзакции, потому
|
||||||
|
что страница 1 в журнал не попадает, если в ней самой ничего не поменялось.
|
||||||
|
|
||||||
|
### 5. Метка — пара «поколение + счётчик»
|
||||||
|
|
||||||
|
Счётчик `data_version` не переживает переоткрытия: свежее соединение всегда
|
||||||
|
отвечает `2`. Значит после рестарта метка `2` означала бы совсем другое
|
||||||
|
состояние, чем метка `2` до него, и клиент со старым `ETag` получил бы `304` на
|
||||||
|
изменившиеся данные — единственный по-настоящему опасный исход всей задачи.
|
||||||
|
Поэтому метка это `"<поколение>-<счётчик>"`, где поколение — ULID, выданный при
|
||||||
|
получении соединения-щупа.
|
||||||
|
|
||||||
|
Побочная выгода названа вслух: поколение меняется и при выкатке новой версии
|
||||||
|
бинаря, то есть смена **формы** ответа при неизменившихся данных тоже
|
||||||
|
инвалидирует метку. Цена — один полный ответ каждому потребителю после
|
||||||
|
рестарта.
|
||||||
|
|
||||||
|
### 6. Метка снимается до и после сборки ответа
|
||||||
|
|
||||||
|
Версия щупа снимается **дважды**: до открытия снимка и после его закрытия.
|
||||||
|
Совпали — метка выставляется; разошлись — ответ уходит **без `ETag`**.
|
||||||
|
|
||||||
|
Причина ровно в обещании сильной метки. Если снять версию только до сборки, то
|
||||||
|
коммит, случившийся во время сборки, даёт ответ более свежий, чем его метка, —
|
||||||
|
и два ответа с одной меткой могут различаться байтами. Устаревания это не даёт
|
||||||
|
(следующий запрос увидит другую версию и получит `200`), но обещание «равная
|
||||||
|
метка ⟹ те же байты» перестаёт быть верным, а на нём держится весь смысл
|
||||||
|
`304`.
|
||||||
|
|
||||||
|
Снимать версию **после** сборки и выставлять её нельзя категорически: это
|
||||||
|
пометило бы старый снимок новой версией, то есть заперло бы клиента на
|
||||||
|
устаревшем ответе навсегда — ровно тот единственный исход, которого нельзя
|
||||||
|
допускать.
|
||||||
|
|
||||||
|
Цена ветки названа и измерена: под непрерывной свёрткой каталог перестаёт
|
||||||
|
отдавать `ETag` вовсе — при коммите раз в 60 мс и сборке каталога 97 мс
|
||||||
|
подписано 0 ответов из 15. То есть на время разбора задолженности (рестарт,
|
||||||
|
широкий проход) условный запрос выключается сам. Это деградация в безопасную
|
||||||
|
сторону — ровно сегодняшнее поведение, — и повтор сборки ради второй попытки не
|
||||||
|
берётся: он удваивает самое дорогое чтение ровно в тот момент, когда база
|
||||||
|
занята записью. На установившемся потоке цена мала: сборка 45 мс против
|
||||||
|
доставки раз в пять минут.
|
||||||
|
|
||||||
|
След у этого состояния есть, хотя и не для владельца: каталог пишет `DEBUG`,
|
||||||
|
когда ответ уходит неподписанным. Владельческий канал — `/stats`, и пункт про
|
||||||
|
долю подписанных ответов внесён в его задачу тем же изменением.
|
||||||
|
|
||||||
|
### 7. Снимок каталога версией не подписывается изнутри хранилища
|
||||||
|
|
||||||
|
Напрашивалось снимать версию внутри той же читающей транзакции, что и снимок:
|
||||||
|
там она равна версии снимка точно, без второй пробы. Отвергнуто ценой: это
|
||||||
|
требует, чтобы снимок каталога шёл по тому же закреплённому соединению, то есть
|
||||||
|
**все читающие запросы выстроились бы в очередь по одному**. Головная блокировка
|
||||||
|
на 693 мс у одного потребителя означала бы ожидание у двух других, и это уже
|
||||||
|
предел маршрута — то самое, что задача откладывает до Read API точек.
|
||||||
|
|
||||||
|
### 7а. Метка слабая (`W/`), и причина названа числом другого рода
|
||||||
|
|
||||||
|
Ответ каталога есть функция не только снимка, но и **горизонта измерения**
|
||||||
|
(`Now() + час`), а горизонт едет вместе с часами. На нормальных данных это
|
||||||
|
ничего не меняет: окно берёт самые свежие общие часы, и пока в витрине нет
|
||||||
|
меток из будущего, ход часов ответ не двигает. Но метки из будущего в витрине
|
||||||
|
возможны (сбитые часы телефона, чужое тело в приёме) — и тогда ответ меняется
|
||||||
|
без единого коммита.
|
||||||
|
|
||||||
|
Поэтому метка **слабая**: `W/"<поколение>-<счётчик>"`. Это же предписывает
|
||||||
|
общая практика для меток, построенных из состояния БД, а не из байтов ответа, и
|
||||||
|
`If-None-Match` сравнивает метки слабо в любом случае — на `304` форма не
|
||||||
|
влияет. Остаток был назван вслух — и оказался больше, чем звучал: враждебный проход
|
||||||
|
построил и прогнал путь, где та же версия витрины даёт `cumulative` против
|
||||||
|
`unknown` при нуле коммитов между. Слабая форма метки этого не лечит: смена
|
||||||
|
измеренного рода — изменение семантическое. Поэтому горизонт вошёл в метку
|
||||||
|
(решение 7в), и остаток закрыт, а не назван.
|
||||||
|
|
||||||
|
Побочное следствие, которое иначе было бы неверным: `WARN` о данных из будущего
|
||||||
|
пишется при сборке ответа, а `304` сборки не делает. С горизонтом в метке полный
|
||||||
|
ответ случается не реже раза в час на потребителя — значит и предупреждение
|
||||||
|
тоже. Без горизонта потребитель на условном опросе гасил бы его насовсем.
|
||||||
|
|
||||||
|
### 7в. Горизонт входит в метку, огрублённый до часа
|
||||||
|
|
||||||
|
Метка ответа каталога — `версия витрины . час горизонта`. Огрубление точное, а
|
||||||
|
не приблизительное: метки объектов лежат ровно на часах (проверено отдельно,
|
||||||
|
включая зоны с неполночасовым смещением), поэтому отбор `hour_utc <= горизонт`
|
||||||
|
меняется ровно при переходе горизонта через час. Цена — один полный ответ в час
|
||||||
|
на потребителя при неизменившейся витрине; сборка стоит 45 мс.
|
||||||
|
|
||||||
|
Отсюда же следует, что версию ответа спрашивает **домен, а не хранилище**:
|
||||||
|
`catalog.Service.Version` знает, что в ответ входит горизонт, а транспорт не
|
||||||
|
знает и знать не должен. Read API точек ответит на том же месте своей версией,
|
||||||
|
включающей канонизированную форму запроса.
|
||||||
|
|
||||||
|
### 7б. Читающий маршрут деградирует до полного ответа, а не до отказа
|
||||||
|
|
||||||
|
Версия не читается — ответ уходит `200` без метки. Это правило названо отдельно,
|
||||||
|
потому что естественная реализация даёт обратное: `DataVersion` возвращает
|
||||||
|
ошибку, транспорт переводит ошибку домена в `500`, и маршрут, работавший до
|
||||||
|
задачи, перестаёт работать из-за машинерии, вся ценность которой — экономия.
|
||||||
|
Отказ пробы поэтому глушится с комментарием: настоящий отказ базы всплывёт
|
||||||
|
сборкой каталога, идущей следом, и будет назван ею один раз.
|
||||||
|
|
||||||
|
### 8. Условный запрос — помощник транспорта, а не свойство каталога
|
||||||
|
|
||||||
|
`If-None-Match` разбирается и метка сравнивается в `httpapi` одним помощником:
|
||||||
|
точки и MCP получат его готовым. Сравнение слабое (`W/"x"` совпадает с `"x"`) —
|
||||||
|
так предписывает HTTP для `If-None-Match`; `*` совпадает с любой существующей
|
||||||
|
меткой.
|
||||||
|
|
||||||
|
Источник версии передаётся транспорту **функцией** (`store.StateVersion`) — той
|
||||||
|
же формой, что и `worker.Notify` у приёма: транспорт не получает доступа к
|
||||||
|
хранилищу целиком ради одного числа.
|
||||||
|
|
||||||
|
Три вещи, которые помощник обязан делать по HTTP и которые легко не сделать:
|
||||||
|
неразбираемое условие даёт `200`, а не `400` (клиент, приславший мусор, получает
|
||||||
|
данные); `*` совпадает с любой **существующей** меткой, а при её отсутствии
|
||||||
|
условие не выполнено; `304` уходит без представленческих заголовков — так же,
|
||||||
|
как их снимает `writeNotModified` в `net/http/fs.go`. Заголовок читается всеми
|
||||||
|
строками (`Header.Values`), а не первой: `If-None-Match` клиент вправе прислать
|
||||||
|
несколькими.
|
||||||
|
|
||||||
|
Ответы чтения помечаются `Cache-Control: private, no-cache`. До появления
|
||||||
|
валидатора эвристическое кеширование посредником было маловероятным; с меткой
|
||||||
|
ответ становится штатно кешируемым, а при выключенной проверке токенов в запросе
|
||||||
|
нет и `Authorization`, на который опирается запрет для разделяемых кешей.
|
||||||
|
|
||||||
|
**`HEAD` маршрут не обслуживает, и это оставлено как было.** Роутер регистрирует
|
||||||
|
только `GET`, так что `HEAD /api/v1/metrics` отвечает `405` — и отвечал им до
|
||||||
|
задачи. Самый дешёвый способ спросить «изменилось ли» у клиента при этом есть:
|
||||||
|
условный `GET`, который на совпавшей метке не собирает ответа вовсе. Заводить
|
||||||
|
`HEAD` вместе с условным запросом значило бы расширять контракт маршрута
|
||||||
|
мимоходом; вопрос принадлежит Read API точек, где маршрутов станет пять.
|
||||||
|
|
||||||
|
### 8а. Остановка: обе фоновые горутины ждутся вместе
|
||||||
|
|
||||||
|
Форма ожидания названа, потому что наивное добавление второго канала в
|
||||||
|
существующий `select` закрыло бы базу по выходу **любой** из двух горутин — а
|
||||||
|
закрытая из-под воркера база даёт `ERROR` по доставке, с которой всё в порядке.
|
||||||
|
Обе горутины идут в один `sync.WaitGroup`, канал закрывается после `Wait`, и
|
||||||
|
`select` против бюджета остановки остаётся один.
|
||||||
|
|
||||||
|
Цена названа: запись о превышении бюджета больше не обвиняет воркер свёртки
|
||||||
|
поимённо — ждут двоих, и назвать виновным одного из них было бы догадкой.
|
||||||
|
Практически это всё тот же воркер (чекпойнт выходит по отмене немедленно), но
|
||||||
|
лог не должен утверждать того, чего не проверял.
|
||||||
|
|
||||||
|
Чекпойнт при этом идёт на контексте цикла, а не на отвязанном, — в отличие от
|
||||||
|
свёртки. Причина в том, что терять ему нечего: перенос страниц идемпотентен,
|
||||||
|
исхода разбора он не пишет, а следующий старт возьмёт журнал с того же места.
|
||||||
|
Зато остановка не ждёт переноса полусотни мегабайт в бюджете, который делится с
|
||||||
|
приёмом и воркером. Прерывание по отмене отказом не считается и в лог не идёт —
|
||||||
|
иначе каждый `task restart` писал бы владельцу об отказе обслуживания.
|
||||||
|
|
||||||
|
### 9. Что где живёт
|
||||||
|
|
||||||
|
- `store.DataVersion(ctx)` — щуп, поколение, пересоздание. Хранилище владеет
|
||||||
|
соединениями, и знание про `data_version` принадлежит ему.
|
||||||
|
- `store.CheckpointWAL(ctx)` — один PRAGMA, тройка чисел наружу. Решение, что с
|
||||||
|
ними делать, принимает не хранилище.
|
||||||
|
- цикл чекпойнта — `cmd/healthlog`, рядом с запуском воркера: это забота
|
||||||
|
жизненного цикла процесса, а не хранилища, и остановка у него общая с
|
||||||
|
остальными горутинами. Асимметрия с воркером свёртки (тот живёт в
|
||||||
|
`internal/replay`) названа вслух: у воркера есть доменный исход, у чекпойнта —
|
||||||
|
только строка владельцу, а чтобы поселить цикл в `store`, пришлось бы внести
|
||||||
|
туда логгер, первый в пакете. Интерпретация чисел при этом осталась в
|
||||||
|
хранилище (`Checkpoint.Stuck`).
|
||||||
|
- `Store.VersionedRead` — двойная проба вокруг чтения. В хранилище, а не в
|
||||||
|
каталоге: правило «версией, снятой после чтения, не подписывать» обязано
|
||||||
|
существовать в одном экземпляре, потому что нарушить его можно ровно одним
|
||||||
|
способом, и точки с MCP заявлены потребителями той же машинерии.
|
||||||
|
- `catalog.Service.Metrics` возвращает снимок вместе с версией — то есть
|
||||||
|
каталог решает, чем подписан его ответ, но не как это делается.
|
||||||
|
|
||||||
|
Имена доменные, а не по PRAGMA: `StateVersion`, а не `DataVersion`. Метка это
|
||||||
|
пара «поколение + счётчик», область её сравнимости задаёт хранилище, и читатель,
|
||||||
|
знающий SQLite, не должен ждать от метода голого значения `data_version`.
|
||||||
|
|
||||||
|
### 10. Как это решают другие
|
||||||
|
|
||||||
|
Обе части задачи — общепринятая практика, и брались они готовыми.
|
||||||
|
|
||||||
|
**Чекпойнт.** Документация SQLite (`wal.html`, разделы 3.1, 3.2 и 6) называет
|
||||||
|
ровно наш случай: при перекрывающихся читателях, среди которых всегда есть
|
||||||
|
активный, «чекпойнты не смогут завершиться, и файл WAL будет расти без границы»;
|
||||||
|
режим `PASSIVE` «делает столько, сколько может, не мешая другим соединениям, и
|
||||||
|
может не дойти до конца»; чекпойнт «обычно не укорачивает файл, если не задан
|
||||||
|
`journal_size_limit`». Механика признака взята из `wal_checkpoint_v2`:
|
||||||
|
полнота — это `checkpointed == log`, а `busy` в пассивном режиме не значит
|
||||||
|
ничего, потому что обработчик занятости в нём не зовётся вовсе. Это же
|
||||||
|
объясняет измеренное `busy=0` при пяти перенесённых страницах из 6256.
|
||||||
|
|
||||||
|
**Litestream** ближе всех по форме: интервал чекпойнта — **минута**, режим
|
||||||
|
`PASSIVE`, а блокирующий `TRUNCATE` — аварийный клапан по порогу размера, а не
|
||||||
|
шаг расписания. Отсюда взят период. Не взят его же совет отключать
|
||||||
|
`wal_autocheckpoint`: он продиктован тем, что Litestream владеет чекпойнтами
|
||||||
|
целиком, а у нас автоматический чекпойнт — первая линия, таймер лишь страхует
|
||||||
|
тишину. Не взят подход **rqlite** (всегда `TRUNCATE`, ожидание читателя до
|
||||||
|
250 мс): он продиктован требованием нулевого WAL для снапшота Raft, которого у
|
||||||
|
нас нет. Прикладные гайды по SQLite в проде (Django/`dj-lite`, «SQLite in
|
||||||
|
production») из всего этого ставят одно — `journal_size_limit` порядка 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
|
||||||
|
|
||||||
|
Нет: развилки закрыты решением по задаче и измерениями выше.
|
||||||
@@ -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`, получает ровно то же, что и сегодня.
|
||||||
+139
@@ -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** предупреждение владельцу не пишется, потому что измерения не было
|
||||||
+279
@@ -0,0 +1,279 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Хранилище отдаёт версию витрины
|
||||||
|
|
||||||
|
Система SHALL отдавать **версию витрины** — метку, которая MUST меняться при
|
||||||
|
любом коммите в базу и MUST NOT меняться, пока коммитов не было. Метка
|
||||||
|
предназначена условному запросу читающих маршрутов: равные метки означают, что
|
||||||
|
между их снятием в базу никто ничего не записал.
|
||||||
|
|
||||||
|
Метка MUST быть парой «поколение + счётчик». Счётчик — `PRAGMA data_version`,
|
||||||
|
поколение — идентификатор, выданный тому соединению, с которого счётчик
|
||||||
|
читается. Монотонной метка не является и сравнению на «новее» не подлежит:
|
||||||
|
гарантируется только неравенство.
|
||||||
|
|
||||||
|
Обе части обязательны, и каждая закрывает измеренный отказ:
|
||||||
|
|
||||||
|
- **счётчик несравним между соединениями.** На одном и том же состоянии базы
|
||||||
|
два соединения одного пула отвечают разными числами, а любое свежее
|
||||||
|
соединение отвечает одним и тем же значением независимо от содержимого базы.
|
||||||
|
Поэтому счётчик MUST читаться с одного закреплённого соединения, которое
|
||||||
|
ничем другим не занято: собственная запись соединения его версию не двигает,
|
||||||
|
и щуп, участвующий в записи, молчал бы о собственных изменениях.
|
||||||
|
- **счётчик не переживает переоткрытия.** После рестарта он начинается заново,
|
||||||
|
поэтому одно и то же значение до и после означает разные состояния витрины.
|
||||||
|
Без поколения клиент со старой меткой получал бы «не изменилось» на
|
||||||
|
изменившиеся данные — единственный по-настоящему опасный исход условного
|
||||||
|
запроса.
|
||||||
|
|
||||||
|
Соединение-щуп MUST NOT удерживать открытую читающую транзакцию между снятиями
|
||||||
|
версии: каждое снятие завершается до возврата. Иначе щуп — единственное
|
||||||
|
долгоживущее соединение процесса — становится тем самым вечным читателем,
|
||||||
|
против которого заведён чекпойнт, и версия витрины отменяет обслуживание
|
||||||
|
журнала при полностью исправном обслуживании.
|
||||||
|
|
||||||
|
Поколение MUST меняться всякий раз, когда соединение-щуп создаётся заново.
|
||||||
|
Переиспользовать поколение MUST NOT: это ровно тот случай, ради которого оно
|
||||||
|
заведено.
|
||||||
|
|
||||||
|
**Непригодность щупа — узкий класс, а не любая ошибка.** Пересоздание
|
||||||
|
допускается только при отказе, означающем закрытое соединение; отмена запроса
|
||||||
|
клиентом, занятость базы и прочие обстоятельства (то, что проект уже отличает
|
||||||
|
предикатом «не сделано» против «не выходит») поколение менять MUST NOT.
|
||||||
|
Измерено: отмена контекста запроса щуп не убивает — следующий запрос на нём
|
||||||
|
проходит. Считай система смертью щупа любую ошибку, каждый оборванный клиентом
|
||||||
|
запрос обнулял бы метки всех потребителей, то есть механизм схлопывался бы под
|
||||||
|
той самой нагрузкой, ради которой заведён.
|
||||||
|
|
||||||
|
Закрытие хранилища MUST освобождать щуп раньше пула и MUST исключать его
|
||||||
|
пересоздание после закрытия. Закреплённое соединение переживает закрытие пула
|
||||||
|
(измерено), а SQLite делает финальный чекпойнт только при закрытии последнего
|
||||||
|
соединения: забытый щуп оставляет рядом с базой неразобранный `-wal`, и
|
||||||
|
пересборка, переносящая один файл базы, теряет хвост записей молча.
|
||||||
|
|
||||||
|
#### Scenario: Витрина не менялась
|
||||||
|
|
||||||
|
- **GIVEN** после первого запроса версии в базу никто не писал
|
||||||
|
- **WHEN** версия запрашивается второй раз
|
||||||
|
- **THEN** обе версии совпадают
|
||||||
|
|
||||||
|
#### Scenario: Свёртка записала объект
|
||||||
|
|
||||||
|
- **GIVEN** версия витрины снята
|
||||||
|
- **WHEN** фоновая свёртка закоммитила изменения в витрину
|
||||||
|
- **THEN** следующая снятая версия отличается от прежней
|
||||||
|
|
||||||
|
#### Scenario: База переоткрыта
|
||||||
|
|
||||||
|
- **GIVEN** версия витрины снята, база закрыта и открыта заново
|
||||||
|
- **WHEN** версия снимается снова на том же файле
|
||||||
|
- **THEN** она отличается от снятой до переоткрытия
|
||||||
|
|
||||||
|
#### Scenario: Соединение-щуп стало непригодным
|
||||||
|
|
||||||
|
- **GIVEN** соединение, с которого читается счётчик, закрыто
|
||||||
|
- **WHEN** версия запрашивается снова
|
||||||
|
- **THEN** запрос отвечает версией НОВОГО поколения, а не отказом
|
||||||
|
|
||||||
|
#### Scenario: Запрос версии оборван клиентом
|
||||||
|
|
||||||
|
- **GIVEN** запрос версии отменён контекстом
|
||||||
|
- **WHEN** версия запрашивается следующим запросом
|
||||||
|
- **THEN** поколение остаётся прежним
|
||||||
|
|
||||||
|
#### Scenario: Щуп не мешает разбирать журнал
|
||||||
|
|
||||||
|
- **GIVEN** версия снималась много раз подряд
|
||||||
|
- **WHEN** выполняется пассивный чекпойнт и других читателей нет
|
||||||
|
- **THEN** журнал перенесён целиком
|
||||||
|
|
||||||
|
#### Scenario: Хранилище закрыто
|
||||||
|
|
||||||
|
- **GIVEN** хранилище закрыто
|
||||||
|
- **WHEN** запрашивается версия витрины
|
||||||
|
- **THEN** запрос отказывает и нового соединения к базе не открывает, а рядом с
|
||||||
|
базой не остаётся файла журнала
|
||||||
|
|
||||||
|
### Requirement: Чтение подписывается версией только целиком
|
||||||
|
|
||||||
|
Система SHALL снимать версию витрины **до и после** чтения, которое ею
|
||||||
|
подписывается, и SHALL отдавать версию, только если обе пробы совпали. При
|
||||||
|
расхождении версии нет, и это не отказ: ответ уходит полным, просто без метки.
|
||||||
|
|
||||||
|
Версия, снятая ПОСЛЕ чтения, MUST NOT выставляться на его результате: она
|
||||||
|
пометила бы устаревший снимок свежим номером и заперла бы клиента на нём
|
||||||
|
навсегда — единственный по-настоящему опасный исход всей конструкции. Версия,
|
||||||
|
снятая только ДО, допускает два разных ответа под одной меткой.
|
||||||
|
|
||||||
|
Правило MUST существовать в одном экземпляре: Read API точек и MCP заявлены
|
||||||
|
потребителями той же машинерии, и вторая её реализация «по образцу»
|
||||||
|
отличалась бы от первой ровно на этот порядок — а тест первой этого не
|
||||||
|
увидел бы.
|
||||||
|
|
||||||
|
Отказ пробы версией не является и чтение не отменяет: маршрут деградирует до
|
||||||
|
полного ответа, а не до отказа.
|
||||||
|
|
||||||
|
#### Scenario: Витрина стояла всё время чтения
|
||||||
|
|
||||||
|
- **WHEN** чтение выполнено и обе пробы дали одну версию
|
||||||
|
- **THEN** версия отдана
|
||||||
|
|
||||||
|
#### Scenario: Витрина изменилась во время чтения
|
||||||
|
|
||||||
|
- **GIVEN** между пробами в базу закоммитили
|
||||||
|
- **THEN** версии нет, а результат чтения отдан целиком
|
||||||
|
|
||||||
|
#### Scenario: Само чтение отказало
|
||||||
|
|
||||||
|
- **WHEN** чтение вернуло ошибку
|
||||||
|
- **THEN** ошибка отдана вызывающему, а не подменена отсутствием версии
|
||||||
|
|
||||||
|
### Requirement: Журнал WAL разбирается по таймеру, и его непродвижение видно
|
||||||
|
|
||||||
|
Система SHALL выполнять `PRAGMA wal_checkpoint(PASSIVE)` **раз в минуту**, пока
|
||||||
|
сервис работает. Автоматический чекпойнт SQLite MUST NOT считаться достаточным:
|
||||||
|
он срабатывает по концу записи, а поток пачечный — журнал, раздутый всплеском,
|
||||||
|
иначе остаётся неразобранным до следующей доставки, и ночью это часы.
|
||||||
|
|
||||||
|
Режим MUST быть `PASSIVE`. `TRUNCATE` и `RESTART` применять MUST NOT: они
|
||||||
|
двигают счётчик версии витрины, то есть каждый тик обнулял бы условный запрос у
|
||||||
|
всех потребителей, а `TRUNCATE` вдобавок ждёт читателей.
|
||||||
|
|
||||||
|
Пассивный чекпойнт не идёт дальше снимка самого старого активного читателя и
|
||||||
|
**ошибки при этом не возвращает**: измерено `busy=0` при 6256 страницах в
|
||||||
|
журнале и 5 перенесённых. Поэтому система SHALL считать признаком беды пару
|
||||||
|
чисел — страниц в журнале больше **16384** (64 МиБ при странице в 4 КиБ) **и**
|
||||||
|
перенесено меньше, чем лежало, — и MUST сообщать об этом владельцу уровнем
|
||||||
|
`WARN`. Флаг занятости признаком «не продвинулись» служить MUST NOT: он молчит
|
||||||
|
ровно в измеренном случае удерживаемого читателя.
|
||||||
|
|
||||||
|
**Зато флаг занятости выражает другое, и это MUST читаться: исход не измерен.**
|
||||||
|
Не взяв блокировку чекпойнта, SQLite отдаёт `busy=1` и **`-1` вместо обоих
|
||||||
|
чисел** — измерено, 1492 таких тика из 5502 при писателе и чекпойнте в цикле.
|
||||||
|
Сравнивать `-1` на шкале страниц MUST NOT: `-1 >= -1` истинно, то есть
|
||||||
|
незамеренный тик читался бы как «журнал разобран целиком», владельцу уходила бы
|
||||||
|
строка о выздоровлении посреди болезни, а подавитель повторов сбрасывался бы —
|
||||||
|
и вместо задуманного молчания получалась бы пара строк в минуту. Незамеренный
|
||||||
|
исход MUST не менять ни объявленного состояния, ни накопленного о нём.
|
||||||
|
|
||||||
|
Размер страницы MUST браться у самой базы, а не предполагаться: он фиксируется
|
||||||
|
при создании файла, и база, созданная чужим инструментом, сместила бы порог в
|
||||||
|
разы. Неизвестен — признак молчит.
|
||||||
|
|
||||||
|
Порог MUST быть выражен через ту же величину, что и предел файла журнала: это
|
||||||
|
одно число в двух ролях (предел возвращает файл, порог сообщает, что вернуть
|
||||||
|
его не выходит), и двумя разошедшимися константами признак стал бы либо
|
||||||
|
недостижимым, либо шумным — молча.
|
||||||
|
|
||||||
|
**Строка о непродвижении не пишется на каждый тик.** Признак заведён ради
|
||||||
|
состояния, которое само не проходит (вечный читатель живёт до конца процесса), а
|
||||||
|
строка в минуту дала бы 1440 одинаковых записей в сутки. Система SHALL сообщать
|
||||||
|
о входе в состояние и повторять, только когда журнал заметно вырос; возврат к
|
||||||
|
норме MUST быть отдельным событием — молчание иначе неотличимо от «сервис
|
||||||
|
перестал проверять».
|
||||||
|
|
||||||
|
Отказ чекпойнта MUST NOT прекращать цикл и MUST быть виден записью лога:
|
||||||
|
обслуживание, умершее от временного отказа базы, молча перестало бы разбирать
|
||||||
|
журнал до конца жизни процесса. Отмена контекста отказом при этом не является.
|
||||||
|
|
||||||
|
Файл журнала MUST иметь названный предел (`journal_size_limit`, 64 МиБ).
|
||||||
|
Предел **роста этим не даётся, и это сказано вслух**: измерено — под
|
||||||
|
удерживаемым читателем файл вырос до 51 МБ при пределе 8 МиБ, и успешный
|
||||||
|
чекпойнт его не укоротил; усечение делает первая запись после полного
|
||||||
|
чекпойнта. Пока читатель держит снимок, журнал растёт, и единственный исход —
|
||||||
|
`WARN` владельцу.
|
||||||
|
|
||||||
|
#### Scenario: Журнал разбирается в тишине
|
||||||
|
|
||||||
|
- **GIVEN** доставок нет, а в журнале остались неразобранные страницы
|
||||||
|
- **WHEN** проходит период чекпойнта
|
||||||
|
- **THEN** страницы перенесены в базу без единой новой записи
|
||||||
|
|
||||||
|
#### Scenario: Читатель держит снимок
|
||||||
|
|
||||||
|
- **GIVEN** идёт запись, и читающая транзакция удерживает старый снимок
|
||||||
|
- **WHEN** выполняется пассивный чекпойнт
|
||||||
|
- **THEN** он завершается без ошибки, переносит меньше, чем лежит в журнале, и
|
||||||
|
флаг занятости остаётся снятым
|
||||||
|
|
||||||
|
#### Scenario: Чекпойнт не взял блокировку
|
||||||
|
|
||||||
|
- **GIVEN** о непродвижении журнала уже сказано
|
||||||
|
- **WHEN** очередной чекпойнт возвращает признак занятости и `-1` вместо чисел
|
||||||
|
- **THEN** ни строки о выздоровлении, ни строки о беде не пишется, а
|
||||||
|
накопленное состояние не меняется
|
||||||
|
|
||||||
|
#### Scenario: Журнал невелик
|
||||||
|
|
||||||
|
- **GIVEN** в журнале меньше страниц, чем названный порог
|
||||||
|
- **WHEN** чекпойнт не смог перенести всё
|
||||||
|
- **THEN** строка `WARN` не пишется
|
||||||
|
|
||||||
|
#### Scenario: Состояние держится
|
||||||
|
|
||||||
|
- **GIVEN** о непродвижении журнала уже сказано
|
||||||
|
- **WHEN** следующий чекпойнт застаёт журнал того же размера
|
||||||
|
- **THEN** строка не повторяется
|
||||||
|
|
||||||
|
#### Scenario: Журнал разобрался
|
||||||
|
|
||||||
|
- **GIVEN** о непродвижении журнала было сказано
|
||||||
|
- **WHEN** очередной чекпойнт переносит журнал ЦЕЛИКОМ
|
||||||
|
- **THEN** о возврате к норме сказано один раз
|
||||||
|
|
||||||
|
#### Scenario: Журнал стал мал, но не перенесён
|
||||||
|
|
||||||
|
- **GIVEN** о непродвижении журнала было сказано
|
||||||
|
- **WHEN** очередной чекпойнт переносит не всё, а журнал при этом ниже порога
|
||||||
|
- **THEN** о возврате к норме не сообщается: перенос — это то, что проверено, а
|
||||||
|
размер ниже порога — нет
|
||||||
|
|
||||||
|
#### Scenario: Чекпойнт отказал
|
||||||
|
|
||||||
|
- **GIVEN** очередной чекпойнт вернул ошибку
|
||||||
|
- **WHEN** наступает следующий период
|
||||||
|
- **THEN** отказ виден строкой лога, а чекпойнт выполняется снова
|
||||||
|
|
||||||
|
### Requirement: Обслуживание журнала останавливается дренированием
|
||||||
|
|
||||||
|
Система SHALL останавливать периодический чекпойнт осознанно: горутина MUST
|
||||||
|
получать отмену и MUST быть дождана вместе с воркером свёртки — база
|
||||||
|
закрывается только после выхода **обеих**. Обрывать её выходом процесса
|
||||||
|
MUST NOT, а закрывать базу по выходу одной из двух MUST NOT: закрытая из-под
|
||||||
|
воркера, она даёт `ERROR` по доставке, с которой всё в порядке.
|
||||||
|
|
||||||
|
Идущий чекпойнт при отмене прерывается, и терять ему нечего: перенос страниц
|
||||||
|
идемпотентен, исхода разбора чекпойнт не пишет, а следующий старт берёт журнал с
|
||||||
|
того же места. Прерывание по отмене отказом MUST NOT считаться.
|
||||||
|
|
||||||
|
Исчерпание бюджета остановки MUST называть этап, не утверждая большего, чем
|
||||||
|
проверено: ждут двоих, и назвать виновным одного из них — догадка.
|
||||||
|
|
||||||
|
Отдельного чекпойнта на остановке система выполнять MUST NOT: закрытие
|
||||||
|
последнего соединения к базе SQLite делает его само. Условие названо в
|
||||||
|
требовании о версии витрины — щуп обязан быть закрыт раньше пула, иначе
|
||||||
|
последнего соединения не наступает вовсе.
|
||||||
|
|
||||||
|
**Остаток назван вслух: в ветке исчерпанного бюджета база не закрывается, а
|
||||||
|
значит финального чекпойнта не наступает и рядом с ней остаётся `-wal`.**
|
||||||
|
Данные при этом целы — следующее открытие проиграет журнал, — но файл базы в
|
||||||
|
этом состоянии переносить без его `-wal` нельзя. Процедура подмены при
|
||||||
|
пересборке этого и требует: она удаляет `-wal` старой базы вместе с ней самой.
|
||||||
|
|
||||||
|
#### Scenario: Сервис останавливается
|
||||||
|
|
||||||
|
- **WHEN** сервис получает сигнал остановки
|
||||||
|
- **THEN** горутина чекпойнта завершается до закрытия базы
|
||||||
|
|
||||||
|
#### Scenario: Чекпойнт идёт в момент остановки
|
||||||
|
|
||||||
|
- **GIVEN** чекпойнт выполняется, когда пришла отмена
|
||||||
|
- **WHEN** он прерывается
|
||||||
|
- **THEN** отказ не пишется, новый цикл не начинается, и горутина выходит
|
||||||
|
|
||||||
|
#### Scenario: Бюджет остановки исчерпан
|
||||||
|
|
||||||
|
- **GIVEN** фоновые горутины не вышли в бюджет
|
||||||
|
- **WHEN** сервис завершается
|
||||||
|
- **THEN** база не закрывается, а запись лога называет этап, не указывая
|
||||||
|
виновной горутины
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
## 1. Версия витрины в хранилище
|
||||||
|
|
||||||
|
- [x] 1.1 Соединение-щуп: `sql.Conn`, взятый по первому запросу версии,
|
||||||
|
поколение (ULID через `internal/ident`), пересоздание с новым поколением
|
||||||
|
только при `sql.ErrConnDone` — обстоятельства поколение не меняют
|
||||||
|
- [x] 1.2 `Store.StateVersion(ctx)` — `PRAGMA data_version` со щупа, метка вида
|
||||||
|
`<поколение>-<счётчик>`; `Store.VersionedRead` — двойная проба вокруг
|
||||||
|
чтения; щуп закрывается раньше пула в `Close` и не воскресает после него
|
||||||
|
- [x] 1.3 Тесты: неизменившаяся база даёт ту же версию; запись из пула её
|
||||||
|
двигает; переоткрытие базы даёт другую версию; щуп пересоздаётся с новым
|
||||||
|
поколением; после `Close` версия отказывает и `-wal` рядом не остаётся;
|
||||||
|
щуп не удерживает читающий снимок
|
||||||
|
|
||||||
|
## 2. Обслуживание WAL
|
||||||
|
|
||||||
|
- [x] 2.1 `journal_size_limit` в DSN рабочего подключения, с причиной в
|
||||||
|
комментарии (пассивный чекпойнт файл не укорачивает)
|
||||||
|
- [x] 2.2 `Store.CheckpointWAL(ctx)` — `PRAGMA wal_checkpoint(PASSIVE)`,
|
||||||
|
наружу тройка чисел (busy, log, checkpointed) без интерпретации
|
||||||
|
- [x] 2.3 Цикл чекпойнта в `cmd/healthlog`: тик в минуту, `WARN` при
|
||||||
|
«страниц больше порога и перенесено меньше», отказ не убивает цикл
|
||||||
|
- [x] 2.4 Запуск и дренирование в `serve`: горутина ждётся в общем бюджете
|
||||||
|
остановки, отдельного чекпойнта на выходе нет
|
||||||
|
- [x] 2.5 Тесты: чекпойнт переносит страницы в тишине; удерживаемый читатель
|
||||||
|
даёт `checkpointed < log` без ошибки; порог молчит на малом журнале;
|
||||||
|
цикл выходит по отмене
|
||||||
|
|
||||||
|
## 3. Условный запрос в транспорте
|
||||||
|
|
||||||
|
- [x] 3.1 Помощник `httpapi`: разбор `If-None-Match` (список, `W/`, `*`),
|
||||||
|
слабое сравнение, `304` без тела — общий для будущих читающих маршрутов
|
||||||
|
- [x] 3.2 Источник версии передаётся транспорту функцией (как `worker.Notify`),
|
||||||
|
проверка токена чтения остаётся раньше условия
|
||||||
|
- [x] 3.3 Тесты помощника на формах заголовка: пусто, список, `W/`, `*`,
|
||||||
|
мусор
|
||||||
|
|
||||||
|
## 4. Каталог отдаёт версию
|
||||||
|
|
||||||
|
- [x] 4.1 `catalog.Service.Metrics` возвращает снимок вместе с версией:
|
||||||
|
проба до, сборка, проба после; расхождение — версии нет
|
||||||
|
- [x] 4.2 `handleMetrics`: `ETag` из версии, `304` по `If-None-Match` без
|
||||||
|
открытия снимка, ответ без `ETag` при расхождении проб
|
||||||
|
- [x] 4.3 Тесты: два ответа подряд — одна метка и одинаковые байты; после
|
||||||
|
свёртки метка другая; `304` не открывает снимок; `401` раньше `304`
|
||||||
|
|
||||||
|
## 5. Приёмочные критерии (рубрика ревью дизайна)
|
||||||
|
|
||||||
|
- [x] 5.1 Равная метка ⟹ побайтово равный ответ (кроме горизонта — назван в
|
||||||
|
дизайне); обратное направление ошибок не допускается ни в одном тесте
|
||||||
|
- [x] 5.2 Ни один новый лог не несёт значений точек, имён метрик без обрезки
|
||||||
|
и секретов; уровень выбран по адресату
|
||||||
|
- [x] 5.3 `task gate` зелёный; `task verify:archive` сходится (обслуживание
|
||||||
|
WAL и версия не меняют витрину)
|
||||||
|
- [x] 5.4 Поведенческая проверка на своём стенде из исходников (отдельный
|
||||||
|
каталог данных, рабочий контейнер не трогаем): `curl` дважды даёт
|
||||||
|
`304`, после доставки — `200`
|
||||||
|
|
||||||
|
## 6. Документация
|
||||||
|
|
||||||
|
- [x] 6.1 `docs/architecture.md`: версия витрины, условный запрос, обслуживание
|
||||||
|
WAL, отвергнутые чужие решения с причинами
|
||||||
|
- [x] 6.2 `README.md`: строка про условный запрос в примерах чтения
|
||||||
|
- [x] 6.3 `docs/backlog`: задача снята, остаток (предел ответа, измеренная цена
|
||||||
|
первого запроса, готовая машинерия условного запроса) перенесён в
|
||||||
|
`read-api-tochki.md`; наблюдаемость — в `stats-nablyudaemost.md`, цена
|
||||||
|
ветки исчерпанного бюджета — в `ostanovka-i-migraciya-sledy.md`
|
||||||
@@ -524,3 +524,141 @@ Read API MUST опираться на фактические объекты за
|
|||||||
- **WHEN** доставка учтена
|
- **WHEN** доставка учтена
|
||||||
- **THEN** в сохранённых заголовках вместо значения стоит пометка о сокрытии
|
- **THEN** в сохранённых заголовках вместо значения стоит пометка о сокрытии
|
||||||
|
|
||||||
|
### Requirement: Каталог отвечает на условный запрос
|
||||||
|
|
||||||
|
Система SHALL выставлять на ответе каталога заголовок `ETag` и SHALL отвечать
|
||||||
|
`304 Not Modified` на запрос с `If-None-Match`, чья метка совпадает с текущей
|
||||||
|
версией витрины. При совпадении снимок витрины открываться MUST NOT: смысл
|
||||||
|
условного запроса в том, что самый частый запрос потребителя — повтор
|
||||||
|
неизменившегося — не стоит ничего.
|
||||||
|
|
||||||
|
Метка MUST строиться из **всего, от чего зависит ответ**: версии витрины и
|
||||||
|
горизонта измерения. Горизонт едет вместе с часами, и метка из будущего,
|
||||||
|
лежащая в витрине, въезжает в окно сама — без единого коммита. Путь построен и
|
||||||
|
прогнан: та же версия витрины, `cumulative` против `unknown`. Значит версии
|
||||||
|
витрины для метки НЕ ДОСТАТОЧНО, и слабая форма метки этого не лечит: смена
|
||||||
|
измеренного рода — изменение семантическое, на нём Read API строит арифметику
|
||||||
|
года.
|
||||||
|
|
||||||
|
Горизонт входит в метку огрублённым до часа, и огрубление точное, а не
|
||||||
|
приблизительное: метки объектов лежат ровно на часах, поэтому отбор по горизонту
|
||||||
|
меняется ровно при переходе через час. Цена названа: один полный ответ в час на
|
||||||
|
потребителя при неизменившейся витрине.
|
||||||
|
|
||||||
|
Форма метки MUST оставаться слабой (`W/"…"`): она выведена из состояния, а не из
|
||||||
|
байтов ответа. На исход `304` это не влияет — `If-None-Match` сравнивается слабо
|
||||||
|
в любом случае.
|
||||||
|
|
||||||
|
Метка MUST выставляться, только если за всё время сборки ответа в базу никто не
|
||||||
|
коммитил; правило снятия версии принадлежит хранилищу и здесь не повторяется.
|
||||||
|
Ответ без метки — законный исход, а не отказ: клиент просто не сможет спросить
|
||||||
|
условно в следующий раз.
|
||||||
|
|
||||||
|
**Метка действительна только в пределах одного ресурса, и область действия
|
||||||
|
MUST входить в саму метку.** Маршрут, чей ответ есть функция параметров запроса
|
||||||
|
(Read API точек), и транспорт, у которого адреса нет вовсе (MCP), обязаны
|
||||||
|
подмешивать в неё канонизированную форму запроса — иначе «не изменилось»
|
||||||
|
ответит на другой набор данных. Требовать этого прозой недостаточно: правило
|
||||||
|
MUST быть выражено формой вызова, потому что забыть его — единственный путь всей
|
||||||
|
задачи, ведущий к выдаче не тех данных.
|
||||||
|
|
||||||
|
Ответ `304` MUST нести ту же метку и MUST NOT нести тела и представленческих
|
||||||
|
заголовков. Клиент, не приславший `If-None-Match`, MUST получать ровно то же,
|
||||||
|
что и до появления условного запроса.
|
||||||
|
|
||||||
|
Ответы каталога MUST быть помечены непригодными для разделяемого кеша
|
||||||
|
(`Cache-Control: private, no-cache`). До появления валидатора эвристическое
|
||||||
|
кеширование посредником было маловероятным; с меткой ответ становится штатно
|
||||||
|
кешируемым, а при выключенной проверке токенов (законная конфигурация) в
|
||||||
|
запросе нет и `Authorization` — тогда выгрузку истории здоровья вправе
|
||||||
|
сохранить любой прокси на пути.
|
||||||
|
|
||||||
|
Проверка токена чтения MUST предшествовать условному запросу: `304` без токена
|
||||||
|
подтверждал бы состояние витрины тому, кому она не открыта.
|
||||||
|
|
||||||
|
Разбор условия MUST следовать HTTP и MUST NOT превращать кривой заголовок в
|
||||||
|
отказ:
|
||||||
|
|
||||||
|
- звёздочка (`*`) совпадает с любой **существующей** меткой; метки нет —
|
||||||
|
условие не выполнено, и клиент со звёздочкой получает данные, а не вечный
|
||||||
|
`304`;
|
||||||
|
- неразбираемое значение условия не выполняет и даёт `200`, а не `400`.
|
||||||
|
|
||||||
|
**Следствие названо вслух: `304` не выполняет измерения и потому не пишет
|
||||||
|
предупреждений владельцу.** Предупреждения каталога (данные из будущего,
|
||||||
|
противоречащий род агрегации) привязаны к сборке ответа; с условным опросом они
|
||||||
|
становятся функцией смены версии витрины, а не числа запросов. Состояние при
|
||||||
|
этом не исчезает: следующая доставка меняет версию, ответ собирается, и
|
||||||
|
предупреждение пишется — а пока витрина стоит, повторять его на каждый опрос
|
||||||
|
трёх потребителей значило бы обесценить уровень.
|
||||||
|
|
||||||
|
#### Scenario: Повтор на неизменившейся витрине
|
||||||
|
|
||||||
|
- **GIVEN** клиент получил каталог и запомнил его `ETag`
|
||||||
|
- **WHEN** он повторяет запрос с `If-None-Match` этой метки, а витрина не
|
||||||
|
менялась
|
||||||
|
- **THEN** ответ — `304` без тела, с той же меткой
|
||||||
|
|
||||||
|
#### Scenario: Витрина изменилась
|
||||||
|
|
||||||
|
- **GIVEN** клиент получил каталог и запомнил его `ETag`
|
||||||
|
- **WHEN** свёртка записала объект и клиент повторяет запрос с прежней меткой
|
||||||
|
- **THEN** ответ — `200` с полным каталогом и новой меткой
|
||||||
|
|
||||||
|
#### Scenario: Горизонт сдвинулся
|
||||||
|
|
||||||
|
- **GIVEN** витрина не менялась
|
||||||
|
- **WHEN** горизонт измерения перешёл через час
|
||||||
|
- **THEN** метка отличается от прежней
|
||||||
|
|
||||||
|
#### Scenario: Метка другого ресурса
|
||||||
|
|
||||||
|
- **GIVEN** клиент присылает метку, выданную другим читающим маршрутом
|
||||||
|
- **WHEN** совпадает версия витрины
|
||||||
|
- **THEN** условие не выполнено, и ответ — `200`
|
||||||
|
|
||||||
|
#### Scenario: Клиент не спрашивает условно
|
||||||
|
|
||||||
|
- **WHEN** каталог запрашивается без `If-None-Match`
|
||||||
|
- **THEN** ответ — `200` с полным каталогом, меткой и правилом кеширования
|
||||||
|
|
||||||
|
#### Scenario: Две метки на неизменившейся витрине совпадают
|
||||||
|
|
||||||
|
- **GIVEN** витрина не менялась между двумя запросами
|
||||||
|
- **WHEN** каталог запрошен дважды
|
||||||
|
- **THEN** метки совпадают, и тела ответов совпадают побайтово
|
||||||
|
|
||||||
|
#### Scenario: Звёздочка в условии
|
||||||
|
|
||||||
|
- **WHEN** каталог запрашивается с `If-None-Match: *`
|
||||||
|
- **THEN** ответ — `304` с текущей меткой
|
||||||
|
|
||||||
|
#### Scenario: Условие нечитаемо
|
||||||
|
|
||||||
|
- **WHEN** каталог запрашивается с `If-None-Match`, который меткой не является
|
||||||
|
- **THEN** ответ — `200` с полным каталогом, а не `400` и не `304`
|
||||||
|
|
||||||
|
#### Scenario: Условный запрос без токена чтения
|
||||||
|
|
||||||
|
- **GIVEN** список токенов чтения непуст
|
||||||
|
- **WHEN** каталог запрашивается с `If-None-Match`, но без токена
|
||||||
|
- **THEN** ответ — `401`, а не `304`
|
||||||
|
|
||||||
|
#### Scenario: Витрина изменилась во время сборки ответа
|
||||||
|
|
||||||
|
- **GIVEN** между снятием версии до и после сборки в базу был коммит
|
||||||
|
- **WHEN** ответ сформирован
|
||||||
|
- **THEN** он уходит с полным телом и без заголовка `ETag`
|
||||||
|
|
||||||
|
#### Scenario: Версия витрины недоступна
|
||||||
|
|
||||||
|
- **GIVEN** версию витрины прочитать не удалось
|
||||||
|
- **WHEN** каталог запрашивается, в том числе с `If-None-Match`
|
||||||
|
- **THEN** ответ — `200` с полным каталогом и без метки, а не `500` и не `304`
|
||||||
|
|
||||||
|
#### Scenario: Условный ответ не собирает каталог
|
||||||
|
|
||||||
|
- **GIVEN** в витрине лежат данные, помеченные будущим
|
||||||
|
- **WHEN** каталог отвечает `304` по совпавшей метке
|
||||||
|
- **THEN** предупреждение владельцу не пишется, потому что измерения не было
|
||||||
|
|
||||||
|
|||||||
@@ -1057,3 +1057,281 @@ SHALL: сегодня ровно этот случай даёт ноль и мо
|
|||||||
- **WHEN** выполняется выборка
|
- **WHEN** выполняется выборка
|
||||||
- **THEN** число обращений к базе не зависит от числа часов
|
- **THEN** число обращений к базе не зависит от числа часов
|
||||||
|
|
||||||
|
### Requirement: Хранилище отдаёт версию витрины
|
||||||
|
|
||||||
|
Система SHALL отдавать **версию витрины** — метку, которая MUST меняться при
|
||||||
|
любом коммите в базу и MUST NOT меняться, пока коммитов не было. Метка
|
||||||
|
предназначена условному запросу читающих маршрутов: равные метки означают, что
|
||||||
|
между их снятием в базу никто ничего не записал.
|
||||||
|
|
||||||
|
Метка MUST быть парой «поколение + счётчик». Счётчик — `PRAGMA data_version`,
|
||||||
|
поколение — идентификатор, выданный тому соединению, с которого счётчик
|
||||||
|
читается. Монотонной метка не является и сравнению на «новее» не подлежит:
|
||||||
|
гарантируется только неравенство.
|
||||||
|
|
||||||
|
Обе части обязательны, и каждая закрывает измеренный отказ:
|
||||||
|
|
||||||
|
- **счётчик несравним между соединениями.** На одном и том же состоянии базы
|
||||||
|
два соединения одного пула отвечают разными числами, а любое свежее
|
||||||
|
соединение отвечает одним и тем же значением независимо от содержимого базы.
|
||||||
|
Поэтому счётчик MUST читаться с одного закреплённого соединения, которое
|
||||||
|
ничем другим не занято: собственная запись соединения его версию не двигает,
|
||||||
|
и щуп, участвующий в записи, молчал бы о собственных изменениях.
|
||||||
|
- **счётчик не переживает переоткрытия.** После рестарта он начинается заново,
|
||||||
|
поэтому одно и то же значение до и после означает разные состояния витрины.
|
||||||
|
Без поколения клиент со старой меткой получал бы «не изменилось» на
|
||||||
|
изменившиеся данные — единственный по-настоящему опасный исход условного
|
||||||
|
запроса.
|
||||||
|
|
||||||
|
Соединение-щуп MUST NOT удерживать открытую читающую транзакцию между снятиями
|
||||||
|
версии: каждое снятие завершается до возврата. Иначе щуп — единственное
|
||||||
|
долгоживущее соединение процесса — становится тем самым вечным читателем,
|
||||||
|
против которого заведён чекпойнт, и версия витрины отменяет обслуживание
|
||||||
|
журнала при полностью исправном обслуживании.
|
||||||
|
|
||||||
|
Поколение MUST меняться всякий раз, когда соединение-щуп создаётся заново.
|
||||||
|
Переиспользовать поколение MUST NOT: это ровно тот случай, ради которого оно
|
||||||
|
заведено.
|
||||||
|
|
||||||
|
**Непригодность щупа — узкий класс, а не любая ошибка.** Пересоздание
|
||||||
|
допускается только при отказе, означающем закрытое соединение; отмена запроса
|
||||||
|
клиентом, занятость базы и прочие обстоятельства (то, что проект уже отличает
|
||||||
|
предикатом «не сделано» против «не выходит») поколение менять MUST NOT.
|
||||||
|
Измерено: отмена контекста запроса щуп не убивает — следующий запрос на нём
|
||||||
|
проходит. Считай система смертью щупа любую ошибку, каждый оборванный клиентом
|
||||||
|
запрос обнулял бы метки всех потребителей, то есть механизм схлопывался бы под
|
||||||
|
той самой нагрузкой, ради которой заведён.
|
||||||
|
|
||||||
|
Закрытие хранилища MUST освобождать щуп раньше пула и MUST исключать его
|
||||||
|
пересоздание после закрытия. Закреплённое соединение переживает закрытие пула
|
||||||
|
(измерено), а SQLite делает финальный чекпойнт только при закрытии последнего
|
||||||
|
соединения: забытый щуп оставляет рядом с базой неразобранный `-wal`, и
|
||||||
|
пересборка, переносящая один файл базы, теряет хвост записей молча.
|
||||||
|
|
||||||
|
#### Scenario: Витрина не менялась
|
||||||
|
|
||||||
|
- **GIVEN** после первого запроса версии в базу никто не писал
|
||||||
|
- **WHEN** версия запрашивается второй раз
|
||||||
|
- **THEN** обе версии совпадают
|
||||||
|
|
||||||
|
#### Scenario: Свёртка записала объект
|
||||||
|
|
||||||
|
- **GIVEN** версия витрины снята
|
||||||
|
- **WHEN** фоновая свёртка закоммитила изменения в витрину
|
||||||
|
- **THEN** следующая снятая версия отличается от прежней
|
||||||
|
|
||||||
|
#### Scenario: База переоткрыта
|
||||||
|
|
||||||
|
- **GIVEN** версия витрины снята, база закрыта и открыта заново
|
||||||
|
- **WHEN** версия снимается снова на том же файле
|
||||||
|
- **THEN** она отличается от снятой до переоткрытия
|
||||||
|
|
||||||
|
#### Scenario: Соединение-щуп стало непригодным
|
||||||
|
|
||||||
|
- **GIVEN** соединение, с которого читается счётчик, закрыто
|
||||||
|
- **WHEN** версия запрашивается снова
|
||||||
|
- **THEN** запрос отвечает версией НОВОГО поколения, а не отказом
|
||||||
|
|
||||||
|
#### Scenario: Запрос версии оборван клиентом
|
||||||
|
|
||||||
|
- **GIVEN** запрос версии отменён контекстом
|
||||||
|
- **WHEN** версия запрашивается следующим запросом
|
||||||
|
- **THEN** поколение остаётся прежним
|
||||||
|
|
||||||
|
#### Scenario: Щуп не мешает разбирать журнал
|
||||||
|
|
||||||
|
- **GIVEN** версия снималась много раз подряд
|
||||||
|
- **WHEN** выполняется пассивный чекпойнт и других читателей нет
|
||||||
|
- **THEN** журнал перенесён целиком
|
||||||
|
|
||||||
|
#### Scenario: Хранилище закрыто
|
||||||
|
|
||||||
|
- **GIVEN** хранилище закрыто
|
||||||
|
- **WHEN** запрашивается версия витрины
|
||||||
|
- **THEN** запрос отказывает и нового соединения к базе не открывает, а рядом с
|
||||||
|
базой не остаётся файла журнала
|
||||||
|
|
||||||
|
### Requirement: Чтение подписывается версией только целиком
|
||||||
|
|
||||||
|
Система SHALL снимать версию витрины **до и после** чтения, которое ею
|
||||||
|
подписывается, и SHALL отдавать версию, только если обе пробы совпали. При
|
||||||
|
расхождении версии нет, и это не отказ: ответ уходит полным, просто без метки.
|
||||||
|
|
||||||
|
Версия, снятая ПОСЛЕ чтения, MUST NOT выставляться на его результате: она
|
||||||
|
пометила бы устаревший снимок свежим номером и заперла бы клиента на нём
|
||||||
|
навсегда — единственный по-настоящему опасный исход всей конструкции. Версия,
|
||||||
|
снятая только ДО, допускает два разных ответа под одной меткой.
|
||||||
|
|
||||||
|
Правило MUST существовать в одном экземпляре: Read API точек и MCP заявлены
|
||||||
|
потребителями той же машинерии, и вторая её реализация «по образцу»
|
||||||
|
отличалась бы от первой ровно на этот порядок — а тест первой этого не
|
||||||
|
увидел бы.
|
||||||
|
|
||||||
|
Отказ пробы версией не является и чтение не отменяет: маршрут деградирует до
|
||||||
|
полного ответа, а не до отказа.
|
||||||
|
|
||||||
|
#### Scenario: Витрина стояла всё время чтения
|
||||||
|
|
||||||
|
- **WHEN** чтение выполнено и обе пробы дали одну версию
|
||||||
|
- **THEN** версия отдана
|
||||||
|
|
||||||
|
#### Scenario: Витрина изменилась во время чтения
|
||||||
|
|
||||||
|
- **GIVEN** между пробами в базу закоммитили
|
||||||
|
- **THEN** версии нет, а результат чтения отдан целиком
|
||||||
|
|
||||||
|
#### Scenario: Само чтение отказало
|
||||||
|
|
||||||
|
- **WHEN** чтение вернуло ошибку
|
||||||
|
- **THEN** ошибка отдана вызывающему, а не подменена отсутствием версии
|
||||||
|
|
||||||
|
### Requirement: Журнал WAL разбирается по таймеру, и его непродвижение видно
|
||||||
|
|
||||||
|
Система SHALL выполнять `PRAGMA wal_checkpoint(PASSIVE)` **раз в минуту**, пока
|
||||||
|
сервис работает. Автоматический чекпойнт SQLite MUST NOT считаться достаточным:
|
||||||
|
он срабатывает по концу записи, а поток пачечный — журнал, раздутый всплеском,
|
||||||
|
иначе остаётся неразобранным до следующей доставки, и ночью это часы.
|
||||||
|
|
||||||
|
Режим MUST быть `PASSIVE`. `TRUNCATE` и `RESTART` применять MUST NOT: они
|
||||||
|
двигают счётчик версии витрины, то есть каждый тик обнулял бы условный запрос у
|
||||||
|
всех потребителей, а `TRUNCATE` вдобавок ждёт читателей.
|
||||||
|
|
||||||
|
Пассивный чекпойнт не идёт дальше снимка самого старого активного читателя и
|
||||||
|
**ошибки при этом не возвращает**: измерено `busy=0` при 6256 страницах в
|
||||||
|
журнале и 5 перенесённых. Поэтому система SHALL считать признаком беды пару
|
||||||
|
чисел — страниц в журнале больше **16384** (64 МиБ при странице в 4 КиБ) **и**
|
||||||
|
перенесено меньше, чем лежало, — и MUST сообщать об этом владельцу уровнем
|
||||||
|
`WARN`. Флаг занятости признаком «не продвинулись» служить MUST NOT: он молчит
|
||||||
|
ровно в измеренном случае удерживаемого читателя.
|
||||||
|
|
||||||
|
**Зато флаг занятости выражает другое, и это MUST читаться: исход не измерен.**
|
||||||
|
Не взяв блокировку чекпойнта, SQLite отдаёт `busy=1` и **`-1` вместо обоих
|
||||||
|
чисел** — измерено, 1492 таких тика из 5502 при писателе и чекпойнте в цикле.
|
||||||
|
Сравнивать `-1` на шкале страниц MUST NOT: `-1 >= -1` истинно, то есть
|
||||||
|
незамеренный тик читался бы как «журнал разобран целиком», владельцу уходила бы
|
||||||
|
строка о выздоровлении посреди болезни, а подавитель повторов сбрасывался бы —
|
||||||
|
и вместо задуманного молчания получалась бы пара строк в минуту. Незамеренный
|
||||||
|
исход MUST не менять ни объявленного состояния, ни накопленного о нём.
|
||||||
|
|
||||||
|
Размер страницы MUST браться у самой базы, а не предполагаться: он фиксируется
|
||||||
|
при создании файла, и база, созданная чужим инструментом, сместила бы порог в
|
||||||
|
разы. Неизвестен — признак молчит.
|
||||||
|
|
||||||
|
Порог MUST быть выражен через ту же величину, что и предел файла журнала: это
|
||||||
|
одно число в двух ролях (предел возвращает файл, порог сообщает, что вернуть
|
||||||
|
его не выходит), и двумя разошедшимися константами признак стал бы либо
|
||||||
|
недостижимым, либо шумным — молча.
|
||||||
|
|
||||||
|
**Строка о непродвижении не пишется на каждый тик.** Признак заведён ради
|
||||||
|
состояния, которое само не проходит (вечный читатель живёт до конца процесса), а
|
||||||
|
строка в минуту дала бы 1440 одинаковых записей в сутки. Система SHALL сообщать
|
||||||
|
о входе в состояние и повторять, только когда журнал заметно вырос; возврат к
|
||||||
|
норме MUST быть отдельным событием — молчание иначе неотличимо от «сервис
|
||||||
|
перестал проверять».
|
||||||
|
|
||||||
|
Отказ чекпойнта MUST NOT прекращать цикл и MUST быть виден записью лога:
|
||||||
|
обслуживание, умершее от временного отказа базы, молча перестало бы разбирать
|
||||||
|
журнал до конца жизни процесса. Отмена контекста отказом при этом не является.
|
||||||
|
|
||||||
|
Файл журнала MUST иметь названный предел (`journal_size_limit`, 64 МиБ).
|
||||||
|
Предел **роста этим не даётся, и это сказано вслух**: измерено — под
|
||||||
|
удерживаемым читателем файл вырос до 51 МБ при пределе 8 МиБ, и успешный
|
||||||
|
чекпойнт его не укоротил; усечение делает первая запись после полного
|
||||||
|
чекпойнта. Пока читатель держит снимок, журнал растёт, и единственный исход —
|
||||||
|
`WARN` владельцу.
|
||||||
|
|
||||||
|
#### Scenario: Журнал разбирается в тишине
|
||||||
|
|
||||||
|
- **GIVEN** доставок нет, а в журнале остались неразобранные страницы
|
||||||
|
- **WHEN** проходит период чекпойнта
|
||||||
|
- **THEN** страницы перенесены в базу без единой новой записи
|
||||||
|
|
||||||
|
#### Scenario: Читатель держит снимок
|
||||||
|
|
||||||
|
- **GIVEN** идёт запись, и читающая транзакция удерживает старый снимок
|
||||||
|
- **WHEN** выполняется пассивный чекпойнт
|
||||||
|
- **THEN** он завершается без ошибки, переносит меньше, чем лежит в журнале, и
|
||||||
|
флаг занятости остаётся снятым
|
||||||
|
|
||||||
|
#### Scenario: Чекпойнт не взял блокировку
|
||||||
|
|
||||||
|
- **GIVEN** о непродвижении журнала уже сказано
|
||||||
|
- **WHEN** очередной чекпойнт возвращает признак занятости и `-1` вместо чисел
|
||||||
|
- **THEN** ни строки о выздоровлении, ни строки о беде не пишется, а
|
||||||
|
накопленное состояние не меняется
|
||||||
|
|
||||||
|
#### Scenario: Журнал невелик
|
||||||
|
|
||||||
|
- **GIVEN** в журнале меньше страниц, чем названный порог
|
||||||
|
- **WHEN** чекпойнт не смог перенести всё
|
||||||
|
- **THEN** строка `WARN` не пишется
|
||||||
|
|
||||||
|
#### Scenario: Состояние держится
|
||||||
|
|
||||||
|
- **GIVEN** о непродвижении журнала уже сказано
|
||||||
|
- **WHEN** следующий чекпойнт застаёт журнал того же размера
|
||||||
|
- **THEN** строка не повторяется
|
||||||
|
|
||||||
|
#### Scenario: Журнал разобрался
|
||||||
|
|
||||||
|
- **GIVEN** о непродвижении журнала было сказано
|
||||||
|
- **WHEN** очередной чекпойнт переносит журнал ЦЕЛИКОМ
|
||||||
|
- **THEN** о возврате к норме сказано один раз
|
||||||
|
|
||||||
|
#### Scenario: Журнал стал мал, но не перенесён
|
||||||
|
|
||||||
|
- **GIVEN** о непродвижении журнала было сказано
|
||||||
|
- **WHEN** очередной чекпойнт переносит не всё, а журнал при этом ниже порога
|
||||||
|
- **THEN** о возврате к норме не сообщается: перенос — это то, что проверено, а
|
||||||
|
размер ниже порога — нет
|
||||||
|
|
||||||
|
#### Scenario: Чекпойнт отказал
|
||||||
|
|
||||||
|
- **GIVEN** очередной чекпойнт вернул ошибку
|
||||||
|
- **WHEN** наступает следующий период
|
||||||
|
- **THEN** отказ виден строкой лога, а чекпойнт выполняется снова
|
||||||
|
|
||||||
|
### Requirement: Обслуживание журнала останавливается дренированием
|
||||||
|
|
||||||
|
Система SHALL останавливать периодический чекпойнт осознанно: горутина MUST
|
||||||
|
получать отмену и MUST быть дождана вместе с воркером свёртки — база
|
||||||
|
закрывается только после выхода **обеих**. Обрывать её выходом процесса
|
||||||
|
MUST NOT, а закрывать базу по выходу одной из двух MUST NOT: закрытая из-под
|
||||||
|
воркера, она даёт `ERROR` по доставке, с которой всё в порядке.
|
||||||
|
|
||||||
|
Идущий чекпойнт при отмене прерывается, и терять ему нечего: перенос страниц
|
||||||
|
идемпотентен, исхода разбора чекпойнт не пишет, а следующий старт берёт журнал с
|
||||||
|
того же места. Прерывание по отмене отказом MUST NOT считаться.
|
||||||
|
|
||||||
|
Исчерпание бюджета остановки MUST называть этап, не утверждая большего, чем
|
||||||
|
проверено: ждут двоих, и назвать виновным одного из них — догадка.
|
||||||
|
|
||||||
|
Отдельного чекпойнта на остановке система выполнять MUST NOT: закрытие
|
||||||
|
последнего соединения к базе SQLite делает его само. Условие названо в
|
||||||
|
требовании о версии витрины — щуп обязан быть закрыт раньше пула, иначе
|
||||||
|
последнего соединения не наступает вовсе.
|
||||||
|
|
||||||
|
**Остаток назван вслух: в ветке исчерпанного бюджета база не закрывается, а
|
||||||
|
значит финального чекпойнта не наступает и рядом с ней остаётся `-wal`.**
|
||||||
|
Данные при этом целы — следующее открытие проиграет журнал, — но файл базы в
|
||||||
|
этом состоянии переносить без его `-wal` нельзя. Процедура подмены при
|
||||||
|
пересборке этого и требует: она удаляет `-wal` старой базы вместе с ней самой.
|
||||||
|
|
||||||
|
#### Scenario: Сервис останавливается
|
||||||
|
|
||||||
|
- **WHEN** сервис получает сигнал остановки
|
||||||
|
- **THEN** горутина чекпойнта завершается до закрытия базы
|
||||||
|
|
||||||
|
#### Scenario: Чекпойнт идёт в момент остановки
|
||||||
|
|
||||||
|
- **GIVEN** чекпойнт выполняется, когда пришла отмена
|
||||||
|
- **WHEN** он прерывается
|
||||||
|
- **THEN** отказ не пишется, новый цикл не начинается, и горутина выходит
|
||||||
|
|
||||||
|
#### Scenario: Бюджет остановки исчерпан
|
||||||
|
|
||||||
|
- **GIVEN** фоновые горутины не вышли в бюджет
|
||||||
|
- **WHEN** сервис завершается
|
||||||
|
- **THEN** база не закрывается, а запись лога называет этап, не указывая
|
||||||
|
виновной горутины
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user