Цена читающего маршрута: чекпойнт WAL по таймеру и условный запрос

- рядом с воркером свёртки живёт горутина, раз в минуту разбирающая журнал
  пассивным чекпойнтом; «журнал не разбирается» видно строкой владельцу, а не
  только по `df`. Признак — пара чисел, а не флаг занятости: тот молчит под
  удерживаемым читателем (`busy=0` при 6256 страницах и пяти перенесённых), а
  при занятой блокировке отдаёт `-1` вместо ответа, и `-1 >= -1` читалось бы как
  «разобрано целиком»
- каталог отвечает `304` на `If-None-Match`, не открывая снимок витрины. Метка
  собрана из всего, от чего зависит ответ: версии витрины (`data_version` с
  закреплённого соединения плюс поколение — значение локально для соединения и
  не переживает переоткрытия), горизонта измерения и области действия ресурса.
  Версия снимается до и после сборки: снятая после пометила бы устаревший снимок
  свежим номером
- предел и дедлайн ответа отложены в задачу Read API точек вместе с измеренной
  ценой первого запроса; попутно починен флаки-тест чужой задачи, искавший
  значение точки в сыром буфере записи лога
This commit is contained in:
av
2026-08-02 20:42:22 +03:00
parent 6b729bbd2f
commit 8db2ec7ff4
37 changed files with 3575 additions and 156 deletions
+75 -11
View File
@@ -204,6 +204,54 @@ type Metric struct {
Layers []LayerRange `json:"layers"`
}
// Snapshot — каталог вместе с версией ответа.
//
// Версия пустая, когда подписать ответ нечем: витрина изменилась, пока он
// собирался, или прочитать её версию не удалось. Это не отказ — ответ уходит
// целиком, просто без условной метки, ровно как до появления условного запроса.
//
// Имя перекликается с `store.CatalogSnapshot` намеренно и означает другое: тот
// снимок — вход измерения (объекты и разрезы), этот — готовый ответ.
type Snapshot struct {
Version string
Metrics []Metric
}
// Version — версия ОТВЕТА каталога: версия витрины плюс горизонт измерения.
//
// Горизонт входит в неё, потому что ответ есть функция обоих. Час объекта
// сравнивается с `Now() + horizonSlack`, и с ходом часов состав окна меняется
// без единого коммита: метка из будущего, лежащая в витрине (сбитые часы
// телефона — состояние, о котором рядом пишется WARN), въезжает в окно сама и
// способна перевернуть измеренный род. Построено и прогнано: та же версия
// витрины, `cumulative` против `unknown`, ноль коммитов между.
//
// Огрубление до часа не приблизительное, а точное: `hour_utc` объектов лежит
// ровно на часах, поэтому отбор `hour_utc <= горизонт` меняется ровно при
// переходе горизонта через час. Цена — один полный ответ в час на потребителя
// при неизменившейся витрине; сборка каталога на живом корпусе стоит 45 мс.
func (s *Service) Version(ctx context.Context) (string, error) {
version, err := s.store.StateVersion(ctx)
if err != nil {
// Отказ пробы отказом маршрута не является — но и молчать о нём нельзя:
// без метки условный запрос выключается для всех потребителей, а
// снаружи это неотличимо от нормы. DEBUG, потому что адресат здесь
// разработчик: владельцу об этом скажет `/stats`, когда появится.
s.log.DebugContext(ctx, "state version unavailable", "capability", "query", "error", err)
return "", err
}
return stamp(version, store.Now().Add(horizonSlack)), nil
}
// stamp склеивает версию витрины с горизонтом. Пустая версия остаётся пустой:
// подписывать нечем — значит нечем, и горизонт этого не меняет.
func stamp(version string, horizon time.Time) string {
if version == "" {
return ""
}
return version + "." + horizon.Truncate(time.Hour).Format("2006010215")
}
// Service собирает каталог по витрине.
type Service struct {
store *store.Store
@@ -223,17 +271,26 @@ func New(st *store.Store, log *slog.Logger) *Service {
// и объяснять, на каком составе данных оно снято; устаревшее при этом выглядит
// ровно как свежее. Вычисленный на запрос род есть функция витрины, а витрина —
// функция журнала, и устаревать в нём нечему.
func (s *Service) Metrics(ctx context.Context) ([]Metric, error) {
// Ответ подписывается версией витрины: она нужна условному запросу, и снимает
// её хранилище — двумя пробами вокруг чтения. Порядок проб там же и объяснён:
// версия, снятая после чтения, пометила бы устаревший снимок свежей меткой.
func (s *Service) Metrics(ctx context.Context) (Snapshot, error) {
horizon := store.Now().Add(horizonSlack)
snap, err := s.store.ReadCatalog(ctx, store.CatalogWindow{
Fine: string(hae.LayerMinute),
Coarse: string(hae.LayerHour),
Hours: Window,
Horizon: horizon,
CoarsePoints: coarsePoints,
MinFinePoints: minFinePoints,
var snap store.CatalogSnapshot
version, err := s.store.VersionedRead(ctx, func(ctx context.Context) error {
var err error
snap, err = s.store.ReadCatalog(ctx, store.CatalogWindow{
Fine: string(hae.LayerMinute),
Coarse: string(hae.LayerHour),
Hours: Window,
Horizon: horizon,
CoarsePoints: coarsePoints,
MinFinePoints: minFinePoints,
})
return err
})
if err != nil {
if err != nil { //nolint:nestif // ветка одна, вложенность даёт лог по адресату
// Единственный логирующий чекпоинт исхода: транспорт переводит ошибку в
// ответ и второй раз её не пишет.
//
@@ -247,7 +304,7 @@ func (s *Service) Metrics(ctx context.Context) ([]Metric, error) {
} else {
s.log.ErrorContext(ctx, "catalog failed", "capability", "query", "error", err)
}
return nil, err
return Snapshot{}, err
}
out := make([]Metric, 0, len(snap.Layers))
@@ -286,7 +343,14 @@ func (s *Service) Metrics(ctx context.Context) ([]Metric, error) {
Layers: group.layers,
})
}
return out, nil
if version == "" {
// Витрина изменилась, пока ответ собирался (или версию не прочитать).
// Ответ уйдёт без метки — это безопасная сторона, но след нужен: под
// плотным потоком доставок так может уходить каждый ответ, и тогда
// механизм не окупается вовсе.
s.log.DebugContext(ctx, "catalog unsigned", "capability", "query")
}
return Snapshot{Version: stamp(version, horizon), Metrics: out}, nil
}
type metricGroup struct {
+36 -14
View File
@@ -129,7 +129,7 @@ func TestКаталогОтдаётРазрезыИИзмеренныйРод(t
incoming("sleep_analysis", "raw", "hr", time.Date(2026, 6, 1, 3, 7, 0, 0, time.UTC), 1),
})
metrics, err := service(t, st).Metrics(context.Background())
metrics, err := metricsOf(context.Background(), service(t, st))
if err != nil {
t.Fatalf("каталог: %v", err)
}
@@ -179,7 +179,7 @@ func TestКаталогНеИзмеряетПоНижнемуСлою(t *testing
}
merge(t, st, points)
metrics, err := service(t, st).Metrics(context.Background())
metrics, err := metricsOf(context.Background(), service(t, st))
if err != nil {
t.Fatalf("каталог: %v", err)
}
@@ -198,7 +198,7 @@ func TestКаталогОграничиваетОкно(t *testing.T) {
st := openStore(t)
fill(t, st, "step_count", catalog.Window+7, true)
metrics, err := service(t, st).Metrics(context.Background())
metrics, err := metricsOf(context.Background(), service(t, st))
if err != nil {
t.Fatalf("каталог: %v", err)
}
@@ -227,7 +227,7 @@ func TestКаталогПоказываетРасхождениеЕдиниц(t
incoming("walking_running_distance", "minute", "m", base.Add(2*time.Hour), 2),
})
metrics, err := service(t, st).Metrics(context.Background())
metrics, err := metricsOf(context.Background(), service(t, st))
if err != nil {
t.Fatalf("каталог: %v", err)
}
@@ -249,7 +249,7 @@ func TestКаталогПоказываетРасхождениеЕдиниц(t
func TestКаталогПустойВитриныПуст(t *testing.T) {
t.Parallel()
metrics, err := service(t, openStore(t)).Metrics(context.Background())
metrics, err := metricsOf(context.Background(), service(t, openStore(t)))
if err != nil {
t.Fatalf("каталог: %v", err)
}
@@ -268,7 +268,7 @@ func TestКаталогПересчитываетРодНаКаждыйЗапр
ctx := context.Background()
fill(t, st, "step_count", 2, true)
before, err := svc.Metrics(ctx)
before, err := metricsOf(ctx, svc)
if err != nil {
t.Fatalf("каталог: %v", err)
}
@@ -277,7 +277,7 @@ func TestКаталогПересчитываетРодНаКаждыйЗапр
}
fill(t, st, "step_count", 5, true)
after, err := svc.Metrics(ctx)
after, err := metricsOf(ctx, svc)
if err != nil {
t.Fatalf("каталог: %v", err)
}
@@ -310,7 +310,7 @@ func TestКаталогНеИзмеряетПоБудущимЧасам(t *testi
merge(t, st, future)
handler := &logged{}
metrics, err := catalog.New(st, slog.New(handler)).Metrics(context.Background())
metrics, err := metricsOf(context.Background(), catalog.New(st, slog.New(handler)))
if err != nil {
t.Fatalf("каталог: %v", err)
}
@@ -344,7 +344,7 @@ func TestКаталогНеСверяетСлоиРазныхЕдиниц(t *tes
}
merge(t, st, points)
metrics, err := service(t, st).Metrics(context.Background())
metrics, err := metricsOf(context.Background(), service(t, st))
if err != nil {
t.Fatalf("каталог: %v", err)
}
@@ -369,7 +369,7 @@ func TestКаталогПоказываетМетрикуСПустымИмен
incoming("step_count", "minute", "count", base, 2),
})
metrics, err := service(t, st).Metrics(context.Background())
metrics, err := metricsOf(context.Background(), service(t, st))
if err != nil {
t.Fatalf("каталог: %v", err)
}
@@ -402,7 +402,7 @@ func TestКаталогПишетПредупреждениеОПротивор
merge(t, st, points)
handler := &logged{}
metrics, err := catalog.New(st, slog.New(handler)).Metrics(context.Background())
metrics, err := metricsOf(context.Background(), catalog.New(st, slog.New(handler)))
if err != nil {
t.Fatalf("каталог: %v", err)
}
@@ -439,7 +439,7 @@ func TestКаталогОграничиваетЧислоЕдиниц(t *testing
}
merge(t, st, points)
metrics, err := service(t, st).Metrics(context.Background())
metrics, err := metricsOf(context.Background(), service(t, st))
if err != nil {
t.Fatalf("каталог: %v", err)
}
@@ -463,7 +463,7 @@ func TestКаталогСообщаетОбОтказеХранилища(t *tes
}
handler := &logged{}
if _, err := catalog.New(st, slog.New(handler)).Metrics(context.Background()); err == nil {
if _, err := metricsOf(context.Background(), catalog.New(st, slog.New(handler))); err == nil {
t.Fatal("каталог на закрытой базе собрался")
}
rec, ok := handler.find("catalog failed")
@@ -488,7 +488,7 @@ func TestКаталогНеПутаетОтменуСоСбоем(t *testing.T)
cancel()
handler := &logged{}
if _, err := catalog.New(st, slog.New(handler)).Metrics(ctx); err == nil {
if _, err := metricsOf(ctx, catalog.New(st, slog.New(handler))); err == nil {
t.Fatal("каталог собрался на отменённом контексте")
}
if _, ok := handler.find("catalog failed"); ok {
@@ -498,3 +498,25 @@ func TestКаталогНеПутаетОтменуСоСбоем(t *testing.T)
t.Error("отмена не отмечена вовсе — исход операции обязан быть виден")
}
}
// metricsOf — список метрик каталога без версии витрины: версию проверяют
// отдельные тесты, остальным нужен только состав ответа.
func metricsOf(ctx context.Context, s *catalog.Service) ([]catalog.Metric, error) {
snap, err := s.Metrics(ctx)
return snap.Metrics, err
}
// Каталог подписывает свой ответ версией витрины: без неё транспорту нечего
// поставить в `ETag`, и условный запрос не работает вовсе.
func TestКаталогОтдаётсяСВерсиейВитрины(t *testing.T) {
t.Parallel()
st := openStore(t)
snap, err := service(t, st).Metrics(context.Background())
if err != nil {
t.Fatalf("каталог: %v", err)
}
if snap.Version == "" {
t.Error("каталог собран на стоящей витрине и остался без версии")
}
}
+30
View File
@@ -0,0 +1,30 @@
package catalog
import (
"testing"
"time"
)
// Ответ каталога есть функция снимка И горизонта измерения: метка из будущего,
// лежащая в витрине, въезжает в окно сама, с ходом часов и без единого коммита.
// Построено враждебным проходом: та же версия витрины, `cumulative` против
// `unknown`. Значит горизонт обязан входить в метку — иначе клиент с
// `If-None-Match` получит `304` на изменившийся ответ.
func TestГоризонтВходитВВерсиюОтвета(t *testing.T) {
t.Parallel()
at := time.Date(2026, 6, 1, 10, 30, 0, 0, time.UTC)
if stamp("v", at) == stamp("v", at.Add(2*time.Hour)) {
t.Error("версия не изменилась при сдвиге горизонта на два часа")
}
// Огрубление до часа точное, а не приблизительное: `hour_utc` объектов лежит
// ровно на часах, поэтому отбор меняется ровно при переходе через час.
// Внутри часа метка обязана стоять — иначе она дребезжала бы ежесекундно.
if stamp("v", at) != stamp("v", at.Add(20*time.Minute)) {
t.Error("версия сдвинулась внутри одного часа — метка дребезжит на месте")
}
if stamp("", at) != "" {
t.Error("пустая версия витрины подписана горизонтом — подписывать нечем")
}
}