Каталог разрезов и измеренный род агрегации
- род метрики выводится сверкой минутного слоя с часовым: часовое значение сходится с суммой минутных — накопительная, со средним — мгновенная, иначе `unknown` и свёртка не предлагается вовсе. На живом архиве (123 доставки, 31 метрика) 7 накопительных, 9 мгновенных, противоречащих часов ноль - `GET /api/v1/metrics` под токеном чтения отдаёт единицы, слои с границами и род вместе с основанием измерения; род нигде не хранится — он функция витрины, а витрина функция журнала, устаревать в нём нечему - миграция 00009: покрывающий индекс, чтобы каталог отвечал по учётным колонкам, не разжимая содержимое объектов
This commit is contained in:
@@ -0,0 +1,504 @@
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// 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) ([]Metric, error) {
|
||||
horizon := store.Now().Add(horizonSlack)
|
||||
snap, err := s.store.ReadCatalog(ctx, store.CatalogWindow{
|
||||
Fine: string(hae.LayerMinute),
|
||||
Coarse: string(hae.LayerHour),
|
||||
Hours: Window,
|
||||
Horizon: horizon,
|
||||
CoarsePoints: coarsePoints,
|
||||
MinFinePoints: minFinePoints,
|
||||
})
|
||||
if err != nil {
|
||||
// Единственный логирующий чекпоинт исхода: транспорт переводит ошибку в
|
||||
// ответ и второй раз её не пишет.
|
||||
//
|
||||
// Отмена снаружи и занятость базы означают «не сделано», а не «не
|
||||
// выходит»: клиент, оборвавший запрос по своему тайм-ауту, не должен
|
||||
// давать владельцу 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 nil, 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,
|
||||
})
|
||||
}
|
||||
return 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
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
package catalog_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/catalog"
|
||||
"git.vakhrushev.me/av/healthlog/internal/store"
|
||||
)
|
||||
|
||||
func openStore(t *testing.T) *store.Store {
|
||||
t.Helper()
|
||||
|
||||
st, err := store.Open(filepath.Join(t.TempDir(), "healthlog.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("открытие базы: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
return st
|
||||
}
|
||||
|
||||
func service(t *testing.T, st *store.Store) *catalog.Service {
|
||||
t.Helper()
|
||||
|
||||
return catalog.New(st, slog.New(slog.DiscardHandler))
|
||||
}
|
||||
|
||||
// logged собирает записи лога: чекпоинт, который никто не перехватывает, ничем
|
||||
// не удерживается — его перевод на DEBUG или потеря атрибутов пройдут зелёным
|
||||
// гейтом, а владелец о смене рода не узнает.
|
||||
type logged struct {
|
||||
records []slog.Record
|
||||
}
|
||||
|
||||
func (h *logged) Enabled(context.Context, slog.Level) bool { return true }
|
||||
func (h *logged) WithAttrs([]slog.Attr) slog.Handler { return h }
|
||||
func (h *logged) WithGroup(string) slog.Handler { return h }
|
||||
|
||||
func (h *logged) Handle(_ context.Context, r slog.Record) error {
|
||||
h.records = append(h.records, r.Clone())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *logged) find(msg string) (slog.Record, bool) {
|
||||
for _, r := range h.records {
|
||||
if r.Message == msg {
|
||||
return r, true
|
||||
}
|
||||
}
|
||||
return slog.Record{}, false
|
||||
}
|
||||
|
||||
func attr(r slog.Record, key string) string {
|
||||
out := ""
|
||||
r.Attrs(func(a slog.Attr) bool {
|
||||
if a.Key == key {
|
||||
out = a.Value.String()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func incoming(metric, layer, units string, at time.Time, v float64) store.IncomingPoint {
|
||||
return store.IncomingPoint{
|
||||
Metric: metric,
|
||||
Layer: layer,
|
||||
Units: units,
|
||||
Point: store.Point{Start: at, End: at, Raw: qty(v)},
|
||||
}
|
||||
}
|
||||
|
||||
func merge(t *testing.T, st *store.Store, points []store.IncomingPoint) {
|
||||
t.Helper()
|
||||
|
||||
_, err := st.Merge(context.Background(), store.Incoming{Points: points},
|
||||
store.DeliveryRef{ID: "delivery"})
|
||||
if err != nil {
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// метрику кладём в оба слоя за n часов: часовая точка на границе часа, три
|
||||
// минутных внутри. `sum` задаёт, будет ли часовое значение суммой или средним.
|
||||
func fill(t *testing.T, st *store.Store, metric string, hours int, sum bool) {
|
||||
t.Helper()
|
||||
|
||||
var points []store.IncomingPoint
|
||||
for i := range hours {
|
||||
h := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC).Add(time.Duration(i) * time.Hour)
|
||||
coarse := 2.0
|
||||
if sum {
|
||||
coarse = 6.0
|
||||
}
|
||||
points = append(points, incoming(metric, "hour", "count", h, coarse))
|
||||
for m, v := range []float64{1, 2, 3} {
|
||||
points = append(points, incoming(metric, "minute", "count", h.Add(time.Duration(m)*time.Minute), v))
|
||||
}
|
||||
}
|
||||
merge(t, st, points)
|
||||
}
|
||||
|
||||
func find(t *testing.T, metrics []catalog.Metric, name string) catalog.Metric {
|
||||
t.Helper()
|
||||
|
||||
for _, m := range metrics {
|
||||
if m.Metric == name {
|
||||
return m
|
||||
}
|
||||
}
|
||||
t.Fatalf("метрики %q в каталоге нет", name)
|
||||
return catalog.Metric{}
|
||||
}
|
||||
|
||||
func TestКаталогОтдаётРазрезыИИзмеренныйРод(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := openStore(t)
|
||||
fill(t, st, "step_count", 5, true)
|
||||
fill(t, st, "heart_rate", 5, false)
|
||||
// Метрика, лежащая только в нижнем слое: слой в каталоге есть, рода нет.
|
||||
merge(t, st, []store.IncomingPoint{
|
||||
incoming("sleep_analysis", "raw", "hr", time.Date(2026, 6, 1, 3, 7, 0, 0, time.UTC), 1),
|
||||
})
|
||||
|
||||
metrics, err := service(t, st).Metrics(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("каталог: %v", err)
|
||||
}
|
||||
|
||||
steps := find(t, metrics, "step_count")
|
||||
if steps.Aggregation.Style != catalog.Cumulative {
|
||||
t.Errorf("step_count: род %v", steps.Aggregation.Style)
|
||||
}
|
||||
if len(steps.Units) != 1 || steps.Units[0] != "count" {
|
||||
t.Errorf("step_count: единицы %v", steps.Units)
|
||||
}
|
||||
if len(steps.Layers) != 2 {
|
||||
t.Errorf("step_count: слоёв %d, ждали 2", len(steps.Layers))
|
||||
}
|
||||
|
||||
hr := find(t, metrics, "heart_rate")
|
||||
if hr.Aggregation.Style != catalog.Instant {
|
||||
t.Errorf("heart_rate: род %v", hr.Aggregation.Style)
|
||||
}
|
||||
|
||||
sleep := find(t, metrics, "sleep_analysis")
|
||||
if sleep.Aggregation.Style != catalog.Unknown {
|
||||
t.Errorf("sleep_analysis: род %v, ждали unknown", sleep.Aggregation.Style)
|
||||
}
|
||||
if sleep.Aggregation.Hours != 0 || sleep.Aggregation.FirstHour != nil {
|
||||
t.Errorf("sleep_analysis: основание %+v — сравнивать было нечего", sleep.Aggregation.Basis)
|
||||
}
|
||||
if len(sleep.Layers) != 1 || sleep.Layers[0].Layer != "raw" {
|
||||
t.Errorf("sleep_analysis: слои %+v", sleep.Layers)
|
||||
}
|
||||
}
|
||||
|
||||
// Нижний слой в сверке не участвует: у метрики есть `raw` и `hour`, но нет
|
||||
// `minute` — рода быть не должно, сколько бы данных ни лежало в нижнем слое.
|
||||
func TestКаталогНеИзмеряетПоНижнемуСлою(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := openStore(t)
|
||||
var points []store.IncomingPoint
|
||||
for i := range 5 {
|
||||
h := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC).Add(time.Duration(i) * time.Hour)
|
||||
points = append(points, incoming("active_energy", "hour", "kJ", h, 6))
|
||||
for m, v := range []float64{1, 2, 3} {
|
||||
points = append(points, incoming("active_energy", "raw",
|
||||
"kJ", h.Add(time.Duration(m)*time.Second), v))
|
||||
}
|
||||
}
|
||||
merge(t, st, points)
|
||||
|
||||
metrics, err := service(t, st).Metrics(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("каталог: %v", err)
|
||||
}
|
||||
m := find(t, metrics, "active_energy")
|
||||
if m.Aggregation.Style != catalog.Unknown {
|
||||
t.Errorf("род %v, ждали unknown", m.Aggregation.Style)
|
||||
}
|
||||
if m.Aggregation.Hours != 0 {
|
||||
t.Errorf("часов окна %d — нижний слой не имеет права попадать в сверку", m.Aggregation.Hours)
|
||||
}
|
||||
}
|
||||
|
||||
func TestКаталогОграничиваетОкно(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := openStore(t)
|
||||
fill(t, st, "step_count", catalog.Window+7, true)
|
||||
|
||||
metrics, err := service(t, st).Metrics(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("каталог: %v", err)
|
||||
}
|
||||
m := find(t, metrics, "step_count")
|
||||
if m.Aggregation.Hours != catalog.Window {
|
||||
t.Errorf("часов окна %d, ждали %d", m.Aggregation.Hours, catalog.Window)
|
||||
}
|
||||
// Окно берёт САМЫЕ СВЕЖИЕ часы: старейший из сравненных обязан быть позже
|
||||
// первого часа истории.
|
||||
first := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
|
||||
if m.Aggregation.FirstHour == nil || !m.Aggregation.FirstHour.After(first) {
|
||||
t.Errorf("начало окна %v — окно взяло не свежие часы", m.Aggregation.FirstHour)
|
||||
}
|
||||
}
|
||||
|
||||
// Единицы, разошедшиеся между объектами, показываются множеством, а слой
|
||||
// остаётся одним элементом: клиент, читающий слои словарём, иначе потерял бы
|
||||
// половину диапазона молча.
|
||||
func TestКаталогПоказываетРасхождениеЕдиниц(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := openStore(t)
|
||||
base := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
|
||||
merge(t, st, []store.IncomingPoint{
|
||||
incoming("walking_running_distance", "minute", "km", base, 1),
|
||||
incoming("walking_running_distance", "minute", "m", base.Add(2*time.Hour), 2),
|
||||
})
|
||||
|
||||
metrics, err := service(t, st).Metrics(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("каталог: %v", err)
|
||||
}
|
||||
m := find(t, metrics, "walking_running_distance")
|
||||
if len(m.Units) != 2 {
|
||||
t.Errorf("единицы %v, ждали оба значения", m.Units)
|
||||
}
|
||||
if len(m.Layers) != 1 {
|
||||
t.Fatalf("слоёв %d, ждали один элемент на слой: %+v", len(m.Layers), m.Layers)
|
||||
}
|
||||
if m.Layers[0].Points != 2 {
|
||||
t.Errorf("точек в слое %d, ждали 2 — диапазоны обязаны объединиться", m.Layers[0].Points)
|
||||
}
|
||||
if !m.Layers[0].From.Equal(base) || !m.Layers[0].To.Equal(base.Add(2*time.Hour)) {
|
||||
t.Errorf("границы слоя %v..%v", m.Layers[0].From, m.Layers[0].To)
|
||||
}
|
||||
}
|
||||
|
||||
func TestКаталогПустойВитриныПуст(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
metrics, err := service(t, openStore(t)).Metrics(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("каталог: %v", err)
|
||||
}
|
||||
if len(metrics) != 0 {
|
||||
t.Errorf("метрик %d, ждали ноль", len(metrics))
|
||||
}
|
||||
}
|
||||
|
||||
// Род нигде не хранится: новая доставка меняет его в следующем же ответе, без
|
||||
// перезапуска и без пересчёта чего бы то ни было.
|
||||
func TestКаталогПересчитываетРодНаКаждыйЗапрос(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := openStore(t)
|
||||
svc := service(t, st)
|
||||
ctx := context.Background()
|
||||
|
||||
fill(t, st, "step_count", 2, true)
|
||||
before, err := svc.Metrics(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("каталог: %v", err)
|
||||
}
|
||||
if find(t, before, "step_count").Aggregation.Style != catalog.Unknown {
|
||||
t.Fatal("двух часов не хватает на вердикт — ждали unknown")
|
||||
}
|
||||
|
||||
fill(t, st, "step_count", 5, true)
|
||||
after, err := svc.Metrics(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("каталог: %v", err)
|
||||
}
|
||||
if got := find(t, after, "step_count").Aggregation.Style; got != catalog.Cumulative {
|
||||
t.Errorf("после доставки род %v, ждали cumulative", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Час объекта берётся из метки в теле доставки, а тело не наше. Без верхней
|
||||
// границы окна одна доставка с метками в будущем вытесняет всю настоящую
|
||||
// историю метрики и подменяет измеренный род: мгновенная объявлялась
|
||||
// накопительной при нуле противоречащих часов.
|
||||
func TestКаталогНеИзмеряетПоБудущимЧасам(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := openStore(t)
|
||||
fill(t, st, "blood_oxygen_saturation", 6, false) // прошлое: среднее → instant
|
||||
|
||||
// Будущее: столько же часов «суммой», сколько влезает в окно целиком.
|
||||
var future []store.IncomingPoint
|
||||
base := store.Now().Add(72 * time.Hour).Truncate(time.Hour)
|
||||
for i := range catalog.Window {
|
||||
h := base.Add(time.Duration(i) * time.Hour)
|
||||
future = append(future, incoming("blood_oxygen_saturation", "hour", "count", h, 6))
|
||||
for m, v := range []float64{1, 2, 3} {
|
||||
future = append(future, incoming("blood_oxygen_saturation", "minute", "count",
|
||||
h.Add(time.Duration(m)*time.Minute), v))
|
||||
}
|
||||
}
|
||||
merge(t, st, future)
|
||||
|
||||
handler := &logged{}
|
||||
metrics, err := catalog.New(st, slog.New(handler)).Metrics(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("каталог: %v", err)
|
||||
}
|
||||
m := find(t, metrics, "blood_oxygen_saturation")
|
||||
if m.Aggregation.Style != catalog.Instant {
|
||||
t.Errorf("род %v: часы из будущего заняли окно и подменили измерение", m.Aggregation.Style)
|
||||
}
|
||||
if m.Aggregation.LastHour == nil || m.Aggregation.LastHour.After(store.Now().Add(time.Hour)) {
|
||||
t.Errorf("конец окна %v — окно ушло в будущее", m.Aggregation.LastHour)
|
||||
}
|
||||
if _, ok := handler.find("future data"); !ok {
|
||||
t.Error("данные из будущего есть, а предупреждения владельцу нет")
|
||||
}
|
||||
}
|
||||
|
||||
// Единицы обеих сторон обязаны совпасть: минутный слой в count/min и часовой в
|
||||
// count/hour дают «часовое = 60 · среднее = сумма» в полном часе, то есть
|
||||
// уверенный ложный cumulative при нуле противоречащих часов.
|
||||
func TestКаталогНеСверяетСлоиРазныхЕдиниц(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := openStore(t)
|
||||
var points []store.IncomingPoint
|
||||
for i := range 6 {
|
||||
h := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC).Add(time.Duration(i) * time.Hour)
|
||||
points = append(points, incoming("walking_speed", "hour", "count/hour", h, 6))
|
||||
for m, v := range []float64{1, 2, 3} {
|
||||
points = append(points, incoming("walking_speed", "minute", "count/min",
|
||||
h.Add(time.Duration(m)*time.Minute), v))
|
||||
}
|
||||
}
|
||||
merge(t, st, points)
|
||||
|
||||
metrics, err := service(t, st).Metrics(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("каталог: %v", err)
|
||||
}
|
||||
m := find(t, metrics, "walking_speed")
|
||||
if m.Aggregation.Style != catalog.Unknown {
|
||||
t.Errorf("род %v: единицы слоёв разошлись, сравнивать было нечего", m.Aggregation.Style)
|
||||
}
|
||||
if m.Aggregation.Compared != 0 {
|
||||
t.Errorf("пригодных часов %d, ждали 0", m.Aggregation.Compared)
|
||||
}
|
||||
}
|
||||
|
||||
// Пустое имя метрики — законное значение колонки. Каталог отвечает на вопрос
|
||||
// «что у тебя вообще есть», и терять на нём то, что лежит в витрине, нельзя.
|
||||
func TestКаталогПоказываетМетрикуСПустымИменем(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := openStore(t)
|
||||
base := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
|
||||
merge(t, st, []store.IncomingPoint{
|
||||
incoming("", "minute", "count", base, 1),
|
||||
incoming("step_count", "minute", "count", base, 2),
|
||||
})
|
||||
|
||||
metrics, err := service(t, st).Metrics(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("каталог: %v", err)
|
||||
}
|
||||
if len(metrics) != 2 {
|
||||
t.Fatalf("метрик в каталоге %d, ждали 2: %+v", len(metrics), metrics)
|
||||
}
|
||||
if metrics[0].Metric != "" || metrics[0].Layers[0].Points != 1 {
|
||||
t.Errorf("метрика с пустым именем потерялась: %+v", metrics[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestКаталогПишетПредупреждениеОПротиворечии(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := openStore(t)
|
||||
long := strings.Repeat("метрика", 200)
|
||||
var points []store.IncomingPoint
|
||||
base := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
|
||||
for i := range 6 {
|
||||
h := base.Add(time.Duration(i) * time.Hour)
|
||||
coarse := 6.0 // сумма
|
||||
if i >= 4 {
|
||||
coarse = 2.0 // среднее — противоречие
|
||||
}
|
||||
points = append(points, incoming(long, "hour", "count", h, coarse))
|
||||
for m, v := range []float64{1, 2, 3} {
|
||||
points = append(points, incoming(long, "minute", "count", h.Add(time.Duration(m)*time.Minute), v))
|
||||
}
|
||||
}
|
||||
merge(t, st, points)
|
||||
|
||||
handler := &logged{}
|
||||
metrics, err := catalog.New(st, slog.New(handler)).Metrics(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("каталог: %v", err)
|
||||
}
|
||||
if find(t, metrics, long).Aggregation.Style != catalog.Unknown {
|
||||
t.Error("противоречие обязано гасить род")
|
||||
}
|
||||
|
||||
rec, ok := handler.find("aggregation style conflict")
|
||||
if !ok {
|
||||
t.Fatal("противоречие есть, а предупреждения владельцу нет")
|
||||
}
|
||||
if rec.Level != slog.LevelWarn {
|
||||
t.Errorf("уровень %v, ждали WARN: адресат записи — владелец", rec.Level)
|
||||
}
|
||||
if got := attr(rec, "metric"); len(got) > 128 {
|
||||
t.Errorf("имя метрики в логе не обрезано: %d байт", len(got))
|
||||
}
|
||||
if attr(rec, "conflicting") == "" {
|
||||
t.Error("в записи нет чисел основания — по ней нечего разбирать")
|
||||
}
|
||||
}
|
||||
|
||||
// Число различных единиц у метрики равно числу её объектов: без потолка одна
|
||||
// запись каталога растёт вместе с витриной.
|
||||
func TestКаталогОграничиваетЧислоЕдиниц(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := openStore(t)
|
||||
base := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
|
||||
var points []store.IncomingPoint
|
||||
for i := range 40 {
|
||||
points = append(points, incoming("step_count", "minute",
|
||||
fmt.Sprintf("unit-%02d", i), base.Add(time.Duration(i)*time.Hour), 1))
|
||||
}
|
||||
merge(t, st, points)
|
||||
|
||||
metrics, err := service(t, st).Metrics(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("каталог: %v", err)
|
||||
}
|
||||
m := find(t, metrics, "step_count")
|
||||
if len(m.Units) == 0 || len(m.Units) > 16 {
|
||||
t.Errorf("единиц в ответе %d — потолок не работает", len(m.Units))
|
||||
}
|
||||
if len(m.Layers) != 1 {
|
||||
t.Errorf("слоёв %d, ждали один элемент на слой", len(m.Layers))
|
||||
}
|
||||
}
|
||||
|
||||
// Отказ хранилища логируется доменной границей один раз и уходит наверх: у
|
||||
// транспорта своей записи нет, он только переводит ошибку в ответ.
|
||||
func TestКаталогСообщаетОбОтказеХранилища(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := openStore(t)
|
||||
if err := st.Close(); err != nil {
|
||||
t.Fatalf("закрытие базы: %v", err)
|
||||
}
|
||||
|
||||
handler := &logged{}
|
||||
if _, err := catalog.New(st, slog.New(handler)).Metrics(context.Background()); err == nil {
|
||||
t.Fatal("каталог на закрытой базе собрался")
|
||||
}
|
||||
rec, ok := handler.find("catalog failed")
|
||||
if !ok {
|
||||
t.Fatal("отказ не записан на доменной границе")
|
||||
}
|
||||
if rec.Level != slog.LevelError {
|
||||
t.Errorf("уровень %v, ждали ERROR: сбой хранилища адресован владельцу", rec.Level)
|
||||
}
|
||||
}
|
||||
|
||||
// Отмена снаружи — «не сделано», а не «не выходит»: она не имеет права давать
|
||||
// владельцу ERROR, иначе единственный канал настоящих сбоев забивается
|
||||
// клиентами, оборвавшими запрос по своему тайм-ауту.
|
||||
func TestКаталогНеПутаетОтменуСоСбоем(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := openStore(t)
|
||||
fill(t, st, "step_count", 3, true)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
handler := &logged{}
|
||||
if _, err := catalog.New(st, slog.New(handler)).Metrics(ctx); err == nil {
|
||||
t.Fatal("каталог собрался на отменённом контексте")
|
||||
}
|
||||
if _, ok := handler.find("catalog failed"); ok {
|
||||
t.Error("отмена снаружи записана как сбой сервиса")
|
||||
}
|
||||
if _, ok := handler.find("catalog interrupted"); !ok {
|
||||
t.Error("отмена не отмечена вовсе — исход операции обязан быть виден")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
package catalog_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/catalog"
|
||||
"git.vakhrushev.me/av/healthlog/internal/store"
|
||||
)
|
||||
|
||||
func hour(n int) time.Time {
|
||||
return time.Date(2026, 8, 1, n, 0, 0, 0, time.UTC).UTC()
|
||||
}
|
||||
|
||||
// pair собирает час: одна часовая точка на границе часа и минутные точки внутри.
|
||||
func pair(h time.Time, coarse float64, fine ...float64) store.HourPair {
|
||||
p := store.HourPair{
|
||||
Hour: h,
|
||||
Coarse: []store.Point{{Start: h, End: h, Raw: qty(coarse)}},
|
||||
}
|
||||
for i, v := range fine {
|
||||
at := h.Add(time.Duration(i) * time.Minute)
|
||||
p.Fine = append(p.Fine, store.Point{Start: at, End: at, Raw: qty(v)})
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func qty(v float64) json.RawMessage {
|
||||
b, err := json.Marshal(map[string]float64{"qty": v})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// window собирает окно из одинаковых по устройству часов, от свежих к старым —
|
||||
// в том же порядке, в каком их отдаёт хранилище.
|
||||
func window(n int, f func(h time.Time) store.HourPair) []store.HourPair {
|
||||
out := make([]store.HourPair, 0, n)
|
||||
for i := n - 1; i >= 0; i-- {
|
||||
out = append(out, f(hour(i)))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestMeasureНакопительная(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pairs := window(4, func(h time.Time) store.HourPair {
|
||||
return pair(h, 6, 1, 2, 3)
|
||||
})
|
||||
|
||||
style, basis := catalog.Measure(pairs)
|
||||
if style != catalog.Cumulative {
|
||||
t.Fatalf("род: получили %v, ждали cumulative", style)
|
||||
}
|
||||
if basis.Agreeing != 4 || basis.Conflicting != 0 || basis.Compared != 4 || basis.Hours != 4 {
|
||||
t.Errorf("основание: %+v", basis)
|
||||
}
|
||||
if basis.FirstHour == nil || !basis.FirstHour.Equal(hour(0)) {
|
||||
t.Errorf("начало окна: %v", basis.FirstHour)
|
||||
}
|
||||
if basis.LastHour == nil || !basis.LastHour.Equal(hour(3)) {
|
||||
t.Errorf("конец окна: %v", basis.LastHour)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeasureМгновенная(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pairs := window(4, func(h time.Time) store.HourPair {
|
||||
return pair(h, 2, 1, 2, 3)
|
||||
})
|
||||
|
||||
style, basis := catalog.Measure(pairs)
|
||||
if style != catalog.Instant {
|
||||
t.Fatalf("род: получили %v, ждали instant", style)
|
||||
}
|
||||
if basis.Agreeing != 4 || basis.Conflicting != 0 {
|
||||
t.Errorf("основание: %+v", basis)
|
||||
}
|
||||
}
|
||||
|
||||
// Нулевой час различить гипотезы не может: сумма равна среднему. Без этого
|
||||
// фильтра `walking_asymmetry_percentage` на живом корпусе давала конфликт из
|
||||
// одних нулевых часов.
|
||||
func TestMeasureНулевойЧасНеСвидетельствует(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pairs := window(5, func(h time.Time) store.HourPair {
|
||||
return pair(h, 0, 0, 0, 0)
|
||||
})
|
||||
|
||||
style, basis := catalog.Measure(pairs)
|
||||
if style != catalog.Unknown {
|
||||
t.Fatalf("род: получили %v, ждали unknown", style)
|
||||
}
|
||||
if basis.Compared != 0 || basis.Hours != 5 {
|
||||
t.Errorf("основание: %+v — нулевые часы обязаны быть непригодны", basis)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeasureПротиворечиеГаситРод(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pairs := []store.HourPair{
|
||||
pair(hour(4), 6, 1, 2, 3), // сумма
|
||||
pair(hour(3), 6, 1, 2, 3), // сумма
|
||||
pair(hour(2), 6, 1, 2, 3), // сумма
|
||||
pair(hour(1), 2, 1, 2, 3), // среднее
|
||||
pair(hour(0), 12, 1, 2, 3), // ни то ни то
|
||||
}
|
||||
|
||||
style, basis := catalog.Measure(pairs)
|
||||
if style != catalog.Unknown {
|
||||
t.Fatalf("род: получили %v, ждали unknown при противоречии", style)
|
||||
}
|
||||
if basis.Agreeing != 3 || basis.Conflicting != 1 {
|
||||
t.Errorf("основание: %+v", basis)
|
||||
}
|
||||
if basis.Compared != 5 {
|
||||
t.Errorf("пригодных часов: %d, ждали 5", basis.Compared)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeasureМалоСвидетельств(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pairs := window(2, func(h time.Time) store.HourPair {
|
||||
return pair(h, 6, 1, 2, 3)
|
||||
})
|
||||
|
||||
style, basis := catalog.Measure(pairs)
|
||||
if style != catalog.Unknown {
|
||||
t.Fatalf("род: получили %v, ждали unknown при двух согласных часах", style)
|
||||
}
|
||||
if basis.Agreeing != 2 {
|
||||
t.Errorf("согласных: %d", basis.Agreeing)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeasureОбщихЧасовНет(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
style, basis := catalog.Measure(nil)
|
||||
if style != catalog.Unknown {
|
||||
t.Fatalf("род: получили %v", style)
|
||||
}
|
||||
if basis.Hours != 0 || basis.FirstHour != nil || basis.LastHour != nil {
|
||||
t.Errorf("основание: %+v — границ окна быть не должно", basis)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeasureНепригодныеЧасы(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
двеЧасовые := func(h time.Time) store.HourPair {
|
||||
p := pair(h, 6, 1, 2, 3)
|
||||
p.Coarse = append(p.Coarse, store.Point{Start: h, End: h.Add(time.Hour), Raw: qty(6)})
|
||||
return p
|
||||
}
|
||||
однаМинутная := func(h time.Time) store.HourPair {
|
||||
return pair(h, 1, 1)
|
||||
}
|
||||
// Метка часовой точки на середине часа UTC: так выглядит часовой слой в
|
||||
// зоне с получасовым смещением, и сравнивать его с минутными точками того
|
||||
// же объекта нельзя — они покрывают другой интервал.
|
||||
сдвинутая := func(h time.Time) store.HourPair {
|
||||
p := pair(h, 6, 1, 2, 3)
|
||||
p.Coarse[0].Start = h.Add(30 * time.Minute)
|
||||
return p
|
||||
}
|
||||
безЧисла := func(h time.Time) store.HourPair {
|
||||
p := pair(h, 6, 1, 2, 3)
|
||||
p.Coarse[0].Raw = json.RawMessage(`{"date":"…"}`)
|
||||
return p
|
||||
}
|
||||
|
||||
cases := map[string]func(time.Time) store.HourPair{
|
||||
"часовой объект несёт две точки": двеЧасовые,
|
||||
"минутный объект несёт одну": однаМинутная,
|
||||
"часовая метка не выровнена": сдвинутая,
|
||||
"часовая точка без числа": безЧисла,
|
||||
}
|
||||
|
||||
for name, mk := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
style, basis := catalog.Measure(window(5, mk))
|
||||
if style != catalog.Unknown {
|
||||
t.Errorf("род: получили %v, ждали unknown", style)
|
||||
}
|
||||
if basis.Compared != 0 {
|
||||
t.Errorf("пригодных часов: %d, ждали 0 (основание %+v)", basis.Compared, basis)
|
||||
}
|
||||
if basis.Hours != 5 {
|
||||
t.Errorf("часов окна: %d, ждали 5", basis.Hours)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Вердикт обязан быть функцией состава, а не порядка точек внутри объекта:
|
||||
// порядок ключей и элементов в теле HAE нестабилен (находка 2).
|
||||
func TestMeasureНеЗависитОтПорядкаТочек(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
forward := window(4, func(h time.Time) store.HourPair {
|
||||
return pair(h, 6, 1, 2, 3)
|
||||
})
|
||||
backward := window(4, func(h time.Time) store.HourPair {
|
||||
p := pair(h, 6, 1, 2, 3)
|
||||
p.Fine[0], p.Fine[2] = p.Fine[2], p.Fine[0]
|
||||
return p
|
||||
})
|
||||
|
||||
s1, b1 := catalog.Measure(forward)
|
||||
s2, b2 := catalog.Measure(backward)
|
||||
// Границы окна сравниваются значением: в основании они указатели, чтобы
|
||||
// «окна не было» отличалось от нулевой метки в ответе.
|
||||
same := s1 == s2 &&
|
||||
b1.Hours == b2.Hours && b1.Compared == b2.Compared &&
|
||||
b1.Agreeing == b2.Agreeing && b1.Conflicting == b2.Conflicting &&
|
||||
b1.FirstHour.Equal(*b2.FirstHour) && b1.LastHour.Equal(*b2.LastHour)
|
||||
if !same {
|
||||
t.Errorf("перестановка точек изменила исход: %v %+v против %v %+v", s1, b1, s2, b2)
|
||||
}
|
||||
}
|
||||
|
||||
// Точка без числа в сумму не входит и числа точек часа не увеличивает: час из
|
||||
// одной значащей точки и одной пустой различить гипотезы не может.
|
||||
func TestMeasureТочкаБезЧислаНеСчитается(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pairs := window(5, func(h time.Time) store.HourPair {
|
||||
p := pair(h, 1, 1)
|
||||
p.Fine = append(p.Fine, store.Point{
|
||||
Start: h.Add(time.Minute), End: h.Add(time.Minute),
|
||||
Raw: json.RawMessage(`{"source":"часы"}`),
|
||||
})
|
||||
return p
|
||||
})
|
||||
|
||||
_, basis := catalog.Measure(pairs)
|
||||
if basis.Compared != 0 {
|
||||
t.Errorf("пригодных часов: %d, ждали 0", basis.Compared)
|
||||
}
|
||||
}
|
||||
|
||||
// Перевес мгновенной гипотезы над накопительной: разложение основания одно на
|
||||
// обе ветки, и `agreeing` всегда относится к преобладающему вердикту.
|
||||
func TestMeasureПротиворечиеСПеревесомМгновенной(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pairs := []store.HourPair{
|
||||
pair(hour(4), 2, 1, 2, 3),
|
||||
pair(hour(3), 2, 1, 2, 3),
|
||||
pair(hour(2), 2, 1, 2, 3),
|
||||
pair(hour(1), 2, 1, 2, 3),
|
||||
pair(hour(0), 6, 1, 2, 3),
|
||||
}
|
||||
|
||||
style, basis := catalog.Measure(pairs)
|
||||
if style != catalog.Unknown {
|
||||
t.Fatalf("род: получили %v, ждали unknown", style)
|
||||
}
|
||||
if basis.Agreeing != 4 || basis.Conflicting != 1 {
|
||||
t.Errorf("основание: %+v — согласные обязаны быть у преобладающей гипотезы", basis)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStyleСловарь(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := map[catalog.Style]string{
|
||||
catalog.Cumulative: `"cumulative"`,
|
||||
catalog.Instant: `"instant"`,
|
||||
catalog.Unknown: `"unknown"`,
|
||||
catalog.Style(42): `"unknown"`,
|
||||
}
|
||||
for style, want := range cases {
|
||||
got, err := json.Marshal(style)
|
||||
if err != nil {
|
||||
t.Fatalf("сериализация %v: %v", style, err)
|
||||
}
|
||||
if string(got) != want {
|
||||
t.Errorf("род %d: получили %s, ждали %s", style, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package hae
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// PointValue достаёт число точки: `qty`, а при его отсутствии — `Avg`.
|
||||
//
|
||||
// Единственное место в проекте, знающее, какое поле точки HAE несёт число.
|
||||
// Порядок именно такой: `qty` несут все метрики, `Avg` — только `heart_rate`
|
||||
// (находка 40), и без второго кандидата самая частая метрика потока не
|
||||
// измерялась бы вовсе.
|
||||
//
|
||||
// Это чтение, а не интерпретация: значение никуда не пишется и ничего не
|
||||
// подменяет. Форма точки при этом рода метрики не выдаёт — род измеряется
|
||||
// сверкой слоёв, а не выводится отсюда.
|
||||
//
|
||||
// **Ноль — значение.** Словарь пустоты из `canon` сюда не применяется и
|
||||
// применяться не должен: там ноль объявлен пустотой, чтобы точка без измерений
|
||||
// не вытесняла настоящее измерение при столкновении координат, — вопрос совсем
|
||||
// другой. Взяв его, измерение не увидело бы точки `{"qty":0}`, час выпал бы из
|
||||
// счётчиков ещё до правила различимости, и проверка «нулевой час свидетельством
|
||||
// не является» зеленела бы по неверной причине.
|
||||
//
|
||||
// **Значением считается только JSON-число.** Строка `"72.5"` — не число, хотя
|
||||
// `json.Number` её принимает (проверено): такой формы поток не приносил, и
|
||||
// прочитать её как измерение значило бы угадать за источник. Отсутствие ключа и
|
||||
// `null` — тоже «нет значения»; при этом `qty: null` не мешает прочитать `Avg`,
|
||||
// а `qty` не того типа мешает: форма точки поменялась, и догадываться не о чем.
|
||||
func PointValue(raw json.RawMessage) (float64, bool) {
|
||||
var p struct {
|
||||
Qty *json.RawMessage `json:"qty"`
|
||||
Avg *json.RawMessage `json:"Avg"`
|
||||
}
|
||||
// Указатели, а не значения: `null` обязан отличаться от нуля, и в этой форме
|
||||
// он приходит нулевым указателем.
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
if p.Qty != nil {
|
||||
return jsonNumber(*p.Qty)
|
||||
}
|
||||
if p.Avg != nil {
|
||||
return jsonNumber(*p.Avg)
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// jsonNumber разбирает значение поля, требуя, чтобы это было JSON-число.
|
||||
//
|
||||
// Проверка первого байта нужна потому, что `json.Unmarshal` в `float64`
|
||||
// отвергает строку, но `json.Number` — принимает; полагаться на тип-приёмник
|
||||
// значило бы получить разное поведение от невидимой детали.
|
||||
//
|
||||
// Бесконечность значением не считается: `1e400` разбирается с ошибкой
|
||||
// диапазона, и проглоти её — `+Inf` отравил бы и сумму, и среднее всего часа, а
|
||||
// видно это было бы лишь тем, что род перестал определяться. Отдельной проверки
|
||||
// на `Inf`/`NaN` после разбора нет намеренно: их литералов в JSON не бывает, а
|
||||
// число вне диапазона `float64` даёт ошибку, а не бесконечность.
|
||||
func jsonNumber(raw json.RawMessage) (float64, bool) {
|
||||
b := bytes.TrimSpace(raw)
|
||||
if len(b) == 0 || (b[0] != '-' && (b[0] < '0' || b[0] > '9')) {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
var v float64
|
||||
if err := json.Unmarshal(b, &v); err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package hae_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/hae"
|
||||
)
|
||||
|
||||
func TestPointValueФормыТочки(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
want float64
|
||||
ok bool
|
||||
}{
|
||||
{"обычная точка", `{"qty":72.5,"date":"2026-07-31 12:00:00 +0300"}`, 72.5, true},
|
||||
{"пульс без qty", `{"Min":60,"Avg":70.25,"Max":80}`, 70.25, true},
|
||||
{"qty впереди Avg", `{"qty":1,"Avg":9}`, 1, true},
|
||||
{"ноль — значение", `{"qty":0}`, 0, true},
|
||||
{"отрицательное значение", `{"qty":-1.5}`, -1.5, true},
|
||||
{"нет числовых полей", `{"date":"…","source":"часы"}`, 0, false},
|
||||
{"qty равен null", `{"qty":null,"Avg":3}`, 3, true},
|
||||
{"qty строкой", `{"qty":"72.5"}`, 0, false},
|
||||
{"qty объектом", `{"qty":{"value":1}}`, 0, false},
|
||||
{"переполнение float64", `{"qty":1e400}`, 0, false},
|
||||
{"точка не объект", `[1,2,3]`, 0, false},
|
||||
{"пустой объект", `{}`, 0, false},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, ok := hae.PointValue(json.RawMessage(c.raw))
|
||||
if ok != c.ok {
|
||||
t.Fatalf("наличие значения: получили %v, ждали %v", ok, c.ok)
|
||||
}
|
||||
if ok && got != c.want {
|
||||
t.Errorf("значение: получили %v, ждали %v", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Формы точки берутся из реальных пакетов: документация формата тонкая и
|
||||
// местами расходится с тем, что приложение шлёт (docs/local-research.md).
|
||||
func TestPointValueНаРеальныхПакетах(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, name := range []string{"minute.json", "hour.json", "raw.json"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body, err := os.ReadFile(filepath.Join("testdata", name))
|
||||
if err != nil {
|
||||
t.Fatalf("фикстура: %v", err)
|
||||
}
|
||||
var doc struct {
|
||||
Data struct {
|
||||
Metrics []struct {
|
||||
Name string `json:"name"`
|
||||
Data []json.RawMessage `json:"data"`
|
||||
} `json:"metrics"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &doc); err != nil {
|
||||
t.Fatalf("разбор фикстуры: %v", err)
|
||||
}
|
||||
|
||||
total, valued := 0, 0
|
||||
for _, m := range doc.Data.Metrics {
|
||||
for _, raw := range m.Data {
|
||||
total++
|
||||
if _, ok := hae.PointValue(raw); ok {
|
||||
valued++
|
||||
}
|
||||
}
|
||||
}
|
||||
if total == 0 {
|
||||
t.Fatal("в фикстуре нет точек — проверять нечего")
|
||||
}
|
||||
// Утверждается свойство, а не число: корпус фикстур пополняется, и
|
||||
// константа протухла бы молча. Значение несёт подавляющее
|
||||
// большинство точек — если вдруг перестанет, это видно сразу.
|
||||
if valued*2 < total {
|
||||
t.Errorf("значение прочиталось лишь у %d точек из %d", valued, total)
|
||||
}
|
||||
t.Logf("точек %d, со значением %d", total, valued)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/catalog"
|
||||
)
|
||||
|
||||
// catalogResponse — оболочка ответа каталога.
|
||||
//
|
||||
// Объект, а не голый массив: список метрик — не единственное, что каталогу
|
||||
// когда-нибудь придётся отдать, а массив верхнего уровня расширить нечем.
|
||||
type catalogResponse struct {
|
||||
Metrics []catalog.Metric `json:"metrics"`
|
||||
}
|
||||
|
||||
// handleMetrics отдаёт каталог разрезов с измеренным родом агрегации.
|
||||
//
|
||||
// Список приходит из домена уже непустым срезом: nil сериализуется в `null`, и
|
||||
// пустая витрина отдавала бы клиенту `"metrics": null` вместо `[]`. Тест,
|
||||
// сравнивающий разобранные структуры, этого не увидел бы — потому приёмочная
|
||||
// проверка сравнивает байты ответа. Второй страховки здесь нет намеренно:
|
||||
// подстраховка поверх подстраховки прячет отказ первой.
|
||||
func (a *api) handleMetrics(w http.ResponseWriter, r *http.Request) {
|
||||
metrics, err := a.catalog.Metrics(r.Context())
|
||||
if err != nil {
|
||||
// Исход операции логирует доменный слой, транспорт только переводит его
|
||||
// в ответ. Наружу уходит человекочитаемое сообщение, а не текст ошибки:
|
||||
// в нём имена колонок и форма запроса.
|
||||
writeError(w, http.StatusInternalServerError, "каталог не собрался")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, catalogResponse{Metrics: metrics})
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package httpapi_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/store"
|
||||
)
|
||||
|
||||
func getCatalog(t *testing.T, h http.Handler, auth string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/metrics", nil)
|
||||
if auth != "" {
|
||||
req.Header.Set("Authorization", auth)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// Пустая витрина отдаёт `[]`, а не `null`. Сравниваются БАЙТЫ: nil-срез
|
||||
// сериализуется в `null`, и тест, сличающий разобранные структуры, этого не
|
||||
// увидел бы — а клиент увидел бы сразу.
|
||||
func TestКаталогПустойВитриныОтдаётПустойСписок(t *testing.T) {
|
||||
h, _, _ := newAPITokens(t, nil, nil)
|
||||
|
||||
rec := getCatalog(t, h, "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("статус %d, тело %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := strings.TrimSpace(rec.Body.String()); got != `{"metrics":[]}` {
|
||||
t.Errorf("тело %q, ждали {\"metrics\":[]}", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Границы неизмеренного окна уезжают как `null`, а не как правдоподобная метка
|
||||
// `0001-01-01`: нулевое время в ответе неотличимо от данных.
|
||||
func TestКаталогНеизмеренноеОкноОтдаётNull(t *testing.T) {
|
||||
h, st, _ := newAPITokens(t, nil, nil)
|
||||
|
||||
at := time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC)
|
||||
_, err := st.Merge(context.Background(), store.Incoming{Points: []store.IncomingPoint{{
|
||||
Metric: "vo2_max", Layer: "raw", Units: "ml/(kg·min)",
|
||||
Point: store.Point{Start: at, End: at, Raw: json.RawMessage(`{"qty":42}`)},
|
||||
}}}, store.DeliveryRef{ID: "d"})
|
||||
if err != nil {
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
|
||||
body := getCatalog(t, h, "").Body.String()
|
||||
for _, want := range []string{`"style":"unknown"`, `"first_hour":null`, `"last_hour":null`, `"hours":0`} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("в ответе нет %s: %s", want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestКаталогТребуетТокенЧтения(t *testing.T) {
|
||||
write := []string{"write-token"}
|
||||
read := []string{"read-token"}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
auth string
|
||||
want int
|
||||
}{
|
||||
{"без заголовка", "", http.StatusUnauthorized},
|
||||
{"токен приёма", "Bearer write-token", http.StatusUnauthorized},
|
||||
{"голое значение без схемы", "read-token", http.StatusUnauthorized},
|
||||
{"чужой токен", "Bearer nope", http.StatusUnauthorized},
|
||||
{"токен чтения", "Bearer read-token", http.StatusOK},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
h, _, _ := newAPITokens(t, write, read)
|
||||
|
||||
rec := getCatalog(t, h, c.auth)
|
||||
if rec.Code != c.want {
|
||||
t.Errorf("статус %d, ждали %d (тело %s)", rec.Code, c.want, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Пустой список токенов чтения = проверка выключена. Это симметрия с приёмом, и
|
||||
// цена её названа в конфиге: открытое чтение — выгрузка истории здоровья.
|
||||
func TestКаталогБезТокеновОтдаётся(t *testing.T) {
|
||||
h, _, _ := newAPITokens(t, []string{"write-token"}, nil)
|
||||
|
||||
if rec := getCatalog(t, h, ""); rec.Code != http.StatusOK {
|
||||
t.Errorf("статус %d, ждали 200", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Токен чтения — такой же секрет, как токен приёма: посланный на маршрут приёма
|
||||
// заголовком с произвольным именем, он не имеет права осесть в учёте доставки.
|
||||
func TestТокенЧтенияНеОседаетВУчётеДоставки(t *testing.T) {
|
||||
h, st, _ := newAPITokens(t, nil, []string{"read-token"})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/ingest", strings.NewReader(samplePayload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Whatever", "read-token")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("статус %d", rec.Code)
|
||||
}
|
||||
|
||||
d, err := st.LastDelivery(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("доставка: %v", err)
|
||||
}
|
||||
if strings.Contains(d.Headers, "read-token") {
|
||||
t.Errorf("токен чтения сохранён в заголовках доставки: %s", d.Headers)
|
||||
}
|
||||
}
|
||||
|
||||
// Форма ответа закреплена БАЙТАМИ на непустой витрине, а не подстроками.
|
||||
// Wire-форма каталога — это доменные структуры с json-тегами, и переименование
|
||||
// поля меняет публичный контракт без единого касания транспорта; страж у него
|
||||
// один — этот литерал.
|
||||
func TestКаталогОтдаётОжидаемыеБайты(t *testing.T) {
|
||||
h, st, _ := newAPITokens(t, nil, nil)
|
||||
|
||||
base := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
|
||||
var points []store.IncomingPoint
|
||||
for i := range 4 {
|
||||
at := base.Add(time.Duration(i) * time.Hour)
|
||||
points = append(points, store.IncomingPoint{
|
||||
Metric: "step_count", Layer: "hour", Units: "count",
|
||||
Point: store.Point{Start: at, End: at, Raw: json.RawMessage(`{"qty":6}`)},
|
||||
})
|
||||
for m, v := range []string{`{"qty":1}`, `{"qty":2}`, `{"qty":3}`} {
|
||||
ts := at.Add(time.Duration(m) * time.Minute)
|
||||
points = append(points, store.IncomingPoint{
|
||||
Metric: "step_count", Layer: "minute", Units: "count",
|
||||
Point: store.Point{Start: ts, End: ts, Raw: json.RawMessage(v)},
|
||||
})
|
||||
}
|
||||
}
|
||||
if _, err := st.Merge(context.Background(), store.Incoming{Points: points},
|
||||
store.DeliveryRef{ID: "d"}); err != nil {
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
|
||||
want := `{"metrics":[{"metric":"step_count","units":["count"],` +
|
||||
`"aggregation":{"style":"cumulative","hours":4,"compared":4,"agreeing":4,` +
|
||||
`"conflicting":0,"first_hour":"2026-06-01T00:00:00Z","last_hour":"2026-06-01T03:00:00Z"},` +
|
||||
`"layers":[{"layer":"hour","from":"2026-06-01T00:00:00Z","to":"2026-06-01T03:00:00Z","points":4},` +
|
||||
`{"layer":"minute","from":"2026-06-01T00:00:00Z","to":"2026-06-01T03:02:00Z","points":12}]}]}`
|
||||
|
||||
if got := strings.TrimSpace(getCatalog(t, h, "").Body.String()); got != want {
|
||||
t.Errorf("форма ответа изменилась:\n получили %s\n ждали %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Два запроса подряд на неизменившейся витрине совпадают побайтово: порядок
|
||||
// метрик и слоёв держится `ORDER BY` в чужом пакете, и снятие сортировки
|
||||
// «раз индекс и так отсортирован» проявилось бы у клиента, а не в тестах.
|
||||
func TestКаталогПовторяетсяПобайтово(t *testing.T) {
|
||||
h, st, _ := newAPITokens(t, nil, nil)
|
||||
|
||||
base := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
|
||||
var points []store.IncomingPoint
|
||||
for i, metric := range []string{"step_count", "heart_rate", "active_energy"} {
|
||||
for _, layer := range []string{"minute", "hour", "raw"} {
|
||||
at := base.Add(time.Duration(i) * time.Hour)
|
||||
points = append(points, store.IncomingPoint{
|
||||
Metric: metric, Layer: layer, Units: "count",
|
||||
Point: store.Point{Start: at, End: at, Raw: json.RawMessage(`{"qty":1}`)},
|
||||
})
|
||||
}
|
||||
}
|
||||
if _, err := st.Merge(context.Background(), store.Incoming{Points: points},
|
||||
store.DeliveryRef{ID: "d"}); err != nil {
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
|
||||
first := getCatalog(t, h, "").Body.String()
|
||||
second := getCatalog(t, h, "").Body.String()
|
||||
if first != second {
|
||||
t.Errorf("два ответа на неизменившейся витрине разошлись:\n %s\n %s", first, second)
|
||||
}
|
||||
if !strings.Contains(first, `"metric":"active_energy"`) {
|
||||
t.Fatalf("в ответе нет ожидаемых метрик: %s", first)
|
||||
}
|
||||
}
|
||||
|
||||
// Отказ хранилища переводится в 500 с человекочитаемым сообщением: текст ошибки
|
||||
// наружу не уходит — в нём имена колонок и форма запроса.
|
||||
func TestКаталогОтвечает500НаОтказХранилища(t *testing.T) {
|
||||
h, st, _ := newAPITokens(t, nil, nil)
|
||||
if err := st.Close(); err != nil {
|
||||
t.Fatalf("закрытие базы: %v", err)
|
||||
}
|
||||
|
||||
rec := getCatalog(t, h, "")
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("статус %d, ждали 500", rec.Code)
|
||||
}
|
||||
if body := rec.Body.String(); strings.Contains(body, "sql") || strings.Contains(body, "bucket") {
|
||||
t.Errorf("наружу уехали внутренности: %s", body)
|
||||
}
|
||||
}
|
||||
+32
-13
@@ -14,14 +14,17 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/catalog"
|
||||
"git.vakhrushev.me/av/healthlog/internal/ingest"
|
||||
)
|
||||
|
||||
// Options — зависимости и настройки транспорта.
|
||||
type Options struct {
|
||||
Ingest *ingest.Service
|
||||
Catalog *catalog.Service
|
||||
Log *slog.Logger
|
||||
WriteTokens []string
|
||||
ReadTokens []string
|
||||
MaxBodyMB int
|
||||
// IngestWriteBudget — сколько отводится маршруту приёма на чтение тела
|
||||
// вместе с отправкой ответа. Ноль означает «полагаться на WriteTimeout
|
||||
@@ -31,8 +34,10 @@ type Options struct {
|
||||
|
||||
type api struct {
|
||||
ingest *ingest.Service
|
||||
catalog *catalog.Service
|
||||
log *slog.Logger
|
||||
writeTokens []string
|
||||
readTokens []string
|
||||
maxBody int64
|
||||
ingestBudget time.Duration
|
||||
}
|
||||
@@ -41,8 +46,10 @@ type api struct {
|
||||
func New(o Options) http.Handler {
|
||||
a := &api{
|
||||
ingest: o.Ingest,
|
||||
catalog: o.Catalog,
|
||||
log: o.Log,
|
||||
writeTokens: o.WriteTokens,
|
||||
readTokens: o.ReadTokens,
|
||||
maxBody: int64(o.MaxBodyMB) << 20,
|
||||
ingestBudget: o.IngestWriteBudget,
|
||||
}
|
||||
@@ -53,7 +60,8 @@ func New(o Options) http.Handler {
|
||||
|
||||
r.Get("/healthz", a.handleHealthz)
|
||||
r.Route("/api/v1", func(r chi.Router) {
|
||||
r.With(a.requireWriteToken).Post("/ingest", a.handleIngest)
|
||||
r.With(requireToken(a.writeTokens)).Post("/ingest", a.handleIngest)
|
||||
r.With(requireToken(a.readTokens)).Get("/metrics", a.handleMetrics)
|
||||
})
|
||||
return r
|
||||
}
|
||||
@@ -64,21 +72,32 @@ func (a *api) handleHealthz(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
||||
}
|
||||
|
||||
// requireWriteToken проверяет токен приёма. Пустой список токенов = проверка
|
||||
// requireToken проверяет токен контура. Пустой список токенов = проверка
|
||||
// выключена: локальный запуск в доверенной сети. О выключенной проверке
|
||||
// сервис предупреждает на старте.
|
||||
func (a *api) requireWriteToken(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if len(a.writeTokens) == 0 {
|
||||
//
|
||||
// Проверка ОДНА на оба контура, параметризованная списком. Копия отличалась бы
|
||||
// одним полем и несла бы три решения сразу — сравнение за постоянное время,
|
||||
// «пустой список = выключено» и текст 401; правка любого из них в одном месте
|
||||
// не дала бы ни ошибки компиляции, ни красного теста, а речь о контуре чтения
|
||||
// данных о здоровье.
|
||||
//
|
||||
// Контуры при этом раздельны: списки разные, и токен приёма маршрут чтения не
|
||||
// открывает. Схема строгая — токеном считается только значение после `Bearer `.
|
||||
func requireToken(tokens []string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if len(tokens) == 0 {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if !tokenAllowed(bearer(r), tokens) {
|
||||
writeError(w, http.StatusUnauthorized, "неверный или отсутствующий токен")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if !tokenAllowed(bearer(r), a.writeTokens) {
|
||||
writeError(w, http.StatusUnauthorized, "неверный или отсутствующий токен")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func bearer(r *http.Request) string {
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/archive"
|
||||
"git.vakhrushev.me/av/healthlog/internal/catalog"
|
||||
"git.vakhrushev.me/av/healthlog/internal/httpapi"
|
||||
"git.vakhrushev.me/av/healthlog/internal/ingest"
|
||||
"git.vakhrushev.me/av/healthlog/internal/store"
|
||||
@@ -284,6 +285,15 @@ func TestПриёмРаботаетНаТранспортеБезДедлайн
|
||||
|
||||
func newAPI(t *testing.T, writeTokens []string) (http.Handler, *store.Store) {
|
||||
t.Helper()
|
||||
|
||||
h, st, _ := newAPITokens(t, writeTokens, nil)
|
||||
return h, st
|
||||
}
|
||||
|
||||
// newAPITokens собирает роутер с обоими контурами. Отдельно от newAPI, чтобы не
|
||||
// переписывать два десятка вызовов ради одного параметра.
|
||||
func newAPITokens(t *testing.T, writeTokens, readTokens []string) (http.Handler, *store.Store, *catalog.Service) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
|
||||
st, err := store.Open(filepath.Join(dir, "healthlog.db"))
|
||||
@@ -298,14 +308,17 @@ func newAPI(t *testing.T, writeTokens []string) (http.Handler, *store.Store) {
|
||||
}
|
||||
|
||||
log := slog.New(slog.DiscardHandler)
|
||||
cat := catalog.New(st, log)
|
||||
h := httpapi.New(httpapi.Options{
|
||||
Ingest: ingest.New(arch, st, nil, log),
|
||||
Catalog: cat,
|
||||
Log: log,
|
||||
WriteTokens: writeTokens,
|
||||
ReadTokens: readTokens,
|
||||
MaxBodyMB: 1,
|
||||
// Бюджет задаётся всегда: httptest.ResponseRecorder дедлайнов не умеет,
|
||||
// и это ровно тот транспорт, на котором приём обязан продолжать работать.
|
||||
IngestWriteBudget: time.Minute,
|
||||
})
|
||||
return h, st
|
||||
return h, st, cat
|
||||
}
|
||||
|
||||
@@ -103,11 +103,21 @@ var secretHeaders = map[string]bool{
|
||||
// redacted подменяет значение, которое оказалось секретом.
|
||||
const redacted = "[redacted]"
|
||||
|
||||
// isSecretValue — совпало ли значение заголовка с каким-нибудь настроенным
|
||||
// токеном, всё равно какого контура.
|
||||
func (a *api) isSecretValue(v string) bool {
|
||||
return tokenAllowed(v, a.writeTokens) || tokenAllowed(v, a.readTokens)
|
||||
}
|
||||
|
||||
// safeHeaders собирает заголовки запроса для хранения, вычищая секреты.
|
||||
//
|
||||
// Двойная защита: имя из чёрного списка вырезается всегда, а любое значение,
|
||||
// совпавшее с настроенным токеном, подменяется — токен можно положить в
|
||||
// заголовок с произвольным именем, и угадать его мы не можем.
|
||||
//
|
||||
// Сверяется с токенами ОБОИХ контуров. Токен чтения так же секрет, как токен
|
||||
// приёма, и посланный на этот маршрут произвольным заголовком осел бы в базе
|
||||
// доставок навсегда.
|
||||
func (a *api) safeHeaders(r *http.Request) map[string][]string {
|
||||
out := make(map[string][]string, len(r.Header)+1)
|
||||
for name, values := range r.Header {
|
||||
@@ -117,7 +127,7 @@ func (a *api) safeHeaders(r *http.Request) map[string][]string {
|
||||
}
|
||||
safe := make([]string, len(values))
|
||||
for i, v := range values {
|
||||
if tokenAllowed(v, a.writeTokens) || tokenAllowed(strings.TrimPrefix(v, "Bearer "), a.writeTokens) {
|
||||
if a.isSecretValue(v) || a.isSecretValue(strings.TrimPrefix(v, "Bearer ")) {
|
||||
safe[i] = redacted
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -6,12 +6,16 @@ import (
|
||||
"context"
|
||||
"flag"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/catalog"
|
||||
"git.vakhrushev.me/av/healthlog/internal/hae"
|
||||
"git.vakhrushev.me/av/healthlog/internal/ident"
|
||||
"git.vakhrushev.me/av/healthlog/internal/store"
|
||||
)
|
||||
@@ -132,6 +136,8 @@ func TestReplayЖивогоАрхива(t *testing.T) {
|
||||
// Значений точек отпечаток не раскрывает: содержимое входит в него хешем.
|
||||
t.Logf("объектов %d, отпечаток содержимого %s", first.Buckets, first.Fingerprint)
|
||||
|
||||
measureStyles(t, dst)
|
||||
|
||||
// Главное свойство ключа: у записей сна он ИНТЕРВАЛ, а не метка — под одним
|
||||
// `date` лежит до трёх записей (docs/local-research.md, находка 47).
|
||||
//
|
||||
@@ -151,6 +157,72 @@ func TestReplayЖивогоАрхива(t *testing.T) {
|
||||
t.Logf("координат сна %d, различных меток %d", coords, labels)
|
||||
}
|
||||
|
||||
// measureStyles прогоняет измерение рода агрегации на витрине, собранной из
|
||||
// живого архива, — единственное место, где правило проверяется на настоящем
|
||||
// потоке, а не на фикстурах.
|
||||
//
|
||||
// Утверждаются СВОЙСТВА, а не числа: корпус растёт с каждой доставкой, а прогон
|
||||
// живого архива в гейт не входит, так что константа, производная от размера
|
||||
// корпуса, покраснела бы молча (docs/review-journal.md, 2026-08-02). Измеренные
|
||||
// числа печатаются.
|
||||
func measureStyles(t *testing.T, st *store.Store) {
|
||||
t.Helper()
|
||||
|
||||
started := time.Now()
|
||||
metrics, err := catalog.New(st, slog.New(slog.DiscardHandler)).Metrics(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("каталог: %v", err)
|
||||
}
|
||||
elapsed := time.Since(started)
|
||||
|
||||
byStyle := map[catalog.Style]int{}
|
||||
for _, m := range metrics {
|
||||
byStyle[m.Aggregation.Style]++
|
||||
|
||||
// Главное свойство правила: свидетельства единогласны. Противоречие —
|
||||
// событие для разбора, а не отказ прогона, поэтому оно печатается с
|
||||
// координатами и валит тест: пока его нет, посылка «род измерим» верна.
|
||||
if m.Aggregation.Conflicting > 0 {
|
||||
t.Errorf("%s: противоречащих часов %d при %d согласных — свидетельства разошлись",
|
||||
m.Metric, m.Aggregation.Conflicting, m.Aggregation.Agreeing)
|
||||
}
|
||||
|
||||
// Род измеряется только сверкой минутного слоя с часовым. Метрика с
|
||||
// объявленным родом обязана иметь оба слоя: иначе он выведен из
|
||||
// нижнего, а нижний слой HAE — посекундная развёртка, и его сумма
|
||||
// завышена.
|
||||
if m.Aggregation.Style == catalog.Unknown {
|
||||
continue
|
||||
}
|
||||
var hasMinute, hasHour bool
|
||||
for _, l := range m.Layers {
|
||||
hasMinute = hasMinute || l.Layer == string(hae.LayerMinute)
|
||||
hasHour = hasHour || l.Layer == string(hae.LayerHour)
|
||||
}
|
||||
if !hasMinute || !hasHour {
|
||||
t.Errorf("%s: род %v при слоях %+v — измерять было нечем", m.Metric, m.Aggregation.Style, m.Layers)
|
||||
}
|
||||
}
|
||||
|
||||
// Роды разошлись: правило различает накопительные и мгновенные, а не
|
||||
// сваливает всё в одну кучу и не отвечает `unknown` на весь корпус.
|
||||
if byStyle[catalog.Cumulative] == 0 {
|
||||
t.Error("накопительных метрик не нашлось — правило не различает роды")
|
||||
}
|
||||
if byStyle[catalog.Instant] == 0 {
|
||||
t.Error("мгновенных метрик не нашлось — правило не различает роды")
|
||||
}
|
||||
|
||||
t.Logf("каталог: метрик %d за %v; накопительных %d, мгновенных %d, неизвестных %d",
|
||||
len(metrics), elapsed.Round(time.Millisecond),
|
||||
byStyle[catalog.Cumulative], byStyle[catalog.Instant], byStyle[catalog.Unknown])
|
||||
for _, m := range metrics {
|
||||
t.Logf(" %-38s %-10s часов %d, пригодных %d, согласных %d",
|
||||
m.Metric, m.Aggregation.Style, m.Aggregation.Hours,
|
||||
m.Aggregation.Compared, m.Aggregation.Agreeing)
|
||||
}
|
||||
}
|
||||
|
||||
// countSleepKeys возвращает число различных координат записей сна и число
|
||||
// различных меток начала. Разница между ними и есть то, что теряет ключ по
|
||||
// метке.
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Запросы каталога вынесены константами: по ним же проверяется план выполнения.
|
||||
// Обе выборки обязаны отвечать по покрывающему индексу `bucket_catalog`, не
|
||||
// касаясь строк таблицы — вместе со строкой пришло бы и сжатое содержимое.
|
||||
const (
|
||||
layerRangesQuery = `
|
||||
SELECT metric, layer, units, min(first_ts), max(last_ts), sum(points)
|
||||
FROM bucket
|
||||
GROUP BY metric, layer, units
|
||||
ORDER BY metric, layer, units`
|
||||
|
||||
// Общие часы: самые свежие часы, за которые у метрики есть объекты обоих
|
||||
// слоёв. Возвращаются вместе с числом точек и единицами каждой стороны —
|
||||
// эти колонки лежат в том же покрывающем индексе, а стоят они того, чтобы
|
||||
// решать пригодность часа НЕ разжимая содержимое.
|
||||
//
|
||||
// Горизонт (`f.hour_utc <= ?`) обязателен. Час объекта берётся из метки в
|
||||
// теле доставки, а тело мы не контролируем: без верхней границы одна
|
||||
// доставка с метками в будущем занимает всё окно и подменяет измеренный род
|
||||
// метрики. Свидетельство из будущего свидетельством не является.
|
||||
commonHoursQuery = `
|
||||
SELECT f.hour_utc, f.points, f.units, c.points, c.units
|
||||
FROM bucket f
|
||||
JOIN bucket c ON c.metric = ? AND c.layer = ? AND c.hour_utc = f.hour_utc
|
||||
WHERE f.metric = ? AND f.layer = ? AND f.hour_utc <= ?
|
||||
ORDER BY f.hour_utc DESC
|
||||
LIMIT ?`
|
||||
)
|
||||
|
||||
// hourPairsQuery строит выборку объектов окна: ОДИН запрос на любое число
|
||||
// часов. Отдельной функцией потому, что число подстановок переменное, а форма
|
||||
// «один запрос независимо от размера окна» — проверяемое свойство, а не
|
||||
// намерение.
|
||||
func hourPairsQuery(hours int) string {
|
||||
return `
|
||||
SELECT layer, hour_utc, payload FROM bucket
|
||||
WHERE metric = ? AND layer IN (?, ?) AND hour_utc IN (` +
|
||||
strings.TrimSuffix(strings.Repeat("?,", hours), ",") + `)`
|
||||
}
|
||||
|
||||
// LayerRange — один разрез метрики: слой, единицы, границы данных, число точек.
|
||||
//
|
||||
// Границы — это границы ДАННЫХ, а не обещание покрытия: внутри диапазона законно
|
||||
// есть дыры (часы без доставок, периоды, чьи верхние слои не пережили
|
||||
// пересборку). Единицы входят в разрез потому, что группировка идёт вместе с
|
||||
// ними: расхождение единиц у объектов одной метрики обязано быть видно строкой,
|
||||
// а не выбираться молча.
|
||||
type LayerRange struct {
|
||||
Metric string
|
||||
Layer string
|
||||
Units string
|
||||
From time.Time
|
||||
To time.Time
|
||||
Points int
|
||||
}
|
||||
|
||||
// HourPair — час, за который у метрики есть объекты обоих слоёв сразу.
|
||||
// Именно на таких часах и держится измерение рода агрегации.
|
||||
//
|
||||
// Точки заполнены не всегда: содержимое читается только у часов, прошедших
|
||||
// предварительный отбор по учётным колонкам (см. CatalogWindow). У остальных
|
||||
// известны число точек и единицы — этого хватает, чтобы признать час
|
||||
// непригодным, не разжимая ни байта.
|
||||
type HourPair struct {
|
||||
Hour time.Time
|
||||
FinePoints int
|
||||
FineUnits string
|
||||
Fine []Point
|
||||
CoarsePoints int
|
||||
CoarseUnits string
|
||||
Coarse []Point
|
||||
}
|
||||
|
||||
// CatalogWindow — что считать окном измерения. Все числа задаёт вызывающий:
|
||||
// правило измерения принадлежит домену, хранилище лишь выбирает по нему строки.
|
||||
type CatalogWindow struct {
|
||||
// Fine и Coarse — мелкий и крупный слои сверки.
|
||||
Fine, Coarse string
|
||||
// Hours — сколько самых свежих общих часов брать.
|
||||
Hours int
|
||||
// Horizon — верхняя граница: часы позже неё в окно не входят.
|
||||
Horizon time.Time
|
||||
// CoarsePoints — сколько точек обязан нести объект крупного слоя, чтобы час
|
||||
// стоило читать. MinFinePoints — сколько минимум обязан нести мелкий.
|
||||
// Отбор ПРЕДВАРИТЕЛЬНЫЙ и строго слабее правила вердикта: он экономит
|
||||
// разжатие заведомо непригодных часов, а решение принимает домен.
|
||||
CoarsePoints int
|
||||
MinFinePoints int
|
||||
}
|
||||
|
||||
// CatalogSnapshot — весь вход каталога, снятый ОДНОЙ транзакцией чтения.
|
||||
//
|
||||
// Единый снимок здесь не аккуратность. Приём идёт непрерывно, и фоновая свёртка
|
||||
// пишет в витрину во время запроса: разрезы, снятые до её коммита, и объекты
|
||||
// окна, прочитанные после, дали бы ответ, внутренне противоречивый и
|
||||
// неотличимый от обычного свежего. Тот же довод записан у отпечатка витрины, и
|
||||
// второй его экземпляр разошёлся бы с первым молча.
|
||||
type CatalogSnapshot struct {
|
||||
// Layers — разрезы всех метрик, упорядоченные по метрике, слою и единицам.
|
||||
Layers []LayerRange
|
||||
// Pairs — по метрике её общие часы, от САМЫХ СВЕЖИХ к старым. Метрики, у
|
||||
// которой нет объектов обоих слоёв, в карте нет вовсе.
|
||||
Pairs map[string][]HourPair
|
||||
}
|
||||
|
||||
// ReadCatalog снимает вход каталога: разрезы всех метрик и объекты окна.
|
||||
//
|
||||
// Число обращений к базе — `1 + 2×метрик` и от размера окна НЕ зависит: объекты
|
||||
// окна читаются пакетом, одним запросом на метрику. Чтение по объекту за раз
|
||||
// давало бы под сотню обращений на метрику, каждое своей транзакцией.
|
||||
//
|
||||
// Содержимое разжимается только у часов, прошедших отбор по учётным колонкам.
|
||||
// Разжимать всё подряд означало бы платить памятью за часы, чей вердикт заранее
|
||||
// известен: замер на раздутой витрине давал 109 МиБ аллокаций при нуле
|
||||
// пригодных часов.
|
||||
func (s *Store) ReadCatalog(ctx context.Context, w CatalogWindow) (CatalogSnapshot, error) {
|
||||
out := CatalogSnapshot{Pairs: make(map[string][]HourPair)}
|
||||
switch {
|
||||
case w.Hours <= 0:
|
||||
return CatalogSnapshot{}, fmt.Errorf("окно каталога: часов должно быть больше нуля, задано %d", w.Hours)
|
||||
case w.Fine == w.Coarse:
|
||||
return CatalogSnapshot{}, fmt.Errorf("окно каталога: слои сверки совпадают (%q)", w.Fine)
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true})
|
||||
if err != nil {
|
||||
return CatalogSnapshot{}, fmt.Errorf("begin read tx: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
out.Layers, err = readLayerRanges(ctx, tx)
|
||||
if err != nil {
|
||||
return CatalogSnapshot{}, err
|
||||
}
|
||||
|
||||
for _, metric := range metricsWithBothLayers(out.Layers, w.Fine, w.Coarse) {
|
||||
pairs, err := commonHours(ctx, tx, metric, w)
|
||||
if err != nil {
|
||||
return CatalogSnapshot{}, err
|
||||
}
|
||||
if len(pairs) == 0 {
|
||||
continue
|
||||
}
|
||||
if err := readHourPairs(ctx, tx, metric, w, pairs); err != nil {
|
||||
return CatalogSnapshot{}, err
|
||||
}
|
||||
out.Pairs[metric] = pairs
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// readLayerRanges отвечает по покрывающему индексу: содержимое объектов ради
|
||||
// границ и счётчиков не разжимается и даже не читается.
|
||||
func readLayerRanges(ctx context.Context, tx *sql.Tx) ([]LayerRange, error) {
|
||||
rows, err := tx.QueryContext(ctx, layerRangesQuery)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("select layer ranges: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
out := make([]LayerRange, 0, 64)
|
||||
for rows.Next() {
|
||||
var r LayerRange
|
||||
var from, to string
|
||||
if err := rows.Scan(&r.Metric, &r.Layer, &r.Units, &from, &to, &r.Points); err != nil {
|
||||
return nil, fmt.Errorf("scan layer range: %w", err)
|
||||
}
|
||||
if r.From, err = ParseTime(from); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.To, err = ParseTime(to); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("select layer ranges: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// metricsWithBothLayers отбирает метрики, у которых есть оба слоя. Без отбора
|
||||
// пришлось бы спрашивать общие часы у метрик, где второго слоя заведомо нет, —
|
||||
// два лишних запроса на каждую.
|
||||
func metricsWithBothLayers(layers []LayerRange, fine, coarse string) []string {
|
||||
seen := make(map[string]map[string]bool, len(layers))
|
||||
order := make([]string, 0, len(layers))
|
||||
for _, l := range layers {
|
||||
set, ok := seen[l.Metric]
|
||||
if !ok {
|
||||
set = map[string]bool{}
|
||||
seen[l.Metric] = set
|
||||
order = append(order, l.Metric)
|
||||
}
|
||||
set[l.Layer] = true
|
||||
}
|
||||
|
||||
out := make([]string, 0, len(order))
|
||||
for _, m := range order {
|
||||
if seen[m][fine] && seen[m][coarse] {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// commonHours возвращает самые свежие часы окна, от свежих к старым, вместе с
|
||||
// учётными колонками обеих сторон. Содержимого не читает.
|
||||
func commonHours(ctx context.Context, tx *sql.Tx, metric string, w CatalogWindow) ([]HourPair, error) {
|
||||
rows, err := tx.QueryContext(ctx, commonHoursQuery,
|
||||
metric, w.Coarse, metric, w.Fine, FormatTime(w.Horizon), w.Hours)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("select common hours: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
out := make([]HourPair, 0, w.Hours)
|
||||
for rows.Next() {
|
||||
var p HourPair
|
||||
var raw string
|
||||
if err := rows.Scan(&raw, &p.FinePoints, &p.FineUnits, &p.CoarsePoints, &p.CoarseUnits); err != nil {
|
||||
return nil, fmt.Errorf("scan common hour: %w", err)
|
||||
}
|
||||
if p.Hour, err = ParseTime(raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("select common hours: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// readHourPairs дочитывает содержимое объектов — ОДНИМ запросом и только у
|
||||
// часов, прошедших отбор по учётным колонкам.
|
||||
func readHourPairs(ctx context.Context, tx *sql.Tx, metric string, w CatalogWindow, pairs []HourPair) error {
|
||||
at := make(map[string]int, len(pairs))
|
||||
args := make([]any, 0, len(pairs)+3)
|
||||
args = append(args, metric, w.Fine, w.Coarse)
|
||||
for i := range pairs {
|
||||
if pairs[i].CoarsePoints != w.CoarsePoints || pairs[i].FinePoints < w.MinFinePoints {
|
||||
continue
|
||||
}
|
||||
key := FormatTime(pairs[i].Hour)
|
||||
at[key] = i
|
||||
args = append(args, key)
|
||||
}
|
||||
if len(at) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
rows, err := tx.QueryContext(ctx, hourPairsQuery(len(at)), args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("select hour pairs: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
for rows.Next() {
|
||||
var layer, hour string
|
||||
var payload []byte
|
||||
if err := rows.Scan(&layer, &hour, &payload); err != nil {
|
||||
return fmt.Errorf("scan hour pair: %w", err)
|
||||
}
|
||||
i, ok := at[hour]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
points, err := decodePayload(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if layer == w.Fine {
|
||||
pairs[i].Fine = points
|
||||
} else {
|
||||
pairs[i].Coarse = points
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return fmt.Errorf("select hour pairs: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// План выполнения — единственный оракул требования «каталог не читает
|
||||
// содержимого объектов». `bucket` объявлена WITHOUT ROWID, то есть строка
|
||||
// целиком, вместе со сжатым payload, живёт в дереве первичного ключа: обход по
|
||||
// нему тащил бы страницы содержимого. Проверяется, что обе выборки идут по
|
||||
// ПОКРЫВАЮЩЕМУ индексу — по нему таблица не открывается вовсе.
|
||||
func TestПланЗапросовКаталогаИдётПоИндексу(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st, err := Open(filepath.Join(t.TempDir(), "healthlog.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("открытие базы: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
sql string
|
||||
args []any
|
||||
}{
|
||||
{"разрезы метрик", layerRangesQuery, nil},
|
||||
{"общие часы двух слоёв", commonHoursQuery,
|
||||
[]any{"step_count", "hour", "step_count", "minute", "2026-08-02T00:00:00Z", 48}},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
plan := explain(t, st, c.sql, c.args...)
|
||||
t.Logf("план: %s", plan)
|
||||
if !strings.Contains(plan, "COVERING INDEX bucket_catalog") {
|
||||
t.Errorf("выборка не идёт по покрывающему индексу:\n%s", plan)
|
||||
}
|
||||
// Обращение к самой таблице выглядит в плане как SCAN/SEARCH bucket
|
||||
// без слова INDEX — именно оно и тащило бы страницы содержимого.
|
||||
for _, line := range strings.Split(plan, "\n") {
|
||||
if strings.Contains(line, "bucket") && !strings.Contains(line, "INDEX") {
|
||||
t.Errorf("строка плана читает таблицу, а не индекс: %q", line)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func explain(t *testing.T, st *Store, query string, args ...any) string {
|
||||
t.Helper()
|
||||
|
||||
rows, err := st.db.QueryContext(context.Background(), "EXPLAIN QUERY PLAN "+query, args...)
|
||||
if err != nil {
|
||||
t.Fatalf("EXPLAIN QUERY PLAN: %v", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var id, parent, notused int
|
||||
var detail string
|
||||
if err := rows.Scan(&id, &parent, ¬used, &detail); err != nil {
|
||||
t.Fatalf("разбор плана: %v", err)
|
||||
}
|
||||
out = append(out, detail)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatalf("EXPLAIN QUERY PLAN: %v", err)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
t.Fatal("план пуст — проверять нечего")
|
||||
}
|
||||
return strings.Join(out, "\n")
|
||||
}
|
||||
|
||||
// Свойство, ради которого выборка объектов окна вынесена отдельной функцией:
|
||||
// сколько бы часов ни было в окне, запрос ОДИН. Чтение по объекту за раз давало
|
||||
// бы под сотню обращений на метрику и столько же снимков витрины.
|
||||
func TestВыборкаОкнаОстаётсяОднимЗапросом(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, hours := range []int{1, 2, 48, 200} {
|
||||
q := hourPairsQuery(hours)
|
||||
if n := strings.Count(q, ";"); n != 0 {
|
||||
t.Errorf("часов %d: в запросе %d разделителей — это уже не один запрос", hours, n)
|
||||
}
|
||||
// Три подстановки на метрику и слои плюс по одной на час.
|
||||
if got, want := strings.Count(q, "?"), hours+3; got != want {
|
||||
t.Errorf("часов %d: подстановок %d, ждали %d", hours, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/store"
|
||||
)
|
||||
|
||||
func window(hours int, horizon time.Time) store.CatalogWindow {
|
||||
return store.CatalogWindow{
|
||||
Fine: "minute", Coarse: "hour",
|
||||
Hours: hours, Horizon: horizon,
|
||||
CoarsePoints: 1, MinFinePoints: 2,
|
||||
}
|
||||
}
|
||||
|
||||
// hourly кладёт метрику в оба слоя за `hours` часов начиная с `base`.
|
||||
func hourly(t *testing.T, st *store.Store, metric, units string, base time.Time, hours int) {
|
||||
t.Helper()
|
||||
|
||||
var in []store.IncomingPoint
|
||||
for i := range hours {
|
||||
h := base.Add(time.Duration(i) * time.Hour)
|
||||
in = append(in, store.IncomingPoint{
|
||||
Metric: metric, Layer: "hour", Units: units,
|
||||
Point: store.Point{Start: h, End: h, Raw: json.RawMessage(`{"qty":6}`)},
|
||||
})
|
||||
for m, v := range []string{`{"qty":1}`, `{"qty":2}`, `{"qty":3}`} {
|
||||
at := h.Add(time.Duration(m) * time.Minute)
|
||||
in = append(in, store.IncomingPoint{
|
||||
Metric: metric, Layer: "minute", Units: units,
|
||||
Point: store.Point{Start: at, End: at, Raw: json.RawMessage(v)},
|
||||
})
|
||||
}
|
||||
}
|
||||
if _, err := st.Merge(context.Background(), store.Incoming{Points: in},
|
||||
store.DeliveryRef{ID: "delivery"}); err != nil {
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadCatalogОтдаётРазрезыИОкно(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
base := ts(t, "2026-06-01T00:00:00Z")
|
||||
hourly(t, st, "step_count", "count", base, 3)
|
||||
|
||||
snap, err := st.ReadCatalog(t.Context(), window(48, base.Add(24*time.Hour)))
|
||||
if err != nil {
|
||||
t.Fatalf("каталог: %v", err)
|
||||
}
|
||||
if len(snap.Layers) != 2 {
|
||||
t.Fatalf("разрезов %d, ждали 2: %+v", len(snap.Layers), snap.Layers)
|
||||
}
|
||||
pairs := snap.Pairs["step_count"]
|
||||
if len(pairs) != 3 {
|
||||
t.Fatalf("общих часов %d, ждали 3", len(pairs))
|
||||
}
|
||||
// От свежих к старым — на этот порядок опирается и окно, и границы основания.
|
||||
if !pairs[0].Hour.After(pairs[len(pairs)-1].Hour) {
|
||||
t.Errorf("часы пришли не от свежих к старым: %v", pairs[0].Hour)
|
||||
}
|
||||
if len(pairs[0].Coarse) != 1 || len(pairs[0].Fine) != 3 {
|
||||
t.Errorf("содержимое пары не прочитано: %+v", pairs[0])
|
||||
}
|
||||
if pairs[0].FineUnits != "count" || pairs[0].CoarseUnits != "count" {
|
||||
t.Errorf("единицы сторон не прочитаны: %+v", pairs[0])
|
||||
}
|
||||
}
|
||||
|
||||
// Час объекта берётся из метки в теле доставки. Без горизонта одна доставка с
|
||||
// метками в будущем занимает окно целиком и подменяет измеренный род.
|
||||
func TestReadCatalogОтсекаетБудущиеЧасы(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
base := ts(t, "2026-06-01T00:00:00Z")
|
||||
hourly(t, st, "step_count", "count", base, 2)
|
||||
hourly(t, st, "step_count", "count", ts(t, "2099-01-01T00:00:00Z"), 5)
|
||||
|
||||
horizon := base.Add(24 * time.Hour)
|
||||
snap, err := st.ReadCatalog(t.Context(), window(48, horizon))
|
||||
if err != nil {
|
||||
t.Fatalf("каталог: %v", err)
|
||||
}
|
||||
pairs := snap.Pairs["step_count"]
|
||||
if len(pairs) != 2 {
|
||||
t.Fatalf("часов в окне %d, ждали 2: будущее не отсечено", len(pairs))
|
||||
}
|
||||
for _, p := range pairs {
|
||||
if p.Hour.After(horizon) {
|
||||
t.Errorf("час %v позже горизонта %v", p.Hour, horizon)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Окно ограничено сверху и берёт самые свежие часы.
|
||||
func TestReadCatalogОграничиваетОкно(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
base := ts(t, "2026-06-01T00:00:00Z")
|
||||
hourly(t, st, "step_count", "count", base, 10)
|
||||
|
||||
snap, err := st.ReadCatalog(t.Context(), window(4, base.Add(48*time.Hour)))
|
||||
if err != nil {
|
||||
t.Fatalf("каталог: %v", err)
|
||||
}
|
||||
pairs := snap.Pairs["step_count"]
|
||||
if len(pairs) != 4 {
|
||||
t.Fatalf("часов %d, ждали 4", len(pairs))
|
||||
}
|
||||
if !pairs[0].Hour.Equal(base.Add(9 * time.Hour)) {
|
||||
t.Errorf("самый свежий час %v, ждали %v", pairs[0].Hour, base.Add(9*time.Hour))
|
||||
}
|
||||
}
|
||||
|
||||
// Содержимое читается только у часов, прошедших отбор по учётным колонкам:
|
||||
// разжимать заведомо непригодный час незачем, а знать о нём — обязательно.
|
||||
func TestReadCatalogНеЧитаетСодержимоеНепригодныхЧасов(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
base := ts(t, "2026-06-01T00:00:00Z")
|
||||
in := []store.IncomingPoint{
|
||||
// Часовой объект с двумя точками: час непригоден.
|
||||
{Metric: "apple_stand_hour", Layer: "hour", Units: "count",
|
||||
Point: store.Point{Start: base, End: base, Raw: json.RawMessage(`{"qty":1}`)}},
|
||||
{Metric: "apple_stand_hour", Layer: "hour", Units: "count",
|
||||
Point: store.Point{Start: base, End: base.Add(time.Hour), Raw: json.RawMessage(`{"qty":1}`)}},
|
||||
}
|
||||
for m, v := range []string{`{"qty":1}`, `{"qty":2}`} {
|
||||
at := base.Add(time.Duration(m) * time.Minute)
|
||||
in = append(in, store.IncomingPoint{Metric: "apple_stand_hour", Layer: "minute", Units: "count",
|
||||
Point: store.Point{Start: at, End: at, Raw: json.RawMessage(v)}})
|
||||
}
|
||||
if _, err := st.Merge(t.Context(), store.Incoming{Points: in}, store.DeliveryRef{ID: "d"}); err != nil {
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
|
||||
snap, err := st.ReadCatalog(t.Context(), window(48, base.Add(24*time.Hour)))
|
||||
if err != nil {
|
||||
t.Fatalf("каталог: %v", err)
|
||||
}
|
||||
pairs := snap.Pairs["apple_stand_hour"]
|
||||
if len(pairs) != 1 {
|
||||
t.Fatalf("часов %d, ждали 1", len(pairs))
|
||||
}
|
||||
if pairs[0].CoarsePoints != 2 || pairs[0].FinePoints != 2 {
|
||||
t.Errorf("учётные колонки не прочитаны: %+v", pairs[0])
|
||||
}
|
||||
if pairs[0].Coarse != nil || pairs[0].Fine != nil {
|
||||
t.Errorf("содержимое непригодного часа разжато напрасно: %+v", pairs[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadCatalogОтвергаетНеверноеОкно(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
cases := map[string]store.CatalogWindow{
|
||||
"нулевое окно": {Fine: "minute", Coarse: "hour", Hours: 0},
|
||||
"слои совпадают": {Fine: "minute", Coarse: "minute", Hours: 48},
|
||||
"окно отрицательно": {Fine: "minute", Coarse: "hour", Hours: -1},
|
||||
}
|
||||
for name, w := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if _, err := st.ReadCatalog(context.Background(), w); err == nil {
|
||||
t.Error("окно вне контракта принято без ошибки")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
-- +goose Up
|
||||
-- Каталог разрезов отвечает по учётным колонкам объекта, а не по его
|
||||
-- содержимому. Без индекса это невозможно: `bucket` объявлена `WITHOUT ROWID`,
|
||||
-- то есть строка целиком, вместе со сжатым `payload`, живёт в дереве первичного
|
||||
-- ключа. Агрегат по всем строкам тащил бы за собой страницы содержимого — при
|
||||
-- 260 тысячах объектов за год это сотни мегабайт чтения на каждый запрос
|
||||
-- каталога, притом что сам ответ несёт три десятка строк.
|
||||
--
|
||||
-- Индекс ПОКРЫВАЮЩИЙ: в нём есть всё, что спрашивает каталог, поэтому обращения
|
||||
-- к самой таблице не будет вовсе. Порядок колонок задан двумя запросами:
|
||||
--
|
||||
-- 1. разрезы метрики — GROUP BY metric, layer (+ units, чтобы расхождение
|
||||
-- единиц было видно строкой, а не выбиралось молча);
|
||||
-- 2. общие часы двух слоёв — обход hour_utc по убыванию внутри (metric,
|
||||
-- layer), поэтому hour_utc стоит третьим и до колонок значений.
|
||||
--
|
||||
-- Первичный ключ (metric, layer, hour_utc) в индекс дописывается самим SQLite:
|
||||
-- у таблицы `WITHOUT ROWID` строка адресуется им.
|
||||
--
|
||||
-- Цена — около 60 байт на объект (≈16 МБ за год) и одна вставка в дерево на
|
||||
-- запись объекта. Платит её только настоящее изменение: широкий проход, у
|
||||
-- которого сошёлся хеш содержимого, объект не переписывает вовсе.
|
||||
CREATE INDEX bucket_catalog ON bucket (metric, layer, hour_utc, first_ts, last_ts, points, units);
|
||||
|
||||
-- +goose Down
|
||||
DROP INDEX bucket_catalog;
|
||||
Reference in New Issue
Block a user