Files
av 29ca8d415c httpapi: точки метрики за период отдаются одним запросом
- `GET /api/v1/metrics/{name}?from&to&layer` — ряд точек за период; конверт
  объявляет слой, измеренный род, его применимость к отданному ряду и границу
  окна измерения, а сам ряд собирается из одного слоя, выбранного по охвату
  точек внутри периода
- use-case вынесен в `internal/points`, чтение — одним входом `store.ReadSeries`
  под одной транзакцией; правило выбора слоя остаётся в домене и приходит в
  хранилище колбэком
- `writeJSON` перестал экранировать HTML-символы и перестал глушить отказ
  записи: дословность содержимого точки иначе не удерживается, а оборванное
  тело уходило под видом успешного `200`
2026-08-04 18:46:45 +03:00

149 lines
5.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}