- `GET /api/v1/metrics/{name}?from&to&layer` — ряд точек за период; конверт
объявляет слой, измеренный род, его применимость к отданному ряду и границу
окна измерения, а сам ряд собирается из одного слоя, выбранного по охвату
точек внутри периода
- use-case вынесен в `internal/points`, чтение — одним входом `store.ReadSeries`
под одной транзакцией; правило выбора слоя остаётся в домене и приходит в
хранилище колбэком
- `writeJSON` перестал экранировать HTML-символы и перестал глушить отказ
записи: дословность содержимого точки иначе не удерживается, а оборванное
тело уходило под видом успешного `200`
287 lines
14 KiB
Go
287 lines
14 KiB
Go
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("пустой словарь слоёв принят молча")
|
|
}
|
|
}
|