Каталог разрезов и измеренный род агрегации
- род метрики выводится сверкой минутного слоя с часовым: часовое значение сходится с суммой минутных — накопительная, со средним — мгновенная, иначе `unknown` и свёртка не предлагается вовсе. На живом архиве (123 доставки, 31 метрика) 7 накопительных, 9 мгновенных, противоречащих часов ноль - `GET /api/v1/metrics` под токеном чтения отдаёт единицы, слои с границами и род вместе с основанием измерения; род нигде не хранится — он функция витрины, а витрина функция журнала, устаревать в нём нечему - миграция 00009: покрывающий индекс, чтобы каталог отвечал по учётным колонкам, не разжимая содержимое объектов
This commit is contained in:
@@ -131,6 +131,7 @@ task run
|
|||||||
```
|
```
|
||||||
curl localhost:8080/healthz
|
curl localhost:8080/healthz
|
||||||
curl -X POST localhost:8080/api/v1/ingest -d '{"data":{"metrics":[]}}'
|
curl -X POST localhost:8080/api/v1/ingest -d '{"data":{"metrics":[]}}'
|
||||||
|
curl localhost:8080/api/v1/metrics # каталог: слои, диапазоны, род агрегации
|
||||||
```
|
```
|
||||||
|
|
||||||
### Подключение телефона по локальной сети
|
### Подключение телефона по локальной сети
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.vakhrushev.me/av/healthlog/internal/archive"
|
"git.vakhrushev.me/av/healthlog/internal/archive"
|
||||||
|
"git.vakhrushev.me/av/healthlog/internal/catalog"
|
||||||
"git.vakhrushev.me/av/healthlog/internal/config"
|
"git.vakhrushev.me/av/healthlog/internal/config"
|
||||||
"git.vakhrushev.me/av/healthlog/internal/fold"
|
"git.vakhrushev.me/av/healthlog/internal/fold"
|
||||||
"git.vakhrushev.me/av/healthlog/internal/httpapi"
|
"git.vakhrushev.me/av/healthlog/internal/httpapi"
|
||||||
@@ -78,6 +79,13 @@ func serve(ctx context.Context, cfg *config.Config, log *slog.Logger, ready func
|
|||||||
if len(cfg.Auth.WriteTokens) == 0 {
|
if len(cfg.Auth.WriteTokens) == 0 {
|
||||||
log.Warn("write auth disabled", "reason", "auth.write_tokens пуст")
|
log.Warn("write auth disabled", "reason", "auth.write_tokens пуст")
|
||||||
}
|
}
|
||||||
|
// Цена у двух контуров разная, и это сказано вслух: открытый приём означает
|
||||||
|
// мусор во входе, открытое чтение — выгрузку истории здоровья любому, кто
|
||||||
|
// нашёл порт. Пока сервис живёт в доверенной сети, это осознанный выбор;
|
||||||
|
// перед выкладкой наружу список обязан быть непуст.
|
||||||
|
if len(cfg.Auth.ReadTokens) == 0 {
|
||||||
|
log.Warn("read auth disabled", "reason", "auth.read_tokens пуст")
|
||||||
|
}
|
||||||
|
|
||||||
// Воркер и приём делят одну свёртку: приём её только будит, сворачивает
|
// Воркер и приём делят одну свёртку: приём её только будит, сворачивает
|
||||||
// воркер — и в порядке журнала, чего синхронная свёртка внутри обработчика
|
// воркер — и в порядке журнала, чего синхронная свёртка внутри обработчика
|
||||||
@@ -87,8 +95,10 @@ func serve(ctx context.Context, cfg *config.Config, log *slog.Logger, ready func
|
|||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Handler: httpapi.New(httpapi.Options{
|
Handler: httpapi.New(httpapi.Options{
|
||||||
Ingest: ingest.New(arch, st, worker.Notify, log),
|
Ingest: ingest.New(arch, st, worker.Notify, log),
|
||||||
|
Catalog: catalog.New(st, log),
|
||||||
Log: log,
|
Log: log,
|
||||||
WriteTokens: cfg.Auth.WriteTokens,
|
WriteTokens: cfg.Auth.WriteTokens,
|
||||||
|
ReadTokens: cfg.Auth.ReadTokens,
|
||||||
MaxBodyMB: cfg.Ingest.MaxBodyMB,
|
MaxBodyMB: cfg.Ingest.MaxBodyMB,
|
||||||
// Бюджет ответа маршрута приёма: `WriteTimeout` сервера ставится ДО
|
// Бюджет ответа маршрута приёма: `WriteTimeout` сервера ставится ДО
|
||||||
// вызова обработчика и потому покрывает чтение тела, обрывая
|
// вызова обработчика и потому покрывает чтение тела, обрывая
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ write_timeout = "30s" # прочих маршрутов; приём держ
|
|||||||
|
|
||||||
[auth]
|
[auth]
|
||||||
write_tokens = [] # ПУСТО = проверка выключена, см. предупреждение выше
|
write_tokens = [] # ПУСТО = проверка выключена, см. предупреждение выше
|
||||||
|
# Чтение открыто так же, как приём, но цена другая: это выгрузка истории
|
||||||
|
# здоровья. Годится только для доверенной локальной сети.
|
||||||
read_tokens = []
|
read_tokens = []
|
||||||
|
|
||||||
[storage]
|
[storage]
|
||||||
|
|||||||
+7
-2
@@ -19,9 +19,14 @@ write_timeout = "30s" # на отправку ответа прочих ма
|
|||||||
# Health Auto Export умеет слать произвольные заголовки — токен задаётся в
|
# Health Auto Export умеет слать произвольные заголовки — токен задаётся в
|
||||||
# настройках автоматизации.
|
# настройках автоматизации.
|
||||||
# ПУСТОЙ СПИСОК = ПРОВЕРКА ВЫКЛЮЧЕНА. Так можно в доверенной локальной сети;
|
# ПУСТОЙ СПИСОК = ПРОВЕРКА ВЫКЛЮЧЕНА. Так можно в доверенной локальной сети;
|
||||||
# сервис предупреждает об этом на старте записью `write auth disabled`.
|
# сервис предупреждает об этом на старте записями `write auth disabled` и
|
||||||
|
# `read auth disabled`.
|
||||||
|
#
|
||||||
|
# Цена у контуров РАЗНАЯ, и это стоит помнить: открытый приём означает мусор во
|
||||||
|
# входе, открытое чтение — выгрузку всей истории здоровья любому, кто нашёл
|
||||||
|
# порт. Перед выкладкой наружу `read_tokens` обязан быть непуст.
|
||||||
write_tokens = [] # токены на приём данных
|
write_tokens = [] # токены на приём данных
|
||||||
read_tokens = [] # токены на чтение (read API появится позже)
|
read_tokens = [] # токены на чтение: каталог `GET /api/v1/metrics`
|
||||||
|
|
||||||
[storage]
|
[storage]
|
||||||
# ВНИМАНИЕ: умолчания в коде (./healthlog.db и ./raw) остались от прежней
|
# ВНИМАНИЕ: умолчания в коде (./healthlog.db и ./raw) остались от прежней
|
||||||
|
|||||||
+167
-7
@@ -215,6 +215,7 @@ HRV); у накопительных — только `date`. Поэтому то
|
|||||||
| `ingest` | use-case приёма, общий для HTTP и CLI `import` |
|
| `ingest` | use-case приёма, общий для HTTP и CLI `import` |
|
||||||
| `fold` | свёртка одной доставки в часовые объекты |
|
| `fold` | свёртка одной доставки в часовые объекты |
|
||||||
| `replay` | проигрывание журнала в витрину: состав, порядок, отчёт |
|
| `replay` | проигрывание журнала в витрину: состав, порядок, отчёт |
|
||||||
|
| `catalog` | каталог разрезов и измерение рода агрегации |
|
||||||
| `store` | SQLite: доставки, часовые объекты, тренировки, записи |
|
| `store` | SQLite: доставки, часовые объекты, тренировки, записи |
|
||||||
| `httpapi` | приём и read API |
|
| `httpapi` | приём и read API |
|
||||||
|
|
||||||
@@ -616,7 +617,8 @@ hour метки выровнены на час heart_rate 00:00:00
|
|||||||
процентов девяносто и ломаются на краях — `six_minute_walking_test_distance`
|
процентов девяносто и ломаются на краях — `six_minute_walking_test_distance`
|
||||||
в метрах складывать нельзя, а `walking_running_distance` в километрах можно
|
в метрах складывать нельзя, а `walking_running_distance` в километрах можно
|
||||||
(находка 40). Где данных на сверку не хватило, род остаётся неизвестным и
|
(находка 40). Где данных на сверку не хватило, род остаётся неизвестным и
|
||||||
агрегация по метрике не предлагается вовсе.
|
агрегация по метрике не предлагается вовсе. Правило целиком — ниже, «Измерение
|
||||||
|
рода агрегации».
|
||||||
|
|
||||||
Отдельно: **нижний слой HAE не суммируется никогда.** Он не сэмплы, а
|
Отдельно: **нижний слой HAE не суммируется никогда.** Он не сэмплы, а
|
||||||
посекундная развёртка (находка 34) и в сверке не сходится — сумма по нему
|
посекундная развёртка (находка 34) и в сверке не сходится — сумма по нему
|
||||||
@@ -798,6 +800,140 @@ hour метки выровнены на час heart_rate 00:00:00
|
|||||||
выбор зависит от рода метрики, а род измеряется сверкой слоёв между собой —
|
выбор зависит от рода метрики, а род измеряется сверкой слоёв между собой —
|
||||||
значит он и станет известен точно, вместо того чтобы быть угаданным.
|
значит он и станет известен точно, вместо того чтобы быть угаданным.
|
||||||
|
|
||||||
|
### Измерение рода агрегации
|
||||||
|
|
||||||
|
Род метрики — `cumulative`, `instant` или `unknown` — выводится сверкой
|
||||||
|
минутного слоя с часовым. Правило целиком:
|
||||||
|
|
||||||
|
```
|
||||||
|
час пригоден, если
|
||||||
|
у метрики есть объекты обоих слоёв за этот час
|
||||||
|
час не позже текущего времени плюс час
|
||||||
|
единицы обоих объектов совпадают
|
||||||
|
часовой объект несёт ровно одну точку, и она несёт значение
|
||||||
|
метка этой точки совпадает с началом часа
|
||||||
|
у минутного объекта не меньше двух точек со значением
|
||||||
|
сумма минутных отличима от их среднего
|
||||||
|
|
||||||
|
вердикт пригодного часа
|
||||||
|
часовое ≈ сумма минутных → cumulative
|
||||||
|
часовое ≈ среднее минутных → instant
|
||||||
|
иначе → свидетельства нет
|
||||||
|
|
||||||
|
вердикт метрики
|
||||||
|
≥3 согласных часа и ни одного противоречащего → род
|
||||||
|
иначе → unknown
|
||||||
|
```
|
||||||
|
|
||||||
|
Измерено на живом архиве (123 доставки, 31 метрика): 7 накопительных,
|
||||||
|
9 мгновенных, 15 неизвестных, **противоречащих часов ноль**. Каталог собирается
|
||||||
|
за 68 мс.
|
||||||
|
|
||||||
|
**Горизонт обязателен, и это условие корректности, а не защита от вредителя.**
|
||||||
|
Час объекта берётся из метки в теле доставки, а тело не наше: без верхней
|
||||||
|
границы одна доставка с метками в будущем занимает окно целиком и подменяет
|
||||||
|
измеренный род — путь построен и прогнан, мгновенная метрика объявлялась
|
||||||
|
накопительной при нуле противоречащих часов. Данные, помеченные будущим, пишутся
|
||||||
|
`WARN`: сбитые часы телефона и чужое тело в приёме лечатся не кодом.
|
||||||
|
|
||||||
|
**Единицы обеих сторон обязаны совпасть.** Мгновенная метрика в `count/min`
|
||||||
|
минутным слоем и в `count/hour` часовым даёт в полном часе
|
||||||
|
`часовое = 60 · среднее = сумма`, то есть уверенный ложный `cumulative`.
|
||||||
|
Единогласие такого случая не ловит: противоречия нет, есть молчание.
|
||||||
|
|
||||||
|
Каждая часть правила стоит своей причины.
|
||||||
|
|
||||||
|
**Различимость суммы и среднего — не украшение.** В часе, где все значения нули,
|
||||||
|
сумма равна среднему, и «сходится с суммой» выполняется тождественно: без этого
|
||||||
|
условия `walking_asymmetry_percentage` давала 4 часа «накопительная» против
|
||||||
|
3 «мгновенная», причём конфликт целиком состоял из нулевых часов.
|
||||||
|
|
||||||
|
**Выравнивание часовой метки закрывает получасовые пояса.** Слой выводится по
|
||||||
|
выравниванию метки в исходной зоне, а объект адресуется часом UTC: в зоне
|
||||||
|
`+0530` часовая точка попадает на середину часа UTC и описывает не тот интервал,
|
||||||
|
который покрывают минутные точки того же объекта.
|
||||||
|
|
||||||
|
**Единогласие, а не большинство.** Противоречащий час означает, что одна из
|
||||||
|
гипотез для метрики ложна; большинство голосов объявляло бы род при известном
|
||||||
|
контрпримере. Измеренная цена — ноль. Наличие противоречащих часов пишется
|
||||||
|
`WARN`: род — свойство, на котором Read API строит арифметику года.
|
||||||
|
|
||||||
|
**Порог в три часа** — потому что один совпавший час остаётся свидетельством
|
||||||
|
одного часа. Цена измерена: порог уводит в `unknown` метрики с единственным
|
||||||
|
согласным часом.
|
||||||
|
|
||||||
|
**Допуск сравнения — относительный, `1e-9`, и один на все три сравнения.**
|
||||||
|
Разные допуски у «сходимости» и «различимости» породили бы час, подтверждающий
|
||||||
|
обе гипотезы, и его исход определил бы порядок веток кода. Величина названа
|
||||||
|
числом, потому что от неё зависят счётчики основания в ответе: вердикты
|
||||||
|
одинаковы при допуске от `1e-9` до `1e-3`, а число согласных часов у
|
||||||
|
`heart_rate` при этом меняется вдвое. Абсолютного порога нет: около нуля
|
||||||
|
относительное сравнение вырождается в сторону «не сходится», то есть даёт
|
||||||
|
«свидетельства нет», а не ложный род.
|
||||||
|
|
||||||
|
**Родов два, а не четыре.** HealthKit различает `cumulative`,
|
||||||
|
`discreteArithmetic`, `discreteTemporallyWeighted` (пульс) и
|
||||||
|
`discreteEquivalentContinuousLevel` (аудиоэкспозиция). Взять весь словарь
|
||||||
|
напрашивалось и отвергнуто измерением: часовой слой HAE считается
|
||||||
|
арифметически, а не по Apple. Прямое свидетельство — `environmental_audio_exposure`,
|
||||||
|
которую Apple усредняет логарифмически: её часовое значение сходится с обычным
|
||||||
|
арифметическим средним минутных. Стили, которые в наших данных ничем не
|
||||||
|
проявляются, можно было бы только разметить руками — то есть вернуться к тому,
|
||||||
|
от чего уходит вся конструкция.
|
||||||
|
|
||||||
|
**Род нигде не хранится**, а считается на запрос по окну в 48 самых свежих
|
||||||
|
общих часов. Хранимое значение было бы вторым производным состоянием рядом с
|
||||||
|
витриной: его пришлось бы пересчитывать после каждой свёртки, вносить в перечень
|
||||||
|
непереносимого пересборкой и объяснять, на каком составе данных оно снято, —
|
||||||
|
причём устаревшее выглядело бы ровно как свежее. Вычисленный на запрос род есть
|
||||||
|
функция витрины, а витрина — функция журнала.
|
||||||
|
|
||||||
|
Следствие принято вслух: род есть функция окна, поэтому час, въехавший в окно,
|
||||||
|
может сменить объявленный род без единой новой доставки за спрошенный период.
|
||||||
|
Поэтому каталог отдаёт род **вместе с основанием** — сколько часов сравнено,
|
||||||
|
сколько пригодно, сколько согласны и противоречат, на каких границах окна.
|
||||||
|
|
||||||
|
**Второй предел названный вслух: окно измеряется в общих часах, а не в часах
|
||||||
|
календаря.** Выключи минутную автоматизацию — множество общих часов перестаёт
|
||||||
|
пополняться, и окно замирает на последних сорока восьми, когда она ещё работала.
|
||||||
|
Род продолжает объявляться, и единственный след этого — `last_hour` в ответе.
|
||||||
|
Календарного ограничения нет намеренно: оно уводило бы в `unknown` редкие
|
||||||
|
метрики, у которых общие часы копятся месяцами, — то есть лечило бы честный
|
||||||
|
случай ценой другого честного.
|
||||||
|
|
||||||
|
#### Как это решают другие и почему не подошло
|
||||||
|
|
||||||
|
Prior art здесь обширный, и весь он про **объявление** рода, а не про измерение.
|
||||||
|
|
||||||
|
- **HealthKit** зашивает `HKQuantityAggregationStyle` в тип метрики, а
|
||||||
|
`HKStatistics` возвращает `nil` на свёртку, не отвечающую стилю. Второе взято
|
||||||
|
как принцип («род не тот — свёртки нет»), первое неприменимо: HAE тип не шлёт.
|
||||||
|
- **Home Assistant** получает `state_class` от интеграции и при его смене
|
||||||
|
требует **удалить** долгосрочную статистику вручную. Взято признание, что
|
||||||
|
смена рода — событие, а не уточнение поля; отвергнуто объявление: объявить
|
||||||
|
некому.
|
||||||
|
- **Graphite** выводит `aggregationMethod` регуляркой по имени метрики.
|
||||||
|
Отвергнуто: противоречит инварианту «форма Apple не транслируется» и не
|
||||||
|
работает на именах HAE вовсе.
|
||||||
|
- **Prometheus и остальные** принимают тип от отправителя; заголовок HAE врёт
|
||||||
|
уже про слой, оснований верить ему про род нет. Детекция сброса счётчика
|
||||||
|
(`rate`, `total_increasing`) отвечает на другой вопрос — «был ли рестарт у
|
||||||
|
известного счётчика», — и к данным Apple неприменима: монотонного накопителя в
|
||||||
|
них нет.
|
||||||
|
- **`xFilesFactor`** (Graphite) и **`xff`** (RRDtool) — доля заполненности, ниже
|
||||||
|
которой свёртка не делается. Измерению порог не нужен: у него две
|
||||||
|
конкурирующие гипотезы, и неполный час не сходится ни с одной сам собой (у
|
||||||
|
`step_count` 41 час пригоден и 24 дали вердикт — остальные и есть неполные).
|
||||||
|
Свёртке в ответе порог понадобится, и вместе с ним выбор полярности: Graphite
|
||||||
|
задаёт долю **обязательно известных** (0.5 при роллапе и 0 при рендере),
|
||||||
|
RRDtool — долю **допустимо неизвестных**, то есть ровно наоборот. Обе величины
|
||||||
|
выглядят как «0.5», означая противоположное. Решение принимает задача Read API.
|
||||||
|
|
||||||
|
Готовой практики вывода рода **из данных** не нашлось ни одной: у всех
|
||||||
|
перечисленных есть привилегия, которой нет у нас — поставщик объявляет тип на
|
||||||
|
входе, — и все за неё платят (Prometheus теряет тип на remote write, Home
|
||||||
|
Assistant требует ручного удаления статистики). Мы платим измерением.
|
||||||
|
|
||||||
### Категориальные значения
|
### Категориальные значения
|
||||||
|
|
||||||
HAE отдаёт перечислимые значения строками из локали телефона, а не кодами:
|
HAE отдаёт перечислимые значения строками из локали телефона, а не кодами:
|
||||||
@@ -1085,15 +1221,38 @@ GET /healthz
|
|||||||
за какой период:
|
за какой период:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"metric": "heart_rate", "units": "count/min", "aggregation": "instant",
|
{"metric": "heart_rate", "units": ["count/min"],
|
||||||
|
"aggregation": {"style": "instant", "hours": 48, "compared": 48,
|
||||||
|
"agreeing": 20, "conflicting": 0,
|
||||||
|
"first_hour": "2026-07-31T09:00:00Z",
|
||||||
|
"last_hour": "2026-08-02T14:00:00Z"},
|
||||||
"layers": [
|
"layers": [
|
||||||
{"layer": "raw", "from": "2026-07-30", "to": "2026-08-01", "points": 2078},
|
{"layer": "minute", "from": "2026-07-25T00:01:00Z", "to": "2026-08-01T23:59:00Z", "points": 14203},
|
||||||
{"layer": "minute", "from": "2026-07-25", "to": "2026-08-01", "points": 14203}
|
{"layer": "raw", "from": "2026-07-30T00:00:07Z", "to": "2026-08-01T23:59:58Z", "points": 2078}
|
||||||
]}
|
]}
|
||||||
```
|
```
|
||||||
|
|
||||||
`aggregation` — измеренный род (`cumulative` / `instant` / `unknown`), от него
|
`aggregation.style` — измеренный род (`cumulative` / `instant` / `unknown`), от
|
||||||
зависит, что вообще можно спросить.
|
него зависит, что вообще можно спросить. Рядом лежит **основание**: сколько
|
||||||
|
общих часов попало в окно, сколько из них оказалось пригодными, сколько дали
|
||||||
|
преобладающий вердикт и сколько противоречили. Одного числа не хватало —
|
||||||
|
«часов было 48, а пригодным не оказалось ни одного» и «часов не было вовсе»
|
||||||
|
разные события, и различать их клиент обязан без второго запроса. Поле названо
|
||||||
|
`style`, а не `kind`: `kind` в проекте уже занят родом секции записи.
|
||||||
|
|
||||||
|
`units` — множество: единицы на живом потоке не менялись ни разу (находка 48),
|
||||||
|
но одна форма поля для обоих случаев честнее строки, которая при расхождении
|
||||||
|
молча выберет одно из двух. На слой при этом приходится ровно один элемент
|
||||||
|
`layers`.
|
||||||
|
|
||||||
|
Границы слоя — метки **первой и последней точки**, включительно; `first_hour` и
|
||||||
|
`last_hour` — **ярлыки часов** окна измерения. Имена разные потому, что разная
|
||||||
|
семантика: одно имя для двух смыслов в одном ответе стоило бы клиенту ошибки на
|
||||||
|
час, заметной только расхождением сумм.
|
||||||
|
|
||||||
|
Границы слоя — это границы **данных, а не обещание покрытия**: внутри диапазона
|
||||||
|
законно есть дыры. Поэтому правило выбора слоя опирается на фактические объекты
|
||||||
|
запрошенного диапазона, а не на каталожную пару границ.
|
||||||
|
|
||||||
Параметр `layer` выбирает разрез. Если он не указан — берём **самый мелкий
|
Параметр `layer` выбирает разрез. Если он не указан — берём **самый мелкий
|
||||||
слой, покрывающий весь запрошенный диапазон**. Молча переключать слой на
|
слой, покрывающий весь запрошенный диапазон**. Молча переключать слой на
|
||||||
@@ -1126,7 +1285,8 @@ GET /healthz
|
|||||||
|
|
||||||
Свёртка применяет род из каталога: `cumulative` — сумма, `instant` —
|
Свёртка применяет род из каталога: `cumulative` — сумма, `instant` —
|
||||||
среднее с `min`/`max` рядом. При `unknown` свёртка не выполняется, а параметр
|
среднее с `min`/`max` рядом. При `unknown` свёртка не выполняется, а параметр
|
||||||
`bucket` отвергается ошибкой. Накопительные метрики никогда не сворачиваются
|
`bucket` отвергается ошибкой. Порог заполненности ведра (`xFilesFactor`) и его
|
||||||
|
полярность выбирает эта же задача — см. «Измерение рода агрегации». Накопительные метрики никогда не сворачиваются
|
||||||
из нижнего слоя HAE — только из `minute`, `hour` или `sample`.
|
из нижнего слоя HAE — только из `minute`, `hour` или `sample`.
|
||||||
|
|
||||||
### Форма ответа
|
### Форма ответа
|
||||||
|
|||||||
@@ -18,9 +18,10 @@
|
|||||||
либо берётся, либо отвергается с названной причиной.
|
либо берётся, либо отвергается с названной причиной.
|
||||||
|
|
||||||
## блокеры
|
## блокеры
|
||||||
|
- [Тай-брейк при равной полноте точек](taj-brejk-pri-ravnoj-polnote.md) — Сегодняшний порядок канонических форм берёт меньшее значение в 96% случаев — для накопительных это систематический недосчёт
|
||||||
|
- [Цена первого читающего маршрута: память, WAL и повторный опрос](cena-chitayushchego-marshruta.md) — Один запрос каталога способен выесть память процесса и раздуть WAL — а OOM здесь стоит доставок, которых телефон не перешлёт
|
||||||
|
|
||||||
## высокий
|
## высокий
|
||||||
- [Измеренный род агрегации и каталог разрезов](rod-agregacii-i-katalog.md) — Без рода метрики свёртка в ответе неотличима от угадывания — а суммировать нижний слой значит завысить втрое
|
|
||||||
- [Read API: точки, выбор слоя, свёртка по сетке](read-api-tochki.md) — Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может
|
- [Read API: точки, выбор слоя, свёртка по сетке](read-api-tochki.md) — Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может
|
||||||
- [OpenAPI-спека и Swagger UI](openapi-swagger.md) — Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате
|
- [OpenAPI-спека и Swagger UI](openapi-swagger.md) — Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате
|
||||||
- [MCP-сервер поверх Read API](mcp-server.md) — Агент-медик — первый заказчик проекта, а подключить его сейчас нечем
|
- [MCP-сервер поверх Read API](mcp-server.md) — Агент-медик — первый заказчик проекта, а подключить его сейчас нечем
|
||||||
@@ -47,6 +48,7 @@
|
|||||||
- [Сверка живой витрины с пересборкой](sverka-vitriny-s-peresborkoj.md) — reindex печатает оба отпечатка, но сравнивать их некому — расхождение с журналом молчит
|
- [Сверка живой витрины с пересборкой](sverka-vitriny-s-peresborkoj.md) — reindex печатает оба отпечатка, но сравнивать их некому — расхождение с журналом молчит
|
||||||
- [Сущность с id, но неразобранной меткой](hranenie-sushchnosti-bez-metki.md) — Тренировка с меткой в неизвестном формате пропадает целиком — а её id и содержимое разобраны
|
- [Сущность с id, но неразобранной меткой](hranenie-sushchnosti-bez-metki.md) — Тренировка с меткой в неизвестном формате пропадает целиком — а её id и содержимое разобраны
|
||||||
- [Пределы на размер сущности и потоковый расчёт формы](predely-razmera-sushchnosti.md) — Тело 40 МиБ даёт 768 МиБ пика кучи, 63 МиБ держат блокировку 5.019 с — предела на одну сущность нет вовсе
|
- [Пределы на размер сущности и потоковый расчёт формы](predely-razmera-sushchnosti.md) — Тело 40 МиБ даёт 768 МиБ пика кучи, 63 МиБ держат блокировку 5.019 с — предела на одну сущность нет вовсе
|
||||||
|
- [Остановка и миграция: раздельные бюджеты и следы в логе](ostanovka-i-migraciya-sledy.md) — Долгий запрос чтения съедает бюджет остановки, и WARN обвиняет воркер свёртки; миграция молчит и не прерывается SIGTERM
|
||||||
|
|
||||||
## низкий
|
## низкий
|
||||||
- [Устаревание нижнего слоя после экспорта](ustarevanie-nizhnego-sloya.md) — Нижний слой растёт на ~100 тысяч координат в сутки, а после экспорта Apple он избыточен
|
- [Устаревание нижнего слоя после экспорта](ustarevanie-nizhnego-sloya.md) — Нижний слой растёт на ~100 тысяч координат в сутки, а после экспорта Apple он избыточен
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# Цена первого читающего маршрута: память, WAL и повторный опрос
|
||||||
|
|
||||||
|
**Приоритет:** блокеры
|
||||||
|
|
||||||
|
## Что решить
|
||||||
|
|
||||||
|
Чем ограничить стоимость маршрута чтения, у которого нет ни предела ответа, ни
|
||||||
|
собственного дедлайна, ни условного запроса. Вопрос поднялся на каталоге
|
||||||
|
(`GET /api/v1/metrics`, change `2026-08-02-katalog-i-rod-agregacii`), но
|
||||||
|
принадлежит не ему: тот же ответ понадобится Read API точек и MCP, и решать его
|
||||||
|
трижды нельзя.
|
||||||
|
|
||||||
|
Три измеренных проявления одной причины.
|
||||||
|
|
||||||
|
**Память.** Снимок каталога держит разжатые точки окна по всем метрикам сразу,
|
||||||
|
хотя измерение идёт по одной метрике. Замер враждебного прохода ревью: 20 метрик
|
||||||
|
× 8 часов × 5000 точек — 693 мс и +153 МиБ живой кучи на один запрос.
|
||||||
|
Предварительный отбор по учётным колонкам (сделан) снял разжатие заведомо
|
||||||
|
непригодных часов, но множители «метрики × окно × точки × одновременные запросы»
|
||||||
|
остались без потолка. Приём живёт в том же процессе и уже даёт пик 768 МиБ на
|
||||||
|
теле 40 МиБ; OOM убивает приём, а доставка, не попавшая в архив, телефоном не
|
||||||
|
переприсылается.
|
||||||
|
|
||||||
|
**WAL.** Замер эксплуатационного прохода на копии с драйвером и PRAGMA проекта:
|
||||||
|
непрерывная запись плюс четыре читающих транзакции внахлёст дают рост `-wal`
|
||||||
|
около 7 МБ/с без верхней границы (40 МБ за пять секунд), тогда как тот же
|
||||||
|
писатель без читателей стабилизируется на 4 МБ. Пассивный чекпойнт SQLite не
|
||||||
|
продвигается дальше снимка самого старого активного читателя, и ошибки при этом
|
||||||
|
нет — виден только растущий файл. `PRAGMA wal_checkpoint` в проекте не
|
||||||
|
вызывается нигде.
|
||||||
|
|
||||||
|
**Повторный опрос.** Спека каталога требует побайтового совпадения двух ответов
|
||||||
|
на неизменившейся витрине — то есть ресурс по построению пригоден для условного
|
||||||
|
запроса, а `ETag`/`304` не выставляется. Потребителей трое (агент-медик, трекер,
|
||||||
|
игра), и самый частый их запрос — повтор неизменившегося.
|
||||||
|
|
||||||
|
## Варианты и цена
|
||||||
|
|
||||||
|
**а. Предел и дедлайн у маршрута.** Потолок числа метрик и точек в одном ответе,
|
||||||
|
собственный `context.WithTimeout`, честный отказ при превышении. Цена: клиент
|
||||||
|
обязан уметь читать частичный каталог, то есть появляется пагинация — контракт
|
||||||
|
чтения усложняется на первой же ручке.
|
||||||
|
|
||||||
|
**б. Измерение потоком по метрике внутри той же транзакции.** Точки метрики
|
||||||
|
освобождаются сразу после вердикта; требование «один снимок» не нарушается. Цена:
|
||||||
|
хранилище перестаёт возвращать снимок значением и начинает отдавать его
|
||||||
|
последовательно (итератор или колбэк) — то есть меняется форма границы
|
||||||
|
`store`/`catalog`, ради случая, которого живой поток пока не производит.
|
||||||
|
|
||||||
|
**в. Условный запрос: `ETag` по `PRAGMA data_version`.** Снимает и стоимость
|
||||||
|
повтора, и большую часть читающих транзакций разом: клиент с непротухшим `ETag`
|
||||||
|
получает `304`, и снимок не открывается вовсе. Цена: один лишний запрос к базе на
|
||||||
|
каждый вызов и обещание клиенту, что версия витрины меняется не чаще, чем данные.
|
||||||
|
|
||||||
|
**г. Периодический `wal_checkpoint(PASSIVE)` по таймеру рядом с воркером.**
|
||||||
|
Лечит только WAL, зато дёшево и без изменения контракта. Память и повтор
|
||||||
|
остаются.
|
||||||
|
|
||||||
|
**д. Кеш ответа на короткий TTL.** Закрывает всё сразу, но заводит третье
|
||||||
|
представление того же факта, и его инвалидация становится новым местом, где можно
|
||||||
|
ошибиться молча. Дизайн каталога отверг кеш именно поэтому.
|
||||||
|
|
||||||
|
## Что заблокировано
|
||||||
|
|
||||||
|
Ничего сегодня: на живом корпусе каталог собирается за 45 мс, потребителей у него
|
||||||
|
пока нет, а маршрут живёт в доверенной сети. Блокировано будущее — Read API
|
||||||
|
точек, где объёмы на порядок больше, и выкладка наружу, где опрос станет
|
||||||
|
непрерывным.
|
||||||
|
|
||||||
|
## Рекомендация
|
||||||
|
|
||||||
|
**г + в, именно в таком порядке.** Чекпойнт по таймеру закрывает единственное
|
||||||
|
проявление, которое ломает приём (диск), и стоит одной горутины без изменения
|
||||||
|
контракта. `ETag` по `data_version` — один запрос к базе, снимает и повтор, и
|
||||||
|
большую часть читающих транзакций, и делает это без кеша ответа.
|
||||||
|
|
||||||
|
Вариант «а» откладывать до Read API точек: там предел размера ответа всё равно
|
||||||
|
проектируется (`read-api-tochki.md`), и делать его дважды не нужно. Вариант «б»
|
||||||
|
не брать, пока счётчик не заговорит: он меняет форму границы ради случая,
|
||||||
|
которого поток не производит. Вариант «д» — последним, если «в» окажется мало.
|
||||||
|
|
||||||
|
Связано: `docs/architecture.md` → «Измерение рода агрегации», `read-api-tochki.md`,
|
||||||
|
`stats-nablyudaemost.md`.
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Остановка и миграция: раздельные бюджеты и следы в логе
|
||||||
|
|
||||||
|
**Приоритет:** средний
|
||||||
|
|
||||||
|
Две находки эксплуатационного и идиоматического проходов ревью каталога. Обе
|
||||||
|
существовали и раньше, но достижимыми их сделал первый маршрут чтения:
|
||||||
|
`GET /api/v1/metrics` — первый обработчик, способный законно работать заметное
|
||||||
|
время.
|
||||||
|
|
||||||
|
**Бюджет остановки один на оба этапа.** `shutdownCtx` в `runServe` передаётся и
|
||||||
|
в `srv.Shutdown`, и в ожидание фонового воркера. `Shutdown` ждёт, пока
|
||||||
|
обработчики вернутся; контексты обработчиков он при этом не отменяет
|
||||||
|
(`BaseContext` не задан), так что долгий запрос каталога может съесть бюджет
|
||||||
|
целиком. Дальше `select` видит два готовых случая и выбирает равновероятно: база
|
||||||
|
закрывается или нет от запуска к запуску, а в лог уходит
|
||||||
|
`shutdown budget exceeded stage=fold-worker` — обвинение воркеру, который бюджета
|
||||||
|
не превышал. Цена именно в диагнозе: этот `WARN` означает «доставка осталась
|
||||||
|
`pending`, данные под вопросом», и ложное срабатывание обесценивает настоящее.
|
||||||
|
|
||||||
|
Чинится двумя движениями: собственный `context.WithTimeout` второму этапу вместо
|
||||||
|
исчерпанного первого, и `BaseContext`, производный от контекста жизненного цикла,
|
||||||
|
чтобы долгий запрос об остановке узнавал.
|
||||||
|
|
||||||
|
**Миграция молчит и не прерывается штатной остановкой.** `store.migrate` не
|
||||||
|
пишет ни одной записи — ни «начал», ни «закончил», ни длительность, — а первая
|
||||||
|
строка в логе появляется уже после успешного открытия базы. Если миграция идёт
|
||||||
|
долго, владелец не отличит «ещё мигрирует» от «зависло» и от «упало»: тишина
|
||||||
|
одинакова во всех трёх случаях. Плюс `migrate` работает на `context.Background()`,
|
||||||
|
то есть `SIGTERM` она не видит и ждать придётся 30-секундного `SIGKILL`.
|
||||||
|
|
||||||
|
Порчи данных при этом нет: goose оборачивает миграцию в транзакцию, обрыв
|
||||||
|
откатывает её целиком, и следующий старт повторяет с нуля. Замер на синтетической
|
||||||
|
копии годового объёма (260 тысяч объектов, 483 МБ): `CREATE INDEX` миграции
|
||||||
|
`00009` — 297 мс тёплым кешем. То есть сегодня окно тишины — доли секунды;
|
||||||
|
опасность в том, что оно растёт вместе с витриной незаметно.
|
||||||
|
|
||||||
|
Готово, когда `WARN` о превышении бюджета называет виновный этап честно, а в логе
|
||||||
|
старта видно, что миграции накатывались и сколько это заняло.
|
||||||
|
|
||||||
|
Связано: `cmd/healthlog/serve.go`, `internal/store/store.go`,
|
||||||
|
`cena-chitayushchego-marshruta.md`.
|
||||||
@@ -26,5 +26,36 @@
|
|||||||
каждый, тренировка отдаётся одним пакетом вместе с маршрутом, а в ответе всегда
|
каждый, тренировка отдаётся одним пакетом вместе с маршрутом, а в ответе всегда
|
||||||
видно `layer`, `bucket` и `aggregation`.
|
видно `layer`, `bucket` и `aggregation`.
|
||||||
|
|
||||||
Связано: `docs/architecture.md` → «Read API», план → шаг «Read API».
|
**Порог неполного ведра решается здесь, и вместе с ним — его полярность.**
|
||||||
|
Каталог и род агрегации сделаны (change `2026-08-02-katalog-i-rod-agregacii`), и
|
||||||
|
измерению порог заполненности не понадобился: у него две конкурирующие гипотезы,
|
||||||
|
и неполный час не сходится ни с одной сам собой. Свёртке в ответе он нужен, а
|
||||||
|
готовые решения задают его **противоположно**: Graphite `xFilesFactor` — доля
|
||||||
|
обязательно известных точек (умолчание 0.5 при роллапе и 0 при рендере, один
|
||||||
|
параметр с двумя умолчаниями), RRDtool `xff` — доля допустимо неизвестных. Обе
|
||||||
|
величины выглядят как «0.5», означая разное; полярность придётся назвать вслух в
|
||||||
|
`architecture.md`, иначе через полгода два места кода поймут поле по-разному.
|
||||||
|
|
||||||
|
**Предел размера ответа тоже здесь.** У каталога его нет намеренно: правило
|
||||||
|
размера — общее для маршрутов чтения, и задавать его мимоходом на первой ручке
|
||||||
|
значило бы решить контракт до того, как известна форма тяжёлого ответа. Каталог
|
||||||
|
станет первым его потребителем.
|
||||||
|
|
||||||
|
**Форма провода наследуется от каталога, и это надо решить один раз.** Сегодня
|
||||||
|
типы `internal/catalog` сами несут json-теги, а транспорт владеет только
|
||||||
|
обёрткой: переименование поля в домене меняет публичный контракт без касания
|
||||||
|
`httpapi`. Держит это один байтовый тест непустого ответа. Либо объявить в
|
||||||
|
`architecture.md`, что типы чтения и есть форма провода для всех транспортов
|
||||||
|
(HTTP и MCP отдают её байт в байт), либо завести DTO в транспорте — но выбрать до
|
||||||
|
того, как образец скопирует эта задача.
|
||||||
|
|
||||||
|
**Клиент обязан смотреть на границы окна измерения.** Род метрики измерен по
|
||||||
|
48 самым свежим ОБЩИМ часам, а не по последним 48 часам календаря: если минутная
|
||||||
|
автоматизация HAE выключена, множество общих часов не пополняется и окно
|
||||||
|
замирает. Род при этом продолжает объявляться, и единственный след — `last_hour`
|
||||||
|
в ответе. Правило выбора свёртки в Read API обязано это учитывать (или явно
|
||||||
|
объявить, что не учитывает).
|
||||||
|
|
||||||
|
Связано: `docs/architecture.md` → «Read API», «Измерение рода агрегации»,
|
||||||
|
план → шаг «Read API».
|
||||||
|
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
# Измеренный род агрегации и каталог разрезов
|
|
||||||
|
|
||||||
**Приоритет:** высокий
|
|
||||||
|
|
||||||
Решено (вариант «б» груминга): свёртка живёт в ответе, но род метрики
|
|
||||||
**измеряется**, а не размечается руками. Форма точки рода не выдаёт —
|
|
||||||
`Avg`/`Min`/`Max` есть только у `heart_rate`, всё остальное приходит в `qty`
|
|
||||||
(находка 40). Единицы дают процентов девяносто и ломаются на краях.
|
|
||||||
|
|
||||||
Метод: одна метрика лежит в минутном и часовом разрезе одновременно. Часовое
|
|
||||||
значение сходится с суммой минутных — накопительная; со средним — мгновенная;
|
|
||||||
данных не хватило — `unknown`, и свёртка по такой метрике не предлагается вовсе.
|
|
||||||
|
|
||||||
Жёсткое правило: накопительные метрики никогда не сворачиваются из нижнего слоя
|
|
||||||
HAE. Он не сэмплы, а посекундная развёртка (находка 34), сумма по нему завышена.
|
|
||||||
|
|
||||||
Готово, когда каталог отдаёт по каждой метрике единицы, род и список слоёв с
|
|
||||||
диапазонами, а род проставлен измерением на живой истории.
|
|
||||||
|
|
||||||
От этой задачи зависит ещё одно решение: тай-брейк при равной полноте точек.
|
|
||||||
Измерено (находка 49), что сегодняшний лексикографический порядок берёт меньшее
|
|
||||||
значение в 96% случаев — для накопительных это недосчёт, для мгновенных
|
|
||||||
безразлично. Пока рода нет, выбирать нечем; когда каталог появится, тай-брейк
|
|
||||||
доделывается по нему. Остальное правило слияния уже сделано — структурная часть
|
|
||||||
закрыта задачей `pravilo-sliyaniya-tochek` (архив change
|
|
||||||
`2026-08-01-polnota-tochki-mnozhestvom-klyuchey`), здесь остался только выбор
|
|
||||||
победителя при РАВНОЙ полноте.
|
|
||||||
|
|
||||||
Связано: `docs/architecture.md` → «Слои гранулярности», план → шаг «Каталог и род агрегации».
|
|
||||||
|
|
||||||
@@ -32,3 +32,19 @@
|
|||||||
|
|
||||||
Активное уведомление — отдельная задача, здесь только факт.
|
Активное уведомление — отдельная задача, здесь только факт.
|
||||||
|
|
||||||
|
|
||||||
|
**Что добавил каталог рода агрегации.** Реальный сценарий поломки измерения — не
|
||||||
|
противоречие свидетельств (его на корпусе не бывает), а их исчезновение: владелец
|
||||||
|
переставил автоматизацию HAE, минутный слой перестал приходить, метрики одна за
|
||||||
|
другой уезжают в `unknown`, Read API перестаёт агрегировать — и в логах ноль
|
||||||
|
событий. Сюда же вторая половина: пять разных причин непригодности часа
|
||||||
|
(две точки у часового объекта, невыровненная метка, нет числа, мало минутных,
|
||||||
|
неразличимость) схлопнуты в одну разность `hours − compared`, поэтому «HAE
|
||||||
|
переименовал поле точки» неотличимо от «данных мало». Оба сигнала естественно
|
||||||
|
живут в `/stats`: число метрик по родам и число метрик с `compared == 0` при
|
||||||
|
непустом окне.
|
||||||
|
|
||||||
|
**Корреляция у контура чтения.** В записи `http request` нет ни идентификатора
|
||||||
|
запроса, ни адреса клиента: жалобу потребителя не сопоставить с записью, а
|
||||||
|
выгрузку каталога посторонним — не отличить от планового опроса агента. У приёма
|
||||||
|
корреляция есть (`delivery_id`), у чтения аналога нет.
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# Тай-брейк при равной полноте точек
|
||||||
|
|
||||||
|
**Приоритет:** блокеры
|
||||||
|
|
||||||
|
## Что решить
|
||||||
|
|
||||||
|
Какое правило выбирает победителя, когда по одним координатам приехали две точки
|
||||||
|
с **равными** наборами содержательных полей и разными значениями. Структурная
|
||||||
|
часть правила слияния закрыта (`pravilo-sliyaniya-tochek`); открыт только этот
|
||||||
|
разряд.
|
||||||
|
|
||||||
|
Сегодня это порядок канонических форм, и он измеримо смещён: из 1912 случаев, где
|
||||||
|
сравнение чисел определено, лексикографический порядок берёт **меньшее** значение
|
||||||
|
в 1847 — 96% (находка 49). Столкновений с равной полнотой 1916 из 444 256
|
||||||
|
координат, то есть 0.43% координат.
|
||||||
|
|
||||||
|
## Что стало известно
|
||||||
|
|
||||||
|
Задача «Измеренный род агрегации и каталог разрезов» закрыла посылку, ради
|
||||||
|
которой тай-брейк откладывали: род метрик теперь **измерен**, а не угадан
|
||||||
|
(находка 53). Четыре из шести метрик, где тай-брейк системно берёт меньшее
|
||||||
|
(`step_count`, `walking_running_distance`, `active_energy`,
|
||||||
|
`basal_energy_burned`), измерены как **накопительные** — там «меньшее» это
|
||||||
|
систематический недосчёт порядка 0.4% координат, ровно тот, что HAE досчитывает
|
||||||
|
задним числом (находка 10). Самая крупная группа, `heart_rate`, измерена как
|
||||||
|
**мгновенная**, и там выбор безразличен: это пересэмплирование, а не досчёт.
|
||||||
|
|
||||||
|
И тем же измерением закрылся напрашивавшийся ответ: **сделать тай-брейк
|
||||||
|
зависящим от измеренного рода нельзя**. Род есть функция витрины, витрина —
|
||||||
|
результат слияния, и правило слияния, читающее собственную выдачу, повторяет
|
||||||
|
ровно тот дефект, на котором свёртка уже переставала быть функцией префикса
|
||||||
|
журнала (`docs/review-journal.md`, 2026-08-01, наследование слоя «из будущего»).
|
||||||
|
|
||||||
|
## Варианты и цена
|
||||||
|
|
||||||
|
**а. Оставить порядок канонических форм.** Цена: систематический недосчёт 0.4%
|
||||||
|
координат у накопительных метрик, невидимый до сверки с родным экспортом Apple,
|
||||||
|
то есть месяцами. Плюс: ноль работы, правило остаётся структурным и не знает
|
||||||
|
ничего о значениях.
|
||||||
|
|
||||||
|
**б. Брать бо́льшее значение точки.** Правильно для накопительных (досчёт растёт,
|
||||||
|
находка 10, и набор полей у версий тренировки ни разу не уменьшался) и безвредно
|
||||||
|
для мгновенных (пересэмплирование). Цена: слияние перестаёт быть структурным —
|
||||||
|
оно начинает знать, какое поле точки несёт число (`hae.PointValue` уже есть).
|
||||||
|
Метрика, у которой «большее» неверно, в потоке не наблюдалась, но и не
|
||||||
|
исключена; правило приходится делать тотальным (нет числа — откат на порядок
|
||||||
|
канонических форм), то есть в нём появляется вторая ветка.
|
||||||
|
|
||||||
|
**в. Провенанс у точки и тай-брейк по позиции в журнале** — как у сущностей.
|
||||||
|
Цена: колонка провенанса на точку (или на объект) и рост объёма нижнего слоя;
|
||||||
|
плюс это не работает для столкновений **внутри одной доставки**, где
|
||||||
|
`received_at` общий, а таких четверть (находка 47: 33 столкновения внутри
|
||||||
|
доставки на эпизодах сна). То есть вариант не самодостаточен и всё равно требует
|
||||||
|
второго разряда.
|
||||||
|
|
||||||
|
## Что заблокировано
|
||||||
|
|
||||||
|
Ничего срочного: сегодняшнее правило детерминировано и воспроизводимо, витрина
|
||||||
|
остаётся свёрткой журнала. Блокирован только сам недосчёт — он копится молча.
|
||||||
|
Сверить его величину можно будет после `healthlog import`: родной экспорт Apple
|
||||||
|
даст независимый эталон по тем же периодам.
|
||||||
|
|
||||||
|
## Рекомендация
|
||||||
|
|
||||||
|
**Вариант б.** Он чинит измеренное смещение там, где оно есть, и не трогает
|
||||||
|
там, где его нет; цена — одна ветка в правиле слияния и признание, что слияние
|
||||||
|
знает про число точки (а оно уже знает — `hae.PointValue` живёт в разборе). От
|
||||||
|
варианта «а» отличается тем, что перестаёт систематически терять данные;
|
||||||
|
от «в» — тем, что не требует ни колонки, ни решения для внутридоставочных
|
||||||
|
столкновений.
|
||||||
|
|
||||||
|
Проверять на прогоне живого архива: отпечаток витрины обязан измениться (иначе
|
||||||
|
правило не сработало), а число столкновений с равной полнотой — остаться прежним.
|
||||||
|
|
||||||
|
Связано: `docs/architecture.md` → «Разрешение столкновений», находки 10, 47, 49,
|
||||||
|
53.
|
||||||
@@ -7,6 +7,14 @@
|
|||||||
правильно, но это же делает выезд наружу опасным: одна забытая настройка
|
правильно, но это же делает выезд наружу опасным: одна забытая настройка
|
||||||
открывает историю здоровья всему интернету.
|
открывает историю здоровья всему интернету.
|
||||||
|
|
||||||
|
**Контуров теперь два, а не один.** С появлением каталога
|
||||||
|
(`GET /api/v1/metrics`, change `2026-08-02-katalog-i-rod-agregacii`) заработал
|
||||||
|
токен чтения, и цена у контуров разная: открытый приём означает мусор во входе,
|
||||||
|
открытое чтение — выгрузку всей истории здоровья любому, кто нашёл порт. Сервис
|
||||||
|
предупреждает на старте обоими сообщениями (`write auth disabled`,
|
||||||
|
`read auth disabled`), образцы конфига цену называют комментарием — но отказа
|
||||||
|
старта нет, и это решение осталось здесь.
|
||||||
|
|
||||||
Решается перед деплоем, не раньше — так договорились.
|
Решается перед деплоем, не раньше — так договорились.
|
||||||
|
|
||||||
Шаги:
|
Шаги:
|
||||||
|
|||||||
@@ -103,6 +103,16 @@ SQLite (`modernc.org/sqlite`, чистый Go), миграции — goose, фа
|
|||||||
Таблица `WITHOUT ROWID`: обращение всегда по полному первичному ключу, и
|
Таблица `WITHOUT ROWID`: обращение всегда по полному первичному ключу, и
|
||||||
лишний уровень косвенности через rowid ни разу не нужен.
|
лишний уровень косвенности через rowid ни разу не нужен.
|
||||||
|
|
||||||
|
Индекс `bucket_catalog` (`metric, layer, hour_utc, first_ts, last_ts, points,
|
||||||
|
units`) — **покрывающий**, и это следствие той же формы таблицы: у `WITHOUT
|
||||||
|
ROWID` строка целиком, вместе со сжатым `payload`, живёт в дереве первичного
|
||||||
|
ключа, поэтому агрегат «какие слои есть у метрики и за какой период» без индекса
|
||||||
|
тащил бы страницы содержимого — сотни мегабайт чтения на запрос каталога при
|
||||||
|
260 тысячах объектов за год. По нему же идёт поиск часов, за которые у метрики
|
||||||
|
есть объекты сразу в двух слоях. Цена — около 60 байт на объект и одна вставка в
|
||||||
|
дерево на запись; платит её только настоящее изменение, потому что при совпавшем
|
||||||
|
хеше объект не переписывается вовсе.
|
||||||
|
|
||||||
**Идентичность точки внутри объекта** — координаты
|
**Идентичность точки внутри объекта** — координаты
|
||||||
`метрика + слой + начало + конец`, у точки-измерения конец равен началу.
|
`метрика + слой + начало + конец`, у точки-измерения конец равен началу.
|
||||||
`source` в ключ не входит: он нестабилен и переписывается задним числом. При
|
`source` в ключ не входит: он нестабилен и переписывается задним числом. При
|
||||||
|
|||||||
@@ -1725,6 +1725,67 @@ apple_stand_time 14
|
|||||||
ноль, частично разобранных ноль, в витрине 2049 часовых объектов, 2 тренировки и
|
ноль, частично разобранных ноль, в витрине 2049 часовых объектов, 2 тренировки и
|
||||||
2 записи; повторное проигрывание дало тот же отпечаток.
|
2 записи; повторное проигрывание дало тот же отпечаток.
|
||||||
|
|
||||||
|
## 53. Род агрегации измерен: 16 метрик из 31, противоречий ноль
|
||||||
|
|
||||||
|
Правило из находки 40 доведено до кода и прогнано на всём архиве (123 доставки,
|
||||||
|
31 метрика, витрина 2342 объекта). Сверка идёт по парам «минутный объект —
|
||||||
|
часовой объект за тот же час»; час участвует, только если у часового объекта
|
||||||
|
ровно одна точка со значением на границе часа, у минутного не меньше двух точек,
|
||||||
|
а сумма минутных отличима от их среднего.
|
||||||
|
|
||||||
|
| исход | метрик |
|
||||||
|
|---|---|
|
||||||
|
| `cumulative` | 7 |
|
||||||
|
| `instant` | 9 |
|
||||||
|
| `unknown` | 15 |
|
||||||
|
|
||||||
|
```
|
||||||
|
cumulative active_energy, basal_energy_burned, step_count,
|
||||||
|
walking_running_distance, apple_stand_time, apple_exercise_time,
|
||||||
|
time_in_daylight
|
||||||
|
instant heart_rate, respiratory_rate, blood_oxygen_saturation,
|
||||||
|
environmental_audio_exposure, walking_speed, walking_step_length,
|
||||||
|
walking_double_support_percentage, walking_asymmetry_percentage,
|
||||||
|
stair_speed_up
|
||||||
|
```
|
||||||
|
|
||||||
|
**Противоречащих часов ноль на всём корпусе** — ни у одной метрики свидетельства
|
||||||
|
не разошлись. Это и есть главный результат: правило не «чаще всего работает», а
|
||||||
|
не дало ни одного контрпримера.
|
||||||
|
|
||||||
|
### Что выяснилось по дороге
|
||||||
|
|
||||||
|
**Нулевой час обязан отбрасываться, иначе правило конфликтует само с собой.**
|
||||||
|
Первый прогон дал у `walking_asymmetry_percentage` 4 часа «накопительная» против
|
||||||
|
3 «мгновенная». Разбор: в часе, где все значения нули, сумма равна среднему, и
|
||||||
|
проверка «сходится с суммой» выполняется тождественно. Условие «сумма отличима
|
||||||
|
от среднего» убирает весь конфликт.
|
||||||
|
|
||||||
|
**Часовой слой HAE считается арифметически, а не по Apple.** HealthKit относит
|
||||||
|
`environmental_audio_exposure` к логарифмическому усреднению по энергии, а пульс
|
||||||
|
— к среднему, взвешенному по длительности. На наших данных часовое значение
|
||||||
|
аудиоэкспозиции сходится с обычным арифметическим средним минутных в 59 часах из
|
||||||
|
62, а у пульса — точно в 29 часах из 63 и с точностью 0.1% в 49. Значит четыре
|
||||||
|
стиля агрегации HealthKit в потоке ничем не различимы, и родов ровно два.
|
||||||
|
|
||||||
|
**Допуск сравнения на вердикты не влияет, а на счётчики влияет вдвое.** Прогон
|
||||||
|
сеткой: при относительном допуске от `1e-9` до `1e-3` роды всех метрик
|
||||||
|
одинаковы; число согласных часов у `heart_rate` при этом меняется с 29 на 49, у
|
||||||
|
`step_count` — с 25 на 35. Взят строгий `1e-9`: канонизация округляет числа до
|
||||||
|
12 значащих цифр, то есть всё крупнее `1e-12` представлением не объясняется.
|
||||||
|
|
||||||
|
**Окно в 48 часов обходится дешевле, чем кажется, но редкие метрики уводит в
|
||||||
|
`unknown`.** Полный обход всех 696 пар часов занимал 123 мс, окно даёт 68 мс и
|
||||||
|
перестаёт расти вместе с журналом. Плата: у `physical_effort` за всю историю
|
||||||
|
было 5 согласных часов, а в последних 48 — только 2, и метрика уходит в
|
||||||
|
`unknown`. Это честный исход: свидетельств в свежем окне действительно мало.
|
||||||
|
|
||||||
|
**Неполные часы видны в основании и ничего не ломают.** У `step_count` из 48
|
||||||
|
часов окна пригодны 41, а вердикт дали 24 — остальные не сошлись ни с суммой, ни
|
||||||
|
со средним, потому что минутный слой за них неполон. Отдельного порога
|
||||||
|
заполненности (`xFilesFactor`) измерению не нужно: две конкурирующие гипотезы
|
||||||
|
отсеивают неполный час сами.
|
||||||
|
|
||||||
## Инструмент
|
## Инструмент
|
||||||
|
|
||||||
Разбор ведётся скриптом `tmp/research/hl.py` (Python 3, только стандартная
|
Разбор ведётся скриптом `tmp/research/hl.py` (Python 3, только стандартная
|
||||||
|
|||||||
+7
-2
@@ -24,7 +24,12 @@
|
|||||||
|
|
||||||
Тренировки и записи со своими `id` разбираются: `workouts` и `stateOfMind` —
|
Тренировки и записи со своими `id` разбираются: `workouts` и `stateOfMind` —
|
||||||
половина потока — перестали лежать неразобранными. От разбора остался словарь
|
половина потока — перестали лежать неразобранными. От разбора остался словарь
|
||||||
категориальных значений; дальше — каталог и род агрегации.
|
категориальных значений.
|
||||||
|
|
||||||
|
**Род агрегации измерен**: сверка минутного слоя с часовым разложила метрики
|
||||||
|
живого корпуса на накопительные и мгновенные, не сойдясь ни на одной. Каталог
|
||||||
|
разрезов отдаётся первым маршрутом чтения — дальше Read API, которому теперь
|
||||||
|
есть на чём строить свёртку.
|
||||||
|
|
||||||
Разведка закончена: правило вывода слоя, модель идентичности и формы точки
|
Разведка закончена: правило вывода слоя, модель идентичности и формы точки
|
||||||
проверены на живом потоке, выводы — в [local-research.md](local-research.md).
|
проверены на живом потоке, выводы — в [local-research.md](local-research.md).
|
||||||
@@ -35,7 +40,7 @@
|
|||||||
- [x] **2. Приём без разбора.** ← **подключаем телефон по локальной сети**
|
- [x] **2. Приём без разбора.** ← **подключаем телефон по локальной сети**
|
||||||
- [~] **3. Разбор и хранилище.** Метрики, тренировки и записи со своими `id`,
|
- [~] **3. Разбор и хранилище.** Метрики, тренировки и записи со своими `id`,
|
||||||
`reindex` — сделано; словарь категориальных значений — нет.
|
`reindex` — сделано; словарь категориальных значений — нет.
|
||||||
- [ ] **4. Каталог и род агрегации.**
|
- [x] **4. Каталог и род агрегации.**
|
||||||
- [ ] **5. Read API.**
|
- [ ] **5. Read API.**
|
||||||
- [ ] **6. Самоописание.**
|
- [ ] **6. Самоописание.**
|
||||||
- [ ] **7. MCP.**
|
- [ ] **7. MCP.**
|
||||||
|
|||||||
@@ -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"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/healthlog/internal/catalog"
|
||||||
"git.vakhrushev.me/av/healthlog/internal/ingest"
|
"git.vakhrushev.me/av/healthlog/internal/ingest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Options — зависимости и настройки транспорта.
|
// Options — зависимости и настройки транспорта.
|
||||||
type Options struct {
|
type Options struct {
|
||||||
Ingest *ingest.Service
|
Ingest *ingest.Service
|
||||||
|
Catalog *catalog.Service
|
||||||
Log *slog.Logger
|
Log *slog.Logger
|
||||||
WriteTokens []string
|
WriteTokens []string
|
||||||
|
ReadTokens []string
|
||||||
MaxBodyMB int
|
MaxBodyMB int
|
||||||
// IngestWriteBudget — сколько отводится маршруту приёма на чтение тела
|
// IngestWriteBudget — сколько отводится маршруту приёма на чтение тела
|
||||||
// вместе с отправкой ответа. Ноль означает «полагаться на WriteTimeout
|
// вместе с отправкой ответа. Ноль означает «полагаться на WriteTimeout
|
||||||
@@ -31,8 +34,10 @@ type Options struct {
|
|||||||
|
|
||||||
type api struct {
|
type api struct {
|
||||||
ingest *ingest.Service
|
ingest *ingest.Service
|
||||||
|
catalog *catalog.Service
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
writeTokens []string
|
writeTokens []string
|
||||||
|
readTokens []string
|
||||||
maxBody int64
|
maxBody int64
|
||||||
ingestBudget time.Duration
|
ingestBudget time.Duration
|
||||||
}
|
}
|
||||||
@@ -41,8 +46,10 @@ type api struct {
|
|||||||
func New(o Options) http.Handler {
|
func New(o Options) http.Handler {
|
||||||
a := &api{
|
a := &api{
|
||||||
ingest: o.Ingest,
|
ingest: o.Ingest,
|
||||||
|
catalog: o.Catalog,
|
||||||
log: o.Log,
|
log: o.Log,
|
||||||
writeTokens: o.WriteTokens,
|
writeTokens: o.WriteTokens,
|
||||||
|
readTokens: o.ReadTokens,
|
||||||
maxBody: int64(o.MaxBodyMB) << 20,
|
maxBody: int64(o.MaxBodyMB) << 20,
|
||||||
ingestBudget: o.IngestWriteBudget,
|
ingestBudget: o.IngestWriteBudget,
|
||||||
}
|
}
|
||||||
@@ -53,7 +60,8 @@ func New(o Options) http.Handler {
|
|||||||
|
|
||||||
r.Get("/healthz", a.handleHealthz)
|
r.Get("/healthz", a.handleHealthz)
|
||||||
r.Route("/api/v1", func(r chi.Router) {
|
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
|
return r
|
||||||
}
|
}
|
||||||
@@ -64,21 +72,32 @@ func (a *api) handleHealthz(w http.ResponseWriter, _ *http.Request) {
|
|||||||
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
_, _ = 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)
|
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 {
|
func bearer(r *http.Request) string {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.vakhrushev.me/av/healthlog/internal/archive"
|
"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/httpapi"
|
||||||
"git.vakhrushev.me/av/healthlog/internal/ingest"
|
"git.vakhrushev.me/av/healthlog/internal/ingest"
|
||||||
"git.vakhrushev.me/av/healthlog/internal/store"
|
"git.vakhrushev.me/av/healthlog/internal/store"
|
||||||
@@ -284,6 +285,15 @@ func TestПриёмРаботаетНаТранспортеБезДедлайн
|
|||||||
|
|
||||||
func newAPI(t *testing.T, writeTokens []string) (http.Handler, *store.Store) {
|
func newAPI(t *testing.T, writeTokens []string) (http.Handler, *store.Store) {
|
||||||
t.Helper()
|
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()
|
dir := t.TempDir()
|
||||||
|
|
||||||
st, err := store.Open(filepath.Join(dir, "healthlog.db"))
|
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)
|
log := slog.New(slog.DiscardHandler)
|
||||||
|
cat := catalog.New(st, log)
|
||||||
h := httpapi.New(httpapi.Options{
|
h := httpapi.New(httpapi.Options{
|
||||||
Ingest: ingest.New(arch, st, nil, log),
|
Ingest: ingest.New(arch, st, nil, log),
|
||||||
|
Catalog: cat,
|
||||||
Log: log,
|
Log: log,
|
||||||
WriteTokens: writeTokens,
|
WriteTokens: writeTokens,
|
||||||
|
ReadTokens: readTokens,
|
||||||
MaxBodyMB: 1,
|
MaxBodyMB: 1,
|
||||||
// Бюджет задаётся всегда: httptest.ResponseRecorder дедлайнов не умеет,
|
// Бюджет задаётся всегда: httptest.ResponseRecorder дедлайнов не умеет,
|
||||||
// и это ровно тот транспорт, на котором приём обязан продолжать работать.
|
// и это ровно тот транспорт, на котором приём обязан продолжать работать.
|
||||||
IngestWriteBudget: time.Minute,
|
IngestWriteBudget: time.Minute,
|
||||||
})
|
})
|
||||||
return h, st
|
return h, st, cat
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,11 +103,21 @@ var secretHeaders = map[string]bool{
|
|||||||
// redacted подменяет значение, которое оказалось секретом.
|
// redacted подменяет значение, которое оказалось секретом.
|
||||||
const redacted = "[redacted]"
|
const redacted = "[redacted]"
|
||||||
|
|
||||||
|
// isSecretValue — совпало ли значение заголовка с каким-нибудь настроенным
|
||||||
|
// токеном, всё равно какого контура.
|
||||||
|
func (a *api) isSecretValue(v string) bool {
|
||||||
|
return tokenAllowed(v, a.writeTokens) || tokenAllowed(v, a.readTokens)
|
||||||
|
}
|
||||||
|
|
||||||
// safeHeaders собирает заголовки запроса для хранения, вычищая секреты.
|
// safeHeaders собирает заголовки запроса для хранения, вычищая секреты.
|
||||||
//
|
//
|
||||||
// Двойная защита: имя из чёрного списка вырезается всегда, а любое значение,
|
// Двойная защита: имя из чёрного списка вырезается всегда, а любое значение,
|
||||||
// совпавшее с настроенным токеном, подменяется — токен можно положить в
|
// совпавшее с настроенным токеном, подменяется — токен можно положить в
|
||||||
// заголовок с произвольным именем, и угадать его мы не можем.
|
// заголовок с произвольным именем, и угадать его мы не можем.
|
||||||
|
//
|
||||||
|
// Сверяется с токенами ОБОИХ контуров. Токен чтения так же секрет, как токен
|
||||||
|
// приёма, и посланный на этот маршрут произвольным заголовком осел бы в базе
|
||||||
|
// доставок навсегда.
|
||||||
func (a *api) safeHeaders(r *http.Request) map[string][]string {
|
func (a *api) safeHeaders(r *http.Request) map[string][]string {
|
||||||
out := make(map[string][]string, len(r.Header)+1)
|
out := make(map[string][]string, len(r.Header)+1)
|
||||||
for name, values := range r.Header {
|
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))
|
safe := make([]string, len(values))
|
||||||
for i, v := range 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
|
safe[i] = redacted
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,16 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"flag"
|
"flag"
|
||||||
"io"
|
"io"
|
||||||
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"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/ident"
|
||||||
"git.vakhrushev.me/av/healthlog/internal/store"
|
"git.vakhrushev.me/av/healthlog/internal/store"
|
||||||
)
|
)
|
||||||
@@ -132,6 +136,8 @@ func TestReplayЖивогоАрхива(t *testing.T) {
|
|||||||
// Значений точек отпечаток не раскрывает: содержимое входит в него хешем.
|
// Значений точек отпечаток не раскрывает: содержимое входит в него хешем.
|
||||||
t.Logf("объектов %d, отпечаток содержимого %s", first.Buckets, first.Fingerprint)
|
t.Logf("объектов %d, отпечаток содержимого %s", first.Buckets, first.Fingerprint)
|
||||||
|
|
||||||
|
measureStyles(t, dst)
|
||||||
|
|
||||||
// Главное свойство ключа: у записей сна он ИНТЕРВАЛ, а не метка — под одним
|
// Главное свойство ключа: у записей сна он ИНТЕРВАЛ, а не метка — под одним
|
||||||
// `date` лежит до трёх записей (docs/local-research.md, находка 47).
|
// `date` лежит до трёх записей (docs/local-research.md, находка 47).
|
||||||
//
|
//
|
||||||
@@ -151,6 +157,72 @@ func TestReplayЖивогоАрхива(t *testing.T) {
|
|||||||
t.Logf("координат сна %d, различных меток %d", coords, labels)
|
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 возвращает число различных координат записей сна и число
|
// 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;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-08-02
|
||||||
@@ -0,0 +1,453 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
Витрина уже держит одну метрику в нескольких слоях одновременно
|
||||||
|
(`sample`/`raw`/`minute`/`hour`/`day`) и своей агрегации при записи не делает.
|
||||||
|
Read API обязан уметь свести ряд к запрошенной сетке — но правильная свёртка
|
||||||
|
зависит от рода метрики, а рода в потоке нет:
|
||||||
|
|
||||||
|
- **форма точки его не выдаёт** — `Avg`/`Min`/`Max` есть только у `heart_rate`,
|
||||||
|
заведомо мгновенные `walking_speed` и `blood_oxygen_saturation` приходят в
|
||||||
|
`qty` ровно так же, как шаги (находка 40);
|
||||||
|
- **заголовок доставки не описывает данные** — `automation-aggregation`
|
||||||
|
принимает значение `Default` у трёх разных режимов (находка 31);
|
||||||
|
- **единицы дают процентов девяносто и ломаются на краях** —
|
||||||
|
`six_minute_walking_test_distance` в метрах складывать нельзя, а
|
||||||
|
`walking_running_distance` в километрах можно.
|
||||||
|
|
||||||
|
Зато витрина содержит собственную сверку: у одной метрики есть и минутный, и
|
||||||
|
часовой разрез за один и тот же час, и они приходят из разных автоматизаций.
|
||||||
|
Часовое значение либо равно сумме минутных, либо их среднему — и это
|
||||||
|
наблюдаемое различие.
|
||||||
|
|
||||||
|
Prior art по этому вопросу богат, но весь он про **объявление** рода, а не про
|
||||||
|
измерение: HealthKit зашивает `HKQuantityAggregationStyle` в тип метрики, Home
|
||||||
|
Assistant получает `state_class` от интеграции, Graphite выводит
|
||||||
|
`aggregationMethod` регуляркой по имени метрики, Prometheus/Datadog/Splunk
|
||||||
|
принимают тип от отправителя. Прецедента вывода рода из значений нет ни одного —
|
||||||
|
и паспорт проекта предупреждал об этом заранее.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
|
||||||
|
- Род метрики (`cumulative` / `instant` / `unknown`) выводится измерением на
|
||||||
|
накопленных данных, без ручной разметки и без списка имён в коде.
|
||||||
|
- Потребитель одним запросом узнаёт, что в хранилище есть: метрики, единицы,
|
||||||
|
слои с диапазонами и числом точек, род и основание, на котором он измерен.
|
||||||
|
- Каталог отдаёт наблюдаемое состояние витрины и ничего не досчитывает: период,
|
||||||
|
у которого верхние слои не пересобираемы, честно объявляет то, что есть.
|
||||||
|
- Стоимость каталога не растёт вместе с историей.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
|
||||||
|
- **Свёртка ряда в ответе.** Каталог отвечает «какая свёртка осмысленна», сама
|
||||||
|
свёртка — задача Read API.
|
||||||
|
- **Правило неполного ведра** (`xFilesFactor`). Оно нужно свёртке, а не
|
||||||
|
измерению — см. решение 6.
|
||||||
|
- **Тай-брейк при равной полноте точек.** Вынут блокером — решение 8.
|
||||||
|
- **Каталог сущностей** (`workout`, `record`). Тренировки и записи адресуются
|
||||||
|
своим `id`, слоёв у них нет; их перечисление приезжает вместе с их же
|
||||||
|
маршрутами Read API.
|
||||||
|
- **Хранение измеренного рода.** Решение 2.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### 1. Род измеряется двумя конкурирующими гипотезами и единогласием
|
||||||
|
|
||||||
|
Для каждого часа, за который у метрики есть **и** минутный, **и** часовой
|
||||||
|
объект:
|
||||||
|
|
||||||
|
```
|
||||||
|
час пригоден, если
|
||||||
|
час не позже текущего времени плюс час
|
||||||
|
единицы обоих объектов совпадают
|
||||||
|
часовой объект несёт ровно одну точку, и она несёт значение
|
||||||
|
метка этой точки совпадает с началом часа
|
||||||
|
у минутного не меньше двух точек со значением
|
||||||
|
сумма минутных и их среднее сами различимы
|
||||||
|
|
||||||
|
вердикт пригодного часа:
|
||||||
|
часовое ≈ сумма минутных → cumulative
|
||||||
|
часовое ≈ среднее минутных → instant
|
||||||
|
иначе → час свидетельства не даёт
|
||||||
|
```
|
||||||
|
|
||||||
|
Вердикт метрики: **не меньше трёх согласных часов и ни одного противоречащего**.
|
||||||
|
Иначе — `unknown`, и свёртка по метрике не предлагается вовсе. Наличие
|
||||||
|
противоречащих часов пишется `WARN`: род — свойство, на котором Read API строит
|
||||||
|
арифметику года, и его смена не имеет права проходить молча.
|
||||||
|
|
||||||
|
**Все три сравнения — один предикат с одним допуском**, относительным, величиной
|
||||||
|
`1e-9`. Это не аккуратность, а устранение целого класса: напиши «различимость»
|
||||||
|
точным неравенством, а «сходимость» с допуском — появится час, подтверждающий
|
||||||
|
обе гипотезы сразу, и его исход молча определит порядок веток `if`. При одном
|
||||||
|
предикате такой час невыразим.
|
||||||
|
|
||||||
|
Величина названа числом, потому что от неё зависят счётчики основания в ответе.
|
||||||
|
Измерено на живом корпусе: вердикты метрик одинаковы при допуске от `1e-9` до
|
||||||
|
`1e-3`, а число согласных часов у `heart_rate` при этом меняется с 29 на 49 —
|
||||||
|
то есть выбор не влияет на вывод, но влияет на то, что мы о нём сообщаем. Взято
|
||||||
|
строгое: канонизация содержимого округляет числа до 12 значащих цифр, значит всё
|
||||||
|
крупнее `1e-12` представлением не объясняется; `1e-9` оставляет три порядка
|
||||||
|
запаса и остаётся на шесть порядков строже любого содержательного расхождения —
|
||||||
|
сумма и среднее при `n ≥ 2` различаются не меньше чем вдвое.
|
||||||
|
|
||||||
|
Абсолютного порога нет намеренно: второй константы, которую пришлось бы
|
||||||
|
объяснять, задача не заводит. Цена названа вслух — при обоих нулях предикат
|
||||||
|
истинен, и от нулевого часа защищает не он, а проверка различимости. Полагаться
|
||||||
|
на «около нуля не сходится» нельзя: там ровно наоборот.
|
||||||
|
|
||||||
|
**Горизонт закрывает подделку и сбитые часы.** Час объекта берётся из метки в
|
||||||
|
теле доставки, а тело не наше: без верхней границы одна доставка с метками в
|
||||||
|
будущем занимает окно целиком и подменяет измеренный род. Путь построен и
|
||||||
|
прогнан — мгновенная метрика объявлялась накопительной при нуле противоречащих
|
||||||
|
часов, то есть с виду безупречным основанием. Часы позже `now + час` в окно не
|
||||||
|
входят, а сам факт данных из будущего пишется `WARN`: сбитые часы телефона и
|
||||||
|
чужое тело в приёме лечатся не кодом.
|
||||||
|
|
||||||
|
**Единицы обеих сторон обязаны совпасть.** Мгновенная метрика в `count/min`
|
||||||
|
минутным слоем и в `count/hour` часовым даёт в полном часе
|
||||||
|
`часовое = 60 · среднее = сумма` — уверенный ложный `cumulative`, которого
|
||||||
|
правило единогласия не ловит по построению: противоречия нет, есть молчание.
|
||||||
|
Единицы лежат в том же покрывающем индексе, так что проверка не стоит ничего.
|
||||||
|
|
||||||
|
**Выравнивание часовой метки закрывает получасовые пояса.** Слой выводится по
|
||||||
|
выравниванию метки в исходной зоне, а объект адресуется часом UTC: в зоне
|
||||||
|
`+0530` часовая точка попадает на середину часа UTC и описывает не тот интервал,
|
||||||
|
который покрывают минутные точки того же объекта. Сравнивать их нельзя. Условие
|
||||||
|
стоит копейки, а закрывает класс целиком — на корпусе одной зоны его не
|
||||||
|
воспроизвести, и потому оно и записано правилом, а не оставлено на «когда
|
||||||
|
поедем».
|
||||||
|
|
||||||
|
Измерено на живом архиве (123 доставки, 31 метрика, окно — все общие часы):
|
||||||
|
|
||||||
|
| исход | метрик |
|
||||||
|
|---|---|
|
||||||
|
| `cumulative` | 7 (`active_energy`, `basal_energy_burned`, `step_count`, `walking_running_distance`, `apple_stand_time`, `apple_exercise_time`, `time_in_daylight`) |
|
||||||
|
| `instant` | 9 (`heart_rate`, `respiratory_rate`, `blood_oxygen_saturation`, `environmental_audio_exposure`, `walking_speed`, `walking_step_length`, `walking_double_support_percentage`, `walking_asymmetry_percentage`, `stair_speed_up`) |
|
||||||
|
| `unknown` | 15 |
|
||||||
|
| **противоречащих часов** | **0 на всём корпусе** |
|
||||||
|
|
||||||
|
Числа сняты при допуске `1e-9` и окне в 48 часов. Полный обход всей истории дал
|
||||||
|
бы `instant` ещё и `physical_effort` (5 согласных часов за всё время против 2 в
|
||||||
|
свежем окне) — окно честно уводит редкие метрики в `unknown`, и это то же
|
||||||
|
правило, а не издержка.
|
||||||
|
|
||||||
|
Три части правила стоят каждая своей причины.
|
||||||
|
|
||||||
|
**Фильтр различимости — не украшение.** Без него
|
||||||
|
`walking_asymmetry_percentage` давала 4 часа «накопительная» против 3
|
||||||
|
«мгновенная»: в нулевом часе сумма равна среднему, и «сходится с суммой»
|
||||||
|
выполняется тождественно. Час, в котором гипотезы неразличимы, свидетельством не
|
||||||
|
является.
|
||||||
|
|
||||||
|
**Порог в три часа.** Один совпавший час — свидетельство одного часа, а на роде
|
||||||
|
потом суммируют год. Цена измерена: порог уводит в `unknown` ровно одну метрику
|
||||||
|
(`headphone_audio_exposure`, один согласный час).
|
||||||
|
|
||||||
|
**Единогласие, а не большинство.** Противоречие означает, что одна из гипотез
|
||||||
|
ложна для этой метрики; большинство голосов позволило бы объявить род при
|
||||||
|
известном контрпримере. Измеренная цена этого решения — ноль: конфликтов нет.
|
||||||
|
|
||||||
|
Альтернативы отвергнуты:
|
||||||
|
|
||||||
|
- **Разметка руками** (так делают все, кроме нас) — она и есть то, от чего
|
||||||
|
задача уходит: список из сотни метрик Apple, который устареет в день
|
||||||
|
появления новой.
|
||||||
|
- **Вывод по имени метрики** (Graphite, `pattern = \.count$`) — противоречит
|
||||||
|
инварианту «форма Apple не транслируется» и не работает на именах HAE вовсе.
|
||||||
|
- **Вывод по единицам** — ломается на краях (находка 40).
|
||||||
|
- **Заголовок доставки** — врёт уже про слой, оснований верить про род нет.
|
||||||
|
- **Детекция сброса счётчика** (Prometheus `rate`, Home Assistant
|
||||||
|
`total_increasing` с допуском 10%) — отвечает на другой вопрос: «был ли
|
||||||
|
рестарт у известного счётчика», а не «счётчик ли это». К данным Apple
|
||||||
|
неприменима: монотонного накопителя в них нет, накопительная метрика приходит
|
||||||
|
уже поинтервальными значениями.
|
||||||
|
|
||||||
|
### 2. Родов два, а не четыре — потому что больше нечем измерить
|
||||||
|
|
||||||
|
HealthKit различает четыре стиля: `cumulative`, `discreteArithmetic`,
|
||||||
|
`discreteTemporallyWeighted` (пульс) и `discreteEquivalentContinuousLevel`
|
||||||
|
(аудиоэкспозиция, логарифмическое усреднение по энергии). Взять весь словарь
|
||||||
|
напрашивалось — и отвергнуто **измерением**: часовой слой HAE считается
|
||||||
|
арифметически, а не по Apple.
|
||||||
|
|
||||||
|
Прямое свидетельство даёт `environmental_audio_exposure`: Apple усредняет её
|
||||||
|
логарифмически, а часовое значение HAE сошлось с обычным арифметическим средним
|
||||||
|
минутных в 59 часах из 62. У `heart_rate`, который Apple взвешивает по
|
||||||
|
длительности, часовое значение сходится с арифметическим средним точно в 29
|
||||||
|
часах из 63 и с точностью 0.1% — в 49; с суммой не сошлось ни разу.
|
||||||
|
|
||||||
|
Значит четвёртый и третий стили в наших данных ничем не проявляются, и ввести
|
||||||
|
их можно было бы только разметкой руками — то есть тем, от чего задача уходит.
|
||||||
|
Правило проекта прежнее: **род, который нечем измерить, не объявляется.**
|
||||||
|
Появится источник, различающий больше родов (родной экспорт Apple несёт
|
||||||
|
интервалы сэмплов), — словарь расширится тем же измерением.
|
||||||
|
|
||||||
|
### 3. Род не хранится, а считается на запрос по ограниченному окну
|
||||||
|
|
||||||
|
Хранить измеренный род означало бы завести **второе производное состояние**
|
||||||
|
рядом с витриной: колонку, которую надо пересчитывать после каждой свёртки,
|
||||||
|
переносить или не переносить пересборкой (перечень в `architecture.md`),
|
||||||
|
мигрировать и объяснять, на каком составе данных она измерена. Цена ошибки
|
||||||
|
здесь — молчаливая: устаревшее значение выглядит ровно как свежее.
|
||||||
|
|
||||||
|
Считанный на запрос род — по построению функция текущей витрины, а витрина есть
|
||||||
|
функция журнала. Устареть нечему.
|
||||||
|
|
||||||
|
Плата — стоимость чтения, и она ограничена **окном в 48 самых свежих общих
|
||||||
|
часов** метрики. Измерено на живом корпусе: полное измерение по всем 696 парам
|
||||||
|
часов всех 31 метрики — 123 мс; окно даёт тот же результат и не даёт стоимости
|
||||||
|
расти вместе с историей (за год окно ограничивает работу 1488 парами вместо
|
||||||
|
270 тысяч).
|
||||||
|
|
||||||
|
Кеш в памяти сознательно не заводится: он вводит третье представление того же
|
||||||
|
факта, а вопрос его инвалидации («изменился ли хоть один объект окна») стоит
|
||||||
|
дороже самого измерения. Появится профиль нагрузки, показывающий обратное, —
|
||||||
|
кеш добавится с числом в руках.
|
||||||
|
|
||||||
|
### 4. В измерении участвуют только `minute` и `hour`
|
||||||
|
|
||||||
|
Нижний слой HAE — посекундная развёртка настоящих сэмплов с инфляцией 2.4×
|
||||||
|
(находка 20) и до 478× у базального обмена (находка 34); его сумма завышена, и
|
||||||
|
в сверке он не сходится. Слой `sample` из родного экспорта в витрине пока пуст,
|
||||||
|
а его точки несут собственные интервалы — их сверка с часовым слоем это другая
|
||||||
|
задача (`healthlog import`).
|
||||||
|
|
||||||
|
`day` в измерении не участвует: суточная сводка сна — не разрез часов, а другая
|
||||||
|
схема под тем же именем (находка 38).
|
||||||
|
|
||||||
|
### 5. Значение точки — `qty`, а при его отсутствии `Avg`
|
||||||
|
|
||||||
|
Единственное место, знающее, какое поле точки HAE несёт число, — пакет `hae`.
|
||||||
|
Порядок именно такой: `qty` несут все метрики, `Avg` — только `heart_rate`
|
||||||
|
(находка 40), и без второго кандидата самая важная метрика потока не измерялась
|
||||||
|
бы вовсе. Точка, не несущая ни того, ни другого, в сумму не входит и число
|
||||||
|
точек часа не увеличивает.
|
||||||
|
|
||||||
|
Это чтение, а не интерпретация: значение никуда не пишется и ничего не
|
||||||
|
подменяет.
|
||||||
|
|
||||||
|
**Ноль — значение.** Словарь пустоты из `canon` сюда не годится и применяться не
|
||||||
|
должен: там ноль объявлен пустотой, чтобы точка без измерений не вытесняла
|
||||||
|
настоящее измерение при столкновении координат, — вопрос другой. Взяв его,
|
||||||
|
измерение не увидело бы точки `{"qty":0}`, час выпал бы из счётчиков ещё до
|
||||||
|
правила различимости, и сценарий «нулевой час свидетельством не является»
|
||||||
|
позеленел бы по неверной причине. Поэтому разбор здесь свой: `*json.Number` для
|
||||||
|
обоих полей, отсутствие ключа и `null` — «нет значения», ноль — значение.
|
||||||
|
|
||||||
|
**Бесконечность — не значение.** `json.Number("1e400").Float64()` возвращает
|
||||||
|
`+Inf` вместе с `ErrRange`; проглоченная ошибка отравила бы и сумму, и среднее
|
||||||
|
всего часа. Значение, не разобравшееся в конечное число, считается
|
||||||
|
неприсланным.
|
||||||
|
|
||||||
|
### 6. `xFilesFactor` здесь не нужен, и это сказано вслух
|
||||||
|
|
||||||
|
Graphite и RRDtool закрывают вопрос «что делать со свёрткой неполного ведра»
|
||||||
|
долей заполненности: ниже порога — не число, а пусто. Паспорт называл это
|
||||||
|
готовым ответом на вопрос, который у нас ещё не задан.
|
||||||
|
|
||||||
|
Измерению порог не нужен, потому что у него **две конкурирующие гипотезы**, а
|
||||||
|
не одна: неполный минутный час не сходится ни с суммой, ни со средним и
|
||||||
|
свидетельства не даёт сам собой. Это видно в измерении — у `step_count` 25
|
||||||
|
часов согласны и 17 не дали ничего; ровно эти 17 и есть неполные часы.
|
||||||
|
|
||||||
|
Свёртке в ответе порог понадобится, и вместе с ним — выбор полярности:
|
||||||
|
Graphite `xFilesFactor` задаёт долю **обязательно известных** (умолчание 0.5 при
|
||||||
|
роллапе и 0 при рендере — один параметр с двумя умолчаниями), RRDtool `xff` —
|
||||||
|
долю **допустимо неизвестных**, то есть ровно наоборот. Обе величины будут
|
||||||
|
выглядеть как «0.5», означая противоположное. Решение и его полярность
|
||||||
|
принимает задача Read API; здесь оно названо, чтобы не решалось дважды.
|
||||||
|
|
||||||
|
### 7. Разрезы отвечают по индексу, окно читается пакетом, ответ — из одного снимка
|
||||||
|
|
||||||
|
Границу надо назвать точно, иначе она запрещает то, ради чего задача есть:
|
||||||
|
**не разжимается содержимое ради разрезов и границ; объекты окна измерения
|
||||||
|
разжимаются обязательно** — сумма минутных значений иначе невычислима. Разжатых
|
||||||
|
объектов не больше `2 × 48` на метрику.
|
||||||
|
|
||||||
|
Диапазоны и число точек лежат учётными колонками объекта (`first_ts`,
|
||||||
|
`last_ts`, `points`), но `bucket` — таблица `WITHOUT ROWID`, то есть строка
|
||||||
|
целиком, вместе с `payload`, живёт в самом дереве первичного ключа. Обход всех
|
||||||
|
строк ради агрегата тащил бы за собой страницы сжатого содержимого: при
|
||||||
|
260 тысячах объектов за год это сотни мегабайт на каждый запрос каталога.
|
||||||
|
|
||||||
|
Поэтому миграция `00009` заводит **покрывающий индекс**
|
||||||
|
`bucket(metric, layer, hour_utc, first_ts, last_ts, points, units)`: и
|
||||||
|
агрегат разрезов, и поиск общих часов двух слоёв читают только его. Цена —
|
||||||
|
около 60 байт на объект (≈16 МБ за год) и одна вставка в дерево на запись
|
||||||
|
объекта.
|
||||||
|
|
||||||
|
Данных индекс не меняет, поэтому в перечне того, что не переносит пересборка,
|
||||||
|
ему места нет.
|
||||||
|
|
||||||
|
**Содержимое разжимается только у часов, прошедших отбор по учётным колонкам.**
|
||||||
|
Число точек и единицы обеих сторон лежат в покрывающем индексе, а условия
|
||||||
|
пригодности «у крупного слоя ровно одна точка, у мелкого не меньше двух»
|
||||||
|
проверяются по ним. Замер на раздутой витрине: 109 МиБ аллокаций при нуле
|
||||||
|
пригодных часов — вся работа шла до того, как выяснялось, что вердикта не будет.
|
||||||
|
Отбор ПРЕДВАРИТЕЛЬНЫЙ и строго слабее правила вердикта: числа задаёт домен,
|
||||||
|
хранилище лишь выбирает по ним строки.
|
||||||
|
|
||||||
|
**Объекты окна берутся пакетом и в одной транзакции чтения со всем остальным.**
|
||||||
|
Существующий `Store.Bucket` открывает собственную read-only транзакцию на каждый
|
||||||
|
вызов: окно в 48 часов дало бы под сотню транзакций на метрику, а ответ
|
||||||
|
собрался бы из смеси снимков — разрезы одного состояния витрины, род другого,
|
||||||
|
причём под непрерывным приёмом и неотличимо от обычного свежего ответа. Проект
|
||||||
|
уже записал это рассуждение у отпечатка витрины, и второй раз оно разошлось бы
|
||||||
|
молча.
|
||||||
|
|
||||||
|
Поэтому хранилище отдаёт каталогу **один снимок**: агрегат разрезов, общие часы
|
||||||
|
каждой метрики и объекты её окна — за одну транзакцию чтения, двумя запросами на
|
||||||
|
метрику плюс один общий. Число обращений к базе перестаёт зависеть от размера
|
||||||
|
окна. В WAL длинная транзакция чтения писателей не блокирует, а измеренные
|
||||||
|
130 мс на живом корпусе — цена, которую видно.
|
||||||
|
|
||||||
|
### 8. Тай-брейк при равной полноте точек не трогаем — вынут блокером
|
||||||
|
|
||||||
|
Задача обещала доделать его «по каталогу», и измерение действительно
|
||||||
|
подтвердило посылку: четыре из шести метрик, где тай-брейк системно берёт
|
||||||
|
меньшее значение (находка 49), измерены как накопительные — то есть там это
|
||||||
|
недосчёт, а у `heart_rate` (самая крупная группа) род мгновенный, и выбор
|
||||||
|
безразличен.
|
||||||
|
|
||||||
|
Но сделать тай-брейк зависящим от **измеренного** рода нельзя: род есть функция
|
||||||
|
витрины, витрина — результат слияния, и правило слияния, читающее собственную
|
||||||
|
выдачу, повторяет ровно тот дефект, на котором свёртка уже переставала быть
|
||||||
|
функцией префикса журнала (`docs/review-journal.md`, 2026-08-01). Остаются
|
||||||
|
варианты, не зависящие от рода, и выбор между ними — развилка с ценой; она
|
||||||
|
уходит блокером вместе с измеренным основанием.
|
||||||
|
|
||||||
|
### 9. Каталог живёт под токеном чтения
|
||||||
|
|
||||||
|
`GET /api/v1/metrics` — первый маршрут, который отдаёт данные наружу, поэтому
|
||||||
|
здесь же появляется проверка `auth.read_tokens`. Правило то же, что у приёма:
|
||||||
|
пустой список означает выключенную проверку, и о ней сервис предупреждает на
|
||||||
|
старте. Токен приёма каталог не открывает — раздельность контуров объявлена
|
||||||
|
архитектурой, и «пишущий умеет читать» её бы отменило.
|
||||||
|
|
||||||
|
Цена симметрии названа вслух, потому что она несимметрична: у приёма открытый
|
||||||
|
контур означает мусор во входе, у чтения — выгрузку истории здоровья любому, кто
|
||||||
|
нашёл порт. Отказ старта при пустом списке рассматривался и не взят здесь:
|
||||||
|
сегодня оба образца конфига в репозитории идут с пустыми списками сознательно
|
||||||
|
(доверенная локальная сеть), и такой отказ сломал бы `task up` до правки
|
||||||
|
конфигов, заведя асимметрию с приёмом, которую пришлось бы объяснять. Вопрос
|
||||||
|
принадлежит задаче об управлении секретами — он там уже стоит, и эта задача
|
||||||
|
добавляет ему второй контур, а не заводит третье место для того же решения.
|
||||||
|
|
||||||
|
Проверка **одна на оба контура**, параметризованная списком: копия отличалась бы
|
||||||
|
одним полем и несла бы три решения сразу — сравнение за постоянное время,
|
||||||
|
«пустой список = выключено» и текст 401, — правка любого из них в одном месте не
|
||||||
|
дала бы ни ошибки компиляции, ни красного теста.
|
||||||
|
|
||||||
|
Схема строгая: токеном считается только `Authorization: Bearer <значение>`.
|
||||||
|
Снисходительности к голому значению у приёма нет и не было; заводить её на
|
||||||
|
контуре чтения, клиенты которого свои, тем более не за чем.
|
||||||
|
|
||||||
|
Отдельно — **редакция заголовков**: сохраняемые заголовки доставки чистятся
|
||||||
|
подстановкой по списку токенов, и сегодня в этом списке только токены приёма.
|
||||||
|
Токен чтения, посланный заголовком с произвольным именем, осел бы в базе; список
|
||||||
|
становится общим.
|
||||||
|
|
||||||
|
### 10. Форма ответа
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"metrics": [
|
||||||
|
{"metric": "step_count",
|
||||||
|
"units": ["count"],
|
||||||
|
"aggregation": {"style": "cumulative",
|
||||||
|
"hours": 48, "compared": 40, "agreeing": 25, "conflicting": 0,
|
||||||
|
"first_hour": "2026-07-31T09:00:00Z",
|
||||||
|
"last_hour": "2026-08-02T14:00:00Z"},
|
||||||
|
"layers": [
|
||||||
|
{"layer": "minute", "from": "2026-07-30T21:48:00Z",
|
||||||
|
"to": "2026-08-02T14:59:00Z", "points": 1102},
|
||||||
|
{"layer": "raw", "from": "…", "to": "…", "points": 25636}]}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `units` — **массив**: единицы метрики на живом потоке не менялись ни разу
|
||||||
|
(находка 48), но одна форма поля для обоих случаев честнее строки, которая при
|
||||||
|
расхождении молча выберет одно из двух. Та же форма, что у самоописания. На
|
||||||
|
слой при этом приходится ровно один элемент `layers`: строки выборки,
|
||||||
|
разошедшиеся единицами, схлопываются в общий диапазон и общую сумму точек, а
|
||||||
|
различие видно множеством единиц метрики. Не поручить это схлопывание явно
|
||||||
|
значило бы отдать клиенту два элемента с одинаковым `layer` в тот единственный
|
||||||
|
день, ради которого `units` и сделали массивом.
|
||||||
|
- `aggregation` — объект, а не строка: он несёт **основание**, и числа в нём
|
||||||
|
подобраны так, чтобы их разности были осмысленны. `hours` — сколько общих
|
||||||
|
часов попало в окно, `compared` — сколько из них оказалось пригодными,
|
||||||
|
`agreeing` и `conflicting` — вердикты пригодных. `hours − compared` — часы,
|
||||||
|
отброшенные проверкой пригодности; `compared − agreeing − conflicting` — часы,
|
||||||
|
не сошедшиеся ни с одной гипотезой. Одного числа не хватало: «часов было 48, а
|
||||||
|
пригодным не оказалось ни одного» и «часов не было вовсе» — разные события.
|
||||||
|
- Поле называется `style`, а не `kind`: слово `kind` в проекте уже занято родом
|
||||||
|
секции записи (`record.kind`), и два смысла под одним именем в одном API — это
|
||||||
|
сноска в документации навсегда. `style` — слово HealthKit
|
||||||
|
(`HKQuantityAggregationStyle`) для ровно этого понятия.
|
||||||
|
- Значения рода остаются `cumulative` / `instant` / `unknown`. `cumulative`
|
||||||
|
совпадает со словарём HealthKit; `instant` не совпадает ни с чьим (у Apple
|
||||||
|
`discrete`, у Prometheus `gauge`, у Home Assistant `measurement`) — и взят
|
||||||
|
сознательно: `discrete` описывает **природу сэмпла**, а мы называем то, что
|
||||||
|
измерили, — свёртку средним. Архитектура пользуется словом «мгновенная» с
|
||||||
|
самого начала, и менять словарь ради чужого сходства значило бы переименовать
|
||||||
|
понятие, не изменив его.
|
||||||
|
- `first_hour`/`last_hour` вместо `from`/`to` — потому что это **ярлыки часов**,
|
||||||
|
а не метки данных: у слоя `to` — метка последней точки (`…14:59:00Z`), у окна
|
||||||
|
— начало последнего часа окна (`…14:00:00Z`), включая непригодные. Одно имя для двух
|
||||||
|
семантик в одном ответе стоило бы клиенту ошибки на час, заметной только
|
||||||
|
расхождением сумм.
|
||||||
|
- `layers` — только то, что есть. Числа часовых объектов в ответе нет: объект —
|
||||||
|
деталь хранения, клиент про него не знает.
|
||||||
|
- Поля присутствуют всегда, в том числе со значением `null`: клиент не должен
|
||||||
|
выводить смысл из наличия или отсутствия ключа. Это требует внимания к
|
||||||
|
нулевым значениям Go: nil-срез сериализуется в `null`, а нулевой `time.Time` —
|
||||||
|
в правдоподобную метку `0001-01-01T00:00:00Z`, неотличимую от данных. Поэтому
|
||||||
|
срезы конструируются пустыми, границы окна — указателями, а приёмочный тест
|
||||||
|
сравнивает **байты** ответа с литералом, а не разобранную структуру с
|
||||||
|
разобранной.
|
||||||
|
- Порядок метрик и слоёв детерминирован: два ответа на неизменившейся витрине
|
||||||
|
обязаны совпасть побайтово, иначе «повторный запрос не опирается на прошлый»
|
||||||
|
нечем проверить.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- **Род измеряется по свежему окну, а метрика могла его сменить в прошлом** →
|
||||||
|
окно и его границы отдаются в ответе (`from`/`to`, `compared`), то есть род
|
||||||
|
объявлен вместе с периодом, на котором измерен. Так же поступает Home
|
||||||
|
Assistant, признавая смену `state_class` разрушительным событием, а не
|
||||||
|
уточнением поля.
|
||||||
|
- **Метрика приходит только в одном слое — род не измерить никогда** → штатный
|
||||||
|
`unknown` с `compared: 0`. Сегодня это 14 метрик из 31, в том числе
|
||||||
|
`sleep_analysis` и `heart_rate_variability`. Лечится не кодом, а второй
|
||||||
|
автоматизацией HAE на том же наборе метрик.
|
||||||
|
- **Стоимость каталога растёт с числом метрик** (~4 мс на метрику на живом
|
||||||
|
корпусе) → окно ограничивает вклад каждой; при сотне метрик это порядка
|
||||||
|
полусекунды. Число измерено и попадёт в отчёт; кеш заводится по профилю
|
||||||
|
нагрузки, а не заранее.
|
||||||
|
- **Часовой слой HAE — тоже досчитываемое задним числом значение** (находка 10)
|
||||||
|
→ свежайший час окна может быть неполным и вердикта не дать. На исход это не
|
||||||
|
влияет: неполный час просто не свидетельствует, а окно в 48 часов заведомо
|
||||||
|
содержит устоявшиеся.
|
||||||
|
- **Каталог метрик не говорит о невосстановимости `stateOfMind`** — секция живёт
|
||||||
|
в `record`, а не в метриках, и её единственный источник это доставки HAE
|
||||||
|
(находка 46). Граница названа: за это отвечает ретеншен и перечень
|
||||||
|
непокрытого, а не каталог разрезов.
|
||||||
|
- **Покрывающий индекс удорожает запись объекта** → одна вставка в дерево на
|
||||||
|
объект; широкий проход, у которого хеш сошёлся, объект не переписывает вовсе,
|
||||||
|
поэтому цену платят только настоящие изменения.
|
||||||
|
- **Род дребезжит вместе со скользящим окном**: час, въехавший в окно, может
|
||||||
|
сменить `cumulative` на `unknown` без единой новой доставки за спрошенный
|
||||||
|
период, и для агента это выглядит поломкой сервиса → следствие принято вслух и
|
||||||
|
снабжено двумя средствами. Первое — основание измерения в ответе: клиент
|
||||||
|
видит, что изменилось и почему. Второе — `WARN` при появлении противоречащих
|
||||||
|
часов: событие адресовано владельцу, потому что лечится оно настройкой
|
||||||
|
автоматизаций HAE, а не кодом. Смягчать правило долей согласных вместо
|
||||||
|
единогласия отвергнуто: это объявление рода при известном контрпримере.
|
||||||
|
- **Пустой список токенов чтения открывает историю здоровья** → предупреждение на
|
||||||
|
старте и запись цены в образцах конфига; отказ старта рассмотрен и оставлен
|
||||||
|
задаче об управлении секретами (решение 9). До выкладки наружу это домашняя
|
||||||
|
сеть, после — блокирующее условие деплоя, и оно уже записано там.
|
||||||
|
- **Каталог — первая ручка, где повторный запрос стоит заметного CPU** (порядка
|
||||||
|
4 мс на метрику) → предела на размер ответа и тайм-аута у него нет, потому что
|
||||||
|
и то, и другое — правило Read API, которое пишется следующей задачей вместе с
|
||||||
|
остальными его маршрутами. Названо, чтобы не оказалось забытым.
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
Read API обязан уметь сворачивать метрику к запрошенной сетке, а род свёртки
|
||||||
|
(сумма или среднее) HAE не присылает: `Avg`/`Min`/`Max` есть только у
|
||||||
|
`heart_rate`, всё остальное приходит в `qty` (находка 40), заголовок доставки
|
||||||
|
про род молчит, а единицы врут на краях. Просуммировать мгновенную метрику или
|
||||||
|
сложить нижний слой HAE значит завысить ответ втрое — поэтому род измеряется
|
||||||
|
**до** Read API, а не угадывается внутри него.
|
||||||
|
|
||||||
|
Второй пробел того же размера: потребитель не может спросить «что у тебя вообще
|
||||||
|
есть». Слои и их диапазоны — часть контракта (после пересборки старый период
|
||||||
|
законно теряет верхние слои), и узнать их сегодня можно только через sqlite на
|
||||||
|
хосте.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- **Род агрегации измеряется сверкой минутного и часового слоёв между собой**:
|
||||||
|
часовое значение сходится с суммой минутных — метрика накопительная, с
|
||||||
|
арифметическим средним — мгновенная, ни с тем ни с другим или свидетельства
|
||||||
|
противоречат — `unknown`. Измерено на живом архиве (123 доставки, 31 метрика):
|
||||||
|
17 метрик классифицируются, 14 остаются `unknown`, **противоречивых
|
||||||
|
свидетельств ноль**.
|
||||||
|
- **Нижний слой (`raw`) в измерении не участвует и не суммируется никогда** — он
|
||||||
|
посекундная развёртка, а не сэмплы (находка 34).
|
||||||
|
- **Новая ручка `GET /api/v1/metrics`** — каталог: по каждой метрике единицы,
|
||||||
|
измеренный род с основанием измерения и список слоёв с диапазонами и числом
|
||||||
|
точек. Первый маршрут под токеном чтения; появляется проверка этого токена.
|
||||||
|
- **Род нигде не хранится**: он производен от витрины и считается на запрос по
|
||||||
|
ограниченному окну. Ни новой колонки, ни строки в перечне того, что не
|
||||||
|
переносит пересборка.
|
||||||
|
- Схема получает **только индекс** (`00009`): разрезы и границы обязаны
|
||||||
|
отвечать, не разжимая содержимое объектов. Само измерение содержимое читает —
|
||||||
|
иначе сумму минутных значений не получить, — но не больше `2 × 48` объектов на
|
||||||
|
метрику и одной транзакцией чтения на весь ответ.
|
||||||
|
- Тай-брейк при равной полноте точек **в этой задаче не меняется** — вынут
|
||||||
|
блокером: род измеряется из витрины, а витрина есть результат слияния, и
|
||||||
|
правило слияния, читающее собственную выдачу, повторяет дефект вывода слоя из
|
||||||
|
журнала ревью.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
- `catalog`: каталог разрезов и измеренный род агрегации — что за метрики есть,
|
||||||
|
в каких слоях, за какие периоды и какая свёртка по ним осмысленна.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
- `storage`: каталог отвечает по учётным полям объекта, не разжимая `payload`;
|
||||||
|
отсюда требование к стоимости выборки разрезов.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Новый пакет `internal/catalog` — измерение рода и сборка каталога.
|
||||||
|
- `internal/store` — выборка разрезов метрики и общих часов двух слоёв.
|
||||||
|
- `internal/hae` — единственное место, знающее, какое поле точки несёт число.
|
||||||
|
- `internal/httpapi` — маршрут каталога и проверка токена чтения.
|
||||||
|
- `internal/store/migrations/00009_bucket_catalog.sql` — покрывающий индекс.
|
||||||
|
- Документация: `architecture.md` (метод измерения и отвергнутые чужие решения),
|
||||||
|
`database.md` (индекс), `local-research.md` (находка о результате измерения),
|
||||||
|
`config.example.toml` (read_tokens перестали быть заделом на будущее).
|
||||||
@@ -0,0 +1,517 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Каталог разрезов отдаёт наблюдаемое состояние витрины
|
||||||
|
|
||||||
|
Система SHALL отдавать каталог метрик, где по каждой метрике перечислены
|
||||||
|
единицы и слои с границами данных и числом точек. Каталог MUST показывать
|
||||||
|
только то, что в витрине есть: досчитывать отсутствующий слой,
|
||||||
|
экстраполировать границы или помнить о том, чего больше нет, он MUST NOT.
|
||||||
|
|
||||||
|
**Границы слоя — это границы данных, а не обещание покрытия.** Внутри
|
||||||
|
диапазона законно есть дыры: часы, за которые доставок не было, и периоды,
|
||||||
|
верхние слои которых не пережили пересборку. Поэтому правило выбора слоя в
|
||||||
|
Read API MUST опираться на фактические объекты запрошенного диапазона, а не
|
||||||
|
считать каталожную пару границ доказательством непрерывности.
|
||||||
|
|
||||||
|
Слои — часть контракта, а не деталь хранения: без каталога вопрос «в каком
|
||||||
|
разрезе спрашивать» не задать. Часовой объект при этом деталью остаётся, и его
|
||||||
|
число в ответ не идёт.
|
||||||
|
|
||||||
|
Отсюда честность после пересборки: экспорт Apple восстанавливает только слой
|
||||||
|
`sample`, а `minute` и `hour` за периоды с удалёнными доставками не воскресают.
|
||||||
|
Метрика, потерявшая слой целиком, объявляет его отсутствие тем, что слоя нет в
|
||||||
|
списке.
|
||||||
|
|
||||||
|
Метрика с пустым именем — законное значение колонки, и каталог MUST показывать
|
||||||
|
её наравне с остальными: терять на границе, которая отвечает «что у тебя вообще
|
||||||
|
есть», нельзя ничего.
|
||||||
|
|
||||||
|
Единицы отдаются **множеством различных значений** метрики, отсортированным и
|
||||||
|
ограниченным потолком (пустые в множество не входят):
|
||||||
|
на живом потоке они не менялись ни разу, но одна форма поля для обоих случаев
|
||||||
|
честнее строки, которая при расхождении молча выберет одно из двух. На слой при
|
||||||
|
этом приходится **ровно один** элемент списка: объекты слоя с разными единицами
|
||||||
|
дают общий диапазон и общую сумму точек, а различие видно множеством единиц
|
||||||
|
метрики.
|
||||||
|
|
||||||
|
#### Scenario: Метрика лежит в нескольких слоях
|
||||||
|
|
||||||
|
- **WHEN** у метрики есть объекты в слоях `raw`, `minute` и `hour`
|
||||||
|
- **THEN** каталог перечисляет все три слоя, у каждого — границы данных и число
|
||||||
|
точек
|
||||||
|
|
||||||
|
#### Scenario: Слоя за период не осталось
|
||||||
|
|
||||||
|
- **GIVEN** витрина пересобрана, и у метрики остались объекты только слоя
|
||||||
|
`sample`
|
||||||
|
- **WHEN** запрашивается каталог
|
||||||
|
- **THEN** у метрики объявлен слой `sample` и не объявлены `minute` и `hour`
|
||||||
|
|
||||||
|
#### Scenario: Внутри диапазона слоя есть дыра
|
||||||
|
|
||||||
|
- **GIVEN** у метрики есть объекты слоя `minute` за январь и за июнь, а между
|
||||||
|
ними нет ни одного
|
||||||
|
- **WHEN** запрашивается каталог
|
||||||
|
- **THEN** слой `minute` объявлен один раз с границами от января до июня, и
|
||||||
|
каталог не утверждает, что данные есть за весь этот период
|
||||||
|
|
||||||
|
#### Scenario: Единицы метрики разошлись
|
||||||
|
|
||||||
|
- **GIVEN** объекты одной метрики несут разные единицы
|
||||||
|
- **WHEN** запрашивается каталог
|
||||||
|
- **THEN** множество единиц метрики содержит оба значения, а слой остаётся одним
|
||||||
|
элементом списка с объединённым диапазоном и суммой точек
|
||||||
|
|
||||||
|
#### Scenario: Метрика приехала без имени
|
||||||
|
|
||||||
|
- **GIVEN** в витрине есть объекты метрики с пустым именем
|
||||||
|
- **WHEN** запрашивается каталог
|
||||||
|
- **THEN** метрика присутствует в ответе со своими слоями
|
||||||
|
|
||||||
|
#### Scenario: Единиц у метрики стало неправдоподобно много
|
||||||
|
|
||||||
|
- **GIVEN** объекты метрики несут десятки различных строк единиц
|
||||||
|
- **WHEN** запрашивается каталог
|
||||||
|
- **THEN** множество единиц в ответе ограничено потолком, а слой остаётся одним
|
||||||
|
элементом
|
||||||
|
|
||||||
|
#### Scenario: Витрина пуста
|
||||||
|
|
||||||
|
- **WHEN** в витрине нет ни одного объекта
|
||||||
|
- **THEN** каталог отдаёт пустой список метрик, а не отказ
|
||||||
|
|
||||||
|
### Requirement: Форма ответа каталога
|
||||||
|
|
||||||
|
Система SHALL отдавать каталог по маршруту `GET /api/v1/metrics` в виде объекта
|
||||||
|
с полем `metrics`. Каждая запись MUST нести поля `metric`, `units`,
|
||||||
|
`aggregation` и `layers`; элемент `layers` — `layer`, `from`, `to`, `points`;
|
||||||
|
объект `aggregation` — `style`, `hours`, `compared`, `agreeing`, `conflicting`,
|
||||||
|
`first_hour`, `last_hour`.
|
||||||
|
|
||||||
|
Все перечисленные поля MUST присутствовать всегда, в том числе со значением
|
||||||
|
`null`: клиент не должен выводить смысл из наличия или отсутствия ключа. Пустой
|
||||||
|
список MUST отдаваться как `[]`, а не как `null`, и отсутствие измеренного окна
|
||||||
|
— как `null`, а не как нулевая метка времени: правдоподобная дата в ответе
|
||||||
|
неотличима от настоящей.
|
||||||
|
|
||||||
|
Семантика границ различна, поэтому имена различны:
|
||||||
|
|
||||||
|
- `from`/`to` слоя — метки **первой и последней точки** слоя, включительно;
|
||||||
|
- `first_hour`/`last_hour` — **ярлыки часов**, первого и последнего часа окна
|
||||||
|
измерения, включительно.
|
||||||
|
|
||||||
|
Порядок метрик и слоёв в ответе MUST быть детерминированным, чтобы два ответа
|
||||||
|
на одинаковом состоянии витрины совпадали побайтово.
|
||||||
|
|
||||||
|
Поле `style` называет род (`cumulative` / `instant` / `unknown`), а не «kind»:
|
||||||
|
слово `kind` в проекте уже занято родом секции записи (`record.kind`), и два
|
||||||
|
разных смысла под одним именем в одном API — вечная сноска.
|
||||||
|
|
||||||
|
#### Scenario: Пустая витрина отдаётся пустым списком
|
||||||
|
|
||||||
|
- **WHEN** каталог запрашивается на пустой витрине
|
||||||
|
- **THEN** тело ответа — `{"metrics":[]}`
|
||||||
|
|
||||||
|
#### Scenario: Род не измерен
|
||||||
|
|
||||||
|
- **WHEN** у метрики нет общих часов двух слоёв
|
||||||
|
- **THEN** `style` равен `unknown`, `hours` равен нулю, а `first_hour` и
|
||||||
|
`last_hour` равны `null`
|
||||||
|
|
||||||
|
#### Scenario: Два запроса подряд дают один ответ
|
||||||
|
|
||||||
|
- **WHEN** каталог запрашивается дважды на неизменившейся витрине
|
||||||
|
- **THEN** тела ответов совпадают побайтово
|
||||||
|
|
||||||
|
### Requirement: Число точки берётся из одного объявленного поля
|
||||||
|
|
||||||
|
Система SHALL считать числом точки значение поля `qty`, а при его отсутствии —
|
||||||
|
значение поля `Avg`, и MUST NOT выводить число из других полей.
|
||||||
|
|
||||||
|
Порядок именно такой: `qty` несут все метрики, `Avg` — только `heart_rate`, и
|
||||||
|
без второго кандидата самая важная метрика потока не измерялась бы вовсе.
|
||||||
|
|
||||||
|
**Ноль — значение, а не отсутствие.** Правило пустоты, принятое для сравнения
|
||||||
|
полноты точек, здесь неприменимо: там ноль считается пустотой, чтобы точка без
|
||||||
|
измерений не вытесняла настоящее измерение, а тут нулевой час обязан дойти до
|
||||||
|
правила различимости и быть отброшенным им, а не исчезнуть раньше и молча.
|
||||||
|
|
||||||
|
Значение, которое не разбирается как конечное число (строка, `null`, объект,
|
||||||
|
переполнение), считается неприсланным: бесконечность, попавшая в сумму,
|
||||||
|
отравляет и сумму, и среднее всего часа.
|
||||||
|
|
||||||
|
Точка без числа в сумму не входит и число точек часа не увеличивает.
|
||||||
|
|
||||||
|
К `Avg` система переходит только при **отсутствующем или `null`** `qty`. `qty`
|
||||||
|
не того типа означает, что форма точки изменилась, и догадываться о числе не о
|
||||||
|
чем: точка считается не несущей значения целиком.
|
||||||
|
|
||||||
|
#### Scenario: Точка несёт только qty
|
||||||
|
|
||||||
|
- **WHEN** точка имеет вид `{"qty":72.5,"date":"…"}`
|
||||||
|
- **THEN** её число равно `72.5`
|
||||||
|
|
||||||
|
#### Scenario: Точка несёт Min/Avg/Max без qty
|
||||||
|
|
||||||
|
- **WHEN** точка имеет вид `{"Min":60,"Avg":70,"Max":80,"date":"…"}`
|
||||||
|
- **THEN** её число равно значению `Avg`
|
||||||
|
|
||||||
|
#### Scenario: Нулевое значение остаётся значением
|
||||||
|
|
||||||
|
- **WHEN** точка имеет вид `{"qty":0,"date":"…"}`
|
||||||
|
- **THEN** её число равно нулю, и точка считается несущей значение
|
||||||
|
|
||||||
|
#### Scenario: Значение не разбирается как конечное число
|
||||||
|
|
||||||
|
- **WHEN** точка несёт `qty` строкой или числом вне диапазона `float64`
|
||||||
|
- **THEN** точка считается не несущей значения и в сумму не входит
|
||||||
|
|
||||||
|
### Requirement: Род агрегации выводится сверкой минутного и часового слоёв
|
||||||
|
|
||||||
|
Система SHALL выводить род агрегации метрики (`cumulative` / `instant` /
|
||||||
|
`unknown`) сравнением её часового слоя с минутным и MUST NOT определять его по
|
||||||
|
имени метрики, единицам, форме точки или заголовку доставки.
|
||||||
|
|
||||||
|
Час **пригоден** для сверки, когда выполнено всё:
|
||||||
|
|
||||||
|
- у метрики есть объекты обоих слоёв за этот час;
|
||||||
|
- час не лежит в будущем — его метка не позже текущего времени плюс запас;
|
||||||
|
- единицы обоих объектов совпадают;
|
||||||
|
- часовой объект несёт ровно одну точку, и она несёт значение, а её метка
|
||||||
|
совпадает с началом часа;
|
||||||
|
- у минутного объекта не меньше двух точек со значением;
|
||||||
|
- сумма минутных значений **отличима** от их среднего.
|
||||||
|
|
||||||
|
**Горизонт обязателен, и это не защита от вредителя, а условие корректности.**
|
||||||
|
Час объекта берётся из метки в теле доставки, а тело не наше: одна доставка с
|
||||||
|
метками в будущем занимает окно целиком и подменяет измеренный род метрики —
|
||||||
|
построено и прогнано, мгновенная метрика объявлялась накопительной при нуле
|
||||||
|
противоречащих часов. Запас нужен на расхождение часов телефона и сервера.
|
||||||
|
Данные, помеченные будущим, MUST порождать предупреждение владельцу: это либо
|
||||||
|
сбитые часы, либо чужое тело, и оба случая лечатся не кодом.
|
||||||
|
|
||||||
|
**Совпадение единиц обязательно.** Мгновенная метрика, приехавшая минутным
|
||||||
|
слоем в `count/min` и часовым в `count/hour`, даёт в полном часе
|
||||||
|
`часовое = 60 · среднее = сумма` — то есть **уверенный ложный** `cumulative` при
|
||||||
|
нуле противоречащих часов. Правило единогласия этот случай не ловит по
|
||||||
|
построению: противоречия нет, есть молчание.
|
||||||
|
|
||||||
|
**Часовой объект несёт ровно одну точку.** Две точки за час описывают разные
|
||||||
|
интервалы, и какая из них относится к часу целиком — неизвестно; час непригоден
|
||||||
|
целиком, а не «по той, у которой есть значение».
|
||||||
|
|
||||||
|
Требование выравнивания часовой метки закрывает зоны с неполночасовым
|
||||||
|
смещением: слой выводится по выравниванию метки в исходной зоне, а объект
|
||||||
|
адресуется часом UTC, поэтому в зоне `+0530` часовая точка описывает не тот
|
||||||
|
интервал, который покрывают минутные точки того же объекта. Сравнивать их
|
||||||
|
нельзя, и такой час свидетельства не даёт.
|
||||||
|
|
||||||
|
Требование различимости обязательно: в часе, где все значения нули, сумма равна
|
||||||
|
среднему, и совпадение с любой из гипотез не значит ничего.
|
||||||
|
|
||||||
|
Все три сравнения — «сходится с суммой», «сходится со средним», «сумма отличима
|
||||||
|
от среднего» — MUST выполняться **одним предикатом с одним допуском**:
|
||||||
|
относительным, величиной `1e-9`. Тогда час, подтверждающий обе гипотезы сразу,
|
||||||
|
невыразим по построению, и исход не зависит от порядка веток.
|
||||||
|
|
||||||
|
Величина названа числом, потому что от неё зависят счётчики основания в ответе:
|
||||||
|
измерено, что вердикты метрик на живом корпусе одинаковы при допуске от `1e-9`
|
||||||
|
до `1e-3`, а число согласных часов у `heart_rate` при этом меняется с 29 на 49.
|
||||||
|
Взято строгое значение: канонизация содержимого округляет числа до 12 значащих
|
||||||
|
цифр, то есть всё, что крупнее `1e-12`, представлением не объясняется, а
|
||||||
|
`1e-9` оставляет три порядка запаса и остаётся на шесть порядков строже любого
|
||||||
|
содержательного расхождения (сумма и среднее при `n ≥ 2` различаются не меньше
|
||||||
|
чем вдвое).
|
||||||
|
|
||||||
|
Абсолютного порога у сравнения нет намеренно: около нуля относительный допуск
|
||||||
|
вырождается в сторону «не сходится», то есть даёт «свидетельства нет», а не
|
||||||
|
ложный род.
|
||||||
|
|
||||||
|
Вердикт пригодного часа: часовое значение сходится с суммой минутных —
|
||||||
|
`cumulative`, со средним — `instant`, иначе час свидетельства не даёт.
|
||||||
|
|
||||||
|
Сумма минутных значений MUST считаться в порядке возрастания метки точки, чтобы
|
||||||
|
вердикт не зависел от порядка точек внутри объекта.
|
||||||
|
|
||||||
|
#### Scenario: Часовое значение равно сумме минутных
|
||||||
|
|
||||||
|
- **GIVEN** у метрики есть минутный и часовой объекты за один час
|
||||||
|
- **WHEN** часовое значение сходится с суммой минутных значений
|
||||||
|
- **THEN** метрика получает род `cumulative`
|
||||||
|
|
||||||
|
#### Scenario: Часовое значение равно среднему минутных
|
||||||
|
|
||||||
|
- **WHEN** часовое значение сходится со средним минутных значений
|
||||||
|
- **THEN** метрика получает род `instant`
|
||||||
|
|
||||||
|
#### Scenario: Нулевой час свидетельством не является
|
||||||
|
|
||||||
|
- **GIVEN** все минутные значения часа равны нулю, и часовое значение тоже
|
||||||
|
- **WHEN** измеряется род
|
||||||
|
- **THEN** этот час непригоден и в подсчёт согласных не идёт
|
||||||
|
|
||||||
|
#### Scenario: Час лежит в будущем
|
||||||
|
|
||||||
|
- **GIVEN** доставка принесла объекты обоих слоёв с метками позже текущего
|
||||||
|
времени
|
||||||
|
- **WHEN** измеряется род
|
||||||
|
- **THEN** эти часы в окно не входят, род остаётся измеренным по настоящей
|
||||||
|
истории, и владельцу пишется предупреждение
|
||||||
|
|
||||||
|
#### Scenario: Единицы слоёв разошлись
|
||||||
|
|
||||||
|
- **GIVEN** минутный объект часа несёт одни единицы, а часовой — другие
|
||||||
|
- **WHEN** измеряется род
|
||||||
|
- **THEN** час непригоден и свидетельства не даёт
|
||||||
|
|
||||||
|
#### Scenario: Часовой объект несёт две точки
|
||||||
|
|
||||||
|
- **GIVEN** у метрики за час есть часовой объект с двумя точками
|
||||||
|
- **WHEN** измеряется род
|
||||||
|
- **THEN** час непригоден и свидетельства не даёт
|
||||||
|
|
||||||
|
#### Scenario: Минутный объект несёт одну точку
|
||||||
|
|
||||||
|
- **GIVEN** минутный объект часа несёт единственную точку
|
||||||
|
- **WHEN** измеряется род
|
||||||
|
- **THEN** час непригоден: сумма и среднее совпадают, различить гипотезы нечем
|
||||||
|
|
||||||
|
#### Scenario: Метка часовой точки не выровнена на начало часа
|
||||||
|
|
||||||
|
- **GIVEN** часовая точка стоит на середине часа UTC
|
||||||
|
- **WHEN** измеряется род
|
||||||
|
- **THEN** час непригоден и свидетельства не даёт
|
||||||
|
|
||||||
|
#### Scenario: Форма точки на исход не влияет
|
||||||
|
|
||||||
|
- **WHEN** метрика приходит только с полем `qty`, без `Avg`/`Min`/`Max`
|
||||||
|
- **THEN** род всё равно измеряется сверкой слоёв, а не выводится из формы
|
||||||
|
|
||||||
|
### Requirement: Род объявляется только при единогласном свидетельстве
|
||||||
|
|
||||||
|
Система SHALL объявлять род метрики, только если согласных часов не меньше трёх
|
||||||
|
и ни один час не дал противоположного вердикта. В остальных случаях род MUST
|
||||||
|
быть `unknown`, и агрегация по такой метрике предлагаться MUST NOT.
|
||||||
|
|
||||||
|
Наличие противоречащих часов MUST быть записано чекпоинтом уровня `WARN` с
|
||||||
|
именем метрики и числами основания, без значений точек: род — свойство, на
|
||||||
|
котором Read API строит арифметику года, и его смена не имеет права проходить
|
||||||
|
молча. На живом корпусе противоречащих часов не встретилось ни разу, поэтому
|
||||||
|
шума правило не создаёт.
|
||||||
|
|
||||||
|
Единогласие, а не большинство: противоречащий час означает, что одна из гипотез
|
||||||
|
для этой метрики ложна, и объявлять род при известном контрпримере нельзя. Порог
|
||||||
|
в три часа — потому что на этом роде потом суммируют год, а один совпавший час
|
||||||
|
остаётся свидетельством одного часа.
|
||||||
|
|
||||||
|
Следствие принято вслух: род есть функция окна, поэтому час, въехавший в окно,
|
||||||
|
может сменить объявленный род без единой новой доставки за спрошенный период.
|
||||||
|
Клиент, которому это важно, различает случаи по основанию измерения — оно
|
||||||
|
отдаётся вместе с родом.
|
||||||
|
|
||||||
|
#### Scenario: Свидетельства противоречат
|
||||||
|
|
||||||
|
- **GIVEN** у метрики есть часы с вердиктом `cumulative` и часы с вердиктом
|
||||||
|
`instant`
|
||||||
|
- **WHEN** измеряется род
|
||||||
|
- **THEN** род равен `unknown`, число противоречащих часов отдаётся в каталоге,
|
||||||
|
и пишется `WARN` с именем метрики
|
||||||
|
|
||||||
|
#### Scenario: Свидетельств мало
|
||||||
|
|
||||||
|
- **WHEN** согласных часов меньше трёх
|
||||||
|
- **THEN** род равен `unknown`
|
||||||
|
|
||||||
|
#### Scenario: Второго слоя нет вовсе
|
||||||
|
|
||||||
|
- **WHEN** метрика лежит только в одном слое
|
||||||
|
- **THEN** род равен `unknown`, а число часов окна равно нулю
|
||||||
|
|
||||||
|
### Requirement: Нижний слой в измерении не участвует
|
||||||
|
|
||||||
|
Система SHALL измерять род только по слоям `minute` и `hour` и MUST NOT
|
||||||
|
использовать в сверке слои `raw`, `sample` и `day`.
|
||||||
|
|
||||||
|
Нижний слой HAE — не сэмплы, а посекундная развёртка настоящих сэмплов с
|
||||||
|
инфляцией до 478×: его сумма завышена и в сверке не сходится. Слой `sample`
|
||||||
|
несёт собственные интервалы сэмплов, и его сверка с часовым слоем — другая
|
||||||
|
задача, вместе с импортом родного экспорта. Слой `day` — суточная сводка сна,
|
||||||
|
другая схема под тем же именем, а не разрез часов.
|
||||||
|
|
||||||
|
#### Scenario: Метрика есть только в нижнем слое
|
||||||
|
|
||||||
|
- **WHEN** у метрики есть объекты только в слое `raw`
|
||||||
|
- **THEN** род равен `unknown`
|
||||||
|
|
||||||
|
#### Scenario: Нижний слой не подменяет минутный
|
||||||
|
|
||||||
|
- **GIVEN** у метрики есть слои `raw` и `hour`, но нет `minute`
|
||||||
|
- **WHEN** измеряется род
|
||||||
|
- **THEN** сверка не выполняется и род равен `unknown`
|
||||||
|
|
||||||
|
#### Scenario: Метрика лежит только в суточном слое
|
||||||
|
|
||||||
|
- **WHEN** у метрики есть объекты только слоя `day`
|
||||||
|
- **THEN** слой объявлен в каталоге, а род равен `unknown`
|
||||||
|
|
||||||
|
### Requirement: Каталог отдаёт основание измерения, а не только вывод
|
||||||
|
|
||||||
|
Система SHALL отдавать вместе с родом четыре числа и границы окна, и клиент MUST
|
||||||
|
иметь возможность отличить «свидетельств не было» от «свидетельства
|
||||||
|
противоречат», не делая второго запроса.
|
||||||
|
|
||||||
|
Числа определены так, что их разность осмысленна:
|
||||||
|
|
||||||
|
- `hours` — сколько общих часов двух слоёв попало в окно;
|
||||||
|
- `compared` — сколько из них оказалось **пригодными**;
|
||||||
|
- `agreeing` — сколько пригодных часов дали **преобладающий** вердикт (при
|
||||||
|
объявленном роде это он и есть);
|
||||||
|
- `conflicting` — сколько дали другой.
|
||||||
|
|
||||||
|
Разложение одно и то же независимо от того, объявлен род или нет: иначе
|
||||||
|
`agreeing` пришлось бы толковать по-разному в двух ветках, и клиент читал бы
|
||||||
|
одно поле двумя способами.
|
||||||
|
|
||||||
|
Разность `compared − agreeing − conflicting` — часы, не сошедшиеся ни с одной
|
||||||
|
гипотезой; разность `hours − compared` — часы, отброшенные проверкой
|
||||||
|
пригодности. Без этого различения `hours` в одиночку выдавал бы «измерение шло,
|
||||||
|
данные молчат» там, где ни один час не был пригоден вовсе.
|
||||||
|
|
||||||
|
`first_hour` и `last_hour` — границы окна; род объявляется вместе с периодом, на
|
||||||
|
котором измерен, потому что окно ограничено самыми свежими общими часами, а не
|
||||||
|
всей историей.
|
||||||
|
|
||||||
|
#### Scenario: Род измерен
|
||||||
|
|
||||||
|
- **WHEN** метрика получила род `cumulative`
|
||||||
|
- **THEN** рядом стоят число часов окна, число пригодных, число согласных, ноль
|
||||||
|
противоречащих и границы окна
|
||||||
|
|
||||||
|
#### Scenario: Часы были, но ни один не пригоден
|
||||||
|
|
||||||
|
- **WHEN** все часы окна отброшены проверкой пригодности
|
||||||
|
- **THEN** `hours` больше нуля, `compared` равен нулю, род равен `unknown`
|
||||||
|
|
||||||
|
### Requirement: Окно измерения ограничено сорока восемью часами
|
||||||
|
|
||||||
|
Система SHALL измерять род по не более чем 48 самым свежим общим часам метрики
|
||||||
|
и MUST NOT читать ради этого всю историю: стоимость каталога не имеет права
|
||||||
|
расти вместе с журналом.
|
||||||
|
|
||||||
|
Число названо в спеке, а не оставлено реализации, по той же причине, что и
|
||||||
|
порог согласных часов: от него зависят счётчики основания в ответе.
|
||||||
|
|
||||||
|
Измерено, что на живом корпусе окно сохраняет вердикты всех метрик, кроме
|
||||||
|
редких: у `physical_effort` за всю историю набиралось пять согласных часов, а в
|
||||||
|
последних сорока восьми — два, и метрика честно уходит в `unknown`. Это не
|
||||||
|
издержка, а то же правило: свидетельств в свежем окне действительно мало.
|
||||||
|
|
||||||
|
Окно ограничено и сверху — часами не позже текущего времени плюс запас, см.
|
||||||
|
правило пригодности часа.
|
||||||
|
|
||||||
|
#### Scenario: История длиннее окна
|
||||||
|
|
||||||
|
- **GIVEN** у метрики общих часов больше сорока восьми
|
||||||
|
- **WHEN** измеряется род
|
||||||
|
- **THEN** сравниваются только сорок восемь самых свежих, и `hours` равен
|
||||||
|
сорока восьми
|
||||||
|
|
||||||
|
### Requirement: Измеренный род нигде не сохраняется
|
||||||
|
|
||||||
|
Система SHALL вычислять род при каждом запросе каталога и MUST NOT хранить его
|
||||||
|
ни колонкой, ни кешем.
|
||||||
|
|
||||||
|
Хранимое значение было бы вторым производным состоянием рядом с витриной: его
|
||||||
|
пришлось бы пересчитывать после каждой свёртки, переносить или не переносить
|
||||||
|
пересборкой и объяснять, на каком составе данных оно снято; устаревшее значение
|
||||||
|
при этом выглядит ровно как свежее. Вычисленный на запрос род есть функция
|
||||||
|
витрины, а витрина — функция журнала, и устаревать в нём нечему.
|
||||||
|
|
||||||
|
#### Scenario: Новая доставка меняет род без перезапуска
|
||||||
|
|
||||||
|
- **GIVEN** метрика числится `unknown`, потому что общих часов было мало
|
||||||
|
- **WHEN** приезжает доставка, добавляющая согласные часы, и каталог
|
||||||
|
запрашивается снова
|
||||||
|
- **THEN** ответ отдаёт новый род, и перезапуск сервиса для этого не нужен
|
||||||
|
|
||||||
|
### Requirement: Каталог читается одним снимком витрины
|
||||||
|
|
||||||
|
Система SHALL собирать ответ каталога из одного снимка базы: разрезы, границы и
|
||||||
|
объекты окна измерения MUST читаться в одной транзакции чтения.
|
||||||
|
|
||||||
|
Приём идёт непрерывно, и фоновая свёртка пишет в витрину во время запроса.
|
||||||
|
Запросы вне общей транзакции дали бы смесь «разрезы до» и «род после» — ответ,
|
||||||
|
внутренне противоречивый и неотличимый от обычного свежего.
|
||||||
|
|
||||||
|
Число обращений к хранилищу на один запрос каталога MUST быть ограничено
|
||||||
|
константой на метрику и не зависеть от размера окна: чтение объектов окна по
|
||||||
|
одному даёт тысячи обращений там, где хватает двух на метрику.
|
||||||
|
|
||||||
|
#### Scenario: Доставка приезжает во время сборки каталога
|
||||||
|
|
||||||
|
- **GIVEN** каталог собирается, и в этот момент фоновая свёртка пишет объекты
|
||||||
|
- **WHEN** ответ сформирован
|
||||||
|
- **THEN** он целиком описывает одно состояние витрины
|
||||||
|
|
||||||
|
#### Scenario: Размер окна не умножает число запросов
|
||||||
|
|
||||||
|
- **WHEN** окно измерения увеличено
|
||||||
|
- **THEN** число обращений к хранилищу на метрику не меняется
|
||||||
|
|
||||||
|
### Requirement: Каталог доступен по токену чтения
|
||||||
|
|
||||||
|
Система SHALL требовать токен чтения на маршруте каталога и MUST NOT принимать
|
||||||
|
на нём токен приёма. Токен MUST передаваться заголовком `Authorization` со
|
||||||
|
схемой `Bearer`; значение без этой схемы токеном не считается.
|
||||||
|
|
||||||
|
Пустой список токенов чтения означает выключенную проверку, и о выключенной
|
||||||
|
проверке сервис предупреждает на старте — тем же способом, что о выключенной
|
||||||
|
проверке приёма. Цена симметрии названа вслух: у приёма открытый контур означает
|
||||||
|
мусор во входе, у чтения — выгрузку данных о здоровье, поэтому перед выкладкой
|
||||||
|
наружу список обязан быть непуст. Отвечает за это отдельная задача об управлении
|
||||||
|
секретами; здесь фиксируется, что предупреждение существует и адресовано
|
||||||
|
владельцу.
|
||||||
|
|
||||||
|
Токен чтения MUST вычищаться из сохраняемых заголовков доставки наравне с
|
||||||
|
токеном приёма: заголовок с произвольным именем иначе донесёт его до базы.
|
||||||
|
|
||||||
|
Контуры раздельны по архитектуре: клиент, читающий данные, писать не может, и
|
||||||
|
обратное тоже неверно.
|
||||||
|
|
||||||
|
#### Scenario: Запрос без токена при заданном списке
|
||||||
|
|
||||||
|
- **GIVEN** список токенов чтения непуст
|
||||||
|
- **WHEN** каталог запрашивается без заголовка `Authorization`
|
||||||
|
- **THEN** ответ — 401, и данные не отдаются
|
||||||
|
|
||||||
|
#### Scenario: Токен приёма каталога не открывает
|
||||||
|
|
||||||
|
- **GIVEN** заданы разные списки токенов приёма и чтения
|
||||||
|
- **WHEN** каталог запрашивается с токеном приёма
|
||||||
|
- **THEN** ответ — 401
|
||||||
|
|
||||||
|
#### Scenario: Токен без схемы Bearer
|
||||||
|
|
||||||
|
- **GIVEN** список токенов чтения непуст
|
||||||
|
- **WHEN** каталог запрашивается с заголовком `Authorization`, где стоит голое
|
||||||
|
значение токена без слова `Bearer`
|
||||||
|
- **THEN** ответ — 401
|
||||||
|
|
||||||
|
#### Scenario: Проверка выключена
|
||||||
|
|
||||||
|
- **GIVEN** список токенов чтения пуст
|
||||||
|
- **WHEN** каталог запрашивается без заголовка `Authorization`
|
||||||
|
- **THEN** каталог отдаётся
|
||||||
|
|
||||||
|
#### Scenario: О выключенной проверке предупреждают на старте
|
||||||
|
|
||||||
|
- **GIVEN** список токенов чтения пуст
|
||||||
|
- **WHEN** сервис стартует
|
||||||
|
- **THEN** в логе появляется предупреждение владельцу
|
||||||
|
|
||||||
|
#### Scenario: Токен чтения не оседает в учёте доставки
|
||||||
|
|
||||||
|
- **GIVEN** токен чтения послан на маршрут приёма заголовком с произвольным
|
||||||
|
именем
|
||||||
|
- **WHEN** доставка учтена
|
||||||
|
- **THEN** в сохранённых заголовках вместо значения стоит пометка о сокрытии
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Перечисление разрезов не читает содержимое объектов
|
||||||
|
|
||||||
|
Хранилище SHALL отвечать на вопрос «какие слои есть у метрики, за какой период и
|
||||||
|
сколько в них точек» по учётным колонкам объекта, не разжимая `payload` и не
|
||||||
|
затрагивая страниц с содержимым. Тот же запрет действует на поиск часов, за
|
||||||
|
которые у метрики есть объекты сразу в двух слоях.
|
||||||
|
|
||||||
|
Запрет ограничен именно этими двумя выборками. Измерение рода обязано прочитать
|
||||||
|
значения точек, то есть разжать содержимое объектов окна, и требование его не
|
||||||
|
касается — иначе оно запрещало бы то, ради чего каталог существует.
|
||||||
|
|
||||||
|
Причина в форме таблицы: `bucket` объявлена `WITHOUT ROWID`, то есть строка
|
||||||
|
целиком, вместе со сжатым содержимым, живёт в дереве первичного ключа. Обход
|
||||||
|
всех строк ради агрегата тащил бы за собой страницы содержимого — при 260 тысячах
|
||||||
|
объектов за год это сотни мегабайт на каждый запрос каталога, притом что сам
|
||||||
|
ответ несёт три десятка строк.
|
||||||
|
|
||||||
|
Поэтому колонки, по которым отвечают эти выборки, MUST быть покрыты индексом, и
|
||||||
|
новая колонка, попадающая в ответ каталога, входит в него тем же изменением.
|
||||||
|
|
||||||
|
#### Scenario: Разрезы метрики за длинную историю
|
||||||
|
|
||||||
|
- **GIVEN** в витрине объекты за многие месяцы
|
||||||
|
- **WHEN** запрашиваются слои метрики с границами и числом точек
|
||||||
|
- **THEN** запрос отвечает по индексу, не читая содержимого объектов
|
||||||
|
|
||||||
|
#### Scenario: Общие часы двух слоёв
|
||||||
|
|
||||||
|
- **WHEN** запрашиваются самые свежие часы, за которые у метрики есть объекты и
|
||||||
|
в минутном, и в часовом слое
|
||||||
|
- **THEN** запрос отвечает по индексу и читает не больше запрошенного числа
|
||||||
|
часов
|
||||||
|
|
||||||
|
### Requirement: Объекты перечисленных часов читаются пакетом
|
||||||
|
|
||||||
|
Хранилище SHALL уметь отдать объекты двух слоёв за перечисленные часы одной
|
||||||
|
метрики **одним запросом**, а не по объекту за раз.
|
||||||
|
|
||||||
|
Чтение по одному даёт число обращений, растущее вместе с окном измерения, и
|
||||||
|
делает каждое обращение собственной транзакцией — то есть ответ, собранный из
|
||||||
|
разных снимков витрины под непрерывным приёмом.
|
||||||
|
|
||||||
|
#### Scenario: Окно из многих часов
|
||||||
|
|
||||||
|
- **GIVEN** запрошены объекты двух слоёв за сорок восемь часов
|
||||||
|
- **WHEN** выполняется выборка
|
||||||
|
- **THEN** число обращений к базе не зависит от числа часов
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
## 1. Схема
|
||||||
|
|
||||||
|
- [x] 1.1 Миграция `00009_bucket_catalog.sql` — покрывающий индекс
|
||||||
|
`bucket(metric, layer, hour_utc, first_ts, last_ts, points, units)`
|
||||||
|
- [x] 1.2 Обновить `docs/database.md`: индекс и зачем он
|
||||||
|
|
||||||
|
## 2. Хранилище
|
||||||
|
|
||||||
|
- [x] 2.1 `store.ReadCatalog(ctx, store.CatalogWindow)` — весь вход каталога
|
||||||
|
**одной транзакцией чтения**: разрезы всех метрик, общие часы пары слоёв
|
||||||
|
по каждой метрике, объекты окна обоих слоёв
|
||||||
|
- [x] 2.2 Разрезы — одним запросом `metric, layer, units, min(first_ts),
|
||||||
|
max(last_ts), sum(points)`, группировка вместе с единицами (расхождение
|
||||||
|
видно, а не выбирается молча)
|
||||||
|
- [x] 2.3 Общие часы — самые свежие часы с объектами обоих слоёв, от свежих к
|
||||||
|
старым, не больше `window`
|
||||||
|
- [x] 2.4 Объекты окна — **пакетом**, один запрос на метрику на оба слоя
|
||||||
|
(`hour_utc IN (…)`), а не по объекту за раз
|
||||||
|
- [x] 2.5 Тест: разрезы и общие часы отвечают по индексу (`EXPLAIN QUERY PLAN`
|
||||||
|
не содержит обхода таблицы)
|
||||||
|
- [x] 2.6 Тест: число обращений к базе на метрику не зависит от размера окна
|
||||||
|
- [x] 2.7 Тест: `CommonHours` отдаёт не больше `window` и именно свежие часы
|
||||||
|
|
||||||
|
## 3. Значение точки
|
||||||
|
|
||||||
|
- [x] 3.1 `hae.PointValue(raw)` — число точки: `qty`, при его отсутствии `Avg`;
|
||||||
|
разбор через `*json.Number`, **ноль — значение**, отсутствие ключа и
|
||||||
|
`null` — нет значения, нечисловое и не влезающее в `float64` (`ErrRange`,
|
||||||
|
`±Inf`) — нет значения
|
||||||
|
- [x] 3.2 В док-комментарии сказать, что словарь пустоты `canon` сюда не
|
||||||
|
применяется, и почему
|
||||||
|
- [x] 3.3 Тесты на реальных формах точки из `testdata`: `heart_rate` с
|
||||||
|
`Min`/`Avg`/`Max` без `qty`, `{"qty":0}`, точка без числового поля,
|
||||||
|
`1e400`
|
||||||
|
|
||||||
|
## 4. Измерение рода
|
||||||
|
|
||||||
|
- [x] 4.1 Пакет `internal/catalog`: тип рода (`cumulative`/`instant`/`unknown`,
|
||||||
|
пустое значение невыразимо) и основание измерения
|
||||||
|
(`hours`/`compared`/`agreeing`/`conflicting`/границы окна)
|
||||||
|
- [x] 4.2 Именованная константа допуска `1e-9` с измеренной ценой в комментарии
|
||||||
|
и **один** предикат `close(a, b)`, которым выражены все три сравнения
|
||||||
|
- [x] 4.3 Пригодность часа: ровно одна точка со значением у часового объекта,
|
||||||
|
её метка совпадает с началом часа, ≥2 точек со значением у минутного,
|
||||||
|
сумма отличима от среднего
|
||||||
|
- [x] 4.4 Сумма минутных значений считается в порядке возрастания метки
|
||||||
|
- [x] 4.5 Правило метрики: ≥3 согласных и 0 противоречащих, иначе `unknown`
|
||||||
|
- [x] 4.6 Окно 48 самых свежих общих часов
|
||||||
|
- [x] 4.7 Сборка каталога: разрезы из снимка + род из измерения; строки слоя,
|
||||||
|
разошедшиеся единицами, схлопываются в один элемент, множество единиц
|
||||||
|
метрики отсортировано
|
||||||
|
- [x] 4.8 Чекпоинт `WARN` при `conflicting > 0`: имя метрики и числа основания,
|
||||||
|
без значений точек
|
||||||
|
- [x] 4.9 Тесты: накопительная, мгновенная, нулевой час, двухточечный часовой
|
||||||
|
объект, одноточечный минутный, невыровненная часовая метка, противоречие,
|
||||||
|
единственный слой, только слой `day`, история длиннее окна, разошедшиеся
|
||||||
|
единицы, идемпотентность двух вызовов
|
||||||
|
|
||||||
|
## 5. HTTP
|
||||||
|
|
||||||
|
- [x] 5.1 Проверка токена **одна на оба контура**, параметризованная списком;
|
||||||
|
схема строгая (`Bearer`), пустой список = выключено
|
||||||
|
- [x] 5.2 Предупреждение на старте о выключенной проверке чтения
|
||||||
|
- [x] 5.3 Редакция сохраняемых заголовков доставки чистит токены **обоих**
|
||||||
|
контуров
|
||||||
|
- [x] 5.4 `GET /api/v1/metrics` — форма ответа из дизайна: `style`, `hours`,
|
||||||
|
`compared`, `agreeing`, `conflicting`, `first_hour`, `last_hour`; срезы
|
||||||
|
пустые, а не nil; границы окна — указатели; порядок детерминирован
|
||||||
|
- [x] 5.5 Тесты: 401 без токена, 401 с токеном приёма, 401 с голым значением без
|
||||||
|
схемы, отдача при выключенной проверке, пустая витрина — **сравнением
|
||||||
|
байтов** ответа с литералом
|
||||||
|
- [x] 5.6 `config.example.toml` и `config.docker.toml`: `read_tokens` перестал
|
||||||
|
быть заделом на будущее, цена пустого списка названа комментарием
|
||||||
|
|
||||||
|
## 6. Проверка на живом архиве
|
||||||
|
|
||||||
|
- [x] 6.1 Прогон измерения в `internal/replay/archive_test.go`: свойства, а не
|
||||||
|
числа — конфликтующих свидетельств ноль; накопительные и мгновенные
|
||||||
|
метрики разошлись по родам; ни одна метрика не измерена по слою `raw`
|
||||||
|
- [x] 6.2 Печать измеренного рода по метрикам и стоимости каталога в `t.Logf`
|
||||||
|
- [x] 6.3 `task verify:archive` зелёный, отпечаток витрины не изменился
|
||||||
|
|
||||||
|
## 7. Документация и беклог
|
||||||
|
|
||||||
|
- [x] 7.1 `docs/architecture.md`: метод измерения, окно, порог, допуск, почему
|
||||||
|
род не хранится, почему родов два, а не четыре, где нужен `xFilesFactor` и
|
||||||
|
какой у него подвох с полярностью
|
||||||
|
- [x] 7.2 `docs/architecture.md`: форма каталога приведена к реализованной
|
||||||
|
- [x] 7.3 `docs/local-research.md`: находка с результатом измерения на живом
|
||||||
|
корпусе
|
||||||
|
- [x] 7.4 Блокер «тай-брейк при равной полноте точек» в беклог, с вариантами,
|
||||||
|
ценой и рекомендацией
|
||||||
|
- [x] 7.5 Пометка в `docs/backlog/read-api-tochki.md`: порог неполного ведра,
|
||||||
|
его полярность и предел размера ответа решаются там
|
||||||
|
- [x] 7.6 Пометка в `docs/backlog/upravlenie-sekretami.md`: контуров теперь два
|
||||||
|
- [x] 7.7 Убрать задачу из беклога, обновить индекс
|
||||||
|
|
||||||
|
## 8. Дозакрыто по ревью кода
|
||||||
|
|
||||||
|
- [x] 8.0 Горизонт окна: часы позже `now + час` в сверку не входят, данные из
|
||||||
|
будущего пишутся `WARN`
|
||||||
|
- [x] 8.0 Единицы обеих сторон обязаны совпасть — иначе уверенный ложный род
|
||||||
|
- [x] 8.0 Содержимое разжимается только у часов, прошедших отбор по учётным
|
||||||
|
колонкам
|
||||||
|
- [x] 8.0 Имя метрики в логе обрезано, множество единиц ограничено потолком
|
||||||
|
- [x] 8.0 Метрика с пустым именем показывается, а не выбрасывается сентинелом
|
||||||
|
- [x] 8.0 Отмена снаружи не пишется как сбой сервиса
|
||||||
|
- [x] 8.0 Байтовый тест непустого ответа и повтора запроса
|
||||||
|
|
||||||
|
## 9. Приёмочные критерии (рубрика ревью предложения)
|
||||||
|
|
||||||
|
- [x] 9.1 Вердикт — чистая функция состояния витрины и окна: не зависит от
|
||||||
|
порядка строк SQL, порядка точек в объекте и момента вызова
|
||||||
|
- [x] 9.2 Допуск назван величиной, один предикат на все сравнения, поведение
|
||||||
|
около нуля объявлено
|
||||||
|
- [x] 9.3 Вырожденные свидетельства исключены явно, кворум назван числом, ниже
|
||||||
|
кворума исход — `unknown`, а не умолчание
|
||||||
|
- [x] 9.4 `unknown` — исход первого класса, и его причины различимы клиентом без
|
||||||
|
второго запроса
|
||||||
|
- [x] 9.5 Измерение ничего не пишет и не кешируется скрытно
|
||||||
|
- [x] 9.6 Стоимость ответа ограничена сверху и по числу запросов, и по числу
|
||||||
|
прочитанных страниц; не растёт вместе с историей
|
||||||
|
- [x] 9.7 HTTP-контракт полон: только `GET`, пустая витрина — 200 с пустым
|
||||||
|
списком, авторизация до работы, токены и значения здоровья не в логах
|
||||||
|
выше `DEBUG`
|
||||||
|
- [x] 9.8 Ответ самоописателен: словарь слоёв тот же, что везде; границы
|
||||||
|
объявляют, что метят; расхождение единиц показано, а не выбрано молча
|
||||||
|
- [x] 9.9 Каталог отдаёт наблюдаемое, а не досчитанное; деталь хранения наружу
|
||||||
|
не протекает
|
||||||
|
- [x] 9.10 Нижний слой в сверке не участвует; `source` в измерение не входит
|
||||||
|
- [x] 9.11 Смена вердикта наблюдаема чекпоинтом
|
||||||
|
- [x] 9.12 Правило часа и правило метрики тестируются без БД; на живом архиве
|
||||||
|
проверяются свойства, а не числа
|
||||||
@@ -0,0 +1,526 @@
|
|||||||
|
# catalog Specification
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Отвечает потребителю на два вопроса: «что у тебя вообще есть» — метрики,
|
||||||
|
единицы, слои с границами данных и числом точек — и «какая свёртка по этой
|
||||||
|
метрике осмысленна». Второй ответ **измеряется** сверкой минутного слоя с
|
||||||
|
часовым, а не размечается руками: HAE рода не шлёт, и всё, что можно было бы
|
||||||
|
объявить, пришлось бы угадать. Род неизвестен — свёртка не предлагается вовсе.
|
||||||
|
## Requirements
|
||||||
|
### Requirement: Каталог разрезов отдаёт наблюдаемое состояние витрины
|
||||||
|
|
||||||
|
Система SHALL отдавать каталог метрик, где по каждой метрике перечислены
|
||||||
|
единицы и слои с границами данных и числом точек. Каталог MUST показывать
|
||||||
|
только то, что в витрине есть: досчитывать отсутствующий слой,
|
||||||
|
экстраполировать границы или помнить о том, чего больше нет, он MUST NOT.
|
||||||
|
|
||||||
|
**Границы слоя — это границы данных, а не обещание покрытия.** Внутри
|
||||||
|
диапазона законно есть дыры: часы, за которые доставок не было, и периоды,
|
||||||
|
верхние слои которых не пережили пересборку. Поэтому правило выбора слоя в
|
||||||
|
Read API MUST опираться на фактические объекты запрошенного диапазона, а не
|
||||||
|
считать каталожную пару границ доказательством непрерывности.
|
||||||
|
|
||||||
|
Слои — часть контракта, а не деталь хранения: без каталога вопрос «в каком
|
||||||
|
разрезе спрашивать» не задать. Часовой объект при этом деталью остаётся, и его
|
||||||
|
число в ответ не идёт.
|
||||||
|
|
||||||
|
Отсюда честность после пересборки: экспорт Apple восстанавливает только слой
|
||||||
|
`sample`, а `minute` и `hour` за периоды с удалёнными доставками не воскресают.
|
||||||
|
Метрика, потерявшая слой целиком, объявляет его отсутствие тем, что слоя нет в
|
||||||
|
списке.
|
||||||
|
|
||||||
|
Метрика с пустым именем — законное значение колонки, и каталог MUST показывать
|
||||||
|
её наравне с остальными: терять на границе, которая отвечает «что у тебя вообще
|
||||||
|
есть», нельзя ничего.
|
||||||
|
|
||||||
|
Единицы отдаются **множеством различных значений** метрики, отсортированным и
|
||||||
|
ограниченным потолком (пустые в множество не входят):
|
||||||
|
на живом потоке они не менялись ни разу, но одна форма поля для обоих случаев
|
||||||
|
честнее строки, которая при расхождении молча выберет одно из двух. На слой при
|
||||||
|
этом приходится **ровно один** элемент списка: объекты слоя с разными единицами
|
||||||
|
дают общий диапазон и общую сумму точек, а различие видно множеством единиц
|
||||||
|
метрики.
|
||||||
|
|
||||||
|
#### Scenario: Метрика лежит в нескольких слоях
|
||||||
|
|
||||||
|
- **WHEN** у метрики есть объекты в слоях `raw`, `minute` и `hour`
|
||||||
|
- **THEN** каталог перечисляет все три слоя, у каждого — границы данных и число
|
||||||
|
точек
|
||||||
|
|
||||||
|
#### Scenario: Слоя за период не осталось
|
||||||
|
|
||||||
|
- **GIVEN** витрина пересобрана, и у метрики остались объекты только слоя
|
||||||
|
`sample`
|
||||||
|
- **WHEN** запрашивается каталог
|
||||||
|
- **THEN** у метрики объявлен слой `sample` и не объявлены `minute` и `hour`
|
||||||
|
|
||||||
|
#### Scenario: Внутри диапазона слоя есть дыра
|
||||||
|
|
||||||
|
- **GIVEN** у метрики есть объекты слоя `minute` за январь и за июнь, а между
|
||||||
|
ними нет ни одного
|
||||||
|
- **WHEN** запрашивается каталог
|
||||||
|
- **THEN** слой `minute` объявлен один раз с границами от января до июня, и
|
||||||
|
каталог не утверждает, что данные есть за весь этот период
|
||||||
|
|
||||||
|
#### Scenario: Единицы метрики разошлись
|
||||||
|
|
||||||
|
- **GIVEN** объекты одной метрики несут разные единицы
|
||||||
|
- **WHEN** запрашивается каталог
|
||||||
|
- **THEN** множество единиц метрики содержит оба значения, а слой остаётся одним
|
||||||
|
элементом списка с объединённым диапазоном и суммой точек
|
||||||
|
|
||||||
|
#### Scenario: Метрика приехала без имени
|
||||||
|
|
||||||
|
- **GIVEN** в витрине есть объекты метрики с пустым именем
|
||||||
|
- **WHEN** запрашивается каталог
|
||||||
|
- **THEN** метрика присутствует в ответе со своими слоями
|
||||||
|
|
||||||
|
#### Scenario: Единиц у метрики стало неправдоподобно много
|
||||||
|
|
||||||
|
- **GIVEN** объекты метрики несут десятки различных строк единиц
|
||||||
|
- **WHEN** запрашивается каталог
|
||||||
|
- **THEN** множество единиц в ответе ограничено потолком, а слой остаётся одним
|
||||||
|
элементом
|
||||||
|
|
||||||
|
#### Scenario: Витрина пуста
|
||||||
|
|
||||||
|
- **WHEN** в витрине нет ни одного объекта
|
||||||
|
- **THEN** каталог отдаёт пустой список метрик, а не отказ
|
||||||
|
|
||||||
|
### Requirement: Форма ответа каталога
|
||||||
|
|
||||||
|
Система SHALL отдавать каталог по маршруту `GET /api/v1/metrics` в виде объекта
|
||||||
|
с полем `metrics`. Каждая запись MUST нести поля `metric`, `units`,
|
||||||
|
`aggregation` и `layers`; элемент `layers` — `layer`, `from`, `to`, `points`;
|
||||||
|
объект `aggregation` — `style`, `hours`, `compared`, `agreeing`, `conflicting`,
|
||||||
|
`first_hour`, `last_hour`.
|
||||||
|
|
||||||
|
Все перечисленные поля MUST присутствовать всегда, в том числе со значением
|
||||||
|
`null`: клиент не должен выводить смысл из наличия или отсутствия ключа. Пустой
|
||||||
|
список MUST отдаваться как `[]`, а не как `null`, и отсутствие измеренного окна
|
||||||
|
— как `null`, а не как нулевая метка времени: правдоподобная дата в ответе
|
||||||
|
неотличима от настоящей.
|
||||||
|
|
||||||
|
Семантика границ различна, поэтому имена различны:
|
||||||
|
|
||||||
|
- `from`/`to` слоя — метки **первой и последней точки** слоя, включительно;
|
||||||
|
- `first_hour`/`last_hour` — **ярлыки часов**, первого и последнего часа окна
|
||||||
|
измерения, включительно.
|
||||||
|
|
||||||
|
Порядок метрик и слоёв в ответе MUST быть детерминированным, чтобы два ответа
|
||||||
|
на одинаковом состоянии витрины совпадали побайтово.
|
||||||
|
|
||||||
|
Поле `style` называет род (`cumulative` / `instant` / `unknown`), а не «kind»:
|
||||||
|
слово `kind` в проекте уже занято родом секции записи (`record.kind`), и два
|
||||||
|
разных смысла под одним именем в одном API — вечная сноска.
|
||||||
|
|
||||||
|
#### Scenario: Пустая витрина отдаётся пустым списком
|
||||||
|
|
||||||
|
- **WHEN** каталог запрашивается на пустой витрине
|
||||||
|
- **THEN** тело ответа — `{"metrics":[]}`
|
||||||
|
|
||||||
|
#### Scenario: Род не измерен
|
||||||
|
|
||||||
|
- **WHEN** у метрики нет общих часов двух слоёв
|
||||||
|
- **THEN** `style` равен `unknown`, `hours` равен нулю, а `first_hour` и
|
||||||
|
`last_hour` равны `null`
|
||||||
|
|
||||||
|
#### Scenario: Два запроса подряд дают один ответ
|
||||||
|
|
||||||
|
- **WHEN** каталог запрашивается дважды на неизменившейся витрине
|
||||||
|
- **THEN** тела ответов совпадают побайтово
|
||||||
|
|
||||||
|
### Requirement: Число точки берётся из одного объявленного поля
|
||||||
|
|
||||||
|
Система SHALL считать числом точки значение поля `qty`, а при его отсутствии —
|
||||||
|
значение поля `Avg`, и MUST NOT выводить число из других полей.
|
||||||
|
|
||||||
|
Порядок именно такой: `qty` несут все метрики, `Avg` — только `heart_rate`, и
|
||||||
|
без второго кандидата самая важная метрика потока не измерялась бы вовсе.
|
||||||
|
|
||||||
|
**Ноль — значение, а не отсутствие.** Правило пустоты, принятое для сравнения
|
||||||
|
полноты точек, здесь неприменимо: там ноль считается пустотой, чтобы точка без
|
||||||
|
измерений не вытесняла настоящее измерение, а тут нулевой час обязан дойти до
|
||||||
|
правила различимости и быть отброшенным им, а не исчезнуть раньше и молча.
|
||||||
|
|
||||||
|
Значение, которое не разбирается как конечное число (строка, `null`, объект,
|
||||||
|
переполнение), считается неприсланным: бесконечность, попавшая в сумму,
|
||||||
|
отравляет и сумму, и среднее всего часа.
|
||||||
|
|
||||||
|
Точка без числа в сумму не входит и число точек часа не увеличивает.
|
||||||
|
|
||||||
|
К `Avg` система переходит только при **отсутствующем или `null`** `qty`. `qty`
|
||||||
|
не того типа означает, что форма точки изменилась, и догадываться о числе не о
|
||||||
|
чем: точка считается не несущей значения целиком.
|
||||||
|
|
||||||
|
#### Scenario: Точка несёт только qty
|
||||||
|
|
||||||
|
- **WHEN** точка имеет вид `{"qty":72.5,"date":"…"}`
|
||||||
|
- **THEN** её число равно `72.5`
|
||||||
|
|
||||||
|
#### Scenario: Точка несёт Min/Avg/Max без qty
|
||||||
|
|
||||||
|
- **WHEN** точка имеет вид `{"Min":60,"Avg":70,"Max":80,"date":"…"}`
|
||||||
|
- **THEN** её число равно значению `Avg`
|
||||||
|
|
||||||
|
#### Scenario: Нулевое значение остаётся значением
|
||||||
|
|
||||||
|
- **WHEN** точка имеет вид `{"qty":0,"date":"…"}`
|
||||||
|
- **THEN** её число равно нулю, и точка считается несущей значение
|
||||||
|
|
||||||
|
#### Scenario: Значение не разбирается как конечное число
|
||||||
|
|
||||||
|
- **WHEN** точка несёт `qty` строкой или числом вне диапазона `float64`
|
||||||
|
- **THEN** точка считается не несущей значения и в сумму не входит
|
||||||
|
|
||||||
|
### Requirement: Род агрегации выводится сверкой минутного и часового слоёв
|
||||||
|
|
||||||
|
Система SHALL выводить род агрегации метрики (`cumulative` / `instant` /
|
||||||
|
`unknown`) сравнением её часового слоя с минутным и MUST NOT определять его по
|
||||||
|
имени метрики, единицам, форме точки или заголовку доставки.
|
||||||
|
|
||||||
|
Час **пригоден** для сверки, когда выполнено всё:
|
||||||
|
|
||||||
|
- у метрики есть объекты обоих слоёв за этот час;
|
||||||
|
- час не лежит в будущем — его метка не позже текущего времени плюс запас;
|
||||||
|
- единицы обоих объектов совпадают;
|
||||||
|
- часовой объект несёт ровно одну точку, и она несёт значение, а её метка
|
||||||
|
совпадает с началом часа;
|
||||||
|
- у минутного объекта не меньше двух точек со значением;
|
||||||
|
- сумма минутных значений **отличима** от их среднего.
|
||||||
|
|
||||||
|
**Горизонт обязателен, и это не защита от вредителя, а условие корректности.**
|
||||||
|
Час объекта берётся из метки в теле доставки, а тело не наше: одна доставка с
|
||||||
|
метками в будущем занимает окно целиком и подменяет измеренный род метрики —
|
||||||
|
построено и прогнано, мгновенная метрика объявлялась накопительной при нуле
|
||||||
|
противоречащих часов. Запас нужен на расхождение часов телефона и сервера.
|
||||||
|
Данные, помеченные будущим, MUST порождать предупреждение владельцу: это либо
|
||||||
|
сбитые часы, либо чужое тело, и оба случая лечатся не кодом.
|
||||||
|
|
||||||
|
**Совпадение единиц обязательно.** Мгновенная метрика, приехавшая минутным
|
||||||
|
слоем в `count/min` и часовым в `count/hour`, даёт в полном часе
|
||||||
|
`часовое = 60 · среднее = сумма` — то есть **уверенный ложный** `cumulative` при
|
||||||
|
нуле противоречащих часов. Правило единогласия этот случай не ловит по
|
||||||
|
построению: противоречия нет, есть молчание.
|
||||||
|
|
||||||
|
**Часовой объект несёт ровно одну точку.** Две точки за час описывают разные
|
||||||
|
интервалы, и какая из них относится к часу целиком — неизвестно; час непригоден
|
||||||
|
целиком, а не «по той, у которой есть значение».
|
||||||
|
|
||||||
|
Требование выравнивания часовой метки закрывает зоны с неполночасовым
|
||||||
|
смещением: слой выводится по выравниванию метки в исходной зоне, а объект
|
||||||
|
адресуется часом UTC, поэтому в зоне `+0530` часовая точка описывает не тот
|
||||||
|
интервал, который покрывают минутные точки того же объекта. Сравнивать их
|
||||||
|
нельзя, и такой час свидетельства не даёт.
|
||||||
|
|
||||||
|
Требование различимости обязательно: в часе, где все значения нули, сумма равна
|
||||||
|
среднему, и совпадение с любой из гипотез не значит ничего.
|
||||||
|
|
||||||
|
Все три сравнения — «сходится с суммой», «сходится со средним», «сумма отличима
|
||||||
|
от среднего» — MUST выполняться **одним предикатом с одним допуском**:
|
||||||
|
относительным, величиной `1e-9`. Тогда час, подтверждающий обе гипотезы сразу,
|
||||||
|
невыразим по построению, и исход не зависит от порядка веток.
|
||||||
|
|
||||||
|
Величина названа числом, потому что от неё зависят счётчики основания в ответе:
|
||||||
|
измерено, что вердикты метрик на живом корпусе одинаковы при допуске от `1e-9`
|
||||||
|
до `1e-3`, а число согласных часов у `heart_rate` при этом меняется с 29 на 49.
|
||||||
|
Взято строгое значение: канонизация содержимого округляет числа до 12 значащих
|
||||||
|
цифр, то есть всё, что крупнее `1e-12`, представлением не объясняется, а
|
||||||
|
`1e-9` оставляет три порядка запаса и остаётся на шесть порядков строже любого
|
||||||
|
содержательного расхождения (сумма и среднее при `n ≥ 2` различаются не меньше
|
||||||
|
чем вдвое).
|
||||||
|
|
||||||
|
Абсолютного порога у сравнения нет намеренно: около нуля относительный допуск
|
||||||
|
вырождается в сторону «не сходится», то есть даёт «свидетельства нет», а не
|
||||||
|
ложный род.
|
||||||
|
|
||||||
|
Вердикт пригодного часа: часовое значение сходится с суммой минутных —
|
||||||
|
`cumulative`, со средним — `instant`, иначе час свидетельства не даёт.
|
||||||
|
|
||||||
|
Сумма минутных значений MUST считаться в порядке возрастания метки точки, чтобы
|
||||||
|
вердикт не зависел от порядка точек внутри объекта.
|
||||||
|
|
||||||
|
#### Scenario: Часовое значение равно сумме минутных
|
||||||
|
|
||||||
|
- **GIVEN** у метрики есть минутный и часовой объекты за один час
|
||||||
|
- **WHEN** часовое значение сходится с суммой минутных значений
|
||||||
|
- **THEN** метрика получает род `cumulative`
|
||||||
|
|
||||||
|
#### Scenario: Часовое значение равно среднему минутных
|
||||||
|
|
||||||
|
- **WHEN** часовое значение сходится со средним минутных значений
|
||||||
|
- **THEN** метрика получает род `instant`
|
||||||
|
|
||||||
|
#### Scenario: Нулевой час свидетельством не является
|
||||||
|
|
||||||
|
- **GIVEN** все минутные значения часа равны нулю, и часовое значение тоже
|
||||||
|
- **WHEN** измеряется род
|
||||||
|
- **THEN** этот час непригоден и в подсчёт согласных не идёт
|
||||||
|
|
||||||
|
#### Scenario: Час лежит в будущем
|
||||||
|
|
||||||
|
- **GIVEN** доставка принесла объекты обоих слоёв с метками позже текущего
|
||||||
|
времени
|
||||||
|
- **WHEN** измеряется род
|
||||||
|
- **THEN** эти часы в окно не входят, род остаётся измеренным по настоящей
|
||||||
|
истории, и владельцу пишется предупреждение
|
||||||
|
|
||||||
|
#### Scenario: Единицы слоёв разошлись
|
||||||
|
|
||||||
|
- **GIVEN** минутный объект часа несёт одни единицы, а часовой — другие
|
||||||
|
- **WHEN** измеряется род
|
||||||
|
- **THEN** час непригоден и свидетельства не даёт
|
||||||
|
|
||||||
|
#### Scenario: Часовой объект несёт две точки
|
||||||
|
|
||||||
|
- **GIVEN** у метрики за час есть часовой объект с двумя точками
|
||||||
|
- **WHEN** измеряется род
|
||||||
|
- **THEN** час непригоден и свидетельства не даёт
|
||||||
|
|
||||||
|
#### Scenario: Минутный объект несёт одну точку
|
||||||
|
|
||||||
|
- **GIVEN** минутный объект часа несёт единственную точку
|
||||||
|
- **WHEN** измеряется род
|
||||||
|
- **THEN** час непригоден: сумма и среднее совпадают, различить гипотезы нечем
|
||||||
|
|
||||||
|
#### Scenario: Метка часовой точки не выровнена на начало часа
|
||||||
|
|
||||||
|
- **GIVEN** часовая точка стоит на середине часа UTC
|
||||||
|
- **WHEN** измеряется род
|
||||||
|
- **THEN** час непригоден и свидетельства не даёт
|
||||||
|
|
||||||
|
#### Scenario: Форма точки на исход не влияет
|
||||||
|
|
||||||
|
- **WHEN** метрика приходит только с полем `qty`, без `Avg`/`Min`/`Max`
|
||||||
|
- **THEN** род всё равно измеряется сверкой слоёв, а не выводится из формы
|
||||||
|
|
||||||
|
### Requirement: Род объявляется только при единогласном свидетельстве
|
||||||
|
|
||||||
|
Система SHALL объявлять род метрики, только если согласных часов не меньше трёх
|
||||||
|
и ни один час не дал противоположного вердикта. В остальных случаях род MUST
|
||||||
|
быть `unknown`, и агрегация по такой метрике предлагаться MUST NOT.
|
||||||
|
|
||||||
|
Наличие противоречащих часов MUST быть записано чекпоинтом уровня `WARN` с
|
||||||
|
именем метрики и числами основания, без значений точек: род — свойство, на
|
||||||
|
котором Read API строит арифметику года, и его смена не имеет права проходить
|
||||||
|
молча. На живом корпусе противоречащих часов не встретилось ни разу, поэтому
|
||||||
|
шума правило не создаёт.
|
||||||
|
|
||||||
|
Единогласие, а не большинство: противоречащий час означает, что одна из гипотез
|
||||||
|
для этой метрики ложна, и объявлять род при известном контрпримере нельзя. Порог
|
||||||
|
в три часа — потому что на этом роде потом суммируют год, а один совпавший час
|
||||||
|
остаётся свидетельством одного часа.
|
||||||
|
|
||||||
|
Следствие принято вслух: род есть функция окна, поэтому час, въехавший в окно,
|
||||||
|
может сменить объявленный род без единой новой доставки за спрошенный период.
|
||||||
|
Клиент, которому это важно, различает случаи по основанию измерения — оно
|
||||||
|
отдаётся вместе с родом.
|
||||||
|
|
||||||
|
#### Scenario: Свидетельства противоречат
|
||||||
|
|
||||||
|
- **GIVEN** у метрики есть часы с вердиктом `cumulative` и часы с вердиктом
|
||||||
|
`instant`
|
||||||
|
- **WHEN** измеряется род
|
||||||
|
- **THEN** род равен `unknown`, число противоречащих часов отдаётся в каталоге,
|
||||||
|
и пишется `WARN` с именем метрики
|
||||||
|
|
||||||
|
#### Scenario: Свидетельств мало
|
||||||
|
|
||||||
|
- **WHEN** согласных часов меньше трёх
|
||||||
|
- **THEN** род равен `unknown`
|
||||||
|
|
||||||
|
#### Scenario: Второго слоя нет вовсе
|
||||||
|
|
||||||
|
- **WHEN** метрика лежит только в одном слое
|
||||||
|
- **THEN** род равен `unknown`, а число часов окна равно нулю
|
||||||
|
|
||||||
|
### Requirement: Нижний слой в измерении не участвует
|
||||||
|
|
||||||
|
Система SHALL измерять род только по слоям `minute` и `hour` и MUST NOT
|
||||||
|
использовать в сверке слои `raw`, `sample` и `day`.
|
||||||
|
|
||||||
|
Нижний слой HAE — не сэмплы, а посекундная развёртка настоящих сэмплов с
|
||||||
|
инфляцией до 478×: его сумма завышена и в сверке не сходится. Слой `sample`
|
||||||
|
несёт собственные интервалы сэмплов, и его сверка с часовым слоем — другая
|
||||||
|
задача, вместе с импортом родного экспорта. Слой `day` — суточная сводка сна,
|
||||||
|
другая схема под тем же именем, а не разрез часов.
|
||||||
|
|
||||||
|
#### Scenario: Метрика есть только в нижнем слое
|
||||||
|
|
||||||
|
- **WHEN** у метрики есть объекты только в слое `raw`
|
||||||
|
- **THEN** род равен `unknown`
|
||||||
|
|
||||||
|
#### Scenario: Нижний слой не подменяет минутный
|
||||||
|
|
||||||
|
- **GIVEN** у метрики есть слои `raw` и `hour`, но нет `minute`
|
||||||
|
- **WHEN** измеряется род
|
||||||
|
- **THEN** сверка не выполняется и род равен `unknown`
|
||||||
|
|
||||||
|
#### Scenario: Метрика лежит только в суточном слое
|
||||||
|
|
||||||
|
- **WHEN** у метрики есть объекты только слоя `day`
|
||||||
|
- **THEN** слой объявлен в каталоге, а род равен `unknown`
|
||||||
|
|
||||||
|
### Requirement: Каталог отдаёт основание измерения, а не только вывод
|
||||||
|
|
||||||
|
Система SHALL отдавать вместе с родом четыре числа и границы окна, и клиент MUST
|
||||||
|
иметь возможность отличить «свидетельств не было» от «свидетельства
|
||||||
|
противоречат», не делая второго запроса.
|
||||||
|
|
||||||
|
Числа определены так, что их разность осмысленна:
|
||||||
|
|
||||||
|
- `hours` — сколько общих часов двух слоёв попало в окно;
|
||||||
|
- `compared` — сколько из них оказалось **пригодными**;
|
||||||
|
- `agreeing` — сколько пригодных часов дали **преобладающий** вердикт (при
|
||||||
|
объявленном роде это он и есть);
|
||||||
|
- `conflicting` — сколько дали другой.
|
||||||
|
|
||||||
|
Разложение одно и то же независимо от того, объявлен род или нет: иначе
|
||||||
|
`agreeing` пришлось бы толковать по-разному в двух ветках, и клиент читал бы
|
||||||
|
одно поле двумя способами.
|
||||||
|
|
||||||
|
Разность `compared − agreeing − conflicting` — часы, не сошедшиеся ни с одной
|
||||||
|
гипотезой; разность `hours − compared` — часы, отброшенные проверкой
|
||||||
|
пригодности. Без этого различения `hours` в одиночку выдавал бы «измерение шло,
|
||||||
|
данные молчат» там, где ни один час не был пригоден вовсе.
|
||||||
|
|
||||||
|
`first_hour` и `last_hour` — границы окна; род объявляется вместе с периодом, на
|
||||||
|
котором измерен, потому что окно ограничено самыми свежими общими часами, а не
|
||||||
|
всей историей.
|
||||||
|
|
||||||
|
#### Scenario: Род измерен
|
||||||
|
|
||||||
|
- **WHEN** метрика получила род `cumulative`
|
||||||
|
- **THEN** рядом стоят число часов окна, число пригодных, число согласных, ноль
|
||||||
|
противоречащих и границы окна
|
||||||
|
|
||||||
|
#### Scenario: Часы были, но ни один не пригоден
|
||||||
|
|
||||||
|
- **WHEN** все часы окна отброшены проверкой пригодности
|
||||||
|
- **THEN** `hours` больше нуля, `compared` равен нулю, род равен `unknown`
|
||||||
|
|
||||||
|
### Requirement: Окно измерения ограничено сорока восемью часами
|
||||||
|
|
||||||
|
Система SHALL измерять род по не более чем 48 самым свежим общим часам метрики
|
||||||
|
и MUST NOT читать ради этого всю историю: стоимость каталога не имеет права
|
||||||
|
расти вместе с журналом.
|
||||||
|
|
||||||
|
Число названо в спеке, а не оставлено реализации, по той же причине, что и
|
||||||
|
порог согласных часов: от него зависят счётчики основания в ответе.
|
||||||
|
|
||||||
|
Измерено, что на живом корпусе окно сохраняет вердикты всех метрик, кроме
|
||||||
|
редких: у `physical_effort` за всю историю набиралось пять согласных часов, а в
|
||||||
|
последних сорока восьми — два, и метрика честно уходит в `unknown`. Это не
|
||||||
|
издержка, а то же правило: свидетельств в свежем окне действительно мало.
|
||||||
|
|
||||||
|
Окно ограничено и сверху — часами не позже текущего времени плюс запас, см.
|
||||||
|
правило пригодности часа.
|
||||||
|
|
||||||
|
#### Scenario: История длиннее окна
|
||||||
|
|
||||||
|
- **GIVEN** у метрики общих часов больше сорока восьми
|
||||||
|
- **WHEN** измеряется род
|
||||||
|
- **THEN** сравниваются только сорок восемь самых свежих, и `hours` равен
|
||||||
|
сорока восьми
|
||||||
|
|
||||||
|
### Requirement: Измеренный род нигде не сохраняется
|
||||||
|
|
||||||
|
Система SHALL вычислять род при каждом запросе каталога и MUST NOT хранить его
|
||||||
|
ни колонкой, ни кешем.
|
||||||
|
|
||||||
|
Хранимое значение было бы вторым производным состоянием рядом с витриной: его
|
||||||
|
пришлось бы пересчитывать после каждой свёртки, переносить или не переносить
|
||||||
|
пересборкой и объяснять, на каком составе данных оно снято; устаревшее значение
|
||||||
|
при этом выглядит ровно как свежее. Вычисленный на запрос род есть функция
|
||||||
|
витрины, а витрина — функция журнала, и устаревать в нём нечему.
|
||||||
|
|
||||||
|
#### Scenario: Новая доставка меняет род без перезапуска
|
||||||
|
|
||||||
|
- **GIVEN** метрика числится `unknown`, потому что общих часов было мало
|
||||||
|
- **WHEN** приезжает доставка, добавляющая согласные часы, и каталог
|
||||||
|
запрашивается снова
|
||||||
|
- **THEN** ответ отдаёт новый род, и перезапуск сервиса для этого не нужен
|
||||||
|
|
||||||
|
### Requirement: Каталог читается одним снимком витрины
|
||||||
|
|
||||||
|
Система SHALL собирать ответ каталога из одного снимка базы: разрезы, границы и
|
||||||
|
объекты окна измерения MUST читаться в одной транзакции чтения.
|
||||||
|
|
||||||
|
Приём идёт непрерывно, и фоновая свёртка пишет в витрину во время запроса.
|
||||||
|
Запросы вне общей транзакции дали бы смесь «разрезы до» и «род после» — ответ,
|
||||||
|
внутренне противоречивый и неотличимый от обычного свежего.
|
||||||
|
|
||||||
|
Число обращений к хранилищу на один запрос каталога MUST быть ограничено
|
||||||
|
константой на метрику и не зависеть от размера окна: чтение объектов окна по
|
||||||
|
одному даёт тысячи обращений там, где хватает двух на метрику.
|
||||||
|
|
||||||
|
#### Scenario: Доставка приезжает во время сборки каталога
|
||||||
|
|
||||||
|
- **GIVEN** каталог собирается, и в этот момент фоновая свёртка пишет объекты
|
||||||
|
- **WHEN** ответ сформирован
|
||||||
|
- **THEN** он целиком описывает одно состояние витрины
|
||||||
|
|
||||||
|
#### Scenario: Размер окна не умножает число запросов
|
||||||
|
|
||||||
|
- **WHEN** окно измерения увеличено
|
||||||
|
- **THEN** число обращений к хранилищу на метрику не меняется
|
||||||
|
|
||||||
|
### Requirement: Каталог доступен по токену чтения
|
||||||
|
|
||||||
|
Система SHALL требовать токен чтения на маршруте каталога и MUST NOT принимать
|
||||||
|
на нём токен приёма. Токен MUST передаваться заголовком `Authorization` со
|
||||||
|
схемой `Bearer`; значение без этой схемы токеном не считается.
|
||||||
|
|
||||||
|
Пустой список токенов чтения означает выключенную проверку, и о выключенной
|
||||||
|
проверке сервис предупреждает на старте — тем же способом, что о выключенной
|
||||||
|
проверке приёма. Цена симметрии названа вслух: у приёма открытый контур означает
|
||||||
|
мусор во входе, у чтения — выгрузку данных о здоровье, поэтому перед выкладкой
|
||||||
|
наружу список обязан быть непуст. Отвечает за это отдельная задача об управлении
|
||||||
|
секретами; здесь фиксируется, что предупреждение существует и адресовано
|
||||||
|
владельцу.
|
||||||
|
|
||||||
|
Токен чтения MUST вычищаться из сохраняемых заголовков доставки наравне с
|
||||||
|
токеном приёма: заголовок с произвольным именем иначе донесёт его до базы.
|
||||||
|
|
||||||
|
Контуры раздельны по архитектуре: клиент, читающий данные, писать не может, и
|
||||||
|
обратное тоже неверно.
|
||||||
|
|
||||||
|
#### Scenario: Запрос без токена при заданном списке
|
||||||
|
|
||||||
|
- **GIVEN** список токенов чтения непуст
|
||||||
|
- **WHEN** каталог запрашивается без заголовка `Authorization`
|
||||||
|
- **THEN** ответ — 401, и данные не отдаются
|
||||||
|
|
||||||
|
#### Scenario: Токен приёма каталога не открывает
|
||||||
|
|
||||||
|
- **GIVEN** заданы разные списки токенов приёма и чтения
|
||||||
|
- **WHEN** каталог запрашивается с токеном приёма
|
||||||
|
- **THEN** ответ — 401
|
||||||
|
|
||||||
|
#### Scenario: Токен без схемы Bearer
|
||||||
|
|
||||||
|
- **GIVEN** список токенов чтения непуст
|
||||||
|
- **WHEN** каталог запрашивается с заголовком `Authorization`, где стоит голое
|
||||||
|
значение токена без слова `Bearer`
|
||||||
|
- **THEN** ответ — 401
|
||||||
|
|
||||||
|
#### Scenario: Проверка выключена
|
||||||
|
|
||||||
|
- **GIVEN** список токенов чтения пуст
|
||||||
|
- **WHEN** каталог запрашивается без заголовка `Authorization`
|
||||||
|
- **THEN** каталог отдаётся
|
||||||
|
|
||||||
|
#### Scenario: О выключенной проверке предупреждают на старте
|
||||||
|
|
||||||
|
- **GIVEN** список токенов чтения пуст
|
||||||
|
- **WHEN** сервис стартует
|
||||||
|
- **THEN** в логе появляется предупреждение владельцу
|
||||||
|
|
||||||
|
#### Scenario: Токен чтения не оседает в учёте доставки
|
||||||
|
|
||||||
|
- **GIVEN** токен чтения послан на маршрут приёма заголовком с произвольным
|
||||||
|
именем
|
||||||
|
- **WHEN** доставка учтена
|
||||||
|
- **THEN** в сохранённых заголовках вместо значения стоит пометка о сокрытии
|
||||||
|
|
||||||
@@ -1009,3 +1009,51 @@ SHALL: сегодня ровно этот случай даёт ноль и мо
|
|||||||
пор не пересворачивалась
|
пор не пересворачивалась
|
||||||
- **THEN** её число пропущенных сущностей отсутствует, а не равно нулю
|
- **THEN** её число пропущенных сущностей отсутствует, а не равно нулю
|
||||||
|
|
||||||
|
### Requirement: Перечисление разрезов не читает содержимое объектов
|
||||||
|
|
||||||
|
Хранилище SHALL отвечать на вопрос «какие слои есть у метрики, за какой период и
|
||||||
|
сколько в них точек» по учётным колонкам объекта, не разжимая `payload` и не
|
||||||
|
затрагивая страниц с содержимым. Тот же запрет действует на поиск часов, за
|
||||||
|
которые у метрики есть объекты сразу в двух слоях.
|
||||||
|
|
||||||
|
Запрет ограничен именно этими двумя выборками. Измерение рода обязано прочитать
|
||||||
|
значения точек, то есть разжать содержимое объектов окна, и требование его не
|
||||||
|
касается — иначе оно запрещало бы то, ради чего каталог существует.
|
||||||
|
|
||||||
|
Причина в форме таблицы: `bucket` объявлена `WITHOUT ROWID`, то есть строка
|
||||||
|
целиком, вместе со сжатым содержимым, живёт в дереве первичного ключа. Обход
|
||||||
|
всех строк ради агрегата тащил бы за собой страницы содержимого — при 260 тысячах
|
||||||
|
объектов за год это сотни мегабайт на каждый запрос каталога, притом что сам
|
||||||
|
ответ несёт три десятка строк.
|
||||||
|
|
||||||
|
Поэтому колонки, по которым отвечают эти выборки, MUST быть покрыты индексом, и
|
||||||
|
новая колонка, попадающая в ответ каталога, входит в него тем же изменением.
|
||||||
|
|
||||||
|
#### Scenario: Разрезы метрики за длинную историю
|
||||||
|
|
||||||
|
- **GIVEN** в витрине объекты за многие месяцы
|
||||||
|
- **WHEN** запрашиваются слои метрики с границами и числом точек
|
||||||
|
- **THEN** запрос отвечает по индексу, не читая содержимого объектов
|
||||||
|
|
||||||
|
#### Scenario: Общие часы двух слоёв
|
||||||
|
|
||||||
|
- **WHEN** запрашиваются самые свежие часы, за которые у метрики есть объекты и
|
||||||
|
в минутном, и в часовом слое
|
||||||
|
- **THEN** запрос отвечает по индексу и читает не больше запрошенного числа
|
||||||
|
часов
|
||||||
|
|
||||||
|
### Requirement: Объекты перечисленных часов читаются пакетом
|
||||||
|
|
||||||
|
Хранилище SHALL уметь отдать объекты двух слоёв за перечисленные часы одной
|
||||||
|
метрики **одним запросом**, а не по объекту за раз.
|
||||||
|
|
||||||
|
Чтение по одному даёт число обращений, растущее вместе с окном измерения, и
|
||||||
|
делает каждое обращение собственной транзакцией — то есть ответ, собранный из
|
||||||
|
разных снимков витрины под непрерывным приёмом.
|
||||||
|
|
||||||
|
#### Scenario: Окно из многих часов
|
||||||
|
|
||||||
|
- **GIVEN** запрошены объекты двух слоёв за сорок восемь часов
|
||||||
|
- **WHEN** выполняется выборка
|
||||||
|
- **THEN** число обращений к базе не зависит от числа часов
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user