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,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)
|
||||
}
|
||||
Reference in New Issue
Block a user