httpapi: точки метрики за период отдаются одним запросом
- `GET /api/v1/metrics/{name}?from&to&layer` — ряд точек за период; конверт
объявляет слой, измеренный род, его применимость к отданному ряду и границу
окна измерения, а сам ряд собирается из одного слоя, выбранного по охвату
точек внутри периода
- use-case вынесен в `internal/points`, чтение — одним входом `store.ReadSeries`
под одной транзакцией; правило выбора слоя остаётся в домене и приходит в
хранилище колбэком
- `writeJSON` перестал экранировать HTML-символы и перестал глушить отказ
записи: дословность содержимого точки иначе не удерживается, а оборванное
тело уходило под видом успешного `200`
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user