Files
healthlog/internal/points/points.go
T
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

245 lines
12 KiB
Go
Raw 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 — ряд точек одной метрики за период.
//
// Отвечает на вопрос потребителя «дай значения» — в отличие от каталога,
// который отвечает «что у тебя есть». Пакет производен от витрины и ничего в
// неё не пишет.
//
// Три решения этого узла живут здесь, потому что все три — правила, а не
// выборки:
//
// - какой слой отдать, когда клиент его не назвал;
// - применим ли измеренный род к отданному ряду (нижний слой 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)
}