- `GET /api/v1/metrics/{name}?from&to&layer` — ряд точек за период; конверт
объявляет слой, измеренный род, его применимость к отданному ряду и границу
окна измерения, а сам ряд собирается из одного слоя, выбранного по охвату
точек внутри периода
- use-case вынесен в `internal/points`, чтение — одним входом `store.ReadSeries`
под одной транзакцией; правило выбора слоя остаётся в домене и приходит в
хранилище колбэком
- `writeJSON` перестал экранировать HTML-символы и перестал глушить отказ
записи: дословность содержимого точки иначе не удерживается, а оборванное
тело уходило под видом успешного `200`
149 lines
5.9 KiB
Go
149 lines
5.9 KiB
Go
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
|
||
}
|