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
}