// Package catalog — каталог разрезов и измеренный род агрегации. // // Отвечает на два вопроса потребителя: «что у тебя вообще есть» (метрики, // единицы, слои с границами и числом точек) и «какая свёртка по этой метрике // осмысленна» (род агрегации). Оба ответа производны от витрины и ничего в неё // не пишут. // // Род **измеряется**, а не размечается. Форма точки его не выдаёт: `Avg`/`Min`/ // `Max` есть только у `heart_rate`, заведомо мгновенные `walking_speed` и // `blood_oxygen_saturation` приходят в `qty` ровно так же, как шаги (находка // 40); заголовок доставки про род молчит; единицы дают процентов девяносто и // ломаются на краях. Зато витрина содержит собственную сверку: одна метрика // лежит в минутном и часовом разрезе одновременно, и часовое значение либо // равно сумме минутных, либо их среднему. // // Ни один известный проект род не измеряет — HealthKit зашивает его в тип // метрики, Home Assistant получает `state_class` от интеграции, Graphite // выводит регуляркой по имени, Prometheus принимает от отправителя. У всех у // них есть привилегия, которой нет у нас: поставщик объявляет тип на входе. package catalog import ( "context" "log/slog" "math" "sort" "time" "git.vakhrushev.me/av/healthlog/internal/hae" "git.vakhrushev.me/av/healthlog/internal/store" ) // Style — род агрегации метрики. // // Нулевое значение — `unknown`, и это не заглушка: «род не измерен» и есть // честное состояние по умолчанию, а забытое присваивание не имеет права // выглядеть как измеренный род. type Style int // Роды агрегации. Их два, а не четыре, и это следствие измерения, а не // упрощения. HealthKit различает `cumulative`, `discreteArithmetic`, // `discreteTemporallyWeighted` (пульс) и `discreteEquivalentContinuousLevel` // (аудиоэкспозиция) — но часовой слой HAE считается арифметически, а не по // Apple: у `environmental_audio_exposure`, которую Apple усредняет // логарифмически, часовое значение сошлось с обычным арифметическим средним // минутных в 59 часах из 62. Стили, которые в наших данных ничем не // проявляются, можно было бы только разметить руками — то есть вернуться к // тому, от чего уходит вся конструкция. const ( Unknown Style = iota Cumulative Instant ) func (s Style) String() string { switch s { case Cumulative: return "cumulative" case Instant: return "instant" default: return "unknown" } } // MarshalJSON отдаёт род строкой. Нулевое значение уезжает как `unknown`, а не // как пустая строка: клиент не должен видеть в ответе состояние, которого в // словаре нет. func (s Style) MarshalJSON() ([]byte, error) { return []byte(`"` + s.String() + `"`), nil } // Параметры измерения. Оба названы числами, а не оставлены на усмотрение вызова, // потому что от них зависят счётчики основания в ответе. const ( // Window — сколько самых свежих общих часов метрики участвует в сверке. // Ограничивает стоимость: полный обход рос бы вместе с журналом, а окно // держит работу в пределах 2×48 объектов на метрику. Измерено, что на живом // корпусе окно даёт те же вердикты, что и полный обход. Window = 48 // MinAgreeing — сколько согласных часов нужно, чтобы объявить род. Один // совпавший час остаётся свидетельством одного часа, а на этом роде потом // суммируют год. Цена порога измерена: он уводит в `unknown` ровно одну // метрику корпуса, у которой согласный час был единственным. MinAgreeing = 3 // coarsePoints и minFinePoints — структурные условия пригодности часа. Они // же уходят в хранилище предварительным отбором: содержимое заведомо // непригодного часа разжимать незачем. coarsePoints = 1 minFinePoints = 2 // horizonSlack — насколько метка часа может опережать текущее время и всё // ещё считаться свидетельством. // // Час объекта берётся из метки в теле доставки, а тело мы не контролируем: // без верхней границы одна доставка с метками в будущем занимает окно // целиком и подменяет измеренный род метрики (построено и прогнано: // мгновенная метрика объявлялась накопительной при нуле противоречащих // часов). Запас — на расхождение часов телефона и сервера; данные из // будущего сверх него свидетельством не являются. horizonSlack = time.Hour // maxUnitsReported — сколько различных единиц метрики попадает в ответ. // // Единицы приходят из тела дословно и ничем не ограничены, а число // различных значений равно числу объектов метрики: сотня доставок с разными // строками единиц раздувает одну запись каталога на десятки килобайт. // Событие невозможное по наблюдениям (единицы не менялись ни разу) и // поэтому не имеющее естественного потолка — потолок ставится здесь. maxUnitsReported = 8 // maxMetricInLog — предел длины имени метрики в записи лога. Тот же предел и // та же причина, что у координат столкновения в хранилище: имя приходит из // тела дословно при пределе приёма в 64 МиБ, а запись повторяется на каждый // запрос каталога. maxMetricInLog = 64 // tolerance — относительный допуск ВСЕХ сравнений измерения. // // Величина названа числом, потому что от неё зависят счётчики основания: // вердикты метрик на живом корпусе одинаковы при допуске от 1e-9 до 1e-3, а // число согласных часов у `heart_rate` при этом меняется с 29 на 49. // Взято строгое: канонизация содержимого округляет числа до 12 значащих // цифр, значит всё крупнее 1e-12 представлением не объясняется; 1e-9 // оставляет три порядка запаса и остаётся на шесть порядков строже любого // содержательного расхождения — сумма и среднее при n ≥ 2 различаются не // меньше чем вдвое. tolerance = 1e-9 ) // closeEnough — ЕДИНСТВЕННЫЙ предикат сравнения чисел в измерении. // // Один на все три сравнения намеренно. Напиши «различимость суммы и среднего» // точным неравенством, а «сходимость с гипотезой» — с допуском, и появится час, // подтверждающий обе гипотезы сразу; его исход молча определил бы порядок веток // `if`. При одном предикате такой час невыразим. // // Допуск относительный, абсолютного порога нет намеренно: второй константы, // которую пришлось бы объяснять, задача не заводит. Цена названа вслух: при // обоих нулях предикат истинен, и от нулевого часа защищает не он, а проверка // различимости в verdictOf. Полагаться здесь на «около нуля не сходится» // нельзя — ровно наоборот. func closeEnough(a, b float64) bool { if a == b { return true } return math.Abs(a-b)/math.Max(math.Abs(a), math.Abs(b)) <= tolerance } func clipMetric(metric string) string { if len(metric) <= maxMetricInLog { return metric } return metric[:maxMetricInLog] + "…" } // Basis — основание, на котором объявлен род. Числа подобраны так, чтобы их // разности были осмысленны: `Hours − Compared` — часы, отброшенные проверкой // пригодности, `Compared − Agreeing − Conflicting` — часы, не сошедшиеся ни с // одной гипотезой. // // Одного числа не хватало: «часов было 48, а пригодным не оказалось ни одного» // и «часов не было вовсе» — разные события, и клиент обязан различать их без // второго запроса. type Basis struct { Hours int `json:"hours"` Compared int `json:"compared"` Agreeing int `json:"agreeing"` Conflicting int `json:"conflicting"` FirstHour *time.Time `json:"first_hour"` LastHour *time.Time `json:"last_hour"` } // Aggregation — род вместе с основанием. type Aggregation struct { Style Style `json:"style"` Basis } // LayerRange — разрез метрики в ответе каталога. // // Границы — метки первой и последней точки слоя, включительно, и это границы // ДАННЫХ, а не обещание покрытия: внутри диапазона законно есть дыры. Числа // часовых объектов здесь нет: объект — деталь хранения, клиент про него не // знает. type LayerRange struct { Layer string `json:"layer"` From time.Time `json:"from"` To time.Time `json:"to"` Points int `json:"points"` } // Metric — запись каталога. type Metric struct { Metric string `json:"metric"` // Units — множество различных единиц метрики, отсортированное. Массив, а не // строка: на живом потоке единицы не менялись ни разу, но одна форма поля // для обоих случаев честнее строки, которая при расхождении молча выберет // одно из двух. Units []string `json:"units"` Aggregation Aggregation `json:"aggregation"` Layers []LayerRange `json:"layers"` } // Snapshot — каталог вместе с версией ответа. // // Версия пустая, когда подписать ответ нечем: витрина изменилась, пока он // собирался, или прочитать её версию не удалось. Это не отказ — ответ уходит // целиком, просто без условной метки, ровно как до появления условного запроса. // // Имя перекликается с `store.CatalogSnapshot` намеренно и означает другое: тот // снимок — вход измерения (объекты и разрезы), этот — готовый ответ. type Snapshot struct { Version string Metrics []Metric } // Version — версия ОТВЕТА каталога: версия витрины плюс горизонт измерения. // // Горизонт входит в неё, потому что ответ есть функция обоих. Час объекта // сравнивается с `Now() + horizonSlack`, и с ходом часов состав окна меняется // без единого коммита: метка из будущего, лежащая в витрине (сбитые часы // телефона — состояние, о котором рядом пишется WARN), въезжает в окно сама и // способна перевернуть измеренный род. Построено и прогнано: та же версия // витрины, `cumulative` против `unknown`, ноль коммитов между. // // Огрубление до часа не приблизительное, а точное: `hour_utc` объектов лежит // ровно на часах, поэтому отбор `hour_utc <= горизонт` меняется ровно при // переходе горизонта через час. Цена — один полный ответ в час на потребителя // при неизменившейся витрине; сборка каталога на живом корпусе стоит 45 мс. func (s *Service) Version(ctx context.Context) (string, error) { version, err := s.store.StateVersion(ctx) if err != nil { // Отказ пробы отказом маршрута не является — но и молчать о нём нельзя: // без метки условный запрос выключается для всех потребителей, а // снаружи это неотличимо от нормы. DEBUG, потому что адресат здесь // разработчик: владельцу об этом скажет `/stats`, когда появится. s.log.DebugContext(ctx, "state version unavailable", "capability", "query", "error", err) return "", err } return stamp(version, store.Now().Add(horizonSlack)), nil } // stamp склеивает версию витрины с горизонтом. Пустая версия остаётся пустой: // подписывать нечем — значит нечем, и горизонт этого не меняет. func stamp(version string, horizon time.Time) string { if version == "" { return "" } return version + "." + horizon.Truncate(time.Hour).Format("2006010215") } // 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} } // Metrics отдаёт каталог: разрезы всех метрик и измеренный род каждой. // // Род нигде не хранится и считается заново на каждый запрос. Хранимое значение // было бы вторым производным состоянием рядом с витриной — его пришлось бы // пересчитывать после каждой свёртки, переносить или не переносить пересборкой // и объяснять, на каком составе данных оно снято; устаревшее при этом выглядит // ровно как свежее. Вычисленный на запрос род есть функция витрины, а витрина — // функция журнала, и устаревать в нём нечему. // Ответ подписывается версией витрины: она нужна условному запросу, и снимает // её хранилище — двумя пробами вокруг чтения. Порядок проб там же и объяснён: // версия, снятая после чтения, пометила бы устаревший снимок свежей меткой. func (s *Service) Metrics(ctx context.Context) (Snapshot, error) { horizon := store.Now().Add(horizonSlack) var snap store.CatalogSnapshot version, err := s.store.VersionedRead(ctx, func(ctx context.Context) error { var err error snap, err = s.store.ReadCatalog(ctx, store.CatalogWindow{ Fine: string(hae.LayerMinute), Coarse: string(hae.LayerHour), Hours: Window, Horizon: horizon, CoarsePoints: coarsePoints, MinFinePoints: minFinePoints, }) return err }) if err != nil { //nolint:nestif // ветка одна, вложенность даёт лог по адресату // Единственный логирующий чекпоинт исхода: транспорт переводит ошибку в // ответ и второй раз её не пишет. // // Отмена снаружи и занятость базы означают «не сделано», а не «не // выходит»: клиент, оборвавший запрос по своему тайм-ауту, не должен // давать владельцу ERROR — иначе единственный канал, по которому видно // настоящий сбой хранилища, забивается штатными событиями. Правило и его // определение живут в store и читаются уже третьим местом. if store.Transient(err) { s.log.DebugContext(ctx, "catalog interrupted", "capability", "query", "error", err) } else { s.log.ErrorContext(ctx, "catalog failed", "capability", "query", "error", err) } return Snapshot{}, err } out := make([]Metric, 0, len(snap.Layers)) for _, group := range groupLayers(snap.Layers) { style, basis := Measure(snap.Pairs[group.metric]) // Единственный чекпоинт этой границы: род — свойство, на котором Read // API строит арифметику года, и его смена не имеет права проходить // молча. Уровень WARN, потому что адресат — владелец, а лечится это // настройкой автоматизаций HAE, а не кодом. Значений точек в записи нет: // данные о здоровье чувствительнее токенов. Имя метрики обрезано — оно // приходит из тела дословно, а запись повторяется на каждый запрос. if basis.Conflicting > 0 { s.log.WarnContext(ctx, "aggregation style conflict", "capability", "query", "metric", clipMetric(group.metric), "hours", basis.Hours, "compared", basis.Compared, "agreeing", basis.Agreeing, "conflicting", basis.Conflicting) } // Данные, помеченные будущим, в измерение не попадают вовсе — но молчать // о них нельзя: это либо сбитые часы телефона, либо чужое тело в приёме, // и оба случая лечатся не кодом. Видно это по разрезам, а не по окну: // окно такие часы уже отбросило. if to := group.latest(); to.After(horizon) { s.log.WarnContext(ctx, "future data", "capability", "query", "metric", clipMetric(group.metric), "last_ts", store.FormatTime(to), "horizon", store.FormatTime(horizon)) } out = append(out, Metric{ Metric: group.metric, Units: group.units, Aggregation: Aggregation{Style: style, Basis: basis}, Layers: group.layers, }) } if version == "" { // Витрина изменилась, пока ответ собирался (или версию не прочитать). // Ответ уйдёт без метки — это безопасная сторона, но след нужен: под // плотным потоком доставок так может уходить каждый ответ, и тогда // механизм не окупается вовсе. s.log.DebugContext(ctx, "catalog unsigned", "capability", "query") } return Snapshot{Version: stamp(version, horizon), Metrics: out}, nil } type metricGroup struct { metric string units []string layers []LayerRange unitSet map[string]bool byLayer map[string]int } // latest — самая поздняя метка данных метрики по всем её слоям. func (g metricGroup) latest() time.Time { var out time.Time for _, l := range g.layers { if l.To.After(out) { out = l.To } } return out } // groupLayers схлопывает строки выборки в записи каталога. // // Схлопывание поручено явно, потому что выборка группируется ВМЕСТЕ с // единицами: у метрики, чьи объекты разошлись единицами, на один слой придут две // строки. Оставь это на самотёк — клиент получит два элемента с одинаковым // `layer` ровно в тот единственный день, ради которого единицы и сделаны // множеством. Различие при этом не теряется: оно видно множеством единиц // метрики. func groupLayers(rows []store.LayerRange) []metricGroup { var out []metricGroup var cur *metricGroup for _, r := range rows { // Указатель, а не сравнение имени с пустой строкой: пустое имя — законное // значение колонки, и сентинел выбрасывал бы такую метрику из каталога // молча. Каталог отвечает на вопрос «что у тебя вообще есть»; терять на // нём то, что в витрине лежит, нельзя. if cur == nil || r.Metric != cur.metric { if cur != nil { out = append(out, *cur) } cur = &metricGroup{metric: r.Metric, byLayer: map[string]int{}, unitSet: map[string]bool{}} } if r.Units != "" { cur.unitSet[r.Units] = true } i, ok := cur.byLayer[r.Layer] if !ok { cur.layers = append(cur.layers, LayerRange{ Layer: r.Layer, From: r.From, To: r.To, Points: r.Points, }) cur.byLayer[r.Layer] = len(cur.layers) - 1 continue } l := &cur.layers[i] if r.From.Before(l.From) { l.From = r.From } if r.To.After(l.To) { l.To = r.To } l.Points += r.Points } if cur != nil { out = append(out, *cur) } for i := range out { out[i].units = sortedKeys(out[i].unitSet) } return out } // sortedKeys отдаёт множество строк отсортированным и обрезанным по потолку: // число различных единиц у метрики равно числу её объектов, и без потолка одна // запись каталога растёт вместе с витриной. func sortedKeys(set map[string]bool) []string { out := make([]string, 0, len(set)) for k := range set { out = append(out, k) } sort.Strings(out) if len(out) > maxUnitsReported { out = out[:maxUnitsReported] } return out } // Measure выводит род метрики сверкой минутного и часового слоёв. // // Час ПРИГОДЕН, когда у часового объекта ровно одна точка со значением и её // метка совпадает с началом часа, у минутного не меньше двух точек со значением, // а сумма минутных отличима от их среднего. // // Требование выравнивания часовой метки закрывает зоны с неполночасовым // смещением: слой выводится по выравниванию метки в исходной зоне, а объект // адресуется часом UTC, поэтому в зоне +0530 часовая точка описывает не тот // интервал, который покрывают минутные точки того же объекта. // // Требование различимости обязательно: в часе, где все значения нули, сумма // равна среднему, и совпадение с любой из гипотез не значит ничего. Без него // `walking_asymmetry_percentage` на живом корпусе давала 4 часа «накопительная» // против 3 «мгновенная» — конфликт из одних нулевых часов. // // Вердикт метрики — не меньше трёх согласных часов и НИ ОДНОГО противоречащего. // Единогласие, а не большинство: противоречащий час означает, что одна из // гипотез для этой метрики ложна, и объявлять род при известном контрпримере // нельзя. func Measure(pairs []store.HourPair) (Style, Basis) { basis := Basis{Hours: len(pairs)} if len(pairs) == 0 { return Unknown, basis } // Часы приходят от свежих к старым; границы окна отдаём по возрастанию. first, last := pairs[len(pairs)-1].Hour, pairs[0].Hour basis.FirstHour, basis.LastHour = &first, &last var cumulative, instant int for _, p := range pairs { verdict, ok := verdictOf(p) if !ok { continue } basis.Compared++ switch verdict { case Cumulative: cumulative++ case Instant: instant++ } } // Согласные — часы преобладающей гипотезы, противоречащие — часы другой. // Разложение одно и то же независимо от того, объявлен род или нет: при // объявленном роде преобладающая гипотеза им и является, а противоречащих // ноль по определению правила. Иначе `agreeing` пришлось бы толковать // по-разному в двух ветках, и клиент читал бы одно поле двумя способами. basis.Agreeing, basis.Conflicting = cumulative, instant if instant > cumulative { basis.Agreeing, basis.Conflicting = instant, cumulative } if basis.Conflicting > 0 || basis.Agreeing < MinAgreeing { return Unknown, basis } if cumulative > instant { return Cumulative, basis } return Instant, basis } // verdictOf оценивает один час. Второй возврат — был ли час пригоден. func verdictOf(p store.HourPair) (Style, bool) { // Единицы обеих сторон обязаны совпасть. Иначе сверка сравнивает величины // разного масштаба: мгновенная метрика, приехавшая в `count/min` минутным // слоем и в `count/hour` часовым, даёт `часовое = 60 · среднее = сумма` в // полном часе — то есть УВЕРЕННЫЙ ложный `cumulative` при нуле // противоречащих часов. Правило единогласия этот случай не ловит по // построению: противоречия нет, есть молчание. if p.FineUnits != p.CoarseUnits { return Unknown, false } if len(p.Coarse) != 1 { return Unknown, false } if !p.Coarse[0].Start.Equal(p.Hour) { return Unknown, false } coarse, ok := hae.PointValue(p.Coarse[0].Raw) if !ok { return Unknown, false } sum, n := sumFine(p.Fine) if n < 2 { return Unknown, false } mean := sum / float64(n) if closeEnough(sum, mean) { return Unknown, false } switch { case closeEnough(coarse, sum): return Cumulative, true case closeEnough(coarse, mean): return Instant, true default: return Unknown, true } } // sumFine складывает значения минутных точек в порядке возрастания метки, чтобы // вердикт не зависел от порядка точек внутри объекта. func sumFine(points []store.Point) (float64, int) { ordered := make([]store.Point, len(points)) copy(ordered, points) sort.SliceStable(ordered, func(i, j int) bool { return ordered[i].Start.Before(ordered[j].Start) }) var sum float64 n := 0 for _, p := range ordered { v, ok := hae.PointValue(p.Raw) if !ok { continue } sum += v n++ } return sum, n }