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