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
+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)
}