Цена читающего маршрута: чекпойнт WAL по таймеру и условный запрос
- рядом с воркером свёртки живёт горутина, раз в минуту разбирающая журнал пассивным чекпойнтом; «журнал не разбирается» видно строкой владельцу, а не только по `df`. Признак — пара чисел, а не флаг занятости: тот молчит под удерживаемым читателем (`busy=0` при 6256 страницах и пяти перенесённых), а при занятой блокировке отдаёт `-1` вместо ответа, и `-1 >= -1` читалось бы как «разобрано целиком» - каталог отвечает `304` на `If-None-Match`, не открывая снимок витрины. Метка собрана из всего, от чего зависит ответ: версии витрины (`data_version` с закреплённого соединения плюс поколение — значение локально для соединения и не переживает переоткрытия), горизонта измерения и области действия ресурса. Версия снимается до и после сборки: снятая после пометила бы устаревший снимок свежим номером - предел и дедлайн ответа отложены в задачу Read API точек вместе с измеренной ценой первого запроса; попутно починен флаки-тест чужой задачи, искавший значение точки в сыром буфере записи лога
This commit is contained in:
+75
-11
@@ -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 {
|
||||
|
||||
@@ -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("каталог собран на стоящей витрине и остался без версии")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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("пустая версия витрины подписана горизонтом — подписывать нечем")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user