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
+148
View File
@@ -0,0 +1,148 @@
package points_test
import (
"context"
"encoding/json"
"log/slog"
"os"
"path/filepath"
"runtime"
"testing"
"time"
"git.vakhrushev.me/av/healthlog/internal/hae"
"git.vakhrushev.me/av/healthlog/internal/points"
"git.vakhrushev.me/av/healthlog/internal/store"
)
// ЦЕНА МАРШРУТА ТОЧЕК, ЗАМЕР.
//
// Предела размера ответа у маршрута нет намеренно — его вводит соседняя задача
// `read-api-response-limit`. Поэтому цена обязана быть НАЗВАНА ЧИСЛОМ, и число
// снимается на том режиме, ради которого предел заводится, а не на том, который
// оказался под рукой: прецедент 2026-08-04 (`docs/review.md`) — замер, снятый на
// корпусе, где измеряемого случая не бывает, стоил решения «индекс не нужен».
//
// Три режима, от лёгкого к худшему:
//
// редкая метрика за год — «вес за год», сценарий постановки;
// плотная метрика за сутки — минутный слой, штатный запрос трекера;
// плотная метрика за неделю в нижнем слое — то, что правило выбора слоя
// отдаёт по умолчанию, когда охваты равны.
//
// Точки размножаются из РЕАЛЬНОГО пакета HAE (`internal/hae/testdata`), а не
// пишутся литералами: форма точки, длина строк и вид числового литерала входят
// в цену — они определяют и объём gzip, и работу разжатия.
//
// Прогон: go test ./internal/points -run XXX -bench . -benchtime 1x
func BenchmarkРядТочек(b *testing.B) {
cases := []struct {
name string
layer hae.Layer
hours int
perDay int
}{
// Год, взвешивание примерно раз в сутки.
{"редкая метрика за год", hae.LayerHour, 365 * 24, 1},
// Сутки минутного слоя: 60 точек в час.
{"плотная метрика за сутки, minute", hae.LayerMinute, 24, 24 * 60},
// Неделя нижнего слоя. Порядок взят из разведки: около 100 тысяч
// координат в сутки на весь поток; на одну плотную метрику — 3600 в час.
{"плотная метрика за неделю, raw", hae.LayerRaw, 7 * 24, 24 * 3600},
}
for _, c := range cases {
b.Run(c.name, func(b *testing.B) {
svc, from, to := benchStore(b, c.layer, c.hours, c.perDay)
var before, after runtime.MemStats
runtime.GC()
runtime.ReadMemStats(&before)
b.ResetTimer()
var got points.Series
for range b.N {
var err error
got, err = svc.Series(context.Background(), points.Request{
Metric: "bench_metric", From: from, To: to, Layer: string(c.layer),
})
if err != nil {
b.Fatalf("Series: %v", err)
}
}
b.StopTimer()
runtime.ReadMemStats(&after)
b.ReportMetric(float64(len(got.Points)), "точек")
b.ReportMetric(float64(after.TotalAlloc-before.TotalAlloc)/(1<<20)/float64(b.N), "МиБ_выделено")
})
}
}
// benchStore наполняет витрину точками, размноженными из реального пакета HAE.
func benchStore(b *testing.B, layer hae.Layer, hours, perDay int) (*points.Service, time.Time, time.Time) {
b.Helper()
raw := realPointBody(b)
st, err := store.Open(filepath.Join(b.TempDir(), "healthlog.db"))
if err != nil {
b.Fatalf("store.Open: %v", err)
}
b.Cleanup(func() { _ = st.Close() })
perHour := max(perDay/24, 1)
step := time.Hour / time.Duration(perHour)
start := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
ctx := context.Background()
// Пачками по часу: одна транзакция на весь год держала бы блокировку минуты.
for h := range hours {
hour := start.Add(time.Duration(h) * time.Hour)
in := make([]store.IncomingPoint, 0, perHour)
for i := range perHour {
at := hour.Add(time.Duration(i) * step)
in = append(in, store.IncomingPoint{
Metric: "bench_metric", Layer: string(layer), Units: "count",
Point: store.Point{Start: at, End: at, OffsetSeconds: 10800, Raw: raw},
})
}
if _, err := st.Merge(ctx, store.Incoming{Points: in}, store.DeliveryRef{ID: "bench"}); err != nil {
b.Fatalf("слияние: %v", err)
}
}
svc := points.New(st, slog.New(slog.DiscardHandler))
return svc, start, start.Add(time.Duration(hours) * time.Hour)
}
// realPointBody достаёт содержимое настоящей точки из пакета HAE.
//
// Конвенция проекта: тесты формата держим на реальных пакетах. Здесь она нужна
// не ради разбора, а ради ЦЕНЫ — выдуманная точка `{"qty":1}` жмётся иначе и
// разжимается быстрее, чем то, что реально шлёт телефон.
func realPointBody(b *testing.B) json.RawMessage {
b.Helper()
body, err := os.ReadFile(filepath.Join("..", "hae", "testdata", "minute.json"))
if err != nil {
b.Fatalf("чтение пакета: %v", err)
}
var pkg struct {
Data struct {
Metrics []struct {
Data []json.RawMessage `json:"data"`
} `json:"metrics"`
} `json:"data"`
}
if err := json.Unmarshal(body, &pkg); err != nil {
b.Fatalf("разбор пакета: %v", err)
}
for _, m := range pkg.Data.Metrics {
if len(m.Data) > 0 {
return m.Data[0]
}
}
b.Fatal("в пакете нет ни одной точки")
return nil
}
+244
View File
@@ -0,0 +1,244 @@
// Package points — ряд точек одной метрики за период.
//
// Отвечает на вопрос потребителя «дай значения» — в отличие от каталога,
// который отвечает «что у тебя есть». Пакет производен от витрины и ничего в
// неё не пишет.
//
// Три решения этого узла живут здесь, потому что все три — правила, а не
// выборки:
//
// - какой слой отдать, когда клиент его не назвал;
// - применим ли измеренный род к отданному ряду (нижний слой HAE не
// суммируется никогда, а род — свойство метрики, не ряда);
// - из чего собрана метка ответа.
//
// Род при этом НЕ измеряется здесь: правило измерения одно и живёт в
// `internal/catalog`. Второй его экземпляр разошёлся бы с первым молча, а на
// роде строится арифметика года.
package points
import (
"context"
"encoding/json"
"log/slog"
"time"
"git.vakhrushev.me/av/healthlog/internal/catalog"
"git.vakhrushev.me/av/healthlog/internal/hae"
"git.vakhrushev.me/av/healthlog/internal/store"
)
// ФОРМЫ ПРОВОДА В ЭТОМ ПАКЕТЕ НЕТ. Типы ниже — форма ответа use-case:
// `json`-тегов они не несут и до сериализации не доезжают. Публичный контракт
// объявляет транспорт (`internal/httpapi`), см. ADR о форме провода.
// Request — запрос ряда. Границы уже разобраны и нормализованы транспортом;
// период — полуинтервал `[From, To)`.
type Request struct {
Metric string
From time.Time
To time.Time
// Layer — явно запрошенный слой; пустая строка означает «выбери сам».
Layer string
}
// Point — точка ряда.
//
// `Raw` — содержимое ровно в том виде, в каком его сохранило хранилище: точки
// хранятся дословно, и нормализовано у них только время.
type Point struct {
TS time.Time
End time.Time
OffsetSeconds int
Units string
Raw json.RawMessage
}
// Series — ответ маршрута точек вместе с версией ответа.
//
// Версия пустая, когда подписать ответ нечем: витрина изменилась, пока ответ
// собирался, или прочитать её версию не удалось. Это не отказ.
type Series struct {
Version string
Metric string
From time.Time
To time.Time
// Layer — слой, из которого собран ряд. Пустая строка означает, что слой
// выбирала система и выбирать было не из чего; явно запрошенный слой
// уезжает здесь всегда, даже когда ряд пуст.
Layer string
// Style — измеренный род метрики, тем же правилом и тем же окном, что у
// каталога.
Style catalog.Style
// Applicable — применим ли объявленный род к ЭТОМУ ряду.
Applicable bool
// LastHour — ярлык самого свежего часа окна измерения; nil при пустом окне.
LastHour *time.Time
Points []Point
}
// Service собирает ряд точек по витрине.
type Service struct {
store *store.Store
log *slog.Logger
}
// New собирает сервис точек.
func New(st *store.Store, log *slog.Logger) *Service {
return &Service{store: st, log: log}
}
// Series собирает ряд точек метрики за период.
//
// Всё, от чего зависит ответ, снимается ОДНОЙ транзакцией чтения: охваты слоёв,
// точки выбранного слоя и объекты окна измерения. Пара проб версии вокруг неё
// нужна для метки, а непротиворечивость тела даёт транзакция — проба
// расхождение обнаружила бы, но тело всё равно уехало бы клиенту.
//
// Горизонт снимается ОДИН РАЗ и уходит и в окно измерения, и в метку: род есть
// функция горизонта, а горизонт едет вместе с часами. Сними их порознь — и
// метка однажды подтвердит неизменность ответа, чей род уже перевернулся.
func (s *Service) Series(ctx context.Context, req Request) (Series, error) {
horizon := catalog.Horizon()
var snap store.SeriesSnapshot
version, err := s.store.VersionedRead(ctx, func(ctx context.Context) error {
var err error
snap, err = s.store.ReadSeries(ctx, store.SeriesWindow{
Metric: req.Metric,
From: req.From,
To: req.To,
Layer: req.Layer,
Layers: layerNames,
Measure: catalog.MeasureWindow(horizon),
}, func(spans []store.LayerSpan) string {
return pickLayer(req.From, req.To, spans)
})
return err
})
if err != nil { //nolint:nestif // ветка одна, вложенность даёт лог по адресату
// Единственный логирующий чекпоинт исхода: транспорт переводит ошибку в
// ответ и второй раз её не пишет. Границ запроса и значений точек в
// записи нет — данные о здоровье чувствительнее токенов; имя метрики
// обрезано тем же пределом, что у каталога: оно приходит из тела
// дословно, а запись повторяется на каждый запрос.
//
// Отмена снаружи и занятость базы означают «не сделано», а не «не
// выходит»: клиент, оборвавший запрос по своему тайм-ауту, не должен
// давать владельцу ERROR.
if store.Transient(err) {
s.log.DebugContext(ctx, "series interrupted", "capability", "query",
"metric", catalog.ClipMetric(req.Metric), "error", err)
} else {
s.log.ErrorContext(ctx, "series failed", "capability", "query",
"metric", catalog.ClipMetric(req.Metric), "error", err)
}
return Series{}, err
}
// Предупреждения измерения (противоречащий род, данные из будущего) здесь
// НЕ пишутся: они привилегия каталога. Агент опрашивает по расписанию, и
// WARN на каждый опрос обесценил бы уровень ровно так же, как обесценила бы
// его строка на каждый `304`.
style, basis := catalog.Measure(snap.Pairs)
out := Series{
Version: catalog.Stamp(version, horizon),
Metric: req.Metric,
From: req.From,
To: req.To,
Layer: snap.Layer,
Style: style,
Applicable: applicable(style, snap.Layer),
LastHour: basis.LastHour,
// Непустой срез, а не nil: пустой ряд обязан уехать клиенту как `[]`.
Points: make([]Point, 0, len(snap.Points)),
}
for _, p := range snap.Points {
out.Points = append(out.Points, Point{
TS: p.Start,
End: p.End,
OffsetSeconds: p.OffsetSeconds,
Units: p.Units,
Raw: p.Raw,
})
}
if version == "" {
s.log.DebugContext(ctx, "series unsigned", "capability", "query",
"metric", catalog.ClipMetric(req.Metric))
}
return out, nil
}
// layerNames — тот же словарь `hae.Layers`, переведённый в строки для выборки.
//
// Выводится из словаря, а не перечисляется заново: собственный список разошёлся
// бы с ним молча. Хранилищу перечень нужен по эксплуатационной причине — без
// предиката по слою выборка охватов просматривает все строки метрики за всю
// историю (измерено проходом `ops`: 13.9 мс против 0.026 мс), и цена росла бы
// вместе с возрастом сервиса при любой ширине запроса.
var layerNames = func() []string {
out := make([]string, 0, len(hae.Layers))
for _, l := range hae.Layers {
out = append(out, string(l))
}
return out
}()
// pickLayer выбирает слой по ОХВАТУ точек внутри периода.
//
// Охват — длина пересечения отрезка [первая метка слоя, последняя метка слоя] с
// запрошенным периодом. Слой с пустым пересечением выбывает. Побеждает
// наибольший охват, при равенстве — самый мелкий слой.
//
// Охват меряется метками ТОЧЕК, а не часами объектов, и это не придирка.
// Объекты адресуются часом, поэтому выборка обязана быть шире запроса (точка
// 10:59 живёт в объекте 10:00), а ряд отбирается точной меткой. На периоде
// [10:30, 10:45) часовой слой имеет объект 10:00 с единственной точкой в 10:00,
// минутный — объект 10:00 с точками 10:31…10:44. По часам объектов охваты
// равны, побеждает часовой — и ответ уходит пустым при непустых минутных
// данных. По меткам точек часовой выбывает сразу.
//
// Число точек мерой не является: нижний слой за три плотных дня даёт их больше,
// чем часовой за год, — и «вес за год» вернул бы три дня, не сказав об этом ни
// словом.
func pickLayer(from, to time.Time, spans []store.LayerSpan) string {
best := ""
var bestCover time.Duration
for _, sp := range spans {
if sp.Last.Before(from) || !sp.First.Before(to) {
continue
}
start, end := sp.First, sp.Last
if start.Before(from) {
start = from
}
if end.After(to) {
end = to
}
cover := end.Sub(start)
finer := hae.Rank(hae.Layer(sp.Layer)) < hae.Rank(hae.Layer(best))
if best == "" || cover > bestCover || (cover == bestCover && finer) {
best, bestCover = sp.Layer, cover
}
}
return best
}
// applicable отвечает, можно ли применить объявленный род к ЭТОМУ ряду.
//
// Род — свойство метрики, слой — свойство ряда, и их сочетание бывает опасным.
// Нижний слой HAE это интерполяция, а не сэмплы: сумма по нему завышает втрое
// (находка 34). Конверт, объявляющий `cumulative` рядом с рядом из `raw` и
// молчащий о неприменимости, приглашает потребителя сложить интерполяцию
// самому — система при этом не складывает ничего, а решение у потребителя уже
// принято по завышенному числу.
//
// Слой `sample` под запрет не подпадает: это настоящие сэмплы HealthKit из
// родного экспорта, а не развёртка HAE.
func applicable(style catalog.Style, layer string) bool {
if style == catalog.Unknown || layer == "" {
return false
}
return style != catalog.Cumulative || layer != string(hae.LayerRaw)
}
+132
View File
@@ -0,0 +1,132 @@
package points
import (
"testing"
"time"
"git.vakhrushev.me/av/healthlog/internal/catalog"
"git.vakhrushev.me/av/healthlog/internal/store"
)
func day(d int) time.Time { return time.Date(2026, 6, d, 0, 0, 0, 0, time.UTC) }
func span(layer string, first, last time.Time) store.LayerSpan {
return store.LayerSpan{Layer: layer, First: first, Last: last}
}
// Правило выбора слоя целиком: охват, равенство охватов, выбывание по пустому
// пересечению и вырожденный вход.
func TestВыборСлояПоОхвату(t *testing.T) {
from, to := day(1), day(30)
cases := []struct {
name string
spans []store.LayerSpan
want string
}{
{
// «Вес за год»: нижний слой плотнее, но короче. Победа по числу
// точек вернула бы три дня вместо периода и не сказала бы ни слова.
name: "мелкий слой охватывает меньше крупного",
spans: []store.LayerSpan{
span("raw", day(1), day(4)),
span("hour", day(1), day(29)),
},
want: "hour",
},
{
name: "охваты равны — побеждает мелкий",
spans: []store.LayerSpan{
span("hour", day(1), day(29)),
span("minute", day(1), day(29)),
},
want: "minute",
},
{
// Слой, чьи данные лежат целиком вне периода, выбывает — иначе он
// выиграл бы охват и отдал пустой ряд.
name: "слой лежит вне периода",
spans: []store.LayerSpan{
span("hour", day(1).Add(-48*time.Hour), day(1).Add(-24*time.Hour)),
span("minute", day(2), day(3)),
},
want: "minute",
},
{
// Пересечение считается по ПЕРИОДУ, а не по данным: слой, торчащий
// за обе границы, не получает бесконечного охвата.
name: "слой шире периода",
spans: []store.LayerSpan{
span("hour", day(1).Add(-240*time.Hour), day(30).Add(240*time.Hour)),
span("minute", day(1), day(30)),
},
want: "minute",
},
{
name: "слоёв нет вовсе",
spans: nil,
want: "",
},
{
// Единственная точка ровно на границе периода: охват нулевой, но
// слой пригоден — точка в ответ попадёт.
name: "единственная точка на границе",
spans: []store.LayerSpan{span("raw", from, from)},
want: "raw",
},
{
// Точка ровно на `to` в период не входит (полуинтервал), значит и
// слой из выбора выбывает.
name: "единственная точка на правой границе",
spans: []store.LayerSpan{span("raw", to, to)},
want: "",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := pickLayer(from, to, c.spans); got != c.want {
t.Errorf("выбран слой %q, ожидался %q", got, c.want)
}
})
}
}
// Порядок слоёв в выборке не должен влиять на исход: иначе ответ стал бы
// функцией порядка строк, который задаёт SQLite, а не правило.
func TestВыборСлояНеЗависитОтПорядка(t *testing.T) {
from, to := day(1), day(30)
a := span("hour", day(1), day(29))
b := span("minute", day(1), day(29))
if pickLayer(from, to, []store.LayerSpan{a, b}) != pickLayer(from, to, []store.LayerSpan{b, a}) {
t.Error("выбор слоя зависит от порядка охватов")
}
}
// Применимость рода к ОТДАННОМУ ряду. Разряд, ради которого поле заведено:
// накопительная метрика на нижнем слое HAE неприменима, потому что нижний слой
// это интерполяция, а не сэмплы, и сумма по нему завышает втрое.
func TestПрименимостьРодаКРяду(t *testing.T) {
cases := []struct {
style catalog.Style
layer string
want bool
}{
{catalog.Cumulative, "raw", false},
{catalog.Cumulative, "minute", true},
{catalog.Cumulative, "hour", true},
{catalog.Cumulative, "sample", true},
{catalog.Instant, "raw", true},
{catalog.Unknown, "minute", false},
{catalog.Unknown, "raw", false},
{catalog.Cumulative, "", false},
{catalog.Instant, "", false},
}
for _, c := range cases {
if got := applicable(c.style, c.layer); got != c.want {
t.Errorf("применимость %s на слое %q = %v, ожидалось %v", c.style, c.layer, got, c.want)
}
}
}
+300
View File
@@ -0,0 +1,300 @@
package points_test
import (
"context"
"encoding/json"
"log/slog"
"path/filepath"
"sync"
"testing"
"time"
"git.vakhrushev.me/av/healthlog/internal/catalog"
"git.vakhrushev.me/av/healthlog/internal/points"
"git.vakhrushev.me/av/healthlog/internal/store"
)
func service(t *testing.T, in ...store.IncomingPoint) (*points.Service, *store.Store) {
t.Helper()
st, err := store.Open(filepath.Join(t.TempDir(), "healthlog.db"))
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
if len(in) > 0 {
if _, err := st.Merge(context.Background(), store.Incoming{Points: in}, store.DeliveryRef{ID: "d"}); err != nil {
t.Fatalf("слияние: %v", err)
}
}
return points.New(st, slog.New(slog.DiscardHandler)), st
}
func hourAt(hh int) time.Time { return time.Date(2026, 6, 1, hh, 0, 0, 0, time.UTC) }
func in(metric, layer string, at time.Time) store.IncomingPoint {
return store.IncomingPoint{
Metric: metric, Layer: layer, Units: "count",
Point: store.Point{Start: at, End: at, Raw: json.RawMessage(`{"qty":1}`)},
}
}
// Версия ответа несёт ГОРИЗОНТ измерения, а не только версию витрины.
//
// Без горизонта метка не меняется, когда час из будущего въезжает в окно сам,
// ходом часов и без единого коммита, — и соседняя задача условного запроса
// подтвердит `304` на ответе, чей род уже перевернулся. Путь проект уже строил
// и закрывал у каталога; здесь он закрывается тем же механизмом.
func TestВерсияОтветаНесётГоризонт(t *testing.T) {
svc, st := service(t, in("m", "raw", hourAt(9)))
ctx := context.Background()
got, err := svc.Series(ctx, points.Request{Metric: "m", From: hourAt(0), To: hourAt(23)})
if err != nil {
t.Fatalf("Series: %v", err)
}
if got.Version == "" {
t.Fatal("ответ без версии — подписывать условный запрос нечем")
}
bare, err := st.StateVersion(ctx)
if err != nil {
t.Fatalf("StateVersion: %v", err)
}
if got.Version == bare {
t.Error("версия ответа равна версии витрины — горизонт в неё не вошёл")
}
if want := catalog.Stamp(bare, catalog.Horizon()); got.Version != want {
t.Errorf("версия ответа %q, ожидалась %q", got.Version, want)
}
}
// Слой выбирается тем же правилом, что проверено на охватах, но уже через
// хранилище: «вес за год» обязан вернуть длинный слой, а не плотный короткий.
func TestРядБерётСлойСНаибольшимОхватом(t *testing.T) {
svc, _ := service(t,
in("body_mass", "raw", hourAt(9)),
in("body_mass", "raw", hourAt(10)),
in("body_mass", "hour", hourAt(1)),
in("body_mass", "hour", hourAt(20)),
)
got, err := svc.Series(context.Background(), points.Request{
Metric: "body_mass", From: hourAt(0), To: hourAt(23),
})
if err != nil {
t.Fatalf("Series: %v", err)
}
if got.Layer != "hour" {
t.Errorf("слой %q, ожидался hour: нижний слой охватывает меньше", got.Layer)
}
if len(got.Points) != 2 {
t.Errorf("точек %d, ожидалось 2", len(got.Points))
}
}
// Явно запрошенный слой уезжает в ответе даже пустым: клиент, спросивший разрез
// поимённо, обязан отличать «за период этого разреза нет» от «параметр
// проигнорирован». Слой, который выбирала система и выбрать не смогла, — пустой.
func TestРядРазличаетПустойЯвныйСлойИОтсутствиеВыбора(t *testing.T) {
svc, _ := service(t, in("m", "raw", hourAt(9)))
ctx := context.Background()
explicit, err := svc.Series(ctx, points.Request{
Metric: "m", From: hourAt(0), To: hourAt(23), Layer: "minute",
})
if err != nil {
t.Fatalf("Series: %v", err)
}
if explicit.Layer != "minute" {
t.Errorf("явный слой %q, ожидался minute", explicit.Layer)
}
if len(explicit.Points) != 0 {
t.Errorf("точек %d, ожидалось 0", len(explicit.Points))
}
chosen, err := svc.Series(ctx, points.Request{
Metric: "нет такой", From: hourAt(0), To: hourAt(23),
})
if err != nil {
t.Fatalf("Series: %v", err)
}
if chosen.Layer != "" {
t.Errorf("слой %q, ожидался пустой: выбирать было не из чего", chosen.Layer)
}
}
// Отмена запроса клиентом — обстоятельство, а не отказ: ответ не собирается, но
// и ERROR владельцу не пишется. Уровень проверяет тест транспорта; здесь —
// что отмена вообще доезжает до драйвера и не игнорируется.
func TestРядУважаетОтменуКонтекста(t *testing.T) {
svc, _ := service(t, in("m", "raw", hourAt(9)))
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := svc.Series(ctx, points.Request{Metric: "m", From: hourAt(0), To: hourAt(23)}); err == nil {
t.Error("отменённый запрос собрал ответ — context до драйвера не доехал")
}
}
// Отказ хранилища доезжает до вызывающего отказом, а не пустым рядом: маршрут
// обязан ответить 500, а не «данных нет». Отказ при этом НЕ транзиентный —
// значит уходит владельцу уровнем ERROR, а не тонет в DEBUG.
func TestРядНаЗакрытомХранилищеОтказывает(t *testing.T) {
svc, st := service(t, in("m", "raw", hourAt(9)))
if err := st.Close(); err != nil {
t.Fatalf("закрытие: %v", err)
}
_, err := svc.Series(context.Background(), points.Request{Metric: "m", From: hourAt(0), To: hourAt(23)})
if err == nil {
t.Fatal("закрытое хранилище отдало ряд")
}
if store.Transient(err) {
t.Error("отказ закрытого хранилища объявлен обстоятельством — владелец о нём не узнает")
}
}
// levels — slog.Handler, копящий уровень и сообщение. Значений атрибутов не
// хранит: проверяется адресат записи, а данные о здоровье в тесты тащить
// незачем.
type levels struct {
mu sync.Mutex
seen []slog.Record
}
func (l *levels) Enabled(context.Context, slog.Level) bool { return true }
func (l *levels) WithAttrs([]slog.Attr) slog.Handler { return l }
func (l *levels) WithGroup(string) slog.Handler { return l }
func (l *levels) Handle(_ context.Context, r slog.Record) error {
l.mu.Lock()
defer l.mu.Unlock()
l.seen = append(l.seen, r.Clone())
return nil
}
func (l *levels) levelOf(msg string) (slog.Level, bool) {
l.mu.Lock()
defer l.mu.Unlock()
for _, r := range l.seen {
if r.Message == msg {
return r.Level, true
}
}
return 0, false
}
func (l *levels) count(level slog.Level) int {
l.mu.Lock()
defer l.mu.Unlock()
n := 0
for _, r := range l.seen {
if r.Level == level {
n++
}
}
return n
}
func loggedService(t *testing.T, in ...store.IncomingPoint) (*points.Service, *store.Store, *levels) {
t.Helper()
st, err := store.Open(filepath.Join(t.TempDir(), "healthlog.db"))
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
if len(in) > 0 {
if _, err := st.Merge(context.Background(), store.Incoming{Points: in}, store.DeliveryRef{ID: "d"}); err != nil {
t.Fatalf("слияние: %v", err)
}
}
seen := &levels{}
return points.New(st, slog.New(seen)), st, seen
}
// Отмена клиентом — обстоятельство, а не отказ, и уровень записи это отражает.
//
// Утверждение прямое, потому что иначе оно не держится ничем: смена
// классификации не даёт ни ошибки компиляции, ни красного теста. Агент
// опрашивает маршрут по расписанию, и `ERROR` на каждый его тайм-аут забил бы
// единственный канал, по которому владелец видит настоящий сбой хранилища.
func TestОтменаЗапросаПишетсяDEBUG(t *testing.T) {
svc, _, seen := loggedService(t, in("m", "raw", hourAt(9)))
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := svc.Series(ctx, points.Request{Metric: "m", From: hourAt(0), To: hourAt(23)}); err == nil {
t.Fatal("отменённый запрос собрал ответ")
}
level, ok := seen.levelOf("series interrupted")
if !ok {
t.Fatal("отмена не оставила чекпоинта — исход не наблюдаем")
}
if level != slog.LevelDebug {
t.Errorf("уровень %s, ожидался DEBUG", level)
}
if n := seen.count(slog.LevelError); n != 0 {
t.Errorf("записей ERROR %d, ожидалось 0: отмена клиента — не сбой хранилища", n)
}
}
// Настоящий отказ хранилища доходит до владельца уровнем ERROR.
func TestОтказХранилищаПишетсяERROR(t *testing.T) {
svc, st, seen := loggedService(t, in("m", "raw", hourAt(9)))
if err := st.Close(); err != nil {
t.Fatalf("закрытие: %v", err)
}
if _, err := svc.Series(context.Background(), points.Request{Metric: "m", From: hourAt(0), To: hourAt(23)}); err == nil {
t.Fatal("закрытое хранилище собрало ответ")
}
level, ok := seen.levelOf("series failed")
if !ok {
t.Fatal("отказ не оставил чекпоинта")
}
if level != slog.LevelError {
t.Errorf("уровень %s, ожидался ERROR", level)
}
}
// Предупреждения измерения — привилегия каталога, и маршрут точек их НЕ
// повторяет: агент опрашивает по расписанию, и WARN на каждый опрос обесценил
// бы уровень ровно так же, как обесценила бы его строка на каждый `304`.
func TestМаршрутТочекНеПовторяетПредупрежденияИзмерения(t *testing.T) {
// Метрика с противоречащим родом: часть часов сходится с суммой, часть — со
// средним. Каталог на таком входе пишет WARN.
var seed []store.IncomingPoint
for h := range 8 {
hour := hourAt(h)
coarse := "30"
if h%2 == 0 {
coarse = "15" // среднее двух минутных значений 10 и 20
}
seed = append(seed,
store.IncomingPoint{Metric: "mixed", Layer: "hour", Units: "kJ",
Point: store.Point{Start: hour, End: hour, Raw: json.RawMessage(`{"qty":` + coarse + `}`)}},
store.IncomingPoint{Metric: "mixed", Layer: "minute", Units: "kJ",
Point: store.Point{Start: hour, End: hour, Raw: json.RawMessage(`{"qty":10}`)}},
store.IncomingPoint{Metric: "mixed", Layer: "minute", Units: "kJ",
Point: store.Point{Start: hour.Add(time.Minute), End: hour.Add(time.Minute), Raw: json.RawMessage(`{"qty":20}`)}},
)
}
svc, _, seen := loggedService(t, seed...)
for range 3 {
if _, err := svc.Series(context.Background(), points.Request{
Metric: "mixed", From: hourAt(0), To: hourAt(23),
}); err != nil {
t.Fatalf("Series: %v", err)
}
}
if n := seen.count(slog.LevelWarn); n != 0 {
t.Errorf("маршрут точек написал %d предупреждений — опрос по расписанию обесценит уровень", n)
}
}