From 03edf1087d0d4b87fa08b0b0fd14d2c80c5b16c5 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Sun, 2 Aug 2026 19:23:59 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9A=D0=B0=D1=82=D0=B0=D0=BB=D0=BE=D0=B3=20?= =?UTF-8?q?=D1=80=D0=B0=D0=B7=D1=80=D0=B5=D0=B7=D0=BE=D0=B2=20=D0=B8=20?= =?UTF-8?q?=D0=B8=D0=B7=D0=BC=D0=B5=D1=80=D0=B5=D0=BD=D0=BD=D1=8B=D0=B9=20?= =?UTF-8?q?=D1=80=D0=BE=D0=B4=20=D0=B0=D0=B3=D1=80=D0=B5=D0=B3=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - род метрики выводится сверкой минутного слоя с часовым: часовое значение сходится с суммой минутных — накопительная, со средним — мгновенная, иначе `unknown` и свёртка не предлагается вовсе. На живом архиве (123 доставки, 31 метрика) 7 накопительных, 9 мгновенных, противоречащих часов ноль - `GET /api/v1/metrics` под токеном чтения отдаёт единицы, слои с границами и род вместе с основанием измерения; род нигде не хранится — он функция витрины, а витрина функция журнала, устаревать в нём нечему - миграция 00009: покрывающий индекс, чтобы каталог отвечал по учётным колонкам, не разжимая содержимое объектов --- README.md | 1 + cmd/healthlog/serve.go | 10 + config.docker.toml | 2 + config.example.toml | 9 +- docs/architecture.md | 174 +++++- docs/backlog/README.md | 4 +- docs/backlog/cena-chitayushchego-marshruta.md | 83 +++ docs/backlog/ostanovka-i-migraciya-sledy.md | 41 ++ docs/backlog/read-api-tochki.md | 33 +- docs/backlog/rod-agregacii-i-katalog.md | 30 - docs/backlog/stats-nablyudaemost.md | 16 + docs/backlog/taj-brejk-pri-ravnoj-polnote.md | 76 +++ docs/backlog/upravlenie-sekretami.md | 8 + docs/database.md | 10 + docs/local-research.md | 61 ++ docs/plan.md | 9 +- internal/catalog/catalog.go | 504 +++++++++++++++++ internal/catalog/catalog_test.go | 500 +++++++++++++++++ internal/catalog/measure_test.go | 292 ++++++++++ internal/hae/value.go | 73 +++ internal/hae/value_test.go | 96 ++++ internal/httpapi/catalog.go | 34 ++ internal/httpapi/catalog_test.go | 211 +++++++ internal/httpapi/httpapi.go | 45 +- internal/httpapi/httpapi_test.go | 15 +- internal/httpapi/ingest.go | 12 +- internal/replay/archive_test.go | 72 +++ internal/store/catalog.go | 292 ++++++++++ internal/store/catalog_internal_test.go | 97 ++++ internal/store/catalog_test.go | 179 ++++++ .../store/migrations/00009_bucket_catalog.sql | 26 + .../.openspec.yaml | 2 + .../design.md | 453 +++++++++++++++ .../proposal.md | 59 ++ .../specs/catalog/spec.md | 517 +++++++++++++++++ .../specs/storage/spec.md | 49 ++ .../tasks.md | 133 +++++ openspec/specs/catalog/spec.md | 526 ++++++++++++++++++ openspec/specs/storage/spec.md | 48 ++ 39 files changed, 4744 insertions(+), 58 deletions(-) create mode 100644 docs/backlog/cena-chitayushchego-marshruta.md create mode 100644 docs/backlog/ostanovka-i-migraciya-sledy.md delete mode 100644 docs/backlog/rod-agregacii-i-katalog.md create mode 100644 docs/backlog/taj-brejk-pri-ravnoj-polnote.md create mode 100644 internal/catalog/catalog.go create mode 100644 internal/catalog/catalog_test.go create mode 100644 internal/catalog/measure_test.go create mode 100644 internal/hae/value.go create mode 100644 internal/hae/value_test.go create mode 100644 internal/httpapi/catalog.go create mode 100644 internal/httpapi/catalog_test.go create mode 100644 internal/store/catalog.go create mode 100644 internal/store/catalog_internal_test.go create mode 100644 internal/store/catalog_test.go create mode 100644 internal/store/migrations/00009_bucket_catalog.sql create mode 100644 openspec/changes/archive/2026-08-02-katalog-i-rod-agregacii/.openspec.yaml create mode 100644 openspec/changes/archive/2026-08-02-katalog-i-rod-agregacii/design.md create mode 100644 openspec/changes/archive/2026-08-02-katalog-i-rod-agregacii/proposal.md create mode 100644 openspec/changes/archive/2026-08-02-katalog-i-rod-agregacii/specs/catalog/spec.md create mode 100644 openspec/changes/archive/2026-08-02-katalog-i-rod-agregacii/specs/storage/spec.md create mode 100644 openspec/changes/archive/2026-08-02-katalog-i-rod-agregacii/tasks.md create mode 100644 openspec/specs/catalog/spec.md diff --git a/README.md b/README.md index 35014a3..9041d1c 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,7 @@ task run ``` curl localhost:8080/healthz curl -X POST localhost:8080/api/v1/ingest -d '{"data":{"metrics":[]}}' +curl localhost:8080/api/v1/metrics # каталог: слои, диапазоны, род агрегации ``` ### Подключение телефона по локальной сети diff --git a/cmd/healthlog/serve.go b/cmd/healthlog/serve.go index a6a89e4..412d746 100644 --- a/cmd/healthlog/serve.go +++ b/cmd/healthlog/serve.go @@ -13,6 +13,7 @@ import ( "time" "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/fold" "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 { 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{ Handler: httpapi.New(httpapi.Options{ Ingest: ingest.New(arch, st, worker.Notify, log), + Catalog: catalog.New(st, log), Log: log, WriteTokens: cfg.Auth.WriteTokens, + ReadTokens: cfg.Auth.ReadTokens, MaxBodyMB: cfg.Ingest.MaxBodyMB, // Бюджет ответа маршрута приёма: `WriteTimeout` сервера ставится ДО // вызова обработчика и потому покрывает чтение тела, обрывая diff --git a/config.docker.toml b/config.docker.toml index 25763a3..9d103b2 100644 --- a/config.docker.toml +++ b/config.docker.toml @@ -13,6 +13,8 @@ write_timeout = "30s" # прочих маршрутов; приём держ [auth] write_tokens = [] # ПУСТО = проверка выключена, см. предупреждение выше +# Чтение открыто так же, как приём, но цена другая: это выгрузка истории +# здоровья. Годится только для доверенной локальной сети. read_tokens = [] [storage] diff --git a/config.example.toml b/config.example.toml index c931eaa..af10490 100644 --- a/config.example.toml +++ b/config.example.toml @@ -19,9 +19,14 @@ write_timeout = "30s" # на отправку ответа прочих ма # Health Auto Export умеет слать произвольные заголовки — токен задаётся в # настройках автоматизации. # ПУСТОЙ СПИСОК = ПРОВЕРКА ВЫКЛЮЧЕНА. Так можно в доверенной локальной сети; -# сервис предупреждает об этом на старте записью `write auth disabled`. +# сервис предупреждает об этом на старте записями `write auth disabled` и +# `read auth disabled`. +# +# Цена у контуров РАЗНАЯ, и это стоит помнить: открытый приём означает мусор во +# входе, открытое чтение — выгрузку всей истории здоровья любому, кто нашёл +# порт. Перед выкладкой наружу `read_tokens` обязан быть непуст. write_tokens = [] # токены на приём данных -read_tokens = [] # токены на чтение (read API появится позже) +read_tokens = [] # токены на чтение: каталог `GET /api/v1/metrics` [storage] # ВНИМАНИЕ: умолчания в коде (./healthlog.db и ./raw) остались от прежней diff --git a/docs/architecture.md b/docs/architecture.md index 2ea06d5..093937c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -215,6 +215,7 @@ HRV); у накопительных — только `date`. Поэтому то | `ingest` | use-case приёма, общий для HTTP и CLI `import` | | `fold` | свёртка одной доставки в часовые объекты | | `replay` | проигрывание журнала в витрину: состав, порядок, отчёт | +| `catalog` | каталог разрезов и измерение рода агрегации | | `store` | SQLite: доставки, часовые объекты, тренировки, записи | | `httpapi` | приём и read API | @@ -616,7 +617,8 @@ hour метки выровнены на час heart_rate 00:00:00 процентов девяносто и ломаются на краях — `six_minute_walking_test_distance` в метрах складывать нельзя, а `walking_running_distance` в километрах можно (находка 40). Где данных на сверку не хватило, род остаётся неизвестным и -агрегация по метрике не предлагается вовсе. +агрегация по метрике не предлагается вовсе. Правило целиком — ниже, «Измерение +рода агрегации». Отдельно: **нижний слой HAE не суммируется никогда.** Он не сэмплы, а посекундная развёртка (находка 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 отдаёт перечислимые значения строками из локали телефона, а не кодами: @@ -1085,15 +1221,38 @@ GET /healthz за какой период: ```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": [ - {"layer": "raw", "from": "2026-07-30", "to": "2026-08-01", "points": 2078}, - {"layer": "minute", "from": "2026-07-25", "to": "2026-08-01", "points": 14203} + {"layer": "minute", "from": "2026-07-25T00:01:00Z", "to": "2026-08-01T23:59:00Z", "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` выбирает разрез. Если он не указан — берём **самый мелкий слой, покрывающий весь запрошенный диапазон**. Молча переключать слой на @@ -1126,7 +1285,8 @@ GET /healthz Свёртка применяет род из каталога: `cumulative` — сумма, `instant` — среднее с `min`/`max` рядом. При `unknown` свёртка не выполняется, а параметр -`bucket` отвергается ошибкой. Накопительные метрики никогда не сворачиваются +`bucket` отвергается ошибкой. Порог заполненности ведра (`xFilesFactor`) и его +полярность выбирает эта же задача — см. «Измерение рода агрегации». Накопительные метрики никогда не сворачиваются из нижнего слоя HAE — только из `minute`, `hour` или `sample`. ### Форма ответа diff --git a/docs/backlog/README.md b/docs/backlog/README.md index 194a948..c55aa7d 100644 --- a/docs/backlog/README.md +++ b/docs/backlog/README.md @@ -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 на хосте — ни один из трёх потребителей ничего прочитать не может - [OpenAPI-спека и Swagger UI](openapi-swagger.md) — Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате - [MCP-сервер поверх Read API](mcp-server.md) — Агент-медик — первый заказчик проекта, а подключить его сейчас нечем @@ -47,6 +48,7 @@ - [Сверка живой витрины с пересборкой](sverka-vitriny-s-peresborkoj.md) — reindex печатает оба отпечатка, но сравнивать их некому — расхождение с журналом молчит - [Сущность с id, но неразобранной меткой](hranenie-sushchnosti-bez-metki.md) — Тренировка с меткой в неизвестном формате пропадает целиком — а её id и содержимое разобраны - [Пределы на размер сущности и потоковый расчёт формы](predely-razmera-sushchnosti.md) — Тело 40 МиБ даёт 768 МиБ пика кучи, 63 МиБ держат блокировку 5.019 с — предела на одну сущность нет вовсе +- [Остановка и миграция: раздельные бюджеты и следы в логе](ostanovka-i-migraciya-sledy.md) — Долгий запрос чтения съедает бюджет остановки, и WARN обвиняет воркер свёртки; миграция молчит и не прерывается SIGTERM ## низкий - [Устаревание нижнего слоя после экспорта](ustarevanie-nizhnego-sloya.md) — Нижний слой растёт на ~100 тысяч координат в сутки, а после экспорта Apple он избыточен diff --git a/docs/backlog/cena-chitayushchego-marshruta.md b/docs/backlog/cena-chitayushchego-marshruta.md new file mode 100644 index 0000000..9bc1ae7 --- /dev/null +++ b/docs/backlog/cena-chitayushchego-marshruta.md @@ -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`. diff --git a/docs/backlog/ostanovka-i-migraciya-sledy.md b/docs/backlog/ostanovka-i-migraciya-sledy.md new file mode 100644 index 0000000..ef06d67 --- /dev/null +++ b/docs/backlog/ostanovka-i-migraciya-sledy.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`. diff --git a/docs/backlog/read-api-tochki.md b/docs/backlog/read-api-tochki.md index 0a36cfd..8dfd087 100644 --- a/docs/backlog/read-api-tochki.md +++ b/docs/backlog/read-api-tochki.md @@ -26,5 +26,36 @@ каждый, тренировка отдаётся одним пакетом вместе с маршрутом, а в ответе всегда видно `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». diff --git a/docs/backlog/rod-agregacii-i-katalog.md b/docs/backlog/rod-agregacii-i-katalog.md deleted file mode 100644 index a26546b..0000000 --- a/docs/backlog/rod-agregacii-i-katalog.md +++ /dev/null @@ -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` → «Слои гранулярности», план → шаг «Каталог и род агрегации». - diff --git a/docs/backlog/stats-nablyudaemost.md b/docs/backlog/stats-nablyudaemost.md index 6c58601..2be6bd0 100644 --- a/docs/backlog/stats-nablyudaemost.md +++ b/docs/backlog/stats-nablyudaemost.md @@ -32,3 +32,19 @@ Активное уведомление — отдельная задача, здесь только факт. + +**Что добавил каталог рода агрегации.** Реальный сценарий поломки измерения — не +противоречие свидетельств (его на корпусе не бывает), а их исчезновение: владелец +переставил автоматизацию HAE, минутный слой перестал приходить, метрики одна за +другой уезжают в `unknown`, Read API перестаёт агрегировать — и в логах ноль +событий. Сюда же вторая половина: пять разных причин непригодности часа +(две точки у часового объекта, невыровненная метка, нет числа, мало минутных, +неразличимость) схлопнуты в одну разность `hours − compared`, поэтому «HAE +переименовал поле точки» неотличимо от «данных мало». Оба сигнала естественно +живут в `/stats`: число метрик по родам и число метрик с `compared == 0` при +непустом окне. + +**Корреляция у контура чтения.** В записи `http request` нет ни идентификатора +запроса, ни адреса клиента: жалобу потребителя не сопоставить с записью, а +выгрузку каталога посторонним — не отличить от планового опроса агента. У приёма +корреляция есть (`delivery_id`), у чтения аналога нет. diff --git a/docs/backlog/taj-brejk-pri-ravnoj-polnote.md b/docs/backlog/taj-brejk-pri-ravnoj-polnote.md new file mode 100644 index 0000000..43b7d02 --- /dev/null +++ b/docs/backlog/taj-brejk-pri-ravnoj-polnote.md @@ -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. diff --git a/docs/backlog/upravlenie-sekretami.md b/docs/backlog/upravlenie-sekretami.md index 4e28fcc..8f88fa7 100644 --- a/docs/backlog/upravlenie-sekretami.md +++ b/docs/backlog/upravlenie-sekretami.md @@ -7,6 +7,14 @@ правильно, но это же делает выезд наружу опасным: одна забытая настройка открывает историю здоровья всему интернету. +**Контуров теперь два, а не один.** С появлением каталога +(`GET /api/v1/metrics`, change `2026-08-02-katalog-i-rod-agregacii`) заработал +токен чтения, и цена у контуров разная: открытый приём означает мусор во входе, +открытое чтение — выгрузку всей истории здоровья любому, кто нашёл порт. Сервис +предупреждает на старте обоими сообщениями (`write auth disabled`, +`read auth disabled`), образцы конфига цену называют комментарием — но отказа +старта нет, и это решение осталось здесь. + Решается перед деплоем, не раньше — так договорились. Шаги: diff --git a/docs/database.md b/docs/database.md index 0f31f9c..656a4d6 100644 --- a/docs/database.md +++ b/docs/database.md @@ -103,6 +103,16 @@ SQLite (`modernc.org/sqlite`, чистый Go), миграции — goose, фа Таблица `WITHOUT ROWID`: обращение всегда по полному первичному ключу, и лишний уровень косвенности через rowid ни разу не нужен. +Индекс `bucket_catalog` (`metric, layer, hour_utc, first_ts, last_ts, points, +units`) — **покрывающий**, и это следствие той же формы таблицы: у `WITHOUT +ROWID` строка целиком, вместе со сжатым `payload`, живёт в дереве первичного +ключа, поэтому агрегат «какие слои есть у метрики и за какой период» без индекса +тащил бы страницы содержимого — сотни мегабайт чтения на запрос каталога при +260 тысячах объектов за год. По нему же идёт поиск часов, за которые у метрики +есть объекты сразу в двух слоях. Цена — около 60 байт на объект и одна вставка в +дерево на запись; платит её только настоящее изменение, потому что при совпавшем +хеше объект не переписывается вовсе. + **Идентичность точки внутри объекта** — координаты `метрика + слой + начало + конец`, у точки-измерения конец равен началу. `source` в ключ не входит: он нестабилен и переписывается задним числом. При diff --git a/docs/local-research.md b/docs/local-research.md index 0380714..5d5bed8 100644 --- a/docs/local-research.md +++ b/docs/local-research.md @@ -1725,6 +1725,67 @@ apple_stand_time 14 ноль, частично разобранных ноль, в витрине 2049 часовых объектов, 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, только стандартная diff --git a/docs/plan.md b/docs/plan.md index 27c0088..5997792 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -24,7 +24,12 @@ Тренировки и записи со своими `id` разбираются: `workouts` и `stateOfMind` — половина потока — перестали лежать неразобранными. От разбора остался словарь -категориальных значений; дальше — каталог и род агрегации. +категориальных значений. + +**Род агрегации измерен**: сверка минутного слоя с часовым разложила метрики +живого корпуса на накопительные и мгновенные, не сойдясь ни на одной. Каталог +разрезов отдаётся первым маршрутом чтения — дальше Read API, которому теперь +есть на чём строить свёртку. Разведка закончена: правило вывода слоя, модель идентичности и формы точки проверены на живом потоке, выводы — в [local-research.md](local-research.md). @@ -35,7 +40,7 @@ - [x] **2. Приём без разбора.** ← **подключаем телефон по локальной сети** - [~] **3. Разбор и хранилище.** Метрики, тренировки и записи со своими `id`, `reindex` — сделано; словарь категориальных значений — нет. -- [ ] **4. Каталог и род агрегации.** +- [x] **4. Каталог и род агрегации.** - [ ] **5. Read API.** - [ ] **6. Самоописание.** - [ ] **7. MCP.** diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go new file mode 100644 index 0000000..e381bfd --- /dev/null +++ b/internal/catalog/catalog.go @@ -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 +} diff --git a/internal/catalog/catalog_test.go b/internal/catalog/catalog_test.go new file mode 100644 index 0000000..42bed1e --- /dev/null +++ b/internal/catalog/catalog_test.go @@ -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("отмена не отмечена вовсе — исход операции обязан быть виден") + } +} diff --git a/internal/catalog/measure_test.go b/internal/catalog/measure_test.go new file mode 100644 index 0000000..a73b60b --- /dev/null +++ b/internal/catalog/measure_test.go @@ -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) + } + } +} diff --git a/internal/hae/value.go b/internal/hae/value.go new file mode 100644 index 0000000..135a140 --- /dev/null +++ b/internal/hae/value.go @@ -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 +} diff --git a/internal/hae/value_test.go b/internal/hae/value_test.go new file mode 100644 index 0000000..dc6694b --- /dev/null +++ b/internal/hae/value_test.go @@ -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) + }) + } +} diff --git a/internal/httpapi/catalog.go b/internal/httpapi/catalog.go new file mode 100644 index 0000000..07af266 --- /dev/null +++ b/internal/httpapi/catalog.go @@ -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}) +} diff --git a/internal/httpapi/catalog_test.go b/internal/httpapi/catalog_test.go new file mode 100644 index 0000000..905bbe4 --- /dev/null +++ b/internal/httpapi/catalog_test.go @@ -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) + } +} diff --git a/internal/httpapi/httpapi.go b/internal/httpapi/httpapi.go index 3b64210..0a438cc 100644 --- a/internal/httpapi/httpapi.go +++ b/internal/httpapi/httpapi.go @@ -14,14 +14,17 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" + "git.vakhrushev.me/av/healthlog/internal/catalog" "git.vakhrushev.me/av/healthlog/internal/ingest" ) // Options — зависимости и настройки транспорта. type Options struct { Ingest *ingest.Service + Catalog *catalog.Service Log *slog.Logger WriteTokens []string + ReadTokens []string MaxBodyMB int // IngestWriteBudget — сколько отводится маршруту приёма на чтение тела // вместе с отправкой ответа. Ноль означает «полагаться на WriteTimeout @@ -31,8 +34,10 @@ type Options struct { type api struct { ingest *ingest.Service + catalog *catalog.Service log *slog.Logger writeTokens []string + readTokens []string maxBody int64 ingestBudget time.Duration } @@ -41,8 +46,10 @@ type api struct { func New(o Options) http.Handler { a := &api{ ingest: o.Ingest, + catalog: o.Catalog, log: o.Log, writeTokens: o.WriteTokens, + readTokens: o.ReadTokens, maxBody: int64(o.MaxBodyMB) << 20, ingestBudget: o.IngestWriteBudget, } @@ -53,7 +60,8 @@ func New(o Options) http.Handler { r.Get("/healthz", a.handleHealthz) r.Route("/api/v1", func(r chi.Router) { - r.With(a.requireWriteToken).Post("/ingest", a.handleIngest) + r.With(requireToken(a.writeTokens)).Post("/ingest", a.handleIngest) + r.With(requireToken(a.readTokens)).Get("/metrics", a.handleMetrics) }) return r } @@ -64,21 +72,32 @@ func (a *api) handleHealthz(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{"status":"ok"}`)) } -// requireWriteToken проверяет токен приёма. Пустой список токенов = проверка +// requireToken проверяет токен контура. Пустой список токенов = проверка // выключена: локальный запуск в доверенной сети. О выключенной проверке // сервис предупреждает на старте. -func (a *api) requireWriteToken(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if len(a.writeTokens) == 0 { +// +// Проверка ОДНА на оба контура, параметризованная списком. Копия отличалась бы +// одним полем и несла бы три решения сразу — сравнение за постоянное время, +// «пустой список = выключено» и текст 401; правка любого из них в одном месте +// не дала бы ни ошибки компиляции, ни красного теста, а речь о контуре чтения +// данных о здоровье. +// +// Контуры при этом раздельны: списки разные, и токен приёма маршрут чтения не +// открывает. Схема строгая — токеном считается только значение после `Bearer `. +func requireToken(tokens []string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if len(tokens) == 0 { + next.ServeHTTP(w, r) + return + } + if !tokenAllowed(bearer(r), tokens) { + writeError(w, http.StatusUnauthorized, "неверный или отсутствующий токен") + return + } next.ServeHTTP(w, r) - return - } - if !tokenAllowed(bearer(r), a.writeTokens) { - writeError(w, http.StatusUnauthorized, "неверный или отсутствующий токен") - return - } - next.ServeHTTP(w, r) - }) + }) + } } func bearer(r *http.Request) string { diff --git a/internal/httpapi/httpapi_test.go b/internal/httpapi/httpapi_test.go index 71afc62..38e9894 100644 --- a/internal/httpapi/httpapi_test.go +++ b/internal/httpapi/httpapi_test.go @@ -14,6 +14,7 @@ import ( "time" "git.vakhrushev.me/av/healthlog/internal/archive" + "git.vakhrushev.me/av/healthlog/internal/catalog" "git.vakhrushev.me/av/healthlog/internal/httpapi" "git.vakhrushev.me/av/healthlog/internal/ingest" "git.vakhrushev.me/av/healthlog/internal/store" @@ -284,6 +285,15 @@ func TestПриёмРаботаетНаТранспортеБезДедлайн func newAPI(t *testing.T, writeTokens []string) (http.Handler, *store.Store) { t.Helper() + + h, st, _ := newAPITokens(t, writeTokens, nil) + return h, st +} + +// newAPITokens собирает роутер с обоими контурами. Отдельно от newAPI, чтобы не +// переписывать два десятка вызовов ради одного параметра. +func newAPITokens(t *testing.T, writeTokens, readTokens []string) (http.Handler, *store.Store, *catalog.Service) { + t.Helper() dir := t.TempDir() st, err := store.Open(filepath.Join(dir, "healthlog.db")) @@ -298,14 +308,17 @@ func newAPI(t *testing.T, writeTokens []string) (http.Handler, *store.Store) { } log := slog.New(slog.DiscardHandler) + cat := catalog.New(st, log) h := httpapi.New(httpapi.Options{ Ingest: ingest.New(arch, st, nil, log), + Catalog: cat, Log: log, WriteTokens: writeTokens, + ReadTokens: readTokens, MaxBodyMB: 1, // Бюджет задаётся всегда: httptest.ResponseRecorder дедлайнов не умеет, // и это ровно тот транспорт, на котором приём обязан продолжать работать. IngestWriteBudget: time.Minute, }) - return h, st + return h, st, cat } diff --git a/internal/httpapi/ingest.go b/internal/httpapi/ingest.go index de7a3cd..5c1fa95 100644 --- a/internal/httpapi/ingest.go +++ b/internal/httpapi/ingest.go @@ -103,11 +103,21 @@ var secretHeaders = map[string]bool{ // redacted подменяет значение, которое оказалось секретом. const redacted = "[redacted]" +// isSecretValue — совпало ли значение заголовка с каким-нибудь настроенным +// токеном, всё равно какого контура. +func (a *api) isSecretValue(v string) bool { + return tokenAllowed(v, a.writeTokens) || tokenAllowed(v, a.readTokens) +} + // safeHeaders собирает заголовки запроса для хранения, вычищая секреты. // // Двойная защита: имя из чёрного списка вырезается всегда, а любое значение, // совпавшее с настроенным токеном, подменяется — токен можно положить в // заголовок с произвольным именем, и угадать его мы не можем. +// +// Сверяется с токенами ОБОИХ контуров. Токен чтения так же секрет, как токен +// приёма, и посланный на этот маршрут произвольным заголовком осел бы в базе +// доставок навсегда. func (a *api) safeHeaders(r *http.Request) map[string][]string { out := make(map[string][]string, len(r.Header)+1) for name, values := range r.Header { @@ -117,7 +127,7 @@ func (a *api) safeHeaders(r *http.Request) map[string][]string { } safe := make([]string, len(values)) for i, v := range values { - if tokenAllowed(v, a.writeTokens) || tokenAllowed(strings.TrimPrefix(v, "Bearer "), a.writeTokens) { + if a.isSecretValue(v) || a.isSecretValue(strings.TrimPrefix(v, "Bearer ")) { safe[i] = redacted continue } diff --git a/internal/replay/archive_test.go b/internal/replay/archive_test.go index 80333b9..c79fe66 100644 --- a/internal/replay/archive_test.go +++ b/internal/replay/archive_test.go @@ -6,12 +6,16 @@ import ( "context" "flag" "io" + "log/slog" "os" "path/filepath" "sort" "strings" "testing" + "time" + "git.vakhrushev.me/av/healthlog/internal/catalog" + "git.vakhrushev.me/av/healthlog/internal/hae" "git.vakhrushev.me/av/healthlog/internal/ident" "git.vakhrushev.me/av/healthlog/internal/store" ) @@ -132,6 +136,8 @@ func TestReplayЖивогоАрхива(t *testing.T) { // Значений точек отпечаток не раскрывает: содержимое входит в него хешем. t.Logf("объектов %d, отпечаток содержимого %s", first.Buckets, first.Fingerprint) + measureStyles(t, dst) + // Главное свойство ключа: у записей сна он ИНТЕРВАЛ, а не метка — под одним // `date` лежит до трёх записей (docs/local-research.md, находка 47). // @@ -151,6 +157,72 @@ func TestReplayЖивогоАрхива(t *testing.T) { t.Logf("координат сна %d, различных меток %d", coords, labels) } +// measureStyles прогоняет измерение рода агрегации на витрине, собранной из +// живого архива, — единственное место, где правило проверяется на настоящем +// потоке, а не на фикстурах. +// +// Утверждаются СВОЙСТВА, а не числа: корпус растёт с каждой доставкой, а прогон +// живого архива в гейт не входит, так что константа, производная от размера +// корпуса, покраснела бы молча (docs/review-journal.md, 2026-08-02). Измеренные +// числа печатаются. +func measureStyles(t *testing.T, st *store.Store) { + t.Helper() + + started := time.Now() + metrics, err := catalog.New(st, slog.New(slog.DiscardHandler)).Metrics(context.Background()) + if err != nil { + t.Fatalf("каталог: %v", err) + } + elapsed := time.Since(started) + + byStyle := map[catalog.Style]int{} + for _, m := range metrics { + byStyle[m.Aggregation.Style]++ + + // Главное свойство правила: свидетельства единогласны. Противоречие — + // событие для разбора, а не отказ прогона, поэтому оно печатается с + // координатами и валит тест: пока его нет, посылка «род измерим» верна. + if m.Aggregation.Conflicting > 0 { + t.Errorf("%s: противоречащих часов %d при %d согласных — свидетельства разошлись", + m.Metric, m.Aggregation.Conflicting, m.Aggregation.Agreeing) + } + + // Род измеряется только сверкой минутного слоя с часовым. Метрика с + // объявленным родом обязана иметь оба слоя: иначе он выведен из + // нижнего, а нижний слой HAE — посекундная развёртка, и его сумма + // завышена. + if m.Aggregation.Style == catalog.Unknown { + continue + } + var hasMinute, hasHour bool + for _, l := range m.Layers { + hasMinute = hasMinute || l.Layer == string(hae.LayerMinute) + hasHour = hasHour || l.Layer == string(hae.LayerHour) + } + if !hasMinute || !hasHour { + t.Errorf("%s: род %v при слоях %+v — измерять было нечем", m.Metric, m.Aggregation.Style, m.Layers) + } + } + + // Роды разошлись: правило различает накопительные и мгновенные, а не + // сваливает всё в одну кучу и не отвечает `unknown` на весь корпус. + if byStyle[catalog.Cumulative] == 0 { + t.Error("накопительных метрик не нашлось — правило не различает роды") + } + if byStyle[catalog.Instant] == 0 { + t.Error("мгновенных метрик не нашлось — правило не различает роды") + } + + t.Logf("каталог: метрик %d за %v; накопительных %d, мгновенных %d, неизвестных %d", + len(metrics), elapsed.Round(time.Millisecond), + byStyle[catalog.Cumulative], byStyle[catalog.Instant], byStyle[catalog.Unknown]) + for _, m := range metrics { + t.Logf(" %-38s %-10s часов %d, пригодных %d, согласных %d", + m.Metric, m.Aggregation.Style, m.Aggregation.Hours, + m.Aggregation.Compared, m.Aggregation.Agreeing) + } +} + // countSleepKeys возвращает число различных координат записей сна и число // различных меток начала. Разница между ними и есть то, что теряет ключ по // метке. diff --git a/internal/store/catalog.go b/internal/store/catalog.go new file mode 100644 index 0000000..e8f9866 --- /dev/null +++ b/internal/store/catalog.go @@ -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 +} diff --git a/internal/store/catalog_internal_test.go b/internal/store/catalog_internal_test.go new file mode 100644 index 0000000..c0b1fac --- /dev/null +++ b/internal/store/catalog_internal_test.go @@ -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) + } + } +} diff --git a/internal/store/catalog_test.go b/internal/store/catalog_test.go new file mode 100644 index 0000000..3f50e88 --- /dev/null +++ b/internal/store/catalog_test.go @@ -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("окно вне контракта принято без ошибки") + } + }) + } +} diff --git a/internal/store/migrations/00009_bucket_catalog.sql b/internal/store/migrations/00009_bucket_catalog.sql new file mode 100644 index 0000000..ed19031 --- /dev/null +++ b/internal/store/migrations/00009_bucket_catalog.sql @@ -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; diff --git a/openspec/changes/archive/2026-08-02-katalog-i-rod-agregacii/.openspec.yaml b/openspec/changes/archive/2026-08-02-katalog-i-rod-agregacii/.openspec.yaml new file mode 100644 index 0000000..d658936 --- /dev/null +++ b/openspec/changes/archive/2026-08-02-katalog-i-rod-agregacii/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-02 diff --git a/openspec/changes/archive/2026-08-02-katalog-i-rod-agregacii/design.md b/openspec/changes/archive/2026-08-02-katalog-i-rod-agregacii/design.md new file mode 100644 index 0000000..07d508a --- /dev/null +++ b/openspec/changes/archive/2026-08-02-katalog-i-rod-agregacii/design.md @@ -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, которое пишется следующей задачей вместе с + остальными его маршрутами. Названо, чтобы не оказалось забытым. diff --git a/openspec/changes/archive/2026-08-02-katalog-i-rod-agregacii/proposal.md b/openspec/changes/archive/2026-08-02-katalog-i-rod-agregacii/proposal.md new file mode 100644 index 0000000..b961bd4 --- /dev/null +++ b/openspec/changes/archive/2026-08-02-katalog-i-rod-agregacii/proposal.md @@ -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 перестали быть заделом на будущее). diff --git a/openspec/changes/archive/2026-08-02-katalog-i-rod-agregacii/specs/catalog/spec.md b/openspec/changes/archive/2026-08-02-katalog-i-rod-agregacii/specs/catalog/spec.md new file mode 100644 index 0000000..11f7b07 --- /dev/null +++ b/openspec/changes/archive/2026-08-02-katalog-i-rod-agregacii/specs/catalog/spec.md @@ -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** в сохранённых заголовках вместо значения стоит пометка о сокрытии diff --git a/openspec/changes/archive/2026-08-02-katalog-i-rod-agregacii/specs/storage/spec.md b/openspec/changes/archive/2026-08-02-katalog-i-rod-agregacii/specs/storage/spec.md new file mode 100644 index 0000000..d467e53 --- /dev/null +++ b/openspec/changes/archive/2026-08-02-katalog-i-rod-agregacii/specs/storage/spec.md @@ -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** число обращений к базе не зависит от числа часов diff --git a/openspec/changes/archive/2026-08-02-katalog-i-rod-agregacii/tasks.md b/openspec/changes/archive/2026-08-02-katalog-i-rod-agregacii/tasks.md new file mode 100644 index 0000000..43c0938 --- /dev/null +++ b/openspec/changes/archive/2026-08-02-katalog-i-rod-agregacii/tasks.md @@ -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 Правило часа и правило метрики тестируются без БД; на живом архиве + проверяются свойства, а не числа diff --git a/openspec/specs/catalog/spec.md b/openspec/specs/catalog/spec.md new file mode 100644 index 0000000..b3f4cb6 --- /dev/null +++ b/openspec/specs/catalog/spec.md @@ -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** в сохранённых заголовках вместо значения стоит пометка о сокрытии + diff --git a/openspec/specs/storage/spec.md b/openspec/specs/storage/spec.md index fc9c6ec..ddf7d48 100644 --- a/openspec/specs/storage/spec.md +++ b/openspec/specs/storage/spec.md @@ -1009,3 +1009,51 @@ SHALL: сегодня ровно этот случай даёт ноль и мо пор не пересворачивалась - **THEN** её число пропущенных сущностей отсутствует, а не равно нулю +### Requirement: Перечисление разрезов не читает содержимое объектов + +Хранилище SHALL отвечать на вопрос «какие слои есть у метрики, за какой период и +сколько в них точек» по учётным колонкам объекта, не разжимая `payload` и не +затрагивая страниц с содержимым. Тот же запрет действует на поиск часов, за +которые у метрики есть объекты сразу в двух слоях. + +Запрет ограничен именно этими двумя выборками. Измерение рода обязано прочитать +значения точек, то есть разжать содержимое объектов окна, и требование его не +касается — иначе оно запрещало бы то, ради чего каталог существует. + +Причина в форме таблицы: `bucket` объявлена `WITHOUT ROWID`, то есть строка +целиком, вместе со сжатым содержимым, живёт в дереве первичного ключа. Обход +всех строк ради агрегата тащил бы за собой страницы содержимого — при 260 тысячах +объектов за год это сотни мегабайт на каждый запрос каталога, притом что сам +ответ несёт три десятка строк. + +Поэтому колонки, по которым отвечают эти выборки, MUST быть покрыты индексом, и +новая колонка, попадающая в ответ каталога, входит в него тем же изменением. + +#### Scenario: Разрезы метрики за длинную историю + +- **GIVEN** в витрине объекты за многие месяцы +- **WHEN** запрашиваются слои метрики с границами и числом точек +- **THEN** запрос отвечает по индексу, не читая содержимого объектов + +#### Scenario: Общие часы двух слоёв + +- **WHEN** запрашиваются самые свежие часы, за которые у метрики есть объекты и + в минутном, и в часовом слое +- **THEN** запрос отвечает по индексу и читает не больше запрошенного числа + часов + +### Requirement: Объекты перечисленных часов читаются пакетом + +Хранилище SHALL уметь отдать объекты двух слоёв за перечисленные часы одной +метрики **одним запросом**, а не по объекту за раз. + +Чтение по одному даёт число обращений, растущее вместе с окном измерения, и +делает каждое обращение собственной транзакцией — то есть ответ, собранный из +разных снимков витрины под непрерывным приёмом. + +#### Scenario: Окно из многих часов + +- **GIVEN** запрошены объекты двух слоёв за сорок восемь часов +- **WHEN** выполняется выборка +- **THEN** число обращений к базе не зависит от числа часов +