httpapi: точки метрики за период отдаются одним запросом

- `GET /api/v1/metrics/{name}?from&to&layer` — ряд точек за период; конверт
  объявляет слой, измеренный род, его применимость к отданному ряду и границу
  окна измерения, а сам ряд собирается из одного слоя, выбранного по охвату
  точек внутри периода
- use-case вынесен в `internal/points`, чтение — одним входом `store.ReadSeries`
  под одной транзакцией; правило выбора слоя остаётся в домене и приходит в
  хранилище колбэком
- `writeJSON` перестал экранировать HTML-символы и перестал глушить отказ
  записи: дословность содержимого точки иначе не удерживается, а оборванное
  тело уходило под видом успешного `200`
This commit is contained in:
av
2026-08-04 18:46:45 +03:00
parent b819b77f62
commit 29ca8d415c
36 changed files with 4721 additions and 58 deletions
+274
View File
@@ -0,0 +1,274 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"sort"
"strings"
"time"
)
// Запросы ряда точек. Вынесены константами по той же причине, что и запросы
// каталога: по ним проверяется план выполнения.
const (
// Объекты выбранного слоя. Точный префикс первичного ключа
// (metric, layer, hour_utc); `payload` здесь и нужен.
seriesPointsQuery = `
SELECT hour_utc, units, payload
FROM bucket
WHERE metric = ? AND layer = ? AND hour_utc BETWEEN ? AND ?
ORDER BY hour_utc`
)
// layerSpansQuery строит выборку охватов слоёв метрики внутри периода.
//
// Границы берутся ТОЧНЫЕ (`first_ts`/`last_ts`), а не по `hour_utc`: объект
// адресуется часом, а ряд отбирается точной меткой, и на периоде короче часа
// эти два множества расходятся. Слой, выбранный по часам, отдал бы пустой ряд
// при непустых данных соседнего слоя — час объекта попадает в период, а его
// единственная точка в период не попадает.
//
// СЛОИ ПЕРЕЧИСЛЕНЫ ЯВНО, и это не украшение запроса, а его цена. Индекс
// `bucket_catalog` идёт `(metric, layer, hour_utc, …)`; без предиката по слою
// SQLite не может сузить поиск по `hour_utc` внутри индекса и просматривает
// ВСЕ строки метрики за всю историю, применяя период построчным фильтром. План
// при этом выглядит успешным (`SEARCH … USING COVERING INDEX`), а цена растёт
// вместе с возрастом сервиса при любой ширине запроса: измерено эксплуатационным
// проходом ревью на копии схемы — 2.06 мс при 52 560 строках метрики против
// 13.9 мс при 350 400, и 0.026 мс с этим перечислением. Словарь слоёв задаёт
// вызывающий: правило принадлежит домену, хранилище лишь выбирает по нему.
func layerSpansQuery(layers int) string {
return `
SELECT layer, min(first_ts), max(last_ts)
FROM bucket
WHERE metric = ? AND layer IN (` +
strings.TrimSuffix(strings.Repeat("?,", layers), ",") + `)
AND hour_utc BETWEEN ? AND ?
GROUP BY layer
ORDER BY layer`
}
// errEmptyWindow — период задан вывернутым. Нарушенный инвариант вызывающего:
// транспорт обязан отвергнуть такой запрос раньше.
var errEmptyWindow = errors.New("окно ряда: from не раньше to")
// errNoLayers — словарь слоёв не задан. Без него выборка охватов выродилась бы
// в скан всей истории метрики, а не отдала бы пустой результат: молчаливая
// деградация цены хуже отказа.
var errNoLayers = errors.New("окно ряда: словарь слоёв пуст")
// LayerSpan — охват одного слоя метрики внутри запрошенного периода.
//
// First и Last — метки первой и последней ТОЧКИ объектов слоя, попавших в
// границы часов запроса. Это границы данных, а не обещание покрытия: внутри
// законно есть дыры.
type LayerSpan struct {
Layer string
First time.Time
Last time.Time
}
// SeriesPoint — точка ряда вместе с единицами объекта, из которого она
// прочитана.
//
// Единицы едут с точкой, а не с рядом: они хранятся на часовом объекте, и
// метрика, чьи объекты разошлись единицами, обязана показать это строкой, а не
// выбрать одно из двух молча.
type SeriesPoint struct {
Point
Units string
}
// SeriesWindow — что читать. Все правила задаёт вызывающий: хранилище выбирает
// строки, а не решает, какие из них правильные.
type SeriesWindow struct {
Metric string
// From включительно, To исключительно.
From, To time.Time
// Layer — явно запрошенный слой; пустая строка означает «выбери сам»,
// и тогда зовётся pick.
Layer string
// Layers — словарь слоёв, среди которых вообще имеет смысл искать. Задаёт
// вызывающий: перечень слоёв — знание домена, а хранилищу он нужен, чтобы
// выборка охватов не превращалась в скан всей истории метрики.
Layers []string
// Measure — окно измерения рода агрегации той же метрики.
Measure CatalogWindow
}
// SeriesSnapshot — весь вход ответа точек, снятый ОДНОЙ транзакцией чтения.
//
// Единый снимок здесь не аккуратность, а условие непротиворечивости: приём идёт
// непрерывно, и фоновая свёртка вправе закоммитить между выбором слоя и чтением
// точек. Тогда слой выбран по одному состоянию витрины, ряд прочитан по
// второму, а род измерен по третьему — ответ внутренне противоречив и от свежего
// неотличим. Пара проб версии такой ответ ОБНАРУЖИТ (метки не будет), но не
// предотвратит: тело всё равно уедет. Тот же довод записан у входа каталога.
type SeriesSnapshot struct {
// Охватов слоёв здесь НЕТ намеренно: они приходят в pick аргументом, и это
// единственные ворота решения о слое. Поле наружу предлагало бы те же данные
// любому будущему вызывающему и приглашало бы решить слой пост-фактум, мимо
// правила, — двое ворот к одному решению.
//
// Layer — слой, из которого собран ряд. Пустая строка означает, что слоя
// нет: выбирать было не из чего либо запрошенный слой пуст.
Layer string
// Points — ряд, отобранный до точных границ периода и упорядоченный.
Points []SeriesPoint
// Pairs — общие часы метрики, от самых свежих к старым.
Pairs []HourPair
}
// ReadSeries снимает вход ответа точек одной транзакцией чтения.
//
// Правило выбора слоя остаётся В ДОМЕНЕ и приходит сюда функцией pick,
// вызываемой ВНУТРИ транзакции. Форма не изобретена: VersionedRead уже
// принимает работу колбэком, а ReadCatalog уже получает параметры правила
// структурой. Перенести само правило сюда значило бы вернуть в хранилище
// решение, которое из него специально убирали.
//
// pick зовётся только когда слой не задан явно и есть из чего выбирать; вернуть
// он может пустую строку — это законный исход «ряда нет».
func (s *Store) ReadSeries(ctx context.Context, w SeriesWindow, pick func([]LayerSpan) string) (SeriesSnapshot, error) {
// Границы В ТЕКСТ ОШИБКИ НЕ ИДУТ. Текст доезжает до записи лога вызывающего,
// а границы периода — часть запроса о здоровье; сегодня транспорт отвергает
// такой запрос раньше, но второй вызывающий (адаптер MCP) откроет этот путь.
if !w.From.Before(w.To) {
return SeriesSnapshot{}, errEmptyWindow
}
if len(w.Layers) == 0 {
return SeriesSnapshot{}, errNoLayers
}
// `ReadOnly` у `modernc.org/sqlite` выбирает `BEGIN` вместо
// `BEGIN IMMEDIATE` и записи НЕ ЗАПРЕЩАЕТ (прочитан исходник драйвера
// зафиксированной версии). То, что этот путь не пишет, держится ревью, а не
// драйвером; полагаться на флаг как на защиту нельзя.
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true})
if err != nil {
return SeriesSnapshot{}, fmt.Errorf("begin read tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
// Границы по часам ШИРЕ запроса: точка 10:59 живёт в объекте 10:00, и
// огрубление до часа — единственный способ её не потерять. Точный отбор
// идёт ниже, по меткам самих точек.
fromHour := FormatTime(w.From.UTC().Truncate(time.Hour))
toHour := FormatTime(w.To.UTC().Truncate(time.Hour))
spans, err := readLayerSpans(ctx, tx, w, fromHour, toHour)
if err != nil {
return SeriesSnapshot{}, err
}
out := SeriesSnapshot{Layer: w.Layer}
if out.Layer == "" && pick != nil {
out.Layer = pick(spans)
}
if out.Layer != "" {
// Слой ВЫБРАННЫЙ, а не запрошенный: они расходятся ровно тогда, когда
// правило сработало, — то есть в самом частом случае.
if out.Points, err = readSeriesPoints(ctx, tx, w, out.Layer, fromHour, toHour); err != nil {
return SeriesSnapshot{}, err
}
}
pairs, err := commonHours(ctx, tx, w.Metric, w.Measure)
if err != nil {
return SeriesSnapshot{}, err
}
if len(pairs) > 0 {
if err := readHourPairs(ctx, tx, w.Metric, w.Measure, pairs); err != nil {
return SeriesSnapshot{}, err
}
}
out.Pairs = pairs
return out, nil
}
func readLayerSpans(ctx context.Context, tx *sql.Tx, w SeriesWindow, fromHour, toHour string) ([]LayerSpan, error) {
args := make([]any, 0, len(w.Layers)+3)
args = append(args, w.Metric)
for _, l := range w.Layers {
args = append(args, l)
}
args = append(args, fromHour, toHour)
rows, err := tx.QueryContext(ctx, layerSpansQuery(len(w.Layers)), args...)
if err != nil {
return nil, fmt.Errorf("select layer spans: %w", err)
}
defer func() { _ = rows.Close() }()
out := make([]LayerSpan, 0, 4)
for rows.Next() {
var sp LayerSpan
var first, last string
if err := rows.Scan(&sp.Layer, &first, &last); err != nil {
return nil, fmt.Errorf("scan layer span: %w", err)
}
if sp.First, err = ParseTime(first); err != nil {
return nil, err
}
if sp.Last, err = ParseTime(last); err != nil {
return nil, err
}
out = append(out, sp)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("select layer spans: %w", err)
}
return out, nil
}
// readSeriesPoints разжимает объекты выбранного слоя и отбирает точки до точных
// границ периода.
//
// Принадлежность точки периоду определяется её НАЧАЛОМ — тем же правилом, каким
// час объекта берётся по началу точки. Цена названа в спеке: интервал,
// начавшийся раньше from, в ответ не входит.
func readSeriesPoints(ctx context.Context, tx *sql.Tx, w SeriesWindow, layer, fromHour, toHour string) ([]SeriesPoint, error) {
rows, err := tx.QueryContext(ctx, seriesPointsQuery, w.Metric, layer, fromHour, toHour)
if err != nil {
return nil, fmt.Errorf("select series points: %w", err)
}
defer func() { _ = rows.Close() }()
// Непустой срез, а не nil: пустой ряд обязан уехать клиенту как `[]`.
out := make([]SeriesPoint, 0, 64)
for rows.Next() {
var hour, units string
var payload []byte
if err := rows.Scan(&hour, &units, &payload); err != nil {
return nil, fmt.Errorf("scan series bucket: %w", err)
}
points, err := decodePayload(payload)
if err != nil {
return nil, err
}
for _, p := range points {
if p.Start.Before(w.From) || !p.Start.Before(w.To) {
continue
}
out = append(out, SeriesPoint{Point: p, Units: units})
}
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("select series points: %w", err)
}
// Порядок утверждается здесь, а не наследуется от порядка объектов: на нём
// стоят и байтовое утверждение формы ответа, и метка условного запроса.
// Пара (начало, конец) внутри одного слоя одной метрики есть ключ
// идентичности, поэтому порядок ею определён однозначно.
sort.SliceStable(out, func(i, j int) bool {
if !out[i].Start.Equal(out[j].Start) {
return out[i].Start.Before(out[j].Start)
}
return out[i].End.Before(out[j].End)
})
return out, nil
}
+286
View File
@@ -0,0 +1,286 @@
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("пустой словарь слоёв принят молча")
}
}