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