package store_test import ( "context" "encoding/json" "testing" "time" "git.vakhrushev.me/av/healthlog/internal/store" ) // seriesStore заводит витрину с точками, разложенными по слоям. func seriesStore(t *testing.T, in ...store.IncomingPoint) *store.Store { t.Helper() st := open(t) if _, err := st.Merge(context.Background(), store.Incoming{Points: in}, store.DeliveryRef{ID: "d"}); err != nil { t.Fatalf("слияние: %v", err) } return st } // testLayers — словарь слоёв: его задаёт домен, и хранилищу он приходит // параметром. var testLayers = []string{"sample", "raw", "minute", "hour", "day"} func seriesAt(hh, mm int) time.Time { return time.Date(2026, 6, 1, hh, mm, 0, 0, time.UTC) } func seriesPoint(metric, layer string, start time.Time) store.IncomingPoint { return seriesInterval(metric, layer, start, start) } func seriesInterval(metric, layer string, start, end time.Time) store.IncomingPoint { return store.IncomingPoint{ Metric: metric, Layer: layer, Units: "count", Point: store.Point{Start: start, End: end, Raw: json.RawMessage(`{"qty":1}`)}, } } // readSeries — обёртка с правилом «бери первый попавшийся слой»: правило выбора // принадлежит домену, а хранилищу проверяется выборка. func readSeries(t *testing.T, st *store.Store, metric string, from, to time.Time, layer string) store.SeriesSnapshot { t.Helper() snap, err := st.ReadSeries(context.Background(), store.SeriesWindow{ Metric: metric, From: from, To: to, Layer: layer, Layers: testLayers, Measure: store.CatalogWindow{Fine: "minute", Coarse: "hour", Hours: 48, Horizon: seriesAt(23, 0), CoarsePoints: 1, MinFinePoints: 2}, }, func(spans []store.LayerSpan) string { if len(spans) == 0 { return "" } return spans[0].Layer }) if err != nil { t.Fatalf("ReadSeries: %v", err) } return snap } // Точка на 10:59 живёт в объекте 10:00. Выборка объектов обязана быть ШИРЕ // запроса, иначе такая точка теряется молча — и потеря видна только тому, кто // пересчитает точки руками. func TestРядНеТеряетТочкуВКонцеЧаса(t *testing.T) { st := seriesStore(t, seriesPoint("m", "minute", seriesAt(10, 59))) snap := readSeries(t, st, "m", seriesAt(10, 30), seriesAt(11, 0), "minute") if len(snap.Points) != 1 { t.Fatalf("точек %d, ожидалась 1", len(snap.Points)) } if !snap.Points[0].Start.Equal(seriesAt(10, 59)) { t.Errorf("метка %s, ожидалась 10:59", snap.Points[0].Start) } } // Полуинтервал: точка ровно на `from` в ответе есть, ровно на `to` — нет. Иначе // два соседних окна посчитали бы граничную точку дважды. func TestРядБерётГраницыПолуинтервалом(t *testing.T) { st := seriesStore(t, seriesPoint("m", "minute", seriesAt(10, 0)), seriesPoint("m", "minute", seriesAt(11, 0)), ) snap := readSeries(t, st, "m", seriesAt(10, 0), seriesAt(11, 0), "minute") if len(snap.Points) != 1 { t.Fatalf("точек %d, ожидалась 1 (только на from)", len(snap.Points)) } if !snap.Points[0].Start.Equal(seriesAt(10, 0)) { t.Errorf("в ответ попала точка %s, а не граница from", snap.Points[0].Start) } } // Охваты меряются метками ТОЧЕК, а не часами объектов, и наблюдается это // ИСХОДОМ: на периоде короче часа часовой объект попадает в границы часов // запроса, а его точка в период не попадает. Слой, выбранный по часам, отдал бы // пустой ряд при непустых минутных данных. // // Утверждение стоит на исходе, а не на промежуточных охватах: охваты приходят в // правило выбора аргументом и наружу не отдаются — двое ворот к одному решению // однажды разошлись бы. func TestОхватыСлоёвМеряютсяМеткамиТочек(t *testing.T) { st := seriesStore(t, seriesPoint("m", "hour", seriesAt(10, 0)), seriesPoint("m", "minute", seriesAt(10, 31)), seriesPoint("m", "minute", seriesAt(10, 44)), ) var offered []store.LayerSpan snap, err := st.ReadSeries(context.Background(), store.SeriesWindow{ Metric: "m", From: seriesAt(10, 30), To: seriesAt(10, 45), Layers: testLayers, Measure: store.CatalogWindow{Fine: "minute", Coarse: "hour", Hours: 48, Horizon: seriesAt(23, 0), CoarsePoints: 1, MinFinePoints: 2}, }, func(spans []store.LayerSpan) string { offered = spans // Правило домена в миниатюре: слой, чьи метки лежат вне периода, выбывает. for _, sp := range spans { if !sp.Last.Before(seriesAt(10, 30)) && sp.First.Before(seriesAt(10, 45)) { return sp.Layer } } return "" }) if err != nil { t.Fatalf("ReadSeries: %v", err) } if len(offered) != 2 { t.Fatalf("правилу предложено %d слоёв, ожидалось 2", len(offered)) } if snap.Layer != "minute" { t.Errorf("выбран слой %q, ожидался minute: у часового нет точек внутри периода", snap.Layer) } if len(snap.Points) != 2 { t.Errorf("точек %d, ожидалось 2 — ряд пуст при непустых данных", len(snap.Points)) } } // Пустой период — законный исход, а не отказ. Срез точек при этом непустой: // nil уехал бы клиенту как `null`. func TestРядПустогоПериодаОтдаётПустойСрез(t *testing.T) { st := seriesStore(t, seriesPoint("m", "minute", seriesAt(10, 0))) snap := readSeries(t, st, "m", seriesAt(20, 0), seriesAt(21, 0), "minute") if len(snap.Points) != 0 { t.Fatalf("точек %d, ожидалось 0", len(snap.Points)) } if snap.Points == nil { t.Error("срез точек nil — пустой ряд уедет клиенту как null") } } // Ряд упорядочен по (начало, конец) независимо от порядка объектов и точек в // хранилище: на этом порядке стоят и байтовое утверждение формы, и метка // условного запроса. func TestРядУпорядоченПоКоординате(t *testing.T) { st := seriesStore(t, seriesPoint("m", "minute", seriesAt(11, 0)), seriesPoint("m", "minute", seriesAt(10, 0)), seriesPoint("m", "minute", seriesAt(10, 30)), ) snap := readSeries(t, st, "m", seriesAt(9, 0), seriesAt(12, 0), "minute") if len(snap.Points) != 3 { t.Fatalf("точек %d, ожидалось 3", len(snap.Points)) } for i := 1; i < len(snap.Points); i++ { if snap.Points[i].Start.Before(snap.Points[i-1].Start) { t.Fatalf("порядок нарушен: %s после %s", snap.Points[i].Start, snap.Points[i-1].Start) } } } // Вывернутый период — отказ хранилища, а не пустой ответ: такой запрос до // витрины доходить не должен, и если дошёл, молчать об этом нельзя. func TestРядОтвергаетВывернутыйПериод(t *testing.T) { st := seriesStore(t, seriesPoint("m", "minute", seriesAt(10, 0))) _, err := st.ReadSeries(context.Background(), store.SeriesWindow{ Metric: "m", From: seriesAt(11, 0), To: seriesAt(10, 0), Layers: testLayers, Measure: store.CatalogWindow{Fine: "minute", Coarse: "hour", Hours: 48, Horizon: seriesAt(23, 0)}, }, nil) if err == nil { t.Error("вывернутый период принят молча") } } // Закрытое хранилище — отказ, а не пустой ряд. Ветка редкая, но молчащая: // маршрут чтения, получивший пустой ряд вместо ошибки, отдал бы клиенту // «данных нет» на остановленном сервисе. func TestРядНаЗакрытомХранилищеОтказывает(t *testing.T) { st := seriesStore(t, seriesPoint("m", "minute", seriesAt(10, 0))) if err := st.Close(); err != nil { t.Fatalf("закрытие: %v", err) } _, err := st.ReadSeries(context.Background(), store.SeriesWindow{ Metric: "m", From: seriesAt(9, 0), To: seriesAt(11, 0), Layer: "minute", Layers: testLayers, Measure: store.CatalogWindow{Fine: "minute", Coarse: "hour", Hours: 48, Horizon: seriesAt(23, 0)}, }, nil) if err == nil { t.Error("закрытое хранилище отдало ряд") } } // Порядок при СОВПАДАЮЩЕМ начале решается концом координаты. Ветка достижима // только на интервальных точках — а под одной меткой лежит до трёх записей сна, // и на их порядке стоят и байтовое утверждение формы ответа, и метка условного // запроса. func TestРядУпорядоченПоКонцуПриРавномНачале(t *testing.T) { start := seriesAt(1, 0) st := seriesStore(t, seriesInterval("sleep_analysis", "raw", start, seriesAt(3, 30)), seriesInterval("sleep_analysis", "raw", start, seriesAt(2, 0)), seriesInterval("sleep_analysis", "raw", start, seriesAt(2, 45)), ) snap := readSeries(t, st, "sleep_analysis", seriesAt(0, 0), seriesAt(5, 0), "raw") if len(snap.Points) != 3 { t.Fatalf("точек %d, ожидалось 3: одна метка, разные интервалы", len(snap.Points)) } for i, want := range []time.Time{seriesAt(2, 0), seriesAt(2, 45), seriesAt(3, 30)} { if !snap.Points[i].Start.Equal(start) { t.Fatalf("точка %d начинается в %s, а не в общей метке", i, snap.Points[i].Start) } if !snap.Points[i].End.Equal(want) { t.Errorf("точка %d кончается в %s, ожидалось %s", i, snap.Points[i].End, want) } } } // ОДИН СНИМОК ВИТРИНЫ на весь ответ, а не три чтения подряд. // // Приём идёт непрерывно, и фоновая свёртка вправе закоммитить между выбором // слоя и чтением точек. Тогда слой выбран по одному состоянию, ряд прочитан по // второму, а род измерен по третьему — ответ внутренне противоречив и от // свежего неотличим. Пара проб версии такой ответ обнаружит (метки не будет), // но НЕ предотвратит: тело всё равно уедет. // // Оракул точный: колбэк выбора слоя — готовая точка вклинивания, и коммит из // него идёт другим соединением, пока транзакция чтения открыта. func TestРядСобранИзОдногоСнимкаВитрины(t *testing.T) { st := seriesStore(t, seriesPoint("m", "minute", seriesAt(10, 0))) ctx := context.Background() committed := false snap, err := st.ReadSeries(ctx, store.SeriesWindow{ Metric: "m", From: seriesAt(9, 0), To: seriesAt(12, 0), Layers: testLayers, Measure: store.CatalogWindow{Fine: "minute", Coarse: "hour", Hours: 48, Horizon: seriesAt(23, 0), CoarsePoints: 1, MinFinePoints: 2}, }, func(spans []store.LayerSpan) string { // Свёртка коммитит ВНУТРИ чтения — ровно то, что делает воркер. if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{ seriesPoint("m", "minute", seriesAt(10, 30)), }}, store.DeliveryRef{ID: "late"}); err != nil { t.Errorf("слияние во время чтения: %v", err) return "" } committed = true return "minute" }) if err != nil { t.Fatalf("ReadSeries: %v", err) } if !committed { t.Fatal("коммит во время чтения не состоялся — утверждение ничего не доказывает") } if len(snap.Points) != 1 { t.Fatalf("точек %d, ожидалась 1: ряд собран из двух состояний витрины", len(snap.Points)) } if !snap.Points[0].Start.Equal(seriesAt(10, 0)) { t.Errorf("в ряд попала точка %s, закоммиченная уже после выбора слоя", snap.Points[0].Start) } } // Пустой словарь слоёв — отказ, а не пустой ответ. Без предиката по слою // выборка охватов просматривает все строки метрики за всю историю, и молчаливая // деградация цены хуже отказа: план запроса при этом выглядит успешным. func TestРядТребуетСловарьСлоёв(t *testing.T) { st := seriesStore(t, seriesPoint("m", "minute", seriesAt(10, 0))) _, err := st.ReadSeries(context.Background(), store.SeriesWindow{ Metric: "m", From: seriesAt(9, 0), To: seriesAt(11, 0), Measure: store.CatalogWindow{Fine: "minute", Coarse: "hour", Hours: 48, Horizon: seriesAt(23, 0)}, }, nil) if err == nil { t.Error("пустой словарь слоёв принят молча") } }