diff --git a/cmd/healthlog/serve.go b/cmd/healthlog/serve.go index c9683bd..82600ee 100644 --- a/cmd/healthlog/serve.go +++ b/cmd/healthlog/serve.go @@ -20,6 +20,7 @@ import ( "git.vakhrushev.me/av/healthlog/internal/httpapi" "git.vakhrushev.me/av/healthlog/internal/ingest" "git.vakhrushev.me/av/healthlog/internal/logging" + "git.vakhrushev.me/av/healthlog/internal/points" "git.vakhrushev.me/av/healthlog/internal/replay" "git.vakhrushev.me/av/healthlog/internal/store" ) @@ -122,6 +123,7 @@ func serve(ctx context.Context, cfg *config.Config, log *slog.Logger, ready func Handler: httpapi.New(httpapi.Options{ Ingest: ingest.New(arch, st, worker.Notify, log), Catalog: catalog.New(st, log), + Points: points.New(st, log), Log: log, WriteTokens: cfg.Auth.WriteTokens, ReadTokens: cfg.Auth.ReadTokens, diff --git a/config.example.toml b/config.example.toml index af10490..a857333 100644 --- a/config.example.toml +++ b/config.example.toml @@ -26,7 +26,7 @@ write_timeout = "30s" # на отправку ответа прочих ма # входе, открытое чтение — выгрузку всей истории здоровья любому, кто нашёл # порт. Перед выкладкой наружу `read_tokens` обязан быть непуст. write_tokens = [] # токены на приём данных -read_tokens = [] # токены на чтение: каталог `GET /api/v1/metrics` +read_tokens = [] # токены на чтение: каталог `GET /api/v1/metrics` и точки `GET /api/v1/metrics/{name}` [storage] # ВНИМАНИЕ: умолчания в коде (./healthlog.db и ./raw) остались от прежней diff --git a/docs/adr/ADR-2026-08-04-otvet-tochek-nesyot-rod-i-ego-primenimost.md b/docs/adr/ADR-2026-08-04-otvet-tochek-nesyot-rod-i-ego-primenimost.md new file mode 100644 index 0000000..5b70670 --- /dev/null +++ b/docs/adr/ADR-2026-08-04-otvet-tochek-nesyot-rod-i-ego-primenimost.md @@ -0,0 +1,85 @@ +# Ответ точек несёт измеренный род и его применимость к отданному ряду + +- **Дата:** 2026-08-04 +- **Источник:** openspec/changes/archive/2026-08-04-tochki-metriki-za-period/design.md + +## Решение + +Конверт ответа маршрута точек несёт `aggregation` **объектом** +`{style, applicable, last_hour}`, а не строкой с применённой свёрткой: + +- `style` — измеренный род метрики, тот же словарь и то же имя, что у каталога; +- `applicable` — применим ли объявленный род к **отданному ряду**; +- `last_hour` — ярлык самого свежего часа окна измерения. + +`docs/architecture.md` до этого изменения обещал `"aggregation": "sum"` — +строку. Решение её **пересматривает**: строка называет применённое и молчит об +основании. + +Отвергнуто и названо поимённо: поле `applied` с именем применённой свёртки +(выводится из `style` и `bucket` тем же инвариантом; как строка неверно +описывает свёртку мгновенной метрики, у которой по архитектуре «среднее с +`min`/`max` рядом»); полное основание каталога (`hours`, `compared`, `agreeing`, +`conflicting`, `first_hour`) в конверте точек — второй экземпляр факта, обязанный +сходиться с первым. + +## Почему + +**Род есть свойство метрики, а слой — свойство ряда, и их сочетание бывает +опасным.** Конверт `{"layer": "raw", "style": "cumulative"}` законен и штатен: +правило выбора слоя при равном охвате предпочитает самый мелкий. Инвариант +«нижний слой HAE не суммируется никогда» система соблюдает, ничего не складывая, +— но потребитель об инварианте не знает, а сумма по нижнему слою завышает втрое +(находка 34 разведки). Разрыв построен проходом `review-rubric` на предложении, +до кода: + +> Конверт `{"layer": "raw", "aggregation": {"style": "cumulative"}}` законен, +> штатен — и он прямо приглашает главного потребителя (агента с ограниченным +> контекстом) сложить ряд самому. Система при этом свёртки не делает, инвариант +> формально цел; результат у потребителя завышен, а решение по нему уже принято. + +`applicable: false` — та самая оговорка, которая едет вместе с данными. + +**`last_hour` — единственный след замершего окна.** Род считается по 48 самым +свежим **общим** часам, а не по последним 48 часам календаря: выключенная +минутная автоматизация HAE останавливает пополнение общих часов, окно замирает и +продолжает объявлять род. + +**Литература расколота, и обе стороны названы.** Род **вместе с данными**: +Google Cloud Monitoring объявляет `metricKind` и `valueType` в каждом объекте +`TimeSeries` ответа, а не только в дескрипторе метрики; CloudWatch +`GetMetricData` кладёт `StatusCode` (`Complete` / `PartialData`) рядом с рядом — +оговорка едет с данными, а не оставляется клиенту на вывод; Home Assistant +`statistics_during_period` держит `start` и `end` в ответе **всегда**, +независимо от запрошенных `types`. Род **отдельно от данных**: Prometheus отдаёт +`{resultType, result}` без единого слова о типе, а тип живёт в +`/api/v1/metadata`; Graphite render не объявляет ничего. Второе отвергнуто по +измеримой причине: клиент обязан сделать второй запрос, а до тех пор не +отличает «род известен» от «род не измерен», — и согласованности между двумя +ответами всё равно нет, потому что род есть функция **окна**, а окно едет с +часами. Принцип HealthKit `HKStatistics` («род не тот — свёртки нет») взят, +механизм неприменим: у нас стиль источником не объявлен. + +## Последствия + +- `+` Потребитель видит не только число, но и на каком основании его можно + сворачивать, без второго запроса и без знания инвариантов проекта. +- `+` Форма объявлена **до** того, как её скопируют свёртка по сетке, порог + неполного ведра, тренировки, записи и MCP. После копирования это была бы не + развилка, а археология. +- `−` Поле `applicable` избыточно по построению: клиент, знающий правило «нижний + слой HAE не суммируется», вывел бы его из `style` и `layer`. Взято сознательно + — правило принадлежит нам, и молчаливо перекладывать его на потребителя + дороже, чем поле. +- `−` Чтобы разобрать, **почему** род `unknown`, придётся спросить каталог: + полное основание живёт там в одном экземпляре. +- `−` Род в конверте точек и род в каталоге считаются в разные моменты и у + клиента, сравнивающего два ответа, могут разойтись. Это свойство измерения, а + не дефект; ровно поэтому `last_hour` едет вместе с родом. + +## Открыто, решает владелец + +**Машинно-различимый код причины отказа.** Тело отказа несёт только +человекочитаемую строку, и агент не отличит «зона не указана» от «слой +незнаком» иначе, чем разбором русского текста. Правило общее для всех маршрутов +и меняет `errorWire`, то есть и контракт приёма, — сюда не взято. diff --git a/docs/adr/ADR-2026-08-04-sloy-vybiraetsya-po-ohvatu-tochek.md b/docs/adr/ADR-2026-08-04-sloy-vybiraetsya-po-ohvatu-tochek.md new file mode 100644 index 0000000..d17cf91 --- /dev/null +++ b/docs/adr/ADR-2026-08-04-sloy-vybiraetsya-po-ohvatu-tochek.md @@ -0,0 +1,80 @@ +# Слой ответа выбирается по охвату точек внутри периода + +- **Дата:** 2026-08-04 +- **Источник:** openspec/changes/archive/2026-08-04-tochki-metriki-za-period/design.md + +## Решение + +Слой, из которого собирается ряд, выбирается так: + +> Охват слоя — длина пересечения отрезка `[первая метка слоя, последняя метка +> слоя]` с запрошенным периодом. Слой с пустым пересечением выбывает. Среди +> оставшихся берётся слой с наибольшим охватом, при равенстве — самый мелкий +> (`sample` → `raw` → `minute` → `hour` → `day`). + +Это **пересмотр** прежнего правила, записанного в `docs/architecture.md`: «самый +мелкий слой, покрывающий весь запрошенный диапазон». + +## Почему + +**Прежняя формулировка неопределена на входе, который тот же документ объявляет +законным.** Границы слоя — границы **данных**, а не обещание покрытия: внутри +диапазона законно есть дыры, и слоя, покрывающего диапазон целиком, может не +существовать вовсе. Правило, не определённое на законном входе, реализатор +доопределяет молча. + +**Мера — охват, а не число точек.** `body_mass` в нижнем слое за три плотных дня +даёт больше объектов, чем часовой слой за год с еженедельным взвешиванием: по +числу точек «вес за год» вернул бы три дня, не сказав об этом ни словом. + +**Охват меряется метками точек, а не часами объектов**, и это не придирка. +Объекты адресуются часом, поэтому выборка обязана быть шире запроса (точка +`10:59` живёт в объекте `10:00`), а ряд отбирается точной меткой. Путь построен +проходом ревью на предложении: + +> `from = 10:30`, `to = 10:45`. Слой `hour` имеет объект `10:00` с единственной +> точкой в `10:00`, слой `minute` — объект `10:00` с точками `10:31…10:44`. По +> часам объектов охваты равны, побеждает `hour` — и после точного отбора ответ +> уходит пустым при непустых минутных данных. + +Класс общий: **предикат выбора источника и предикат отбора данных обязаны +использовать одну границу**. + +**Цена меры измерена, и она не нулевая.** Индекс `bucket_catalog` идёт +`(metric, layer, hour_utc, …)`, и без предиката по слою SQLite не сужает поиск по +`hour_utc` — он просматривает все строки метрики за всю историю, а план при этом +выглядит успешным (`SEARCH … USING COVERING INDEX`). Замер эксплуатационного +прохода на копии схемы: 2.06 мс при 52 560 строках метрики против 13.9 мс при +350 400, то есть цена росла бы вместе с возрастом сервиса при любой ширине +запроса. С явным перечислением слоёв — 0.026 мс. Отсюда же следствие: **словарь +слоёв один** (`hae.Layers`), из него выводятся и порядок, и перечень выборки, и +проверка параметра запроса, и текст отказа клиенту. + +## Последствия + +- `+` Правило определено на любом входе, включая тот, где ни один слой периода + не покрывает. +- `+` Смены слоя внутри одного ответа не бывает: ряд, склеенный из двух слоёв, + поехал бы незаметно для клиента, а вместе с ним поехала бы и будущая свёртка. +- `−` Правило **максимизирует** размер ответа: при равном охвате берётся самый + мелкий слой, то есть «пульс за неделю» без параметров это сотни тысяч точек. + Предел ответа — соседняя задача; цена измерена и названа (см. ниже). +- `−` Краевой объект, у которого есть точки и до, и после периода, но ни одной + внутри, свой слой из выбора не выведет. Остаток узкий и честный: слой в ответе + назван, а `points` пуст. + +## Открыто, решает владелец + +**Инвертировать ли умолчание при равном охвате.** Сегодня берётся самый мелкий — +это правило `architecture.md` до пересмотра, и оно максимизирует размер ответа. +Измерено на этом маршруте: неделя нижнего слоя — 604 800 точек, 1.75 с и +1375 МиБ суммарных выделений на доменном слое; под HTTP вместе с сериализацией — +2.89 с, 279.7 МиБ тела, 1335 МиБ живой кучи; четыре одновременных запроса дают +4322 МиБ. + +- **(а)** оставить как есть, предел вводит `read-api-response-limit`; +- **(б)** при равном охвате брать самый **крупный** слой, мелкий — только по + явному `layer`. + +**Рекомендация:** (а). Решение сцеплено с формой предела, и принимать его +мимоходом на первой ручке — то же, от чего отказались на каталоге. diff --git a/docs/adr/README.md b/docs/adr/README.md index 47bde12..b91b13b 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -33,6 +33,17 @@ | Дата | Запись | Статус | | --- | --- | --- | +- [ADR-2026-08-04-otvet-tochek-nesyot-rod-i-ego-primenimost](ADR-2026-08-04-otvet-tochek-nesyot-rod-i-ego-primenimost.md) + — конверт точек несёт измеренный род, его **применимость к отданному ряду** и + границу окна измерения; строка `"aggregation": "sum"` пересмотрена, поле + `applied` отвергнуто как выводимое; род вместе с данными взят у Google Cloud + Monitoring и CloudWatch, отдельный `/metadata` Prometheus отвергнут. +- [ADR-2026-08-04-sloy-vybiraetsya-po-ohvatu-tochek](ADR-2026-08-04-sloy-vybiraetsya-po-ohvatu-tochek.md) + — «самый мелкий слой, покрывающий весь диапазон» пересмотрено: правило было + неопределено на законном входе. Охват меряется метками **точек**, а не часами + объектов, иначе период короче часа отдаёт пустой ряд при непустых данных; + цена меры измерена (13.9 мс против 0.026 мс) и потребовала одного словаря + слоёв. - [ADR-2026-08-04-forma-provoda-prinadlezhit-transportu](ADR-2026-08-04-forma-provoda-prinadlezhit-transportu.md) — публичный контракт чтения объявляет транспорт, а не домен; «доменные типы и есть форма провода» (`wtf`, Prometheus) отвергнуто фактом — поля `store.Point` diff --git a/docs/architecture.md b/docs/architecture.md index a65e52a..f547253 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -230,7 +230,8 @@ capability**, и здесь стоит ссылка, а не пересказ т | `replay` | проигрывание журнала в витрину: состав, порядок, отчёт | [`reindex`](../openspec/specs/reindex/spec.md) | | `catalog` | каталог разрезов и измерение рода агрегации | [`catalog`](../openspec/specs/catalog/spec.md) | | `store` | SQLite: доставки, часовые объекты, тренировки, записи | [`storage`](../openspec/specs/storage/spec.md) | -| `httpapi` | приём, read API и **форма провода** ответов чтения | [`ingest`](../openspec/specs/ingest/spec.md), [`catalog`](../openspec/specs/catalog/spec.md), [`read-api`](../openspec/specs/read-api/spec.md) | +| `points` | ряд точек метрики за период: выбор слоя, применимость рода | [`points`](../openspec/specs/points/spec.md) | +| `httpapi` | приём, read API и **форма провода** ответов чтения | [`ingest`](../openspec/specs/ingest/spec.md), [`catalog`](../openspec/specs/catalog/spec.md), [`read-api`](../openspec/specs/read-api/spec.md), [`points`](../openspec/specs/points/spec.md) | ## Приём @@ -835,7 +836,7 @@ hour метки выровнены на час heart_rate 00:00:00 доставки той же автоматизации; если её не было, берём **надёжный** заголовок (`Minutes` → `minute`, `Hours` → `hour`). Иначе точки не сохраняются вовсе: молчаливый `raw` создал бы призрачный разрез, который поедет в каталог и в - правило Read API «самый мелкий слой, покрывающий диапазон». + правило Read API выбора слоя (см. «Read API»). Слово «предшествующей» в третьем пункте несёт вес: слой обязан быть функцией от **префикса журнала**. Наследование от последней доставки вообще делает свёртку @@ -1446,7 +1447,7 @@ MongoDB, и так просилось из слова «перезаписыва ``` GET /api/v1/metrics каталог: имя, units, род, слои с диапазонами -GET /api/v1/metrics/{name}?from&to&bucket&layer точки метрики, при желании свёрнутые +GET /api/v1/metrics/{name}?from&to&layer точки метрики за период (bucket — соседняя задача, пока 400) GET /api/v1/workouts?from&to заголовки тренировок GET /api/v1/workouts/{id} тренировка целиком, с маршрутом GET /api/v1/records/{kind}?from&to прочие секции @@ -1497,9 +1498,21 @@ GET /healthz законно есть дыры. Поэтому правило выбора слоя опирается на фактические объекты запрошенного диапазона, а не на каталожную пару границ. -Параметр `layer` выбирает разрез. Если он не указан — берём **самый мелкий -слой, покрывающий весь запрошенный диапазон**. Молча переключать слой на -границе периода нельзя: ряд поедет незаметно для клиента. +Параметр `layer` выбирает разрез. Если он не указан — берём слой с **наибольшим +охватом внутри запрошенного периода**, а при равном охвате самый мелкий (порядок +`sample` → `raw` → `minute` → `hour` → `day`). Молча переключать слой на границе +периода нельзя: ряд поедет незаметно для клиента, и ряд из одного ответа всегда +собран из одного слоя. + +**Охват — длина пересечения** отрезка «первая метка слоя … последняя метка слоя» +с периодом; слой с пустым пересечением выбывает. Меряется он метками **точек**, +а не часами объектов. Почему прежняя формулировка («самый мелкий, покрывающий +весь диапазон») пересмотрена, почему мера именно такая и во что она обошлась — +[ADR-2026-08-04-sloy-vybiraetsya-po-ohvatu-tochek](adr/ADR-2026-08-04-sloy-vybiraetsya-po-ohvatu-tochek.md). + +Словарь слоёв при этом **один** (`hae.Layers`): из него выводятся и порядок, и +перечень слоёв в выборке охватов, и проверка параметра запроса, и текст отказа +клиенту. ### Условный запрос @@ -1578,21 +1591,54 @@ GET /healthz Нормализованная оболочка, сырое содержимое: ```json -{"layer": "minute", "bucket": "hour", "aggregation": "sum", +{"metric": "heart_rate", + "from": "2026-07-31T00:00:00Z", "to": "2026-08-01T00:00:00Z", + "layer": "minute", "bucket": null, + "aggregation": {"style": "instant", "applicable": true, + "last_hour": "2026-08-02T14:00:00Z"}, "points": [ - {"ts": "2026-07-31T09:00:00Z", "tz_offset": 10800, "units": "count", - "values": {"qty": 812}} + {"ts": "2026-07-31T09:00:00Z", "ts_end": "2026-07-31T09:00:00Z", + "tz_offset": 10800, "units": "count", "values": {"qty": 812}} ]} ``` -`layer`, `bucket` и `aggregation` присутствуют всегда, даже когда свёртки не -было (`"bucket": null`): клиент не должен выводить их наличием или -отсутствием поля. +Все поля присутствуют ВСЕГДА, даже когда сообщить нечего: клиент не должен +выводить исход наличием или отсутствием поля. `bucket` равен `null`, когда +свёртки не было; `layer` — `null`, когда слой выбирала система и выбирать было +не из чего (явно запрошенный слой уезжает всегда, в том числе при пустом ряде). + +`aggregation` — **объект, а не строка**. Строка называла бы только применённую +свёртку, а инвариант требует, чтобы клиент видел ещё и основание (решение и +разбор чужих API — +[ADR-2026-08-04-otvet-tochek-nesyot-rod-i-ego-primenimost](adr/ADR-2026-08-04-otvet-tochek-nesyot-rod-i-ego-primenimost.md)): + +- `style` — измеренный род метрики, тот же словарь, что у каталога; +- `applicable` — применим ли род к **отданному ряду**. Род есть свойство + метрики, слой — свойство ряда, и сочетание `{"layer": "raw", "style": + "cumulative"}` законно и штатно: оно приглашает потребителя сложить + интерполяцию самому и завысить втрое. Система при этом не складывает ничего — + а потребитель об инварианте не знает; +- `last_hour` — ярлык самого свежего часа окна измерения. Окно считается в + **общих** часах, а не в часах календаря: выключенная минутная автоматизация + HAE останавливает их пополнение, окно замирает и продолжает объявлять род. + Это единственный след. + +`ts_end` — конец координаты точки; у точки-измерения равен `ts`. Он есть потому, +что идентичность точки — интервал, а не метка: под одной меткой лежит до трёх +записей сна, и конверт с одним `ts` предлагал бы клиенту различать их, разбирая +дословное содержимое. + +Принадлежность точки периоду определяется её **началом** — тем же правилом, +каким час объекта берётся по началу. Цена названа: «сон за ночь с полуночи» не +увидит эпизод, начавшийся в 23:40. Время приведено к единому виду, значения отданы как пришли: ни -переименований, ни пересчёта единиц. Метрик у Apple много и они разные — -семантику разбирает клиент по имени метрики. Полная нормализация означала бы, -что каждая новая метрика требует правки коллектора, а незнакомая теряется. +переименований, ни пересчёта единиц, ни экранирования (сериализатор ответа +HTML-символы не экранирует — иначе `&` в имени источника уезжал бы как +`\u0026`, и обещание дословности переставало быть правдой). Метрик у Apple +много и они разные — семантику разбирает клиент по имени метрики. Полная +нормализация означала бы, что каждая новая метрика требует правки коллектора, +а незнакомая теряется. ### Форма провода diff --git a/docs/conventions/storage.md b/docs/conventions/storage.md index 2ca5663..ce91bb7 100644 --- a/docs/conventions/storage.md +++ b/docs/conventions/storage.md @@ -80,3 +80,31 @@ перестаёт молча. Проверено на закреплённом драйвере: одна испорченная строка `delivery.uncovered_sections` обесценивала и сверку новизны (вечное «сверка не состоялась» на каждой доставке), и перечень целиком. + +## Предикат выбора источника и предикат отбора данных — одна граница + +Объекты витрины адресуются часом, а точки отбираются точной меткой. Выборка +объектов поэтому обязана быть **шире** запроса (точка `10:59` живёт в объекте +`10:00`) — и ровно здесь появляется разрыв: множество «слои, у которых есть +объекты в периоде» не совпадает с множеством «слои, у которых есть точки в +периоде». + +Правило: **решение о том, откуда брать данные, принимается по той же границе, по +которой данные потом отбираются.** Иначе узел выбирает источник, в котором после +точного отбора не остаётся ничего, и отдаёт пустоту при непустых данных +соседнего источника — молча, потому что и выбор, и отбор по отдельности верны. + +Прецедент: правило выбора слоя в Read API мерило охват часами объектов, а ряд +отбирало метками точек; на периоде короче часа ответ уходил пустым при непустых +минутных данных (ADR-2026-08-04-sloy-vybiraetsya-po-ohvatu-tochek). + +## Значение из чужого тела имеет предел длины у КАЖДОГО адресата + +Правило `docs/security.md` про предел длины читается как «в ключ, в лог, в +отчёт» — и адресаты кончаются не там. Имя метрики уезжает ещё и в заголовок +ответа: без предела `ETag` растёт вместе с именем, а кавычка внутри имени по +RFC 9110 кончает метку, и условный запрос по такой метрике не сработает никогда. + +Когда предел неудобен (значение нужно целиком), его заменяет **форма**: в метку +уезжает хеш канонизированной строки, а не строка. Хеш здесь не секрет — он +ограничитель длины и экранирование разом. diff --git a/docs/review.md b/docs/review.md index 43db2aa..ef60501 100644 --- a/docs/review.md +++ b/docs/review.md @@ -142,6 +142,13 @@ **Перестали проверять сознательно.** +- **Шаг покрытия диффа гейт не красит.** `CLAUDE.md` объявляет, что непокрытая + изменённая строка красит гейт безусловно; `scripts/diff-coverage.py` всегда + возвращает `0`, и шаг печатает `OK` при любом покрытии. То есть «гейт зелёный» + не означает «покрытие диффа полное», и разбор непокрытых строк остаётся + человеку или проходу. Найдено проходом `gate` 2026-08-04, подтверждено + триажем; чинить нельзя мимоходом — починка немедленно красит гейт задачи, в + которой её сделали. - Прогон живого архива (`task verify:archive`) и свёртка под удерживаемой блокировкой (`task verify:busy`) в гейт не входят: минута и около 50 секунд соответственно, плюс данные, которых нет ни на какой другой машине. Гоняет их @@ -158,6 +165,42 @@ Запись на каждый воспроизведённый дефект, сразу, а не ретроспективно: со временем теряется не факт, а причина непоймания. +## 2026-08-04 — правило выбора слоя мерило одно, а отбор шёл по другому [пойман] + +**Что было.** Правило выбора слоя ответа Read API мерило охват **часами +объектов**, а ряд отбирался **точной меткой точки**. На периоде короче часа +множества расходятся: часовой объект попадает в границы часов запроса, а его +единственная точка в период не попадает. Ответ уходил бы пустым при непустых +данных соседнего слоя — с непустым `layer`, то есть неотличимо от честной +пустоты только по числу точек. + +**Почему поймано.** Профиль `design` на предложении, до кода: и `review-specs`, +и `review-rubric` построили один и тот же вход независимо друг от друга +(`from = 10:30`, `to = 10:45`). На готовом коде находка стоила бы переписывания +выборки; на предложении — абзаца. + +**Что сделано.** Охват меряется метками точек (`first_ts`/`last_ts` уже лежат в +покрывающем индексе). Класс промоутнут в +`docs/conventions/storage.md` — «предикат выбора источника и предикат отбора +данных используют одну границу»: он повторится всюду, где огрубление ради +полноты выборки соседствует с точным фильтром. + +## 2026-08-04 — чекпоинт, заведённый ревью, не существовал бы в проде [пойман] + +**Что было.** Враждебный проход построил путь «ответ оборвался по `WriteTimeout` +на середине, а `accessLog` написал `200`»: тело в 13 МиБ доехало на 2.7 МиБ, +клиент получил нечитаемый JSON, лог сообщил успех. Чекпоинт об обрыве завели — +и поставили ему уровень `DEBUG`. + +**Почему поймано.** Эксплуатационный проход прочитал **боевой** конфиг +(`config.docker.toml`, `level = "info"`) и показал, что запись уровня `DEBUG` +не проходит фильтр `slog` никогда. То есть находка была закрыта наблюдаемостью, +которой в проде не существует. + +**Что сделано.** Уровень поднят до `WARN`. Правило, которое из этого следует: +**уровень нового чекпоинта сверяется с боевым конфигом, а не с тем, что видно в +тестах** — в тестах уровень всегда `DEBUG`. + Реализованные задачи, находки ревью и решения сюда не пишутся: у них есть коммит, спека и задача. Здесь только промахи конвейера и решения о его составе. diff --git a/docs/security.md b/docs/security.md index ec1ba33..b25af24 100644 --- a/docs/security.md +++ b/docs/security.md @@ -61,9 +61,17 @@ disabled`, `read auth disabled`), но стартовать не отказыв **Ни один сегмент пути не берётся из тела или заголовков доставки** — это и есть защита от выхода за пределы каталога, и она держится ровно на этом. - **Координатный ключ точки** — `метрика + слой + начало + конец`. Имя метрики - приходит из тела и в путь на диске не попадает, но попадает в ключ, в лог и в - ответ каталога. Любое значение из чужого JSON, попадающее в ключ, в лог или в - отчёт, имеет названный предел длины. + приходит из тела и в путь на диске не попадает, но попадает в ключ, в лог, в + ответ каталога и — с появлением маршрута точек — **в адрес запроса и в + заголовок `ETag` ответа**. Любое значение из чужого JSON, попадающее в ключ, в + лог, в отчёт или в заголовок, имеет названный предел длины. У метки ответа + предел взят формой: в неё уезжает не имя, а хеш канонизированной формы запроса + (128 бит). Причина не только в длине — имя законно содержит кавычку, которая + по RFC 9110 кончает метку, и разбор обрезал бы её ровно там. +- **Имя метрики в адресе** декодируется из пути **ровно один раз**. Второе + декодирование превращает имя `a%41b` в имя `aAb` — то есть в имя **другой** + метрики витрины, и маршрут отвечает `200` её данными. Путь построен и прогнан + враждебным проходом ревью. - **Ключ сущности** — `род секции + id` из HealthKit для `record`, `id` для `workout`. `id` приходит из тела. - **Ключ наблюдённого категориального значения** — `метрика + поле + значение`. diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go index 80b66c8..44651b5 100644 --- a/internal/catalog/catalog.go +++ b/internal/catalog/catalog.go @@ -147,7 +147,12 @@ func closeEnough(a, b float64) bool { return math.Abs(a-b)/math.Max(math.Abs(a), math.Abs(b)) <= tolerance } -func clipMetric(metric string) string { +// ClipMetric обрезает имя метрики для записи лога. +// +// Экспортирована потому, что предел один на всех, кто пишет имя метрики в лог: +// имя приходит из тела дословно при пределе приёма в 64 МиБ, а запись +// повторяется на каждый запрос. Второй предел разошёлся бы с первым молча. +func ClipMetric(metric string) string { if len(metric) <= maxMetricInLog { return metric } @@ -252,12 +257,38 @@ func (s *Service) Version(ctx context.Context) (string, error) { s.log.DebugContext(ctx, "state version unavailable", "capability", "query", "error", err) return "", err } - return stamp(version, store.Now().Add(horizonSlack)), nil + return Stamp(version, Horizon()), nil } -// stamp склеивает версию витрины с горизонтом. Пустая версия остаётся пустой: +// Horizon — верхняя граница окна измерения на текущий момент. +// +// Экспортирована потому, что горизонт нужен ВСЕМ, кто объявляет измеренный род: +// маршрут точек снимает род и метку с одного горизонта, иначе метка подтвердит +// неизменность ответа, в котором род уже перевернулся ходом часов. +func Horizon() time.Time { return store.Now().Add(horizonSlack) } + +// MeasureWindow — окно измерения рода для заданного горизонта. +// +// Одно на всех потребителей измерения. Второй экземпляр параметров разошёлся бы +// с первым молча, а вердикт зависит от каждого из них: слои сверки, размер окна +// и оба структурных порога уходят в предварительный отбор хранилища. +func MeasureWindow(horizon time.Time) store.CatalogWindow { + return store.CatalogWindow{ + Fine: string(hae.LayerMinute), + Coarse: string(hae.LayerHour), + Hours: Window, + Horizon: horizon, + CoarsePoints: coarsePoints, + MinFinePoints: minFinePoints, + } +} + +// Stamp склеивает версию витрины с горизонтом. Пустая версия остаётся пустой: // подписывать нечем — значит нечем, и горизонт этого не меняет. -func stamp(version string, horizon time.Time) string { +// +// Экспортирована по той же причине, что и Horizon: правило «метка строится из +// всего, от чего зависит ответ» держится ровно до тех пор, пока склейка одна. +func Stamp(version string, horizon time.Time) string { if version == "" { return "" } @@ -287,19 +318,12 @@ func New(st *store.Store, log *slog.Logger) *Service { // её хранилище — двумя пробами вокруг чтения. Порядок проб там же и объяснён: // версия, снятая после чтения, пометила бы устаревший снимок свежей меткой. func (s *Service) Metrics(ctx context.Context) (Snapshot, error) { - horizon := store.Now().Add(horizonSlack) + horizon := Horizon() var snap store.CatalogSnapshot version, err := s.store.VersionedRead(ctx, func(ctx context.Context) error { var err error - snap, err = s.store.ReadCatalog(ctx, store.CatalogWindow{ - Fine: string(hae.LayerMinute), - Coarse: string(hae.LayerHour), - Hours: Window, - Horizon: horizon, - CoarsePoints: coarsePoints, - MinFinePoints: minFinePoints, - }) + snap, err = s.store.ReadCatalog(ctx, MeasureWindow(horizon)) return err }) if err != nil { //nolint:nestif // ветка одна, вложенность даёт лог по адресату @@ -331,7 +355,7 @@ func (s *Service) Metrics(ctx context.Context) (Snapshot, error) { if basis.Conflicting > 0 { s.log.WarnContext(ctx, "aggregation style conflict", "capability", "query", - "metric", clipMetric(group.metric), + "metric", ClipMetric(group.metric), "hours", basis.Hours, "compared", basis.Compared, "agreeing", basis.Agreeing, @@ -344,7 +368,7 @@ func (s *Service) Metrics(ctx context.Context) (Snapshot, error) { if to := group.latest(); to.After(horizon) { s.log.WarnContext(ctx, "future data", "capability", "query", - "metric", clipMetric(group.metric), + "metric", ClipMetric(group.metric), "last_ts", store.FormatTime(to), "horizon", store.FormatTime(horizon)) } @@ -362,7 +386,7 @@ func (s *Service) Metrics(ctx context.Context) (Snapshot, error) { // механизм не окупается вовсе. s.log.DebugContext(ctx, "catalog unsigned", "capability", "query") } - return Snapshot{Version: stamp(version, horizon), Metrics: out}, nil + return Snapshot{Version: Stamp(version, horizon), Metrics: out}, nil } type metricGroup struct { diff --git a/internal/catalog/catalog_test.go b/internal/catalog/catalog_test.go index af20dc5..3cc0a29 100644 --- a/internal/catalog/catalog_test.go +++ b/internal/catalog/catalog_test.go @@ -520,3 +520,29 @@ func TestКаталогОтдаётсяСВерсиейВитрины(t *testing t.Error("каталог собран на стоящей витрине и остался без версии") } } + +// Версия ответа каталога — это версия витрины ПЛЮС горизонт измерения. +// +// Утверждение прямое, потому что склейка теперь общая: её же зовёт маршрут +// точек. Сломай её — и оба маршрута начнут подтверждать неизменность ответа, +// чей род перевернулся ходом часов, а не коммитом. +func TestВерсияКаталогаНесётГоризонт(t *testing.T) { + st := openStore(t) + ctx := context.Background() + + got, err := service(t, st).Version(ctx) + if err != nil { + t.Fatalf("Version: %v", err) + } + + bare, err := st.StateVersion(ctx) + if err != nil { + t.Fatalf("StateVersion: %v", err) + } + if got == bare { + t.Error("версия ответа равна версии витрины — горизонт в неё не вошёл") + } + if want := catalog.Stamp(bare, catalog.Horizon()); got != want { + t.Errorf("версия ответа %q, ожидалась %q", got, want) + } +} diff --git a/internal/catalog/version_internal_test.go b/internal/catalog/version_internal_test.go index d85f94f..352d72f 100644 --- a/internal/catalog/version_internal_test.go +++ b/internal/catalog/version_internal_test.go @@ -15,16 +15,16 @@ func TestГоризонтВходитВВерсиюОтвета(t *testing.T) { at := time.Date(2026, 6, 1, 10, 30, 0, 0, time.UTC) - if stamp("v", at) == stamp("v", at.Add(2*time.Hour)) { + if Stamp("v", at) == Stamp("v", at.Add(2*time.Hour)) { t.Error("версия не изменилась при сдвиге горизонта на два часа") } // Огрубление до часа точное, а не приблизительное: `hour_utc` объектов лежит // ровно на часах, поэтому отбор меняется ровно при переходе через час. // Внутри часа метка обязана стоять — иначе она дребезжала бы ежесекундно. - if stamp("v", at) != stamp("v", at.Add(20*time.Minute)) { + if Stamp("v", at) != Stamp("v", at.Add(20*time.Minute)) { t.Error("версия сдвинулась внутри одного часа — метка дребезжит на месте") } - if stamp("", at) != "" { + if Stamp("", at) != "" { t.Error("пустая версия витрины подписана горизонтом — подписывать нечем") } } diff --git a/internal/hae/hae.go b/internal/hae/hae.go index 01953e8..7288ada 100644 --- a/internal/hae/hae.go +++ b/internal/hae/hae.go @@ -856,25 +856,36 @@ func headerLayer(aggregation string) Layer { // finer возвращает более мелкий из двух слоёв. func finer(a, b Layer) Layer { - if rank(a) < rank(b) { + if Rank(a) < Rank(b) { return a } return b } -func rank(l Layer) int { - switch l { - case LayerSample: - return 0 - case LayerRaw: - return 1 - case LayerMinute: - return 2 - case LayerHour: - return 3 - case LayerDay: - return 4 - default: - return 5 +// Layers — ЕДИНСТВЕННЫЙ словарь слоёв, упорядоченный от самого мелкого к самому +// крупному. +// +// Один, потому что иначе их становится четыре: порядок для разбора, перечень +// для выборки охватов, проверка параметра запроса и текст отказа клиенту. Ни +// компилятор, ни тест их не сверяют — новая константа `Layer` скомпилировалась +// бы, получила бы ранг «крупнее всех», не попала бы в выборку охватов и +// отвергалась бы маршрутом как незнакомая. Симптомом был бы пустой ряд при +// непустых данных. Случай не гипотетический: слой `sample` наполнится импортом +// родного экспорта Apple. +// +// Срез, а не карта: порядок здесь и есть содержание. +var Layers = []Layer{LayerSample, LayerRaw, LayerMinute, LayerHour, LayerDay} + +// Known отвечает, знаком ли слой. +func Known(l Layer) bool { return Rank(l) < len(Layers) } + +// Rank — место слоя в порядке от мелкого к крупному. Незнакомый слой считается +// крупнее любого известного. +func Rank(l Layer) int { + for i, known := range Layers { + if known == l { + return i + } } + return len(Layers) } diff --git a/internal/hae/hae_test.go b/internal/hae/hae_test.go index 11ed436..20af8dd 100644 --- a/internal/hae/hae_test.go +++ b/internal/hae/hae_test.go @@ -786,3 +786,37 @@ func TestParseЧужаяПричинаОбрезается(t *testing.T) { t.Errorf("текст ошибки %d Б: литерал из тела доехал до сообщения", len(err.Error())) } } + +// Словарь слоёв ОДИН, и порядок в нём — от самого мелкого к самому крупному. +// +// Утверждается прямо, потому что из этого словаря выводятся четыре вещи: ранг +// слоя при разборе, перечень слоёв для выборки охватов при чтении, проверка +// параметра запроса и текст отказа клиенту. Разъехавшись, они дали бы пустой +// ряд при непустых данных — и ни компилятор, ни другой тест этого не увидели бы. +func TestСловарьСлоёвУпорядоченОтМелкогоККрупному(t *testing.T) { + want := []hae.Layer{hae.LayerSample, hae.LayerRaw, hae.LayerMinute, hae.LayerHour, hae.LayerDay} + + if len(hae.Layers) != len(want) { + t.Fatalf("слоёв в словаре %d, ожидалось %d", len(hae.Layers), len(want)) + } + for i, l := range want { + if hae.Layers[i] != l { + t.Errorf("слой %d — %q, ожидался %q", i, hae.Layers[i], l) + } + if hae.Rank(l) != i { + t.Errorf("ранг %q = %d, ожидался %d", l, hae.Rank(l), i) + } + if !hae.Known(l) { + t.Errorf("слой %q словарём не признан", l) + } + } + + // Незнакомый слой крупнее любого известного и словарём не признан: иначе он + // выиграл бы предпочтение при равном охвате. + if got := hae.Rank("weekly"); got != len(hae.Layers) { + t.Errorf("ранг незнакомого слоя %d, ожидался %d", got, len(hae.Layers)) + } + if hae.Known("weekly") { + t.Error("незнакомый слой признан словарём") + } +} diff --git a/internal/httpapi/catalog.go b/internal/httpapi/catalog.go index c0877ea..8caae5c 100644 --- a/internal/httpapi/catalog.go +++ b/internal/httpapi/catalog.go @@ -159,5 +159,8 @@ func (a *api) handleMetrics(w http.ResponseWriter, r *http.Request) { // ответ уходит без метки: это ровно поведение до появления условного // запроса, то есть деградация в безопасную сторону. setReadHeaders(w, etag(scopeMetrics, snap.Version)) - writeJSON(w, http.StatusOK, catalogWire(snap.Metrics)) + // Обрыв записи каталога глушится: ответ здесь — три десятка строк, и + // оборваться на нём нечему. У маршрута точек ряд ничем не ограничен, и там + // отказ записи наблюдается отдельным чекпоинтом. + _ = writeJSON(w, http.StatusOK, catalogWire(snap.Metrics)) } diff --git a/internal/httpapi/httpapi.go b/internal/httpapi/httpapi.go index 48f5d34..9eb5b2d 100644 --- a/internal/httpapi/httpapi.go +++ b/internal/httpapi/httpapi.go @@ -16,12 +16,14 @@ import ( "git.vakhrushev.me/av/healthlog/internal/catalog" "git.vakhrushev.me/av/healthlog/internal/ingest" + "git.vakhrushev.me/av/healthlog/internal/points" ) // Options — зависимости и настройки транспорта. type Options struct { Ingest *ingest.Service Catalog *catalog.Service + Points *points.Service Log *slog.Logger WriteTokens []string ReadTokens []string @@ -35,6 +37,7 @@ type Options struct { type api struct { ingest *ingest.Service catalog *catalog.Service + points *points.Service log *slog.Logger writeTokens []string readTokens []string @@ -47,6 +50,7 @@ func New(o Options) http.Handler { a := &api{ ingest: o.Ingest, catalog: o.Catalog, + points: o.Points, log: o.Log, writeTokens: o.WriteTokens, readTokens: o.ReadTokens, @@ -62,6 +66,10 @@ func New(o Options) http.Handler { r.Route("/api/v1", func(r chi.Router) { r.With(requireToken(a.writeTokens)).Post("/ingest", a.handleIngest) r.With(requireToken(a.readTokens)).Get("/metrics", a.handleMetrics) + // Маршрут точек стоит РЯДОМ с каталогом, а не поверх него: у chi + // литеральный `/metrics` и шаблон `/metrics/{metric}` — разные узлы, и + // каталог остаётся достижим. Утверждается это тестом, а не верой. + r.With(requireToken(a.readTokens)).Get("/metrics/{metric}", a.handlePoints) }) return r } @@ -152,10 +160,27 @@ func routePattern(r *http.Request) string { return r.URL.Path } -func writeJSON(w http.ResponseWriter, status int, v any) { +// writeJSON — единственный сериализатор тел ответа. +// +// Экранирование HTML ВЫКЛЮЧЕНО, и это не косметика. `encoding/json` по +// умолчанию превращает `&`, `<` и `>` в `\u0026`, `\u003c`, `\u003e`; на +// маршруте, отдающем дословно сохранённое содержимое точки, это прямо ломает +// обещание дословности — имя источника приходит с телефона пользовательской +// строкой и законно содержит `&`. Хранилище этот же капкан уже проходило и +// обезвредило тем же способом (`store.encodePayload`). +// +// Правило общее для всех читающих маршрутов намеренно: механизм один, и +// решать его заново на каждом маршруте значило бы завести второй способ. +// Отказ записи ВОЗВРАЩАЕТСЯ, а не глушится: код ответа отдан до сериализации, +// поэтому оборванное на середине тело снаружи неотличимо от успеха, а +// `accessLog` честно напишет `200`. Кто из вызывающих обязан об этом сказать — +// решает он сам; глушить молча нельзя ни одному. +func writeJSON(w http.ResponseWriter, status int, v any) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(v) + enc := json.NewEncoder(w) + enc.SetEscapeHTML(false) + return enc.Encode(v) } // errorWire — форма провода тела отказа, общая для всех маршрутов. @@ -173,5 +198,7 @@ type errorWire struct { // writeError отдаёт человекочитаемое сообщение, а не текст ошибки: в тексте // имена колонок и форма запроса. func writeError(w http.ResponseWriter, status int, msg string) { - writeJSON(w, status, errorWire{Error: msg}) + // Тело отказа — десятки байт: оборваться на нём нечему, и сообщать о + // таком обрыве было бы шумом. + _ = writeJSON(w, status, errorWire{Error: msg}) } diff --git a/internal/httpapi/httpapi_test.go b/internal/httpapi/httpapi_test.go index d30d2c4..4b2d0d8 100644 --- a/internal/httpapi/httpapi_test.go +++ b/internal/httpapi/httpapi_test.go @@ -18,6 +18,7 @@ import ( "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/points" "git.vakhrushev.me/av/healthlog/internal/store" ) @@ -324,6 +325,7 @@ func newAPILogged(t *testing.T, writeTokens, readTokens []string) (http.Handler, h := httpapi.New(httpapi.Options{ Ingest: ingest.New(arch, st, nil, log), Catalog: cat, + Points: points.New(st, log), Log: log, WriteTokens: writeTokens, ReadTokens: readTokens, diff --git a/internal/httpapi/ingest.go b/internal/httpapi/ingest.go index 5c1fa95..b4db017 100644 --- a/internal/httpapi/ingest.go +++ b/internal/httpapi/ingest.go @@ -42,7 +42,9 @@ func (a *api) handleIngest(w http.ResponseWriter, r *http.Request) { return } - writeJSON(w, http.StatusOK, ingestResponse{ + // Ответ приёма — три поля; обрыв записи на нём означает ушедшего клиента, а + // доставка уже сохранена, и это единственное, что здесь обещано. + _ = writeJSON(w, http.StatusOK, ingestResponse{ DeliveryID: res.DeliveryID, Bytes: res.Bytes, SHA256: res.SHA256, diff --git a/internal/httpapi/points.go b/internal/httpapi/points.go new file mode 100644 index 0000000..7968de2 --- /dev/null +++ b/internal/httpapi/points.go @@ -0,0 +1,316 @@ +package httpapi + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/go-chi/chi/v5" + + "git.vakhrushev.me/av/healthlog/internal/catalog" + "git.vakhrushev.me/av/healthlog/internal/hae" + "git.vakhrushev.me/av/healthlog/internal/points" +) + +// ФОРМА ПРОВОДА ТОЧЕК. Объявлена здесь и только здесь — доменные типы +// `internal/points` `json`-тегов не несут и до сериализации не доезжают. +// Образец и его цена — `internal/httpapi/catalog.go`; выбирать заново не нужно. + +// pointsResponse — оболочка ответа точек. +// +// Поля присутствуют ВСЕГДА, даже когда сообщить нечего: клиент не должен +// выводить исход наличием или отсутствием поля. Отсюда указатели у `layer` и +// `bucket` — `null` означает «слоя нет» и «свёртки не было», а не пустую +// строку, которая была бы законным именем слоя. +type pointsResponse struct { + Metric string `json:"metric"` + From time.Time `json:"from"` + To time.Time `json:"to"` + Layer *string `json:"layer"` + // Bucket — сетка свёртки. Всегда `null` в этой версии маршрута: свёртку + // делает соседняя задача. Поле объявлено сразу, потому что менять форму + // конверта после того, как его скопировали четыре маршрута, — не развилка, + // а археология. + Bucket *string `json:"bucket"` + Aggregation seriesAggregation `json:"aggregation"` + Points []pointWire `json:"points"` +} + +// seriesAggregation — род свёртки вместе с его применимостью и свежестью. +// +// `applicable` не украшение: род — свойство МЕТРИКИ, слой — свойство ОТДАННОГО +// РЯДА, и сочетание `{"layer": "raw", "style": "cumulative"}` законно, штатно и +// прямо приглашает потребителя сложить интерполяцию самому. Система при этом +// ничего не складывает, а решение у потребителя уже принято по завышенному +// втрое числу. +// +// `last_hour` — ярлык самого свежего часа окна измерения. Окно считается в +// ОБЩИХ часах, а не в часах календаря: выключенная минутная автоматизация HAE +// останавливает их пополнение, окно замирает и продолжает объявлять род. +// Единственный след этого — вот это поле. +type seriesAggregation struct { + Style string `json:"style"` + Applicable bool `json:"applicable"` + LastHour *time.Time `json:"last_hour"` +} + +// pointWire — точка ряда на проводе. +// +// `values` уезжает СЫРЫМ JSON: точки хранятся дословно, и переписывать их в тип +// провода значило бы нарушить инвариант. Конверт вокруг значения при этом +// объявлен и нормализован, как того требует «форма Apple не транслируется». +// +// `ts_end` есть потому, что идентичность точки — интервал, а не метка: под +// одной меткой лежит до трёх записей сна. У точки-измерения он равен `ts`. +type pointWire struct { + TS time.Time `json:"ts"` + TSEnd time.Time `json:"ts_end"` + TZOffset int `json:"tz_offset"` + Units string `json:"units"` + Values json.RawMessage `json:"values"` +} + +// pointsWire переводит ряд домена в форму провода. +// +// Чистая функция от уже прочитанного значения: ни `context`, ни хранилища, ни +// часов. Версия снимка сюда не идёт — она уезжает в `ETag`. +func pointsWire(s points.Series) pointsResponse { + out := pointsResponse{ + Metric: s.Metric, + From: s.From, + To: s.To, + Aggregation: seriesAggregation{ + Style: s.Style.String(), + Applicable: s.Applicable, + // Указатель переносится КАК УКАЗАТЕЛЬ: разыменование дало бы + // `0001-01-01T00:00:00Z` там, где окна не было, а правдоподобная + // дата в ответе неотличима от настоящей. + LastHour: s.LastHour, + }, + // Непустой срез, а не nil: nil сериализуется в `null`, и клиент + // прочитал бы «поля нет» вместо «точек нет». + Points: make([]pointWire, 0, len(s.Points)), + } + if s.Layer != "" { + layer := s.Layer + out.Layer = &layer + } + for _, p := range s.Points { + out.Points = append(out.Points, pointWire{ + TS: p.TS, + TSEnd: p.End, + TZOffset: p.OffsetSeconds, + Units: p.Units, + Values: p.Raw, + }) + } + return out +} + +// pointsScope — область действия метки ответа: ХЕШ канонизированной формы +// запроса. +// +// Живёт В ТРАНСПОРТЕ, и это решение, а не случайность: у каталога область тоже +// транспортная (`scopeMetrics`), а помощник `etag` прямо просит маршрут положить +// сюда канонизированную форму запроса. Считай её домен — у одного понятия +// оказалось бы два дома, и три следующих маршрута выбирали бы между ними +// монетой. Заодно домен перестал бы носить транспортный артефакт: префикс и hex +// — это форма метки HTTP, а форму провода объявляет транспорт (ADR). +// +// Метка действительна в пределах ОДНОГО набора данных, а ответ этого маршрута +// есть функция параметров. Метка без них однажды подтвердила бы неизменность +// чужого набора — молча и без следов. +// +// Канонизация: границы приводятся к UTC в RFC 3339 с ПОЛНОЙ точностью. Полной, +// а не посекундной, потому что отбор точек идёт по полной метке: два запроса, +// различающиеся долей секунды, дают разные ряды, и одна область на них +// означала бы `304` на чужом наборе данных. Зона при этом канонизируется — +// одно и то же время, записанное разными смещениями, даёт одну область. +// +// Слой берётся ЗАПРОШЕННЫЙ, а не выбранный: областью является запрос, а смену +// выбранного слоя от новых данных ловит версия витрины. +// +// ХЕШ, а не сама форма, и обе причины из `docs/security.md`. Имя метрики +// приходит из чужого тела дословно и ничем не ограничено — в заголовке ответа +// оно дало бы `ETag` в тысячи байт, притом что тот же вход в лог уезжает +// обрезанным. И оно законно содержит кавычку, которая по RFC 9110 кончает +// метку: собственный `scanETag` проекта обрезал бы её ровно там, и условный +// запрос по такой метрике не сработал бы никогда, а симптом увёл бы отладку в +// помощника. Длина впереди остаётся внутри хешируемой строки: без неё имя с +// разделителем склеилось бы с соседним полем. +func pointsScope(req points.Request) string { + canonical := fmt.Sprintf("%d:%s|%s|%s|%s", + len(req.Metric), req.Metric, + req.From.UTC().Format(time.RFC3339Nano), + req.To.UTC().Format(time.RFC3339Nano), + req.Layer) + sum := sha256.Sum256([]byte(canonical)) + return "points." + hex.EncodeToString(sum[:scopeBytes]) +} + +// scopeBytes — сколько байтов хеша попадает в область. +// +// Шестнадцать: 128 бит, то есть столкновение неотличимо от невозможного, а +// заголовок остаётся короче метки каталога. Хеш здесь не криптографический +// секрет — он ограничитель длины и экранирование разом. +const scopeBytes = 16 + +// handlePoints отдаёт точки метрики за период. +func (a *api) handlePoints(w http.ResponseWriter, r *http.Request) { + req, msg := parsePointsRequest(r) + if msg != "" { + // Отказ до чтения витрины: невозможный запрос не должен стоить снимка. + // В сообщении нет ни одного значения из запроса — оно уедет клиенту, а + // в запросе имя метрики и границы периода. + writeError(w, http.StatusBadRequest, msg) + return + } + + series, err := a.points.Series(r.Context(), req) + if err != nil { + // Исход операции логирует доменный слой, транспорт только переводит его + // в ответ. Наружу уходит человекочитаемое сообщение, а не текст ошибки: + // в нём имена колонок и форма запроса. + writeError(w, http.StatusInternalServerError, "ряд точек не собрался") + return + } + + // Область действия метки — канонизированная форма запроса: ответ этого + // маршрута есть функция параметров, и метка без них однажды подтвердила бы + // неизменность чужого набора данных. Горизонт измерения в метке уже есть — + // его кладёт домен вместе с версией витрины. + setReadHeaders(w, etag(pointsScope(req), series.Version)) + if err := writeJSON(w, http.StatusOK, pointsWire(series)); err != nil { + // Тело оборвалось на середине: дедлайн записи, ушедший клиент, полный + // буфер посредника. Код ответа уже отдан, и `accessLog` напишет `200` — + // то есть единственный канал наблюдаемости сообщит успех о неотданном + // ответе. Ряд ничем не ограничен по размеру (предел — соседняя задача), + // поэтому случай не гипотетический: неделя нижнего слоя это 280 МиБ. + // + // WARN, а не DEBUG, и это исправление находки эксплуатационного прохода: + // боевой уровень логирования — `info` (`config.docker.toml`), то есть + // запись уровня `DEBUG` не прошла бы фильтр НИКОГДА, и единственный + // признак недоставленного тела не существовал бы в проде вовсе. + // Обесценивания уровня здесь нет: событие не периодическое — оно + // означает, что потребитель получил битый JSON. + // + // Значений точек и границ запроса в записи нет; имя метрики обрезано. + a.log.WarnContext(r.Context(), "points response truncated", + "capability", "query", "metric", catalog.ClipMetric(req.Metric), "error", err) + } +} + +// parsePointsRequest разбирает параметры маршрута точек. +// +// Второй возврат — человекочитаемая причина отказа; пустая строка означает, что +// запрос принят. Строка, а не ошибка: она целиком уезжает клиенту, поэтому +// обязана быть составлена здесь и не содержать ни одного значения из запроса. +func parsePointsRequest(r *http.Request) (points.Request, string) { + q := r.URL.Query() + + // Свёртка по сетке ещё не поддержана, и молчать об этом нельзя: клиент, + // попросивший суточную сетку и получивший полный минутный ряд, заметил бы + // подмену, только пересчитав точки. Это зеркало того самого промаха, ради + // которого «сетка задана явно» вообще различается. + if q.Has("bucket") { + return points.Request{}, "свёртка по сетке ещё не поддержана: параметр bucket не принимается" + } + + from, msg := parseBound(q, "from") + if msg != "" { + return points.Request{}, msg + } + to, msg := parseBound(q, "to") + if msg != "" { + return points.Request{}, msg + } + if !from.Before(to) { + return points.Request{}, "период пуст: from должен быть строго раньше to" + } + + // `Has`, а не `Get() != ""`, и симметрично `bucket`. Пустое значение + // (`?layer=`) — самый частый способ промахнуться: шаблон клиента с + // невыставленной переменной. Разбор по непустоте молча включил бы + // автоматический выбор, и клиент, спросивший разрез поимённо, не отличил бы + // свой промах от ответа по существу. + layer := q.Get("layer") + if q.Has("layer") && !hae.Known(hae.Layer(layer)) { + return points.Request{}, "неизвестный слой: допустимы " + knownLayers + } + + return points.Request{Metric: metricFromPath(r), From: from, To: to, Layer: layer}, "" +} + +// parseBound разбирает границу периода. +// +// Только RFC 3339 и только с явным смещением зоны. Голая дата отвергается не из +// строгости: у неё нет зоны, а вопрос «в какой зоне считать сутки» в проекте +// открыт отдельной задачей. Принять её значило бы выбрать зону за клиента молча. +func parseBound(q url.Values, name string) (time.Time, string) { + raw := q.Get(name) + if raw == "" { + return time.Time{}, "параметр " + name + " обязателен" + } + t, err := time.Parse(time.RFC3339, raw) + if err != nil { + return time.Time{}, "параметр " + name + + ": ожидается метка времени RFC 3339 с явным смещением зоны, например 2026-01-01T00:00:00Z" + } + + // Год после приведения к UTC обязан остаться четырёхзначным, и это не + // придирка к календарю. Хранилище адресует объекты строкой RFC 3339, а + // сравнение границ идёт лексикографически: `9999-12-31T23:00:00-07:00` + // превращается в `10000-01-01T06:00:00Z`, который меньше любой настоящей + // метки как строка, — и запрос молча отдал бы пустой ряд при непустых + // данных. Отказ здесь честнее пустоты: тот же разряд ловится и на входе + // приёма, но там он унаследован и лечится не тут. + if y := t.UTC().Year(); y < 1 || y > 9999 { + return time.Time{}, "параметр " + name + ": год после приведения к UTC вне диапазона 1–9999" + } + return t.UTC(), "" +} + +// knownLayers — перечень допустимых слоёв ДЛЯ СООБЩЕНИЯ КЛИЕНТУ, собранный из +// того же словаря, что и проверка. Текст отказа уезжает наружу и потому обязан +// перечислять ровно то, что принимается: разъехавшись, он врал бы клиенту. +var knownLayers = func() string { + out := make([]string, 0, len(hae.Layers)) + for _, l := range hae.Layers { + out = append(out, string(l)) + } + return strings.Join(out, ", ") +}() + +// metricFromPath достаёт имя метрики из пути. +// +// Решение принимается по `RawPath`, а НЕ по успеху декодирования, и это ровно +// то место, где легко ошибиться. `chi` сопоставляет по сырому пути только когда +// тот непуст, то есть когда `net/url` увидел в пути экранирование; тогда +// `{metric}` приезжает закодированным и его надо декодировать. Когда `RawPath` +// пуст, `chi` отдаёт уже декодированное имя — и второе декодирование испортило +// бы его молча. +// +// Цена ошибки построена и прогнана враждебным проходом: метрика с именем +// `a%41b` (имена приходят из тела доставки дословно) кодируется клиентом в +// `a%2541b`, `net/url` декодирует это в `a%41b` и оставляет `RawPath` пустым — +// а второе декодирование давало `aAb`, то есть маршрут отвечал `200` и данными +// ДРУГОЙ метрики. +// +// Отказ декодирования не является отказом маршрута: берём имя как есть — оно +// всё равно не совпадёт ни с одной метрикой витрины, и клиент получит честный +// пустой ряд, а не `400` на своё же имя. +func metricFromPath(r *http.Request) string { + raw := chi.URLParam(r, "metric") + if r.URL.RawPath == "" { + return raw + } + if decoded, err := url.PathUnescape(raw); err == nil { + return decoded + } + return raw +} diff --git a/internal/httpapi/points_test.go b/internal/httpapi/points_test.go new file mode 100644 index 0000000..5d23805 --- /dev/null +++ b/internal/httpapi/points_test.go @@ -0,0 +1,677 @@ +package httpapi_test + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "git.vakhrushev.me/av/healthlog/internal/hae" + "git.vakhrushev.me/av/healthlog/internal/store" +) + +// pointsAt — фиксированное «когда» витрины тестов: 1 июня 2026 года. +// +// Час выбран В ПРОШЛОМ и жёстко, а не относительно `time.Now()`, потому что +// окно измерения ограничено горизонтом «сейчас плюс час»: фикстура, +// построенная от текущего времени, при переходе через границу часа дала бы +// другой набор пригодных часов — и другое тело ответа. Класс уже ловили +// (docs/review.md, запись 2026-08-03). +func pointsAt(hh, mm int) time.Time { + return time.Date(2026, 6, 1, hh, mm, 0, 0, time.UTC) +} + +func mergePoints(t *testing.T, st *store.Store, in ...store.IncomingPoint) { + t.Helper() + + if _, err := st.Merge(context.Background(), store.Incoming{Points: in}, store.DeliveryRef{ID: "d"}); err != nil { + t.Fatalf("слияние: %v", err) + } +} + +func pointAt(metric, layer, units string, start, end time.Time, raw string) store.IncomingPoint { + return store.IncomingPoint{ + Metric: metric, Layer: layer, Units: units, + Point: store.Point{Start: start, End: end, Raw: json.RawMessage(raw)}, + } +} + +func getPoints(t *testing.T, h http.Handler, metric, query, auth string) *httptest.ResponseRecorder { + t.Helper() + + req := httptest.NewRequest(http.MethodGet, "/api/v1/metrics/"+url.PathEscape(metric)+"?"+query, nil) + if auth != "" { + req.Header.Set("Authorization", auth) + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec +} + +const dayWindow = "from=2026-06-01T00:00:00Z&to=2026-06-02T00:00:00Z" + +// ФОРМА ОТВЕТА ЦЕЛИКОМ, БАЙТАМИ. Детектор изменения публичного контракта: он +// краснеет в момент правки формы, а не у потребителя. +// +// Ни одно поле литерала не зависит от хода часов: витрина фиксирована, часы +// лежат в прошлом, окно измерения на этих данных пусто. Утверждается заодно +// К2 — `layer`, `aggregation` и `last_hour` присутствуют, — и правило «пустая +// коллекция это `[]`, отсутствующее значение это `null`». +func TestТочкиФормаОтветаБайтами(t *testing.T) { + h, st, _ := newAPITokens(t, nil, nil) + mergePoints(t, st, + pointAt("body_mass", "raw", "kg", pointsAt(9, 30), pointsAt(9, 30), `{"qty":72.5,"date":"2026-06-01 12:30:00 +0300"}`), + ) + + want := `{"metric":"body_mass","from":"2026-06-01T00:00:00Z","to":"2026-06-02T00:00:00Z",` + + `"layer":"raw","bucket":null,` + + `"aggregation":{"style":"unknown","applicable":false,"last_hour":null},` + + `"points":[{"ts":"2026-06-01T09:30:00Z","ts_end":"2026-06-01T09:30:00Z","tz_offset":0,` + + `"units":"kg","values":{"qty":72.5,"date":"2026-06-01 12:30:00 +0300"}}]}` + + rec := getPoints(t, h, "body_mass", dayWindow, "") + if rec.Code != http.StatusOK { + t.Fatalf("статус %d, тело %s", rec.Code, rec.Body.String()) + } + if got := strings.TrimSpace(rec.Body.String()); got != want { + t.Errorf("форма ответа точек изменилась:\n получили %s\n ждали %s", got, want) + } +} + +// К3: род не измерен — сказано словом, а не молчанием, и свёртка не предложена. +// Проверяется через разбор, а не подстрокой: подстрока `"style"` осталась бы на +// месте и у ответа, объявившего род. +func TestТочкиНеизмеренныйРодНазванСловом(t *testing.T) { + h, st, _ := newAPITokens(t, nil, nil) + mergePoints(t, st, + pointAt("six_minute_walking_test_distance", "minute", "m", pointsAt(9, 0), pointsAt(9, 0), `{"qty":420}`), + ) + + var got struct { + Aggregation struct { + Style string `json:"style"` + Applicable bool `json:"applicable"` + LastHour *string `json:"last_hour"` + } `json:"aggregation"` + Points []json.RawMessage `json:"points"` + } + decodeBody(t, getPoints(t, h, "six_minute_walking_test_distance", dayWindow, ""), &got) + + if got.Aggregation.Style != "unknown" { + t.Errorf("род %q, ожидался unknown", got.Aggregation.Style) + } + if got.Aggregation.Applicable { + t.Error("неизмеренный род объявлен применимым — потребителю предложено свернуть по неизвестному") + } + if got.Aggregation.LastHour != nil { + t.Errorf("граница окна %v, ожидался null", *got.Aggregation.LastHour) + } + if len(got.Points) != 1 { + t.Errorf("точек %d, ожидалась 1: род неизвестен — точки отдаются как есть", len(got.Points)) + } +} + +// Измеренный род едет вместе с границей окна и с применимостью. Данные — +// минутный и часовой слои одной метрики, сходящиеся суммой: ровно тот вход, на +// котором каталог объявляет `cumulative`. +func TestТочкиИзмеренныйРодЕдетСГраницейОкна(t *testing.T) { + h, st, _ := newAPITokens(t, nil, nil) + + var in []store.IncomingPoint + for hh := range catalogAgreeingHours { + hour := pointsAt(hh, 0) + // Часовое значение равно сумме минутных, и сумма отличима от среднего. + in = append(in, + pointAt("active_energy", "hour", "kJ", hour, hour, `{"qty":30}`), + pointAt("active_energy", "minute", "kJ", hour, hour, `{"qty":10}`), + pointAt("active_energy", "minute", "kJ", hour.Add(time.Minute), hour.Add(time.Minute), `{"qty":20}`), + ) + } + mergePoints(t, st, in...) + + cases := []struct { + name string + query string + wantLayer string + wantApplicable bool + }{ + {"часовой слой", dayWindow + "&layer=hour", "hour", true}, + {"минутный слой", dayWindow + "&layer=minute", "minute", true}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + var got struct { + Layer *string `json:"layer"` + Aggregation struct { + Style string `json:"style"` + Applicable bool `json:"applicable"` + LastHour *string `json:"last_hour"` + } `json:"aggregation"` + } + decodeBody(t, getPoints(t, h, "active_energy", c.query, ""), &got) + + if got.Aggregation.Style != "cumulative" { + t.Fatalf("род %q, ожидался cumulative", got.Aggregation.Style) + } + if got.Layer == nil || *got.Layer != c.wantLayer { + t.Errorf("слой %v, ожидался %q", got.Layer, c.wantLayer) + } + if got.Aggregation.Applicable != c.wantApplicable { + t.Errorf("применимость %v, ожидалась %v", got.Aggregation.Applicable, c.wantApplicable) + } + // Граница окна — ярлык часа, а не конец периода и не «сейчас». + if got.Aggregation.LastHour == nil { + t.Fatal("граница окна измерения null при измеренном роде") + } + if want := "2026-06-01T13:00:00Z"; *got.Aggregation.LastHour != want { + t.Errorf("граница окна %q, ожидалась %q", *got.Aggregation.LastHour, want) + } + }) + } +} + +// catalogAgreeingHours — сколько согласных часов кладётся в фикстуру. Больше +// порога каталога, и число названо здесь, а не подобрано в теле теста. +const catalogAgreeingHours = 14 + +// Накопительная метрика на нижнем слое HAE объявляется НЕПРИМЕНИМОЙ: нижний +// слой это интерполяция, а не сэмплы, и сумма по нему завышает втрое. Без этого +// поля конверт приглашает агента сложить её самому. +func TestТочкиНакопительнаяМетрикаНаНижнемСлоеНеприменима(t *testing.T) { + h, st, _ := newAPITokens(t, nil, nil) + + var in []store.IncomingPoint + for hh := range catalogAgreeingHours { + hour := pointsAt(hh, 0) + in = append(in, + pointAt("active_energy", "hour", "kJ", hour, hour, `{"qty":30}`), + pointAt("active_energy", "minute", "kJ", hour, hour, `{"qty":10}`), + pointAt("active_energy", "minute", "kJ", hour.Add(time.Minute), hour.Add(time.Minute), `{"qty":20}`), + pointAt("active_energy", "raw", "kJ", hour.Add(7*time.Second), hour.Add(7*time.Second), `{"qty":1}`), + ) + } + mergePoints(t, st, in...) + + var got struct { + Aggregation struct { + Style string `json:"style"` + Applicable bool `json:"applicable"` + } `json:"aggregation"` + } + decodeBody(t, getPoints(t, h, "active_energy", dayWindow+"&layer=raw", ""), &got) + + if got.Aggregation.Style != "cumulative" { + t.Fatalf("род %q, ожидался cumulative", got.Aggregation.Style) + } + if got.Aggregation.Applicable { + t.Error("накопительная метрика на нижнем слое объявлена применимой — потребитель просуммирует интерполяцию") + } +} + +// Содержимое точки уезжает ДОСЛОВНО, включая символы, которые `encoding/json` +// по умолчанию превращает в escape-последовательности. +// +// Случай заведён отдельно и намеренно: фикстуры `testdata` символов `&<>` не +// содержат вовсе, то есть утверждение о дословности на них зелено и будучи +// сломанным. Имя источника приходит с телефона пользовательской строкой. +func TestТочкиСодержимоеНеЭкранируется(t *testing.T) { + h, st, _ := newAPITokens(t, nil, nil) + raw := `{"qty":1,"source":"iPhone "}` + mergePoints(t, st, pointAt("m", "raw", "count", pointsAt(9, 0), pointsAt(9, 0), raw)) + + body := getPoints(t, h, "m", dayWindow, "").Body.String() + if !strings.Contains(body, raw) { + t.Errorf("содержимое точки переписано сериализатором:\n %s", body) + } + for _, escaped := range []string{`\u0026`, `\u003c`, `\u003e`} { + if strings.Contains(body, escaped) { + t.Errorf("сериализатор заэкранировал содержимое точки (%s) — обещание дословности нарушено", escaped) + } + } +} + +// Интервальная точка отдаёт КОНЕЦ координаты: под одной меткой лежит до трёх +// записей сна, и конверт с одним `ts` предложил бы клиенту различать их, +// разбирая дословное содержимое. +func TestТочкиИнтервалОтдаётКонецКоординаты(t *testing.T) { + h, st, _ := newAPITokens(t, nil, nil) + start, end := pointsAt(1, 0), pointsAt(3, 30) + mergePoints(t, st, + pointAt("sleep_analysis", "raw", "hr", start, end, `{"value":"В кровати"}`), + pointAt("sleep_analysis", "raw", "hr", start, pointsAt(2, 0), `{"value":"Глубокий"}`), + ) + + var got struct { + Points []struct { + TS string `json:"ts"` + TSEnd string `json:"ts_end"` + } `json:"points"` + } + decodeBody(t, getPoints(t, h, "sleep_analysis", dayWindow, ""), &got) + + if len(got.Points) != 2 { + t.Fatalf("точек %d, ожидалось 2: одна метка, разные интервалы", len(got.Points)) + } + if got.Points[0].TS != got.Points[1].TS { + t.Fatalf("метки разошлись: %s и %s", got.Points[0].TS, got.Points[1].TS) + } + if got.Points[0].TSEnd == got.Points[1].TSEnd { + t.Errorf("концы координат совпали (%s) — записи неразличимы", got.Points[0].TSEnd) + } + // Порядок детерминирован: при равном начале раньше идёт более короткий. + if got.Points[0].TSEnd != "2026-06-01T02:00:00Z" { + t.Errorf("порядок точек не по (ts, ts_end): первый конец %s", got.Points[0].TSEnd) + } +} + +// Пустой период — успех, а не отсутствие ресурса. `layer` при этом `null` +// ТОЛЬКО когда слой выбирала система: клиент, спросивший разрез поимённо, +// обязан отличать «этого разреза за период нет» от «параметр проигнорирован». +func TestТочкиПустойПериодОтличаетВыбранныйСлойОтЗапрошенного(t *testing.T) { + h, st, _ := newAPITokens(t, nil, nil) + mergePoints(t, st, pointAt("m", "raw", "count", pointsAt(9, 0), pointsAt(9, 0), `{"qty":1}`)) + + empty := "from=2026-07-01T00:00:00Z&to=2026-07-02T00:00:00Z" + + cases := []struct { + name string + query string + want string + }{ + {"слой выбирала система", empty, "null"}, + {"слой задан явно", empty + "&layer=minute", `"minute"`}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + var got struct { + Layer json.RawMessage `json:"layer"` + Points []json.RawMessage `json:"points"` + } + rec := getPoints(t, h, "m", c.query, "") + if rec.Code != http.StatusOK { + t.Fatalf("статус %d, ожидался 200: «данных нет» — не «ресурса нет»", rec.Code) + } + decodeBody(t, rec, &got) + + if string(got.Layer) != c.want { + t.Errorf("слой %s, ожидался %s", got.Layer, c.want) + } + if got.Points == nil { + t.Error("точки уехали как null — клиент прочитает «поля нет» вместо «точек нет»") + } + }) + } +} + +// Имени метрики в витрине нет вовсе — это тоже `200` с пустым рядом: список +// имён маршруту точек не принадлежит, их отдаёт каталог. +func TestТочкиНезнакомойМетрикиОтвечают200(t *testing.T) { + h, _, _ := newAPITokens(t, nil, nil) + + rec := getPoints(t, h, "нет такой метрики", dayWindow, "") + if rec.Code != http.StatusOK { + t.Fatalf("статус %d, ожидался 200", rec.Code) + } +} + +// Имя метрики достаётся из пути ДЕКОДИРОВАННЫМ. Без этого метрика с пробелом +// или слэшем в имени была бы недостижима, а имена приходят из тела доставки +// дословно и ничем не ограничены. +func TestТочкиИмяМетрикиДекодируетсяИзПути(t *testing.T) { + h, st, _ := newAPITokens(t, nil, nil) + + for _, metric := range []string{"с пробелом", "со/слэшем", "с%знаком"} { + t.Run(metric, func(t *testing.T) { + mergePoints(t, st, pointAt(metric, "raw", "count", pointsAt(9, 0), pointsAt(9, 0), `{"qty":1}`)) + + var got struct { + Metric string `json:"metric"` + Points []json.RawMessage `json:"points"` + } + decodeBody(t, getPoints(t, h, metric, dayWindow, ""), &got) + + if got.Metric != metric { + t.Errorf("имя метрики %q, ожидалось %q", got.Metric, metric) + } + if len(got.Points) != 1 { + t.Errorf("точек %d, ожидалась 1 — метрика недостижима по своему имени", len(got.Points)) + } + }) + } +} + +// Маршрут точек НЕ перехватывает каталог: у chi литеральный `/metrics` и +// шаблон `/metrics/{metric}` — разные узлы, но верить в это нельзя. +func TestТочкиНеПерехватываютКаталог(t *testing.T) { + h, _, _ := newAPITokens(t, nil, nil) + + if got := strings.TrimSpace(getCatalog(t, h, "").Body.String()); got != `{"metrics":[]}` { + t.Errorf("каталог перехвачен маршрутом точек: %s", got) + } +} + +// Разбор параметров: каждый отказ — `400`, до чтения витрины, и без единого +// значения из запроса в теле ответа. +func TestТочкиОтвергаютНевозможныйЗапрос(t *testing.T) { + h, _, _ := newAPITokens(t, nil, nil) + + cases := map[string]string{ + "нет from": "to=2026-06-02T00:00:00Z", + "нет to": "from=2026-06-01T00:00:00Z", + "голая дата": "from=2026-06-01&to=2026-06-02", + "без зоны": "from=2026-06-01T00:00:00&to=2026-06-02T00:00:00Z", + "мусор": "from=вчера&to=сегодня", + "вывернутый период": "from=2026-06-02T00:00:00Z&to=2026-06-01T00:00:00Z", + "пустой период": "from=2026-06-01T00:00:00Z&to=2026-06-01T00:00:00Z", + "незнакомый слой": dayWindow + "&layer=weekly", + "свёртка не поддержана": dayWindow + "&bucket=day", + } + + for name, query := range cases { + t.Run(name, func(t *testing.T) { + rec := getPoints(t, h, "секретное_имя_метрики", query, "") + if rec.Code != http.StatusBadRequest { + t.Fatalf("статус %d, ожидался 400 (тело %s)", rec.Code, rec.Body.String()) + } + // Значения из запроса в тело отказа не уезжают: там имя метрики и + // границы периода, а тело отказа читает кто угодно. + for _, leak := range []string{"2026-06-01", "2026-06-02", "weekly", "вчера", "секретное_имя_метрики"} { + if strings.Contains(rec.Body.String(), leak) { + t.Errorf("в теле отказа значение из запроса (%q): %s", leak, rec.Body.String()) + } + } + }) + } +} + +// Незнакомые параметры игнорируются, как принято в HTTP: клиент, приславший +// лишнее, получает данные, а не отказ. +func TestТочкиИгнорируютНезнакомыйПараметр(t *testing.T) { + h, _, _ := newAPITokens(t, nil, nil) + + rec := getPoints(t, h, "m", dayWindow+"&limit=10", "") + if rec.Code != http.StatusOK { + t.Fatalf("статус %d, ожидался 200", rec.Code) + } +} + +func TestТочкиТребуютТокенЧтения(t *testing.T) { + h, _, _ := newAPITokens(t, []string{"write-token"}, []string{"read-token"}) + + cases := []struct { + name string + auth string + want int + }{ + {"без заголовка", "", http.StatusUnauthorized}, + {"токен приёма", "Bearer write-token", http.StatusUnauthorized}, + {"токен чтения", "Bearer read-token", http.StatusOK}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := getPoints(t, h, "m", dayWindow, c.auth).Code; got != c.want { + t.Errorf("статус %d, ожидался %d", got, c.want) + } + }) + } +} + +// Метка ответа различает каждый параметр по очереди при ОДНОМ состоянии +// витрины: метка, их не различающая, однажды подтвердит неизменность чужого +// набора данных. Заодно — метка точек отличается от метки каталога. +func TestМеткаТочекРазличаетЗапросы(t *testing.T) { + h, st, _ := newAPITokens(t, nil, nil) + mergePoints(t, st, + pointAt("m", "raw", "count", pointsAt(9, 0), pointsAt(9, 0), `{"qty":1}`), + pointAt("other", "raw", "count", pointsAt(9, 0), pointsAt(9, 0), `{"qty":1}`), + ) + + base := getPoints(t, h, "m", dayWindow, "").Header().Get("ETag") + if base == "" { + t.Fatal("ответ точек ушёл без метки — условному запросу не на чем стоять") + } + if base == getCatalog(t, h, "").Header().Get("ETag") { + t.Error("метка точек совпала с меткой каталога") + } + + others := map[string]func() string{ + "метрика": func() string { return getPoints(t, h, "other", dayWindow, "").Header().Get("ETag") }, + "начало": func() string { + return getPoints(t, h, "m", "from=2026-06-01T01:00:00Z&to=2026-06-02T00:00:00Z", "").Header().Get("ETag") + }, + "конец": func() string { + return getPoints(t, h, "m", "from=2026-06-01T00:00:00Z&to=2026-06-03T00:00:00Z", "").Header().Get("ETag") + }, + "слой": func() string { return getPoints(t, h, "m", dayWindow+"&layer=raw", "").Header().Get("ETag") }, + } + for name, get := range others { + if got := get(); got == base { + t.Errorf("метка не различает %s: %s", name, got) + } + } + + // Эквивалентная запись границ даёт ту же метку: иначе условный запрос не + // сработал бы у клиента, пишущего смещение зоны иначе, чем сервер. + same := getPoints(t, h, "m", "from=2026-06-01T03:00:00%2B03:00&to=2026-06-02T03:00:00%2B03:00", "").Header().Get("ETag") + if same != base { + t.Errorf("эквивалентная запись дала другую метку:\n %s\n %s", same, base) + } +} + +// Ответ чтения непригоден для разделяемого кеша: при выключенной проверке +// токенов в запросе нет и `Authorization`, и выгрузку истории здоровья вправе +// подержать у себя любой прокси на пути. +func TestТочкиПомеченыЧастнымКешем(t *testing.T) { + h, _, _ := newAPITokens(t, nil, nil) + + if got := getPoints(t, h, "m", dayWindow, "").Header().Get("Cache-Control"); got != "private, no-cache" { + t.Errorf("Cache-Control %q, ожидался private, no-cache", got) + } +} + +func decodeBody(t *testing.T, rec *httptest.ResponseRecorder, v any) { + t.Helper() + + if rec.Code != http.StatusOK { + t.Fatalf("статус %d, тело %s", rec.Code, rec.Body.String()) + } + if err := json.Unmarshal(rec.Body.Bytes(), v); err != nil { + t.Fatalf("разбор ответа: %v (тело %s)", err, rec.Body.String()) + } +} + +// Отказ хранилища переводится в `500` с человекочитаемым сообщением: наружу не +// уходит ни текст ошибки (в нём имена колонок), ни пустой ряд, который клиент +// прочитал бы как «данных нет». +func TestТочкиОтказХранилищаДаёт500(t *testing.T) { + h, st, _ := newAPITokens(t, nil, nil) + mergePoints(t, st, pointAt("m", "raw", "count", pointsAt(9, 0), pointsAt(9, 0), `{"qty":1}`)) + if err := st.Close(); err != nil { + t.Fatalf("закрытие: %v", err) + } + + rec := getPoints(t, h, "m", dayWindow, "") + if rec.Code != http.StatusInternalServerError { + t.Fatalf("статус %d, ожидался 500 (тело %s)", rec.Code, rec.Body.String()) + } + if strings.Contains(rec.Body.String(), "bucket") || strings.Contains(rec.Body.String(), "select") { + t.Errorf("в теле отказа внутренности хранилища: %s", rec.Body.String()) + } +} + +// Имя метрики, содержащее процентную последовательность, обязано доехать до +// витрины НЕИЗМЕНЁННЫМ. +// +// Путь построен враждебным проходом и стоил ответа данными ЧУЖОЙ метрики: +// клиент кодирует `a%41b` в `a%2541b`, `net/url` декодирует это обратно в +// `a%41b` и оставляет `RawPath` пустым, а второе декодирование давало `aAb` — +// имя соседней метрики, лежащей рядом в витрине. +func TestТочкиИмяМетрикиНеДекодируетсяДважды(t *testing.T) { + h, st, _ := newAPITokens(t, nil, nil) + + // Обе метрики лежат рядом: подмена наблюдаема числом точек. + mergePoints(t, st, + pointAt("a%41b", "raw", "count", pointsAt(9, 0), pointsAt(9, 0), `{"qty":1}`), + pointAt("aAb", "raw", "count", pointsAt(9, 0), pointsAt(9, 0), `{"qty":2}`), + pointAt("aAb", "raw", "count", pointsAt(9, 1), pointsAt(9, 1), `{"qty":3}`), + ) + + for _, metric := range []string{"a%41b", "aAb", "100%", "a%zzb", "a+b", "шаги", "со/слэшем", "с пробелом"} { + t.Run(metric, func(t *testing.T) { + var got struct { + Metric string `json:"metric"` + } + decodeBody(t, getPoints(t, h, metric, dayWindow, ""), &got) + if got.Metric != metric { + t.Errorf("маршрут ответил о метрике %q, спрашивали %q", got.Metric, metric) + } + }) + } + + // И прямая проверка исхода: у `a%41b` одна точка, у `aAb` — две. + var got struct { + Points []json.RawMessage `json:"points"` + } + decodeBody(t, getPoints(t, h, "a%41b", dayWindow, ""), &got) + if len(got.Points) != 1 { + t.Errorf("точек %d, ожидалась 1 — ответ собран по чужой метрике", len(got.Points)) + } +} + +// Пустое значение `?layer=` — промах клиента, а не «слой не задан». Разбор по +// непустоте молча включал бы автоматический выбор, и клиент, спросивший разрез +// поимённо, не отличил бы свой промах от ответа по существу. +func TestТочкиОтвергаютПустойСлой(t *testing.T) { + h, _, _ := newAPITokens(t, nil, nil) + + if got := getPoints(t, h, "m", dayWindow+"&layer=", "").Code; got != http.StatusBadRequest { + t.Errorf("статус %d, ожидался 400", got) + } +} + +// Границы, различающиеся ДОЛЯМИ СЕКУНДЫ, дают разные метки: отбор точек идёт по +// полной метке, значит и ряды разные. Путь построен враждебным проходом — две +// побайтово одинаковые метки на разных телах, то есть будущий `304` на чужом +// наборе данных. +func TestМеткаТочекРазличаетДолиСекунды(t *testing.T) { + h, st, _ := newAPITokens(t, nil, nil) + mergePoints(t, st, pointAt("m", "raw", "count", pointsAt(0, 0), pointsAt(0, 0), `{"qty":1}`)) + + whole := getPoints(t, h, "m", "from=2026-06-01T00:00:00Z&to=2026-06-02T00:00:00Z", "") + fraction := getPoints(t, h, "m", "from=2026-06-01T00:00:00.500Z&to=2026-06-02T00:00:00Z", "") + + if a, b := whole.Header().Get("ETag"), fraction.Header().Get("ETag"); a == b { + t.Errorf("метка не различает доли секунды: %s", a) + } + if whole.Body.String() == fraction.Body.String() { + t.Error("тела совпали — вход подобран неверно, утверждение о метках ничего не доказывает") + } +} + +// Метка ОГРАНИЧЕНА по длине и не выносит наружу имя метрики. +// +// Имя приходит из чужого тела дословно и ничем не ограничено: без предела +// заголовок `ETag` разрастался вместе с ним (прогнано: имя в 3000 байт давало +// заголовок в 3099). Кавычка в имени по RFC 9110 кончает метку, и собственный +// `scanETag` проекта обрезал бы её ровно там — условный запрос по такой метрике +// не сработал бы никогда. +func TestМеткаТочекОграниченаИНеНесётИмя(t *testing.T) { + h, _, _ := newAPITokens(t, nil, nil) + + long := strings.Repeat("щ", 1000) + `quote"inside` + tag := getPoints(t, h, long, dayWindow, "").Header().Get("ETag") + + if tag == "" { + t.Fatal("ответ ушёл без метки") + } + if len(tag) > 128 { + t.Errorf("метка в %d байт — имя метрики уехало в заголовок целиком", len(tag)) + } + if strings.Contains(tag, "щ") || strings.Contains(tag, `quote"`) { + t.Errorf("имя метрики видно в метке: %s", tag) + } +} + +// brokenWriter — ResponseWriter, отказывающий на записи тела. Заголовки и код +// он принимает: обрыв случается ПОСЛЕ того, как `200` уже отдан, — ровно так +// это выглядит при сработавшем дедлайне записи или ушедшем клиенте. +type brokenWriter struct { + header http.Header + status int +} + +func (w *brokenWriter) Header() http.Header { return w.header } +func (w *brokenWriter) WriteHeader(s int) { w.status = s } +func (w *brokenWriter) Write([]byte) (int, error) { + return 0, errors.New("соединение оборвано") +} + +// Обрыв записи тела оставляет собственный чекпоинт. +// +// Код ответа отдан до сериализации, поэтому `accessLog` честно напишет `200` — +// то есть единственный канал наблюдаемости сообщит успех о неотданном ответе. +// Путь построен враждебным проходом на настоящем сокете: тело в 13 МиБ +// оборвалось на 2.7 МиБ, клиент получил нечитаемый JSON, лог — `200`. +func TestТочкиОбрывЗаписиТелаНаблюдаем(t *testing.T) { + h, st, _, seen := newAPILogged(t, nil, nil) + mergePoints(t, st, pointAt("m", "raw", "count", pointsAt(9, 0), pointsAt(9, 0), `{"qty":1}`)) + seen.reset() + + req := httptest.NewRequest(http.MethodGet, "/api/v1/metrics/m?"+dayWindow, nil) + w := &brokenWriter{header: http.Header{}} + h.ServeHTTP(w, req) + + if w.status != http.StatusOK { + t.Fatalf("статус %d, ожидался 200: обрыв случается после кода ответа", w.status) + } + if !seen.has("points response truncated") { + t.Error("обрыв записи прошёл молча — владелец увидит только успешный 200") + } +} + +// Словарь слоёв ОДИН: `hae.Layers`. Три копии — порядок, перечень выборки и +// текст отказа — разошлись бы молча, и симптомом был бы пустой ряд при +// непустых данных. +func TestСловарьСлоёвОдин(t *testing.T) { + h, _, _ := newAPITokens(t, nil, nil) + + for _, l := range hae.Layers { + if got := getPoints(t, h, "m", dayWindow+"&layer="+string(l), "").Code; got != http.StatusOK { + t.Errorf("слой %q отвергнут статусом %d, хотя он в словаре", l, got) + } + } + + rec := getPoints(t, h, "m", dayWindow+"&layer=weekly", "") + if rec.Code != http.StatusBadRequest { + t.Fatalf("статус %d, ожидался 400", rec.Code) + } + for _, l := range hae.Layers { + if !strings.Contains(rec.Body.String(), string(l)) { + t.Errorf("текст отказа не называет слой %q: %s", l, rec.Body.String()) + } + } +} + +// Граница, уезжающая за четырёхзначный год, отвергается, а не отдаёт пустой ряд. +// +// Объекты адресуются строкой RFC 3339, границы сравниваются лексикографически: +// `9999-12-31T23:00:00-07:00` становится `10000-01-01T06:00:00Z`, который как +// строка меньше любой настоящей метки. Без отказа запрос молча вернул бы пустой +// ряд при непустых данных — найдено триажем. +func TestТочкиОтвергаютГраницуЗаЧетырёхзначнымГодом(t *testing.T) { + h, st, _ := newAPITokens(t, nil, nil) + mergePoints(t, st, pointAt("m", "raw", "count", pointsAt(9, 0), pointsAt(9, 0), `{"qty":1}`)) + + rec := getPoints(t, h, "m", "from=2020-01-01T00:00:00Z&to=9999-12-31T23:00:00-07:00", "") + if rec.Code != http.StatusBadRequest { + t.Fatalf("статус %d, ожидался 400 (тело %s)", rec.Code, rec.Body.String()) + } +} diff --git a/internal/httpapi/wire_internal_test.go b/internal/httpapi/wire_internal_test.go index a2ad981..a9a1f4c 100644 --- a/internal/httpapi/wire_internal_test.go +++ b/internal/httpapi/wire_internal_test.go @@ -101,6 +101,7 @@ func foreignTypes(t reflect.Type) []string { func TestФормаПроводаДоменаНеСодержит(t *testing.T) { cases := map[string]any{ "каталог": catalogResponse{}, + "точки": pointsResponse{}, "тело отказа": errorWire{}, "учёт приёма": ingestResponse{}, } diff --git a/internal/points/bench_test.go b/internal/points/bench_test.go new file mode 100644 index 0000000..ce043e4 --- /dev/null +++ b/internal/points/bench_test.go @@ -0,0 +1,148 @@ +package points_test + +import ( + "context" + "encoding/json" + "log/slog" + "os" + "path/filepath" + "runtime" + "testing" + "time" + + "git.vakhrushev.me/av/healthlog/internal/hae" + "git.vakhrushev.me/av/healthlog/internal/points" + "git.vakhrushev.me/av/healthlog/internal/store" +) + +// ЦЕНА МАРШРУТА ТОЧЕК, ЗАМЕР. +// +// Предела размера ответа у маршрута нет намеренно — его вводит соседняя задача +// `read-api-response-limit`. Поэтому цена обязана быть НАЗВАНА ЧИСЛОМ, и число +// снимается на том режиме, ради которого предел заводится, а не на том, который +// оказался под рукой: прецедент 2026-08-04 (`docs/review.md`) — замер, снятый на +// корпусе, где измеряемого случая не бывает, стоил решения «индекс не нужен». +// +// Три режима, от лёгкого к худшему: +// +// редкая метрика за год — «вес за год», сценарий постановки; +// плотная метрика за сутки — минутный слой, штатный запрос трекера; +// плотная метрика за неделю в нижнем слое — то, что правило выбора слоя +// отдаёт по умолчанию, когда охваты равны. +// +// Точки размножаются из РЕАЛЬНОГО пакета HAE (`internal/hae/testdata`), а не +// пишутся литералами: форма точки, длина строк и вид числового литерала входят +// в цену — они определяют и объём gzip, и работу разжатия. +// +// Прогон: go test ./internal/points -run XXX -bench . -benchtime 1x +func BenchmarkРядТочек(b *testing.B) { + cases := []struct { + name string + layer hae.Layer + hours int + perDay int + }{ + // Год, взвешивание примерно раз в сутки. + {"редкая метрика за год", hae.LayerHour, 365 * 24, 1}, + // Сутки минутного слоя: 60 точек в час. + {"плотная метрика за сутки, minute", hae.LayerMinute, 24, 24 * 60}, + // Неделя нижнего слоя. Порядок взят из разведки: около 100 тысяч + // координат в сутки на весь поток; на одну плотную метрику — 3600 в час. + {"плотная метрика за неделю, raw", hae.LayerRaw, 7 * 24, 24 * 3600}, + } + + for _, c := range cases { + b.Run(c.name, func(b *testing.B) { + svc, from, to := benchStore(b, c.layer, c.hours, c.perDay) + + var before, after runtime.MemStats + runtime.GC() + runtime.ReadMemStats(&before) + + b.ResetTimer() + var got points.Series + for range b.N { + var err error + got, err = svc.Series(context.Background(), points.Request{ + Metric: "bench_metric", From: from, To: to, Layer: string(c.layer), + }) + if err != nil { + b.Fatalf("Series: %v", err) + } + } + b.StopTimer() + + runtime.ReadMemStats(&after) + b.ReportMetric(float64(len(got.Points)), "точек") + b.ReportMetric(float64(after.TotalAlloc-before.TotalAlloc)/(1<<20)/float64(b.N), "МиБ_выделено") + }) + } +} + +// benchStore наполняет витрину точками, размноженными из реального пакета HAE. +func benchStore(b *testing.B, layer hae.Layer, hours, perDay int) (*points.Service, time.Time, time.Time) { + b.Helper() + + raw := realPointBody(b) + + st, err := store.Open(filepath.Join(b.TempDir(), "healthlog.db")) + if err != nil { + b.Fatalf("store.Open: %v", err) + } + b.Cleanup(func() { _ = st.Close() }) + + perHour := max(perDay/24, 1) + step := time.Hour / time.Duration(perHour) + start := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + + ctx := context.Background() + // Пачками по часу: одна транзакция на весь год держала бы блокировку минуты. + for h := range hours { + hour := start.Add(time.Duration(h) * time.Hour) + in := make([]store.IncomingPoint, 0, perHour) + for i := range perHour { + at := hour.Add(time.Duration(i) * step) + in = append(in, store.IncomingPoint{ + Metric: "bench_metric", Layer: string(layer), Units: "count", + Point: store.Point{Start: at, End: at, OffsetSeconds: 10800, Raw: raw}, + }) + } + if _, err := st.Merge(ctx, store.Incoming{Points: in}, store.DeliveryRef{ID: "bench"}); err != nil { + b.Fatalf("слияние: %v", err) + } + } + + svc := points.New(st, slog.New(slog.DiscardHandler)) + return svc, start, start.Add(time.Duration(hours) * time.Hour) +} + +// realPointBody достаёт содержимое настоящей точки из пакета HAE. +// +// Конвенция проекта: тесты формата держим на реальных пакетах. Здесь она нужна +// не ради разбора, а ради ЦЕНЫ — выдуманная точка `{"qty":1}` жмётся иначе и +// разжимается быстрее, чем то, что реально шлёт телефон. +func realPointBody(b *testing.B) json.RawMessage { + b.Helper() + + body, err := os.ReadFile(filepath.Join("..", "hae", "testdata", "minute.json")) + if err != nil { + b.Fatalf("чтение пакета: %v", err) + } + var pkg struct { + Data struct { + Metrics []struct { + Data []json.RawMessage `json:"data"` + } `json:"metrics"` + } `json:"data"` + } + if err := json.Unmarshal(body, &pkg); err != nil { + b.Fatalf("разбор пакета: %v", err) + } + for _, m := range pkg.Data.Metrics { + if len(m.Data) > 0 { + return m.Data[0] + } + } + b.Fatal("в пакете нет ни одной точки") + return nil +} diff --git a/internal/points/points.go b/internal/points/points.go new file mode 100644 index 0000000..a5e5b47 --- /dev/null +++ b/internal/points/points.go @@ -0,0 +1,244 @@ +// Package points — ряд точек одной метрики за период. +// +// Отвечает на вопрос потребителя «дай значения» — в отличие от каталога, +// который отвечает «что у тебя есть». Пакет производен от витрины и ничего в +// неё не пишет. +// +// Три решения этого узла живут здесь, потому что все три — правила, а не +// выборки: +// +// - какой слой отдать, когда клиент его не назвал; +// - применим ли измеренный род к отданному ряду (нижний слой HAE не +// суммируется никогда, а род — свойство метрики, не ряда); +// - из чего собрана метка ответа. +// +// Род при этом НЕ измеряется здесь: правило измерения одно и живёт в +// `internal/catalog`. Второй его экземпляр разошёлся бы с первым молча, а на +// роде строится арифметика года. +package points + +import ( + "context" + "encoding/json" + "log/slog" + "time" + + "git.vakhrushev.me/av/healthlog/internal/catalog" + "git.vakhrushev.me/av/healthlog/internal/hae" + "git.vakhrushev.me/av/healthlog/internal/store" +) + +// ФОРМЫ ПРОВОДА В ЭТОМ ПАКЕТЕ НЕТ. Типы ниже — форма ответа use-case: +// `json`-тегов они не несут и до сериализации не доезжают. Публичный контракт +// объявляет транспорт (`internal/httpapi`), см. ADR о форме провода. + +// Request — запрос ряда. Границы уже разобраны и нормализованы транспортом; +// период — полуинтервал `[From, To)`. +type Request struct { + Metric string + From time.Time + To time.Time + // Layer — явно запрошенный слой; пустая строка означает «выбери сам». + Layer string +} + +// Point — точка ряда. +// +// `Raw` — содержимое ровно в том виде, в каком его сохранило хранилище: точки +// хранятся дословно, и нормализовано у них только время. +type Point struct { + TS time.Time + End time.Time + OffsetSeconds int + Units string + Raw json.RawMessage +} + +// Series — ответ маршрута точек вместе с версией ответа. +// +// Версия пустая, когда подписать ответ нечем: витрина изменилась, пока ответ +// собирался, или прочитать её версию не удалось. Это не отказ. +type Series struct { + Version string + Metric string + From time.Time + To time.Time + // Layer — слой, из которого собран ряд. Пустая строка означает, что слой + // выбирала система и выбирать было не из чего; явно запрошенный слой + // уезжает здесь всегда, даже когда ряд пуст. + Layer string + // Style — измеренный род метрики, тем же правилом и тем же окном, что у + // каталога. + Style catalog.Style + // Applicable — применим ли объявленный род к ЭТОМУ ряду. + Applicable bool + // LastHour — ярлык самого свежего часа окна измерения; nil при пустом окне. + LastHour *time.Time + Points []Point +} + +// Service собирает ряд точек по витрине. +type Service struct { + store *store.Store + log *slog.Logger +} + +// New собирает сервис точек. +func New(st *store.Store, log *slog.Logger) *Service { + return &Service{store: st, log: log} +} + +// Series собирает ряд точек метрики за период. +// +// Всё, от чего зависит ответ, снимается ОДНОЙ транзакцией чтения: охваты слоёв, +// точки выбранного слоя и объекты окна измерения. Пара проб версии вокруг неё +// нужна для метки, а непротиворечивость тела даёт транзакция — проба +// расхождение обнаружила бы, но тело всё равно уехало бы клиенту. +// +// Горизонт снимается ОДИН РАЗ и уходит и в окно измерения, и в метку: род есть +// функция горизонта, а горизонт едет вместе с часами. Сними их порознь — и +// метка однажды подтвердит неизменность ответа, чей род уже перевернулся. +func (s *Service) Series(ctx context.Context, req Request) (Series, error) { + horizon := catalog.Horizon() + + var snap store.SeriesSnapshot + version, err := s.store.VersionedRead(ctx, func(ctx context.Context) error { + var err error + snap, err = s.store.ReadSeries(ctx, store.SeriesWindow{ + Metric: req.Metric, + From: req.From, + To: req.To, + Layer: req.Layer, + Layers: layerNames, + Measure: catalog.MeasureWindow(horizon), + }, func(spans []store.LayerSpan) string { + return pickLayer(req.From, req.To, spans) + }) + return err + }) + if err != nil { //nolint:nestif // ветка одна, вложенность даёт лог по адресату + // Единственный логирующий чекпоинт исхода: транспорт переводит ошибку в + // ответ и второй раз её не пишет. Границ запроса и значений точек в + // записи нет — данные о здоровье чувствительнее токенов; имя метрики + // обрезано тем же пределом, что у каталога: оно приходит из тела + // дословно, а запись повторяется на каждый запрос. + // + // Отмена снаружи и занятость базы означают «не сделано», а не «не + // выходит»: клиент, оборвавший запрос по своему тайм-ауту, не должен + // давать владельцу ERROR. + if store.Transient(err) { + s.log.DebugContext(ctx, "series interrupted", "capability", "query", + "metric", catalog.ClipMetric(req.Metric), "error", err) + } else { + s.log.ErrorContext(ctx, "series failed", "capability", "query", + "metric", catalog.ClipMetric(req.Metric), "error", err) + } + return Series{}, err + } + + // Предупреждения измерения (противоречащий род, данные из будущего) здесь + // НЕ пишутся: они привилегия каталога. Агент опрашивает по расписанию, и + // WARN на каждый опрос обесценил бы уровень ровно так же, как обесценила бы + // его строка на каждый `304`. + style, basis := catalog.Measure(snap.Pairs) + + out := Series{ + Version: catalog.Stamp(version, horizon), + Metric: req.Metric, + From: req.From, + To: req.To, + Layer: snap.Layer, + Style: style, + Applicable: applicable(style, snap.Layer), + LastHour: basis.LastHour, + // Непустой срез, а не nil: пустой ряд обязан уехать клиенту как `[]`. + Points: make([]Point, 0, len(snap.Points)), + } + for _, p := range snap.Points { + out.Points = append(out.Points, Point{ + TS: p.Start, + End: p.End, + OffsetSeconds: p.OffsetSeconds, + Units: p.Units, + Raw: p.Raw, + }) + } + if version == "" { + s.log.DebugContext(ctx, "series unsigned", "capability", "query", + "metric", catalog.ClipMetric(req.Metric)) + } + return out, nil +} + +// layerNames — тот же словарь `hae.Layers`, переведённый в строки для выборки. +// +// Выводится из словаря, а не перечисляется заново: собственный список разошёлся +// бы с ним молча. Хранилищу перечень нужен по эксплуатационной причине — без +// предиката по слою выборка охватов просматривает все строки метрики за всю +// историю (измерено проходом `ops`: 13.9 мс против 0.026 мс), и цена росла бы +// вместе с возрастом сервиса при любой ширине запроса. +var layerNames = func() []string { + out := make([]string, 0, len(hae.Layers)) + for _, l := range hae.Layers { + out = append(out, string(l)) + } + return out +}() + +// pickLayer выбирает слой по ОХВАТУ точек внутри периода. +// +// Охват — длина пересечения отрезка [первая метка слоя, последняя метка слоя] с +// запрошенным периодом. Слой с пустым пересечением выбывает. Побеждает +// наибольший охват, при равенстве — самый мелкий слой. +// +// Охват меряется метками ТОЧЕК, а не часами объектов, и это не придирка. +// Объекты адресуются часом, поэтому выборка обязана быть шире запроса (точка +// 10:59 живёт в объекте 10:00), а ряд отбирается точной меткой. На периоде +// [10:30, 10:45) часовой слой имеет объект 10:00 с единственной точкой в 10:00, +// минутный — объект 10:00 с точками 10:31…10:44. По часам объектов охваты +// равны, побеждает часовой — и ответ уходит пустым при непустых минутных +// данных. По меткам точек часовой выбывает сразу. +// +// Число точек мерой не является: нижний слой за три плотных дня даёт их больше, +// чем часовой за год, — и «вес за год» вернул бы три дня, не сказав об этом ни +// словом. +func pickLayer(from, to time.Time, spans []store.LayerSpan) string { + best := "" + var bestCover time.Duration + for _, sp := range spans { + if sp.Last.Before(from) || !sp.First.Before(to) { + continue + } + start, end := sp.First, sp.Last + if start.Before(from) { + start = from + } + if end.After(to) { + end = to + } + cover := end.Sub(start) + finer := hae.Rank(hae.Layer(sp.Layer)) < hae.Rank(hae.Layer(best)) + if best == "" || cover > bestCover || (cover == bestCover && finer) { + best, bestCover = sp.Layer, cover + } + } + return best +} + +// applicable отвечает, можно ли применить объявленный род к ЭТОМУ ряду. +// +// Род — свойство метрики, слой — свойство ряда, и их сочетание бывает опасным. +// Нижний слой HAE это интерполяция, а не сэмплы: сумма по нему завышает втрое +// (находка 34). Конверт, объявляющий `cumulative` рядом с рядом из `raw` и +// молчащий о неприменимости, приглашает потребителя сложить интерполяцию +// самому — система при этом не складывает ничего, а решение у потребителя уже +// принято по завышенному числу. +// +// Слой `sample` под запрет не подпадает: это настоящие сэмплы HealthKit из +// родного экспорта, а не развёртка HAE. +func applicable(style catalog.Style, layer string) bool { + if style == catalog.Unknown || layer == "" { + return false + } + return style != catalog.Cumulative || layer != string(hae.LayerRaw) +} diff --git a/internal/points/points_internal_test.go b/internal/points/points_internal_test.go new file mode 100644 index 0000000..2130823 --- /dev/null +++ b/internal/points/points_internal_test.go @@ -0,0 +1,132 @@ +package points + +import ( + "testing" + "time" + + "git.vakhrushev.me/av/healthlog/internal/catalog" + "git.vakhrushev.me/av/healthlog/internal/store" +) + +func day(d int) time.Time { return time.Date(2026, 6, d, 0, 0, 0, 0, time.UTC) } + +func span(layer string, first, last time.Time) store.LayerSpan { + return store.LayerSpan{Layer: layer, First: first, Last: last} +} + +// Правило выбора слоя целиком: охват, равенство охватов, выбывание по пустому +// пересечению и вырожденный вход. +func TestВыборСлояПоОхвату(t *testing.T) { + from, to := day(1), day(30) + + cases := []struct { + name string + spans []store.LayerSpan + want string + }{ + { + // «Вес за год»: нижний слой плотнее, но короче. Победа по числу + // точек вернула бы три дня вместо периода и не сказала бы ни слова. + name: "мелкий слой охватывает меньше крупного", + spans: []store.LayerSpan{ + span("raw", day(1), day(4)), + span("hour", day(1), day(29)), + }, + want: "hour", + }, + { + name: "охваты равны — побеждает мелкий", + spans: []store.LayerSpan{ + span("hour", day(1), day(29)), + span("minute", day(1), day(29)), + }, + want: "minute", + }, + { + // Слой, чьи данные лежат целиком вне периода, выбывает — иначе он + // выиграл бы охват и отдал пустой ряд. + name: "слой лежит вне периода", + spans: []store.LayerSpan{ + span("hour", day(1).Add(-48*time.Hour), day(1).Add(-24*time.Hour)), + span("minute", day(2), day(3)), + }, + want: "minute", + }, + { + // Пересечение считается по ПЕРИОДУ, а не по данным: слой, торчащий + // за обе границы, не получает бесконечного охвата. + name: "слой шире периода", + spans: []store.LayerSpan{ + span("hour", day(1).Add(-240*time.Hour), day(30).Add(240*time.Hour)), + span("minute", day(1), day(30)), + }, + want: "minute", + }, + { + name: "слоёв нет вовсе", + spans: nil, + want: "", + }, + { + // Единственная точка ровно на границе периода: охват нулевой, но + // слой пригоден — точка в ответ попадёт. + name: "единственная точка на границе", + spans: []store.LayerSpan{span("raw", from, from)}, + want: "raw", + }, + { + // Точка ровно на `to` в период не входит (полуинтервал), значит и + // слой из выбора выбывает. + name: "единственная точка на правой границе", + spans: []store.LayerSpan{span("raw", to, to)}, + want: "", + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := pickLayer(from, to, c.spans); got != c.want { + t.Errorf("выбран слой %q, ожидался %q", got, c.want) + } + }) + } +} + +// Порядок слоёв в выборке не должен влиять на исход: иначе ответ стал бы +// функцией порядка строк, который задаёт SQLite, а не правило. +func TestВыборСлояНеЗависитОтПорядка(t *testing.T) { + from, to := day(1), day(30) + a := span("hour", day(1), day(29)) + b := span("minute", day(1), day(29)) + + if pickLayer(from, to, []store.LayerSpan{a, b}) != pickLayer(from, to, []store.LayerSpan{b, a}) { + t.Error("выбор слоя зависит от порядка охватов") + } +} + +// Применимость рода к ОТДАННОМУ ряду. Разряд, ради которого поле заведено: +// накопительная метрика на нижнем слое HAE неприменима, потому что нижний слой +// это интерполяция, а не сэмплы, и сумма по нему завышает втрое. +func TestПрименимостьРодаКРяду(t *testing.T) { + cases := []struct { + style catalog.Style + layer string + want bool + }{ + {catalog.Cumulative, "raw", false}, + {catalog.Cumulative, "minute", true}, + {catalog.Cumulative, "hour", true}, + {catalog.Cumulative, "sample", true}, + {catalog.Instant, "raw", true}, + {catalog.Unknown, "minute", false}, + {catalog.Unknown, "raw", false}, + {catalog.Cumulative, "", false}, + {catalog.Instant, "", false}, + } + + for _, c := range cases { + if got := applicable(c.style, c.layer); got != c.want { + t.Errorf("применимость %s на слое %q = %v, ожидалось %v", c.style, c.layer, got, c.want) + } + } +} diff --git a/internal/points/service_test.go b/internal/points/service_test.go new file mode 100644 index 0000000..d40b81e --- /dev/null +++ b/internal/points/service_test.go @@ -0,0 +1,300 @@ +package points_test + +import ( + "context" + "encoding/json" + "log/slog" + "path/filepath" + "sync" + "testing" + "time" + + "git.vakhrushev.me/av/healthlog/internal/catalog" + "git.vakhrushev.me/av/healthlog/internal/points" + "git.vakhrushev.me/av/healthlog/internal/store" +) + +func service(t *testing.T, in ...store.IncomingPoint) (*points.Service, *store.Store) { + t.Helper() + + st, err := store.Open(filepath.Join(t.TempDir(), "healthlog.db")) + if err != nil { + t.Fatalf("store.Open: %v", err) + } + t.Cleanup(func() { _ = st.Close() }) + + if len(in) > 0 { + if _, err := st.Merge(context.Background(), store.Incoming{Points: in}, store.DeliveryRef{ID: "d"}); err != nil { + t.Fatalf("слияние: %v", err) + } + } + return points.New(st, slog.New(slog.DiscardHandler)), st +} + +func hourAt(hh int) time.Time { return time.Date(2026, 6, 1, hh, 0, 0, 0, time.UTC) } + +func in(metric, layer string, at time.Time) store.IncomingPoint { + return store.IncomingPoint{ + Metric: metric, Layer: layer, Units: "count", + Point: store.Point{Start: at, End: at, Raw: json.RawMessage(`{"qty":1}`)}, + } +} + +// Версия ответа несёт ГОРИЗОНТ измерения, а не только версию витрины. +// +// Без горизонта метка не меняется, когда час из будущего въезжает в окно сам, +// ходом часов и без единого коммита, — и соседняя задача условного запроса +// подтвердит `304` на ответе, чей род уже перевернулся. Путь проект уже строил +// и закрывал у каталога; здесь он закрывается тем же механизмом. +func TestВерсияОтветаНесётГоризонт(t *testing.T) { + svc, st := service(t, in("m", "raw", hourAt(9))) + ctx := context.Background() + + got, err := svc.Series(ctx, points.Request{Metric: "m", From: hourAt(0), To: hourAt(23)}) + if err != nil { + t.Fatalf("Series: %v", err) + } + if got.Version == "" { + t.Fatal("ответ без версии — подписывать условный запрос нечем") + } + + bare, err := st.StateVersion(ctx) + if err != nil { + t.Fatalf("StateVersion: %v", err) + } + if got.Version == bare { + t.Error("версия ответа равна версии витрины — горизонт в неё не вошёл") + } + if want := catalog.Stamp(bare, catalog.Horizon()); got.Version != want { + t.Errorf("версия ответа %q, ожидалась %q", got.Version, want) + } +} + +// Слой выбирается тем же правилом, что проверено на охватах, но уже через +// хранилище: «вес за год» обязан вернуть длинный слой, а не плотный короткий. +func TestРядБерётСлойСНаибольшимОхватом(t *testing.T) { + svc, _ := service(t, + in("body_mass", "raw", hourAt(9)), + in("body_mass", "raw", hourAt(10)), + in("body_mass", "hour", hourAt(1)), + in("body_mass", "hour", hourAt(20)), + ) + + got, err := svc.Series(context.Background(), points.Request{ + Metric: "body_mass", From: hourAt(0), To: hourAt(23), + }) + if err != nil { + t.Fatalf("Series: %v", err) + } + if got.Layer != "hour" { + t.Errorf("слой %q, ожидался hour: нижний слой охватывает меньше", got.Layer) + } + if len(got.Points) != 2 { + t.Errorf("точек %d, ожидалось 2", len(got.Points)) + } +} + +// Явно запрошенный слой уезжает в ответе даже пустым: клиент, спросивший разрез +// поимённо, обязан отличать «за период этого разреза нет» от «параметр +// проигнорирован». Слой, который выбирала система и выбрать не смогла, — пустой. +func TestРядРазличаетПустойЯвныйСлойИОтсутствиеВыбора(t *testing.T) { + svc, _ := service(t, in("m", "raw", hourAt(9))) + ctx := context.Background() + + explicit, err := svc.Series(ctx, points.Request{ + Metric: "m", From: hourAt(0), To: hourAt(23), Layer: "minute", + }) + if err != nil { + t.Fatalf("Series: %v", err) + } + if explicit.Layer != "minute" { + t.Errorf("явный слой %q, ожидался minute", explicit.Layer) + } + if len(explicit.Points) != 0 { + t.Errorf("точек %d, ожидалось 0", len(explicit.Points)) + } + + chosen, err := svc.Series(ctx, points.Request{ + Metric: "нет такой", From: hourAt(0), To: hourAt(23), + }) + if err != nil { + t.Fatalf("Series: %v", err) + } + if chosen.Layer != "" { + t.Errorf("слой %q, ожидался пустой: выбирать было не из чего", chosen.Layer) + } +} + +// Отмена запроса клиентом — обстоятельство, а не отказ: ответ не собирается, но +// и ERROR владельцу не пишется. Уровень проверяет тест транспорта; здесь — +// что отмена вообще доезжает до драйвера и не игнорируется. +func TestРядУважаетОтменуКонтекста(t *testing.T) { + svc, _ := service(t, in("m", "raw", hourAt(9))) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + if _, err := svc.Series(ctx, points.Request{Metric: "m", From: hourAt(0), To: hourAt(23)}); err == nil { + t.Error("отменённый запрос собрал ответ — context до драйвера не доехал") + } +} + +// Отказ хранилища доезжает до вызывающего отказом, а не пустым рядом: маршрут +// обязан ответить 500, а не «данных нет». Отказ при этом НЕ транзиентный — +// значит уходит владельцу уровнем ERROR, а не тонет в DEBUG. +func TestРядНаЗакрытомХранилищеОтказывает(t *testing.T) { + svc, st := service(t, in("m", "raw", hourAt(9))) + if err := st.Close(); err != nil { + t.Fatalf("закрытие: %v", err) + } + + _, err := svc.Series(context.Background(), points.Request{Metric: "m", From: hourAt(0), To: hourAt(23)}) + if err == nil { + t.Fatal("закрытое хранилище отдало ряд") + } + if store.Transient(err) { + t.Error("отказ закрытого хранилища объявлен обстоятельством — владелец о нём не узнает") + } +} + +// levels — slog.Handler, копящий уровень и сообщение. Значений атрибутов не +// хранит: проверяется адресат записи, а данные о здоровье в тесты тащить +// незачем. +type levels struct { + mu sync.Mutex + seen []slog.Record +} + +func (l *levels) Enabled(context.Context, slog.Level) bool { return true } +func (l *levels) WithAttrs([]slog.Attr) slog.Handler { return l } +func (l *levels) WithGroup(string) slog.Handler { return l } + +func (l *levels) Handle(_ context.Context, r slog.Record) error { + l.mu.Lock() + defer l.mu.Unlock() + l.seen = append(l.seen, r.Clone()) + return nil +} + +func (l *levels) levelOf(msg string) (slog.Level, bool) { + l.mu.Lock() + defer l.mu.Unlock() + for _, r := range l.seen { + if r.Message == msg { + return r.Level, true + } + } + return 0, false +} + +func (l *levels) count(level slog.Level) int { + l.mu.Lock() + defer l.mu.Unlock() + n := 0 + for _, r := range l.seen { + if r.Level == level { + n++ + } + } + return n +} + +func loggedService(t *testing.T, in ...store.IncomingPoint) (*points.Service, *store.Store, *levels) { + t.Helper() + + st, err := store.Open(filepath.Join(t.TempDir(), "healthlog.db")) + if err != nil { + t.Fatalf("store.Open: %v", err) + } + t.Cleanup(func() { _ = st.Close() }) + if len(in) > 0 { + if _, err := st.Merge(context.Background(), store.Incoming{Points: in}, store.DeliveryRef{ID: "d"}); err != nil { + t.Fatalf("слияние: %v", err) + } + } + + seen := &levels{} + return points.New(st, slog.New(seen)), st, seen +} + +// Отмена клиентом — обстоятельство, а не отказ, и уровень записи это отражает. +// +// Утверждение прямое, потому что иначе оно не держится ничем: смена +// классификации не даёт ни ошибки компиляции, ни красного теста. Агент +// опрашивает маршрут по расписанию, и `ERROR` на каждый его тайм-аут забил бы +// единственный канал, по которому владелец видит настоящий сбой хранилища. +func TestОтменаЗапросаПишетсяDEBUG(t *testing.T) { + svc, _, seen := loggedService(t, in("m", "raw", hourAt(9))) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := svc.Series(ctx, points.Request{Metric: "m", From: hourAt(0), To: hourAt(23)}); err == nil { + t.Fatal("отменённый запрос собрал ответ") + } + + level, ok := seen.levelOf("series interrupted") + if !ok { + t.Fatal("отмена не оставила чекпоинта — исход не наблюдаем") + } + if level != slog.LevelDebug { + t.Errorf("уровень %s, ожидался DEBUG", level) + } + if n := seen.count(slog.LevelError); n != 0 { + t.Errorf("записей ERROR %d, ожидалось 0: отмена клиента — не сбой хранилища", n) + } +} + +// Настоящий отказ хранилища доходит до владельца уровнем ERROR. +func TestОтказХранилищаПишетсяERROR(t *testing.T) { + svc, st, seen := loggedService(t, in("m", "raw", hourAt(9))) + if err := st.Close(); err != nil { + t.Fatalf("закрытие: %v", err) + } + + if _, err := svc.Series(context.Background(), points.Request{Metric: "m", From: hourAt(0), To: hourAt(23)}); err == nil { + t.Fatal("закрытое хранилище собрало ответ") + } + level, ok := seen.levelOf("series failed") + if !ok { + t.Fatal("отказ не оставил чекпоинта") + } + if level != slog.LevelError { + t.Errorf("уровень %s, ожидался ERROR", level) + } +} + +// Предупреждения измерения — привилегия каталога, и маршрут точек их НЕ +// повторяет: агент опрашивает по расписанию, и WARN на каждый опрос обесценил +// бы уровень ровно так же, как обесценила бы его строка на каждый `304`. +func TestМаршрутТочекНеПовторяетПредупрежденияИзмерения(t *testing.T) { + // Метрика с противоречащим родом: часть часов сходится с суммой, часть — со + // средним. Каталог на таком входе пишет WARN. + var seed []store.IncomingPoint + for h := range 8 { + hour := hourAt(h) + coarse := "30" + if h%2 == 0 { + coarse = "15" // среднее двух минутных значений 10 и 20 + } + seed = append(seed, + store.IncomingPoint{Metric: "mixed", Layer: "hour", Units: "kJ", + Point: store.Point{Start: hour, End: hour, Raw: json.RawMessage(`{"qty":` + coarse + `}`)}}, + store.IncomingPoint{Metric: "mixed", Layer: "minute", Units: "kJ", + Point: store.Point{Start: hour, End: hour, Raw: json.RawMessage(`{"qty":10}`)}}, + store.IncomingPoint{Metric: "mixed", Layer: "minute", Units: "kJ", + Point: store.Point{Start: hour.Add(time.Minute), End: hour.Add(time.Minute), Raw: json.RawMessage(`{"qty":20}`)}}, + ) + } + svc, _, seen := loggedService(t, seed...) + + for range 3 { + if _, err := svc.Series(context.Background(), points.Request{ + Metric: "mixed", From: hourAt(0), To: hourAt(23), + }); err != nil { + t.Fatalf("Series: %v", err) + } + } + if n := seen.count(slog.LevelWarn); n != 0 { + t.Errorf("маршрут точек написал %d предупреждений — опрос по расписанию обесценит уровень", n) + } +} diff --git a/internal/store/series.go b/internal/store/series.go new file mode 100644 index 0000000..1b57fb3 --- /dev/null +++ b/internal/store/series.go @@ -0,0 +1,274 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "sort" + "strings" + "time" +) + +// Запросы ряда точек. Вынесены константами по той же причине, что и запросы +// каталога: по ним проверяется план выполнения. +const ( + // Объекты выбранного слоя. Точный префикс первичного ключа + // (metric, layer, hour_utc); `payload` здесь и нужен. + seriesPointsQuery = ` + SELECT hour_utc, units, payload + FROM bucket + WHERE metric = ? AND layer = ? AND hour_utc BETWEEN ? AND ? + ORDER BY hour_utc` +) + +// layerSpansQuery строит выборку охватов слоёв метрики внутри периода. +// +// Границы берутся ТОЧНЫЕ (`first_ts`/`last_ts`), а не по `hour_utc`: объект +// адресуется часом, а ряд отбирается точной меткой, и на периоде короче часа +// эти два множества расходятся. Слой, выбранный по часам, отдал бы пустой ряд +// при непустых данных соседнего слоя — час объекта попадает в период, а его +// единственная точка в период не попадает. +// +// СЛОИ ПЕРЕЧИСЛЕНЫ ЯВНО, и это не украшение запроса, а его цена. Индекс +// `bucket_catalog` идёт `(metric, layer, hour_utc, …)`; без предиката по слою +// SQLite не может сузить поиск по `hour_utc` внутри индекса и просматривает +// ВСЕ строки метрики за всю историю, применяя период построчным фильтром. План +// при этом выглядит успешным (`SEARCH … USING COVERING INDEX`), а цена растёт +// вместе с возрастом сервиса при любой ширине запроса: измерено эксплуатационным +// проходом ревью на копии схемы — 2.06 мс при 52 560 строках метрики против +// 13.9 мс при 350 400, и 0.026 мс с этим перечислением. Словарь слоёв задаёт +// вызывающий: правило принадлежит домену, хранилище лишь выбирает по нему. +func layerSpansQuery(layers int) string { + return ` + SELECT layer, min(first_ts), max(last_ts) + FROM bucket + WHERE metric = ? AND layer IN (` + + strings.TrimSuffix(strings.Repeat("?,", layers), ",") + `) + AND hour_utc BETWEEN ? AND ? + GROUP BY layer + ORDER BY layer` +} + +// errEmptyWindow — период задан вывернутым. Нарушенный инвариант вызывающего: +// транспорт обязан отвергнуть такой запрос раньше. +var errEmptyWindow = errors.New("окно ряда: from не раньше to") + +// errNoLayers — словарь слоёв не задан. Без него выборка охватов выродилась бы +// в скан всей истории метрики, а не отдала бы пустой результат: молчаливая +// деградация цены хуже отказа. +var errNoLayers = errors.New("окно ряда: словарь слоёв пуст") + +// LayerSpan — охват одного слоя метрики внутри запрошенного периода. +// +// First и Last — метки первой и последней ТОЧКИ объектов слоя, попавших в +// границы часов запроса. Это границы данных, а не обещание покрытия: внутри +// законно есть дыры. +type LayerSpan struct { + Layer string + First time.Time + Last time.Time +} + +// SeriesPoint — точка ряда вместе с единицами объекта, из которого она +// прочитана. +// +// Единицы едут с точкой, а не с рядом: они хранятся на часовом объекте, и +// метрика, чьи объекты разошлись единицами, обязана показать это строкой, а не +// выбрать одно из двух молча. +type SeriesPoint struct { + Point + Units string +} + +// SeriesWindow — что читать. Все правила задаёт вызывающий: хранилище выбирает +// строки, а не решает, какие из них правильные. +type SeriesWindow struct { + Metric string + // From включительно, To исключительно. + From, To time.Time + // Layer — явно запрошенный слой; пустая строка означает «выбери сам», + // и тогда зовётся pick. + Layer string + // Layers — словарь слоёв, среди которых вообще имеет смысл искать. Задаёт + // вызывающий: перечень слоёв — знание домена, а хранилищу он нужен, чтобы + // выборка охватов не превращалась в скан всей истории метрики. + Layers []string + // Measure — окно измерения рода агрегации той же метрики. + Measure CatalogWindow +} + +// SeriesSnapshot — весь вход ответа точек, снятый ОДНОЙ транзакцией чтения. +// +// Единый снимок здесь не аккуратность, а условие непротиворечивости: приём идёт +// непрерывно, и фоновая свёртка вправе закоммитить между выбором слоя и чтением +// точек. Тогда слой выбран по одному состоянию витрины, ряд прочитан по +// второму, а род измерен по третьему — ответ внутренне противоречив и от свежего +// неотличим. Пара проб версии такой ответ ОБНАРУЖИТ (метки не будет), но не +// предотвратит: тело всё равно уедет. Тот же довод записан у входа каталога. +type SeriesSnapshot struct { + // Охватов слоёв здесь НЕТ намеренно: они приходят в pick аргументом, и это + // единственные ворота решения о слое. Поле наружу предлагало бы те же данные + // любому будущему вызывающему и приглашало бы решить слой пост-фактум, мимо + // правила, — двое ворот к одному решению. + // + // Layer — слой, из которого собран ряд. Пустая строка означает, что слоя + // нет: выбирать было не из чего либо запрошенный слой пуст. + Layer string + // Points — ряд, отобранный до точных границ периода и упорядоченный. + Points []SeriesPoint + // Pairs — общие часы метрики, от самых свежих к старым. + Pairs []HourPair +} + +// ReadSeries снимает вход ответа точек одной транзакцией чтения. +// +// Правило выбора слоя остаётся В ДОМЕНЕ и приходит сюда функцией pick, +// вызываемой ВНУТРИ транзакции. Форма не изобретена: VersionedRead уже +// принимает работу колбэком, а ReadCatalog уже получает параметры правила +// структурой. Перенести само правило сюда значило бы вернуть в хранилище +// решение, которое из него специально убирали. +// +// pick зовётся только когда слой не задан явно и есть из чего выбирать; вернуть +// он может пустую строку — это законный исход «ряда нет». +func (s *Store) ReadSeries(ctx context.Context, w SeriesWindow, pick func([]LayerSpan) string) (SeriesSnapshot, error) { + // Границы В ТЕКСТ ОШИБКИ НЕ ИДУТ. Текст доезжает до записи лога вызывающего, + // а границы периода — часть запроса о здоровье; сегодня транспорт отвергает + // такой запрос раньше, но второй вызывающий (адаптер MCP) откроет этот путь. + if !w.From.Before(w.To) { + return SeriesSnapshot{}, errEmptyWindow + } + if len(w.Layers) == 0 { + return SeriesSnapshot{}, errNoLayers + } + + // `ReadOnly` у `modernc.org/sqlite` выбирает `BEGIN` вместо + // `BEGIN IMMEDIATE` и записи НЕ ЗАПРЕЩАЕТ (прочитан исходник драйвера + // зафиксированной версии). То, что этот путь не пишет, держится ревью, а не + // драйвером; полагаться на флаг как на защиту нельзя. + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) + if err != nil { + return SeriesSnapshot{}, fmt.Errorf("begin read tx: %w", err) + } + defer func() { _ = tx.Rollback() }() + + // Границы по часам ШИРЕ запроса: точка 10:59 живёт в объекте 10:00, и + // огрубление до часа — единственный способ её не потерять. Точный отбор + // идёт ниже, по меткам самих точек. + fromHour := FormatTime(w.From.UTC().Truncate(time.Hour)) + toHour := FormatTime(w.To.UTC().Truncate(time.Hour)) + + spans, err := readLayerSpans(ctx, tx, w, fromHour, toHour) + if err != nil { + return SeriesSnapshot{}, err + } + + out := SeriesSnapshot{Layer: w.Layer} + if out.Layer == "" && pick != nil { + out.Layer = pick(spans) + } + if out.Layer != "" { + // Слой ВЫБРАННЫЙ, а не запрошенный: они расходятся ровно тогда, когда + // правило сработало, — то есть в самом частом случае. + if out.Points, err = readSeriesPoints(ctx, tx, w, out.Layer, fromHour, toHour); err != nil { + return SeriesSnapshot{}, err + } + } + + pairs, err := commonHours(ctx, tx, w.Metric, w.Measure) + if err != nil { + return SeriesSnapshot{}, err + } + if len(pairs) > 0 { + if err := readHourPairs(ctx, tx, w.Metric, w.Measure, pairs); err != nil { + return SeriesSnapshot{}, err + } + } + out.Pairs = pairs + + return out, nil +} + +func readLayerSpans(ctx context.Context, tx *sql.Tx, w SeriesWindow, fromHour, toHour string) ([]LayerSpan, error) { + args := make([]any, 0, len(w.Layers)+3) + args = append(args, w.Metric) + for _, l := range w.Layers { + args = append(args, l) + } + args = append(args, fromHour, toHour) + + rows, err := tx.QueryContext(ctx, layerSpansQuery(len(w.Layers)), args...) + if err != nil { + return nil, fmt.Errorf("select layer spans: %w", err) + } + defer func() { _ = rows.Close() }() + + out := make([]LayerSpan, 0, 4) + for rows.Next() { + var sp LayerSpan + var first, last string + if err := rows.Scan(&sp.Layer, &first, &last); err != nil { + return nil, fmt.Errorf("scan layer span: %w", err) + } + if sp.First, err = ParseTime(first); err != nil { + return nil, err + } + if sp.Last, err = ParseTime(last); err != nil { + return nil, err + } + out = append(out, sp) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("select layer spans: %w", err) + } + return out, nil +} + +// readSeriesPoints разжимает объекты выбранного слоя и отбирает точки до точных +// границ периода. +// +// Принадлежность точки периоду определяется её НАЧАЛОМ — тем же правилом, каким +// час объекта берётся по началу точки. Цена названа в спеке: интервал, +// начавшийся раньше from, в ответ не входит. +func readSeriesPoints(ctx context.Context, tx *sql.Tx, w SeriesWindow, layer, fromHour, toHour string) ([]SeriesPoint, error) { + rows, err := tx.QueryContext(ctx, seriesPointsQuery, w.Metric, layer, fromHour, toHour) + if err != nil { + return nil, fmt.Errorf("select series points: %w", err) + } + defer func() { _ = rows.Close() }() + + // Непустой срез, а не nil: пустой ряд обязан уехать клиенту как `[]`. + out := make([]SeriesPoint, 0, 64) + for rows.Next() { + var hour, units string + var payload []byte + if err := rows.Scan(&hour, &units, &payload); err != nil { + return nil, fmt.Errorf("scan series bucket: %w", err) + } + points, err := decodePayload(payload) + if err != nil { + return nil, err + } + for _, p := range points { + if p.Start.Before(w.From) || !p.Start.Before(w.To) { + continue + } + out = append(out, SeriesPoint{Point: p, Units: units}) + } + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("select series points: %w", err) + } + + // Порядок утверждается здесь, а не наследуется от порядка объектов: на нём + // стоят и байтовое утверждение формы ответа, и метка условного запроса. + // Пара (начало, конец) внутри одного слоя одной метрики есть ключ + // идентичности, поэтому порядок ею определён однозначно. + sort.SliceStable(out, func(i, j int) bool { + if !out[i].Start.Equal(out[j].Start) { + return out[i].Start.Before(out[j].Start) + } + return out[i].End.Before(out[j].End) + }) + return out, nil +} diff --git a/internal/store/series_test.go b/internal/store/series_test.go new file mode 100644 index 0000000..1c0d7a5 --- /dev/null +++ b/internal/store/series_test.go @@ -0,0 +1,286 @@ +package store_test + +import ( + "context" + "encoding/json" + "testing" + "time" + + "git.vakhrushev.me/av/healthlog/internal/store" +) + +// seriesStore заводит витрину с точками, разложенными по слоям. +func seriesStore(t *testing.T, in ...store.IncomingPoint) *store.Store { + t.Helper() + + st := open(t) + if _, err := st.Merge(context.Background(), store.Incoming{Points: in}, store.DeliveryRef{ID: "d"}); err != nil { + t.Fatalf("слияние: %v", err) + } + return st +} + +// testLayers — словарь слоёв: его задаёт домен, и хранилищу он приходит +// параметром. +var testLayers = []string{"sample", "raw", "minute", "hour", "day"} + +func seriesAt(hh, mm int) time.Time { + return time.Date(2026, 6, 1, hh, mm, 0, 0, time.UTC) +} + +func seriesPoint(metric, layer string, start time.Time) store.IncomingPoint { + return seriesInterval(metric, layer, start, start) +} + +func seriesInterval(metric, layer string, start, end time.Time) store.IncomingPoint { + return store.IncomingPoint{ + Metric: metric, Layer: layer, Units: "count", + Point: store.Point{Start: start, End: end, Raw: json.RawMessage(`{"qty":1}`)}, + } +} + +// readSeries — обёртка с правилом «бери первый попавшийся слой»: правило выбора +// принадлежит домену, а хранилищу проверяется выборка. +func readSeries(t *testing.T, st *store.Store, metric string, from, to time.Time, layer string) store.SeriesSnapshot { + t.Helper() + + snap, err := st.ReadSeries(context.Background(), store.SeriesWindow{ + Metric: metric, From: from, To: to, Layer: layer, Layers: testLayers, + Measure: store.CatalogWindow{Fine: "minute", Coarse: "hour", Hours: 48, Horizon: seriesAt(23, 0), CoarsePoints: 1, MinFinePoints: 2}, + }, func(spans []store.LayerSpan) string { + if len(spans) == 0 { + return "" + } + return spans[0].Layer + }) + if err != nil { + t.Fatalf("ReadSeries: %v", err) + } + return snap +} + +// Точка на 10:59 живёт в объекте 10:00. Выборка объектов обязана быть ШИРЕ +// запроса, иначе такая точка теряется молча — и потеря видна только тому, кто +// пересчитает точки руками. +func TestРядНеТеряетТочкуВКонцеЧаса(t *testing.T) { + st := seriesStore(t, seriesPoint("m", "minute", seriesAt(10, 59))) + + snap := readSeries(t, st, "m", seriesAt(10, 30), seriesAt(11, 0), "minute") + if len(snap.Points) != 1 { + t.Fatalf("точек %d, ожидалась 1", len(snap.Points)) + } + if !snap.Points[0].Start.Equal(seriesAt(10, 59)) { + t.Errorf("метка %s, ожидалась 10:59", snap.Points[0].Start) + } +} + +// Полуинтервал: точка ровно на `from` в ответе есть, ровно на `to` — нет. Иначе +// два соседних окна посчитали бы граничную точку дважды. +func TestРядБерётГраницыПолуинтервалом(t *testing.T) { + st := seriesStore(t, + seriesPoint("m", "minute", seriesAt(10, 0)), + seriesPoint("m", "minute", seriesAt(11, 0)), + ) + + snap := readSeries(t, st, "m", seriesAt(10, 0), seriesAt(11, 0), "minute") + if len(snap.Points) != 1 { + t.Fatalf("точек %d, ожидалась 1 (только на from)", len(snap.Points)) + } + if !snap.Points[0].Start.Equal(seriesAt(10, 0)) { + t.Errorf("в ответ попала точка %s, а не граница from", snap.Points[0].Start) + } +} + +// Охваты меряются метками ТОЧЕК, а не часами объектов, и наблюдается это +// ИСХОДОМ: на периоде короче часа часовой объект попадает в границы часов +// запроса, а его точка в период не попадает. Слой, выбранный по часам, отдал бы +// пустой ряд при непустых минутных данных. +// +// Утверждение стоит на исходе, а не на промежуточных охватах: охваты приходят в +// правило выбора аргументом и наружу не отдаются — двое ворот к одному решению +// однажды разошлись бы. +func TestОхватыСлоёвМеряютсяМеткамиТочек(t *testing.T) { + st := seriesStore(t, + seriesPoint("m", "hour", seriesAt(10, 0)), + seriesPoint("m", "minute", seriesAt(10, 31)), + seriesPoint("m", "minute", seriesAt(10, 44)), + ) + + var offered []store.LayerSpan + snap, err := st.ReadSeries(context.Background(), store.SeriesWindow{ + Metric: "m", From: seriesAt(10, 30), To: seriesAt(10, 45), Layers: testLayers, + Measure: store.CatalogWindow{Fine: "minute", Coarse: "hour", Hours: 48, Horizon: seriesAt(23, 0), CoarsePoints: 1, MinFinePoints: 2}, + }, func(spans []store.LayerSpan) string { + offered = spans + // Правило домена в миниатюре: слой, чьи метки лежат вне периода, выбывает. + for _, sp := range spans { + if !sp.Last.Before(seriesAt(10, 30)) && sp.First.Before(seriesAt(10, 45)) { + return sp.Layer + } + } + return "" + }) + if err != nil { + t.Fatalf("ReadSeries: %v", err) + } + if len(offered) != 2 { + t.Fatalf("правилу предложено %d слоёв, ожидалось 2", len(offered)) + } + if snap.Layer != "minute" { + t.Errorf("выбран слой %q, ожидался minute: у часового нет точек внутри периода", snap.Layer) + } + if len(snap.Points) != 2 { + t.Errorf("точек %d, ожидалось 2 — ряд пуст при непустых данных", len(snap.Points)) + } +} + +// Пустой период — законный исход, а не отказ. Срез точек при этом непустой: +// nil уехал бы клиенту как `null`. +func TestРядПустогоПериодаОтдаётПустойСрез(t *testing.T) { + st := seriesStore(t, seriesPoint("m", "minute", seriesAt(10, 0))) + + snap := readSeries(t, st, "m", seriesAt(20, 0), seriesAt(21, 0), "minute") + if len(snap.Points) != 0 { + t.Fatalf("точек %d, ожидалось 0", len(snap.Points)) + } + if snap.Points == nil { + t.Error("срез точек nil — пустой ряд уедет клиенту как null") + } +} + +// Ряд упорядочен по (начало, конец) независимо от порядка объектов и точек в +// хранилище: на этом порядке стоят и байтовое утверждение формы, и метка +// условного запроса. +func TestРядУпорядоченПоКоординате(t *testing.T) { + st := seriesStore(t, + seriesPoint("m", "minute", seriesAt(11, 0)), + seriesPoint("m", "minute", seriesAt(10, 0)), + seriesPoint("m", "minute", seriesAt(10, 30)), + ) + + snap := readSeries(t, st, "m", seriesAt(9, 0), seriesAt(12, 0), "minute") + if len(snap.Points) != 3 { + t.Fatalf("точек %d, ожидалось 3", len(snap.Points)) + } + for i := 1; i < len(snap.Points); i++ { + if snap.Points[i].Start.Before(snap.Points[i-1].Start) { + t.Fatalf("порядок нарушен: %s после %s", snap.Points[i].Start, snap.Points[i-1].Start) + } + } +} + +// Вывернутый период — отказ хранилища, а не пустой ответ: такой запрос до +// витрины доходить не должен, и если дошёл, молчать об этом нельзя. +func TestРядОтвергаетВывернутыйПериод(t *testing.T) { + st := seriesStore(t, seriesPoint("m", "minute", seriesAt(10, 0))) + + _, err := st.ReadSeries(context.Background(), store.SeriesWindow{ + Metric: "m", From: seriesAt(11, 0), To: seriesAt(10, 0), Layers: testLayers, + Measure: store.CatalogWindow{Fine: "minute", Coarse: "hour", Hours: 48, Horizon: seriesAt(23, 0)}, + }, nil) + if err == nil { + t.Error("вывернутый период принят молча") + } +} + +// Закрытое хранилище — отказ, а не пустой ряд. Ветка редкая, но молчащая: +// маршрут чтения, получивший пустой ряд вместо ошибки, отдал бы клиенту +// «данных нет» на остановленном сервисе. +func TestРядНаЗакрытомХранилищеОтказывает(t *testing.T) { + st := seriesStore(t, seriesPoint("m", "minute", seriesAt(10, 0))) + if err := st.Close(); err != nil { + t.Fatalf("закрытие: %v", err) + } + + _, err := st.ReadSeries(context.Background(), store.SeriesWindow{ + Metric: "m", From: seriesAt(9, 0), To: seriesAt(11, 0), Layer: "minute", Layers: testLayers, + Measure: store.CatalogWindow{Fine: "minute", Coarse: "hour", Hours: 48, Horizon: seriesAt(23, 0)}, + }, nil) + if err == nil { + t.Error("закрытое хранилище отдало ряд") + } +} + +// Порядок при СОВПАДАЮЩЕМ начале решается концом координаты. Ветка достижима +// только на интервальных точках — а под одной меткой лежит до трёх записей сна, +// и на их порядке стоят и байтовое утверждение формы ответа, и метка условного +// запроса. +func TestРядУпорядоченПоКонцуПриРавномНачале(t *testing.T) { + start := seriesAt(1, 0) + st := seriesStore(t, + seriesInterval("sleep_analysis", "raw", start, seriesAt(3, 30)), + seriesInterval("sleep_analysis", "raw", start, seriesAt(2, 0)), + seriesInterval("sleep_analysis", "raw", start, seriesAt(2, 45)), + ) + + snap := readSeries(t, st, "sleep_analysis", seriesAt(0, 0), seriesAt(5, 0), "raw") + if len(snap.Points) != 3 { + t.Fatalf("точек %d, ожидалось 3: одна метка, разные интервалы", len(snap.Points)) + } + for i, want := range []time.Time{seriesAt(2, 0), seriesAt(2, 45), seriesAt(3, 30)} { + if !snap.Points[i].Start.Equal(start) { + t.Fatalf("точка %d начинается в %s, а не в общей метке", i, snap.Points[i].Start) + } + if !snap.Points[i].End.Equal(want) { + t.Errorf("точка %d кончается в %s, ожидалось %s", i, snap.Points[i].End, want) + } + } +} + +// ОДИН СНИМОК ВИТРИНЫ на весь ответ, а не три чтения подряд. +// +// Приём идёт непрерывно, и фоновая свёртка вправе закоммитить между выбором +// слоя и чтением точек. Тогда слой выбран по одному состоянию, ряд прочитан по +// второму, а род измерен по третьему — ответ внутренне противоречив и от +// свежего неотличим. Пара проб версии такой ответ обнаружит (метки не будет), +// но НЕ предотвратит: тело всё равно уедет. +// +// Оракул точный: колбэк выбора слоя — готовая точка вклинивания, и коммит из +// него идёт другим соединением, пока транзакция чтения открыта. +func TestРядСобранИзОдногоСнимкаВитрины(t *testing.T) { + st := seriesStore(t, seriesPoint("m", "minute", seriesAt(10, 0))) + ctx := context.Background() + + committed := false + snap, err := st.ReadSeries(ctx, store.SeriesWindow{ + Metric: "m", From: seriesAt(9, 0), To: seriesAt(12, 0), Layers: testLayers, + Measure: store.CatalogWindow{Fine: "minute", Coarse: "hour", Hours: 48, Horizon: seriesAt(23, 0), CoarsePoints: 1, MinFinePoints: 2}, + }, func(spans []store.LayerSpan) string { + // Свёртка коммитит ВНУТРИ чтения — ровно то, что делает воркер. + if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{ + seriesPoint("m", "minute", seriesAt(10, 30)), + }}, store.DeliveryRef{ID: "late"}); err != nil { + t.Errorf("слияние во время чтения: %v", err) + return "" + } + committed = true + return "minute" + }) + if err != nil { + t.Fatalf("ReadSeries: %v", err) + } + if !committed { + t.Fatal("коммит во время чтения не состоялся — утверждение ничего не доказывает") + } + if len(snap.Points) != 1 { + t.Fatalf("точек %d, ожидалась 1: ряд собран из двух состояний витрины", len(snap.Points)) + } + if !snap.Points[0].Start.Equal(seriesAt(10, 0)) { + t.Errorf("в ряд попала точка %s, закоммиченная уже после выбора слоя", snap.Points[0].Start) + } +} + +// Пустой словарь слоёв — отказ, а не пустой ответ. Без предиката по слою +// выборка охватов просматривает все строки метрики за всю историю, и молчаливая +// деградация цены хуже отказа: план запроса при этом выглядит успешным. +func TestРядТребуетСловарьСлоёв(t *testing.T) { + st := seriesStore(t, seriesPoint("m", "minute", seriesAt(10, 0))) + + _, err := st.ReadSeries(context.Background(), store.SeriesWindow{ + Metric: "m", From: seriesAt(9, 0), To: seriesAt(11, 0), + Measure: store.CatalogWindow{Fine: "minute", Coarse: "hour", Hours: 48, Horizon: seriesAt(23, 0)}, + }, nil) + if err == nil { + t.Error("пустой словарь слоёв принят молча") + } +} diff --git a/openspec/changes/archive/2026-08-04-tochki-metriki-za-period/.openspec.yaml b/openspec/changes/archive/2026-08-04-tochki-metriki-za-period/.openspec.yaml new file mode 100644 index 0000000..1b062d3 --- /dev/null +++ b/openspec/changes/archive/2026-08-04-tochki-metriki-za-period/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-04 diff --git a/openspec/changes/archive/2026-08-04-tochki-metriki-za-period/design.md b/openspec/changes/archive/2026-08-04-tochki-metriki-za-period/design.md new file mode 100644 index 0000000..996f624 --- /dev/null +++ b/openspec/changes/archive/2026-08-04-tochki-metriki-za-period/design.md @@ -0,0 +1,417 @@ +## Context + +Каталог (`GET /api/v1/metrics`) отвечает, **что** лежит в витрине. Значений он не +отдаёт: «вес за год» сегодня достаётся только `sqlite3` на хосте. + +Что уже решено и берётся, а не выбирается заново: + +- **Форма провода принадлежит транспорту** — + [ADR-2026-08-04](../../../../docs/adr/ADR-2026-08-04-forma-provoda-prinadlezhit-transportu.md). + Типы с `json`-тегами живут в `internal/httpapi`, перевод — присваивание поле в + поле, доменные типы до сериализации не доезжают. Новый маршрут копирует + образец `internal/httpapi/catalog.go` и добавляет строку в таблицу + `wire_internal_test.go`. +- **Пустая коллекция — `[]`, отсутствующее значение — `null`** + (`openspec/specs/read-api/spec.md`). +- **Род агрегации измеряется, а не объявляется** (`openspec/specs/catalog/spec.md`): + сверка минутного слоя с часовым по окну в `catalog.Window` = 48 самых свежих + **общих** часов, порог `MinAgreeing` = 3, единогласие. Правило живёт в + `catalog.Measure` и здесь не повторяется. +- **Метка ответа строится из всего, от чего ответ зависит** — версии витрины + **и горизонта измерения**, огрублённого до часа (`catalog.stamp`). Механизм + берётся тот же, второго экземпляра не заводится. +- **Версия ответа снимается двумя пробами вокруг чтения** (`store.VersionedRead`), + а согласованность самого тела держится **транзакцией чтения**, а не пробами. + +Ограничения, из которых растут решения ниже: + +- `bucket` объявлена `WITHOUT ROWID` с ключом `(metric, layer, hour_utc)`, то + есть сжатый `payload` лежит в дереве ключа: любой запрос, читающий строки ради + учётных колонок, тащит содержимое. Для этого и заведён покрывающий индекс + `bucket_catalog (metric, layer, hour_utc, first_ts, last_ts, points, units)` — + в нём есть и **точные границы точек объекта**, что решает вопрос охвата ниже. +- Точки хранятся дословно, значения наружу уходят сырым JSON. +- Ответ ничем не ограничен по размеру: предел — соседняя задача + `read-api-response-limit`. Здесь цена **измеряется и называется числом**, но + потолок не вводится. + +## Goals / Non-Goals + +**Goals:** + +- Одним запросом получить все точки метрики за период без доступа к файлу базы. +- Конверт самоописателен: из него видно слой, род свёртки, **его применимость к + отданному ряду** и границу окна, в котором род измерен. +- Форма конверта объявлена так, чтобы соседние задачи (свёртка по сетке, порог + неполного ведра, условный запрос, предел ответа) **заполняли** её поля, а не + меняли форму. + +**Non-Goals:** + +- Свёртка по сетке (`bucket`), порог неполного ведра, предел размера ответа, + разбор `If-None-Match` и ответ `304`, пагинация — соседние задачи спринта. +- Тренировки, записи, MCP — свои задачи, копирующие этот же образец. +- Схема базы и миграции: назначенный номер `00012` не понадобился. + +## Decisions + +### Решение 1. Род свёртки объявляется в конверте самого ответа, а не оставляется каталогу + +**Взято:** конверт ответа несёт объект `aggregation` с измеренным родом и +границей окна измерения — при том, что тот же род уже отдаёт каталог. + +**Prior art.** + +- **Google Cloud Monitoring** объявляет `metricKind` (`GAUGE`/`DELTA`/`CUMULATIVE`) + и `valueType` **в каждом объекте `TimeSeries`** ответа с данными, а не только + в дескрипторе метрики. Взято прямо: род едет вместе с данными, второй запрос + за смыслом числа не нужен. +- **Prometheus** делает наоборот: `/api/v1/query_range` отдаёт + `{resultType, result}` без единого слова о типе метрики и о разрешении, а тип + живёт в отдельном `/api/v1/metadata`. **Отвергнуто:** клиент обязан сделать + второй запрос, а до тех пор не отличает «род известен» от «род не измерен». +- **Graphite render API** отдаёт `{target, datapoints}` и не объявляет вообще + ничего. **Отвергнуто** по той же причине, что и Prometheus. +- **HealthKit `HKStatistics`** возвращает `nil` на свёртку, не отвечающую стилю + метрики. Принцип взят (род не тот — свёртки нет), механизм неприменим: у нас + стиль не объявлен источником. +- **Home Assistant** `statistics_during_period`: набор полей зависит от + запрошенных `types`, но `start` и `end` присутствуют **всегда**. Взято: + конверт не выражает исход наличием или отсутствием поля. + +**Почему не «клиент сходит в каталог».** Каталог отвечает по всем метрикам +сразу и стоит 45 мс на живом корпусе; агент, читающий одну метрику, платил бы за +все. И главное — род есть функция **окна**, а окно едет: между запросом каталога +и запросом точек род способен смениться без единой доставки за спрошенный +период. Два запроса дали бы клиенту согласованность, которой нет. + +### Решение 2. `aggregation` — объект `{style, applicable, last_hour}` + +`docs/architecture.md` обещал в конверте точек `"aggregation": "sum"` — строку с +**применённой** свёрткой. Этого мало: инвариант требует, чтобы клиент видел не +только применённое, но и **на каком основании** применять было можно. + +```json +"aggregation": {"style": "instant", "applicable": true, "last_hour": "2026-08-02T14:00:00Z"} +``` + +- `style` — измеренный род; имя и словарь те же, что у каталога, потому что + смысл тот же. Второе имя для того же понятия развело бы два маршрута молча. +- `applicable` — применим ли объявленный род к **отданному ряду**. Поле заведено + находкой ревью и закрывает разрыв, который иначе стоил бы потребителю + завышения втрое: род — свойство **метрики**, слой — свойство **ряда**, и + конверт `{"layer": "raw", "style": "cumulative"}` законен, штатен и прямо + приглашает агента сложить интерполяцию самому. Инвариант «нижний слой HAE не + суммируется никогда» система соблюдает, ничего не складывая, — но потребитель + об инварианте не знает, а `applicable: false` ему об этом говорит. + Prior art формы — **CloudWatch `GetMetricData`**, где `MetricDataResult` несёт + `StatusCode` (`Complete` / `PartialData`): оговорка едет вместе с данными, а не + оставляется клиенту на вывод. +- `last_hour` — **ярлык самого свежего общего часа окна измерения**, `null` при + пустом окне. Это то самое поле, ради которого задача существует: окно + измеряется в общих часах, а не в часах календаря, и при выключенной минутной + автоматизации HAE оно замирает, продолжая объявлять род. + +**Поле `applied` отвергнуто** (архитектурный проход, `Действие: развилка` — +закрыто здесь). Оно детерминированно выводится из `style` и `bucket` тем же +инвариантом, ради которого существует `applicable`; в этом изменении оно всегда +`null`; и как **строка** оно к тому же неверно описало бы свёртку мгновенной +метрики, которая по архитектуре есть «среднее с `min`/`max` рядом», а не одна +операция. Имя применённой свёртки называет задача, которая её применяет. + +**Почему только `last_hour`, а не всё основание каталога.** `hours`, `compared`, +`agreeing`, `conflicting`, `first_hour` в конверте точек не повторяются: полное +основание принадлежит каталогу и живёт там в одном экземпляре. Клиенту точек +нужен ответ на вопрос «насколько свежо то, на чём объявлен род», и это одно +число. Цена названа: чтобы разобрать *почему* род `unknown`, придётся спросить +каталог. + +**Плоскость против вложенности.** Объект, а не три плоских поля: у каталога +`aggregation` уже объект, и разная форма одного понятия на двух маршрутах — та +же ошибка, что разные имена. + +### Решение 3. Слой выбирается по охвату **точек** внутри периода + +`docs/architecture.md` формулировал правило как «самый мелкий слой, покрывающий +весь запрошенный диапазон» и тут же оговаривал, что границы слоя — границы +**данных**, а не обещание покрытия: внутри диапазона законно есть дыры, и слой, +покрывающий диапазон целиком, может не существовать вовсе. + +Взято правило, определённое на любом входе: + +> Охват слоя — длина пересечения отрезка `[первая метка слоя, последняя метка +> слоя]` с запрошенным периодом. Слой с пустым пересечением выбывает. Среди +> оставшихся берётся слой с наибольшим охватом, при равенстве — самый мелкий +> (порядок `sample` → `raw` → `minute` → `hour` → `day`). + +Почему охват, а не число часов с объектами: `body_mass` в нижнем слое за три +плотных дня даёт больше объектов, чем часовой слой за год с еженедельным +взвешиванием, — и «вес за год» вернул бы три дня, не сказав об этом ни словом. + +**Почему охват меряется метками точек, а не часами объектов** — находка ревью, +и она стоила бы пустого ответа на непустых данных. Объекты адресуются часом, а +ряд отбирается точной меткой; выборка объектов **обязана** быть шире запроса +(точка `10:59` живёт в объекте `10:00`). На периоде `[10:30, 10:45)` слой `hour` +имеет объект `10:00` с единственной точкой в `10:00`, слой `minute` — объект +`10:00` с точками `10:31…10:44`. По часам объектов охваты равны, побеждает +`hour` — и после точного отбора ответ уходит пустым при непустых минутных +данных. По меткам точек `hour` выбывает сразу. + +Цена мере названа: границы `first_ts`/`last_ts` — свойства **объекта**, поэтому +краевой объект, у которого есть точки и до, и после периода, но ни одной внутри, +свой слой из выбора не выведет. Остаток узкий и честный: слой в ответе назван, а +`points` пуст. + +Мера почти ничего не стоит, и «почти» здесь измерено. `first_ts` и `last_ts` +лежат в покрывающем индексе `bucket_catalog`, то есть содержимое объектов не +читается вовсе. Но покрывающий индекс сам по себе цену не ограничивает: индекс +идёт `(metric, layer, hour_utc, …)`, и **без предиката по слою** SQLite не +сужает поиск по `hour_utc` — он просматривает все строки метрики за всю историю, +применяя период построчным фильтром, а план при этом выглядит успешным +(`SEARCH … USING COVERING INDEX`). Поймано эксплуатационным проходом ревью и +измерено на копии схемы: 2.06 мс при 52 560 строках метрики против 13.9 мс при +350 400 — то есть цена росла бы вместе с возрастом сервиса при любой ширине +запроса. Поэтому слои перечислены в запросе явно, словарём из домена: план +становится `(metric=? AND layer=? AND hour_utc>? AND hour_utc= to` — `400`. +- **`bucket` отвергается `400`, а не игнорируется.** Архитектура обещает этот + параметр, реализует его соседняя задача; молчаливое игнорирование отдало бы + клиенту, попросившему суточную сетку, полный минутный ряд — зеркало ровно того + промаха, ради которого архитектура различает «сетка задана явно» как защиту. + Прочие незнакомые параметры игнорируются, как принято в HTTP. +- **Принадлежность интервальной точки периоду — по началу.** То же правило, + каким час объекта берётся по началу точки. Цена названа вслух: «сон за ночь с + полуночи» не увидит эпизод, начавшийся в 23:40. Альтернатива — отбор по + пересечению `[ts, ts_end)` с периодом — требует сканировать объекты назад на + неизвестную глубину: длительность эпизода ничем не ограничена, а индекса по + концу координаты нет. Развилка вынесена вопросом владельцу (см. ниже), работа + доведена на остаток. + +### Решение 5. Точка на проводе — `{ts, ts_end, tz_offset, units, values}` + +`values` — **сырой JSON точки, как её прислал HAE**, без переименований и +пересчётов: прямое следствие инварианта «форма Apple не транслируется» и +единственное исключение сторожа графа типов (`json.RawMessage`). + +**Дословность требует выключить HTML-экранирование сериализатора** — находка +ревью с прогнанным оракулом: `encoding/json` по умолчанию превращает `&`, `<`, +`>` в `&`, `<`, `>`, а имя источника приходит с телефона +пользовательской строкой и законно содержит `&`. Хранилище этот капкан уже +проходило и обезвредило тем же способом (`store.encodePayload` — кодировщик с +`SetEscapeHTML(false)` вместо `json.Marshal`). Правка идёт в общий `writeJSON`, +поэтому нормируется не здесь, а в `read-api`: механизм один на все читающие +маршруты, и решать его заново каждому — тот же второй способ. Фикстуры +`testdata` символов `&<>` не содержат вовсе, то есть проверка на них зелена и +будучи сломанной — случай заводится отдельным входом. + +`ts_end` добавлен к обещанной архитектурой форме: идентичность точки — +координаты `(метрика, слой, начало, конец)`, под одной меткой `date` лежит до +трёх записей сна (замер: 174 координаты против 170 по метке). Конверт с одним +`ts` отдал бы три точки с одинаковой меткой и предложил бы различать их, копаясь +в дословном содержимом, — нормализованный слой ответа терял бы то, что хранилище +хранит. У точки-измерения `ts_end` равен `ts`. + +`units` и `tz_offset` лежат **на точке**: единицы хранятся на часовом объекте, и +метрика, чьи объекты разошлись единицами, обязана показать это строкой, а не +выбрать одно из двух молча; смещение зоны — собственное свойство точки. + +Порядок точек — по `(ts, ts_end)` возрастанию. Он однозначен не по соглашению, а +по построению: пара `(начало, конец)` внутри слоя одной метрики есть ключ +идентичности, двух точек с равной парой в витрине не существует. + +**Отвергнуто: класть в конверт охват отданного ряда** (предложение прохода +`rubric`). Первая и последняя метка ряда вычислимы клиентом из самих точек, а +поле, вычислимое из соседнего поля того же ответа, — вторая копия факта, обязанная +с ним сходиться. Пустой ряд при непустом `layer` эту же историю рассказывает сам. + +### Решение 6. Метрика без данных за период — `200`, а не `404` + +`points: []`; `layer` — `null`, только если выбирала система. Так отвечают и +CloudWatch, и Prometheus: «нет данных за окно» — не «нет такого ресурса». +Отличать опечатку в имени метрики от честной пустоты — работа каталога; `404` +здесь означал бы, что маршрут знает список метрик, а он его не знает и знать не +должен (имя приходит из тела доставки дословно). + +Имя берётся из пути после процентного декодирования. **Метрика с пустым именем +маршрутом недостижима** — путь её не выражает, а каталог её показывает +намеренно. Цена названа, а не замолчана: адресация именем в пути этого случая не +покрывает, и лечится он не здесь. + +### Решение 7. Use-case живёт в новом пакете `internal/points` + +Каталог отвечает на вопрос «что у тебя есть», ряд точек — на вопрос «дай +значения». Смешивать их в `internal/catalog` значило бы получить пакет с двумя +несвязанными сборками ответа; смешивать с `internal/store` — вернуть правило +выбора слоя в хранилище, откуда его специально убирали. + +Имя пакета и имя capability совпадают — `points` (архитектурный проход: «одно +понятие — два новых имени» дороже спора сейчас; в проекте пакет и capability +сходятся по имени или очевидной паре). + +`internal/points` зависит от `store` (одна выборка), от `catalog` (`Measure`, +`Horizon`, `Stamp` — второй экземпляр правила измерения или правила метки был бы +прямым нарушением инварианта) и от `hae` (словарь слоёв). + +**Область действия метки живёт в транспорте**, рядом с `etag` и `scopeMetrics` +каталога, а не в домене: у одного понятия иначе оказалось бы два дома, и три +следующих маршрута выбирали бы между ними монетой. Домен отдаёт только версию +ответа — ровно как каталог. Форма метки (префикс, hex) есть форма провода, а её +объявляет транспорт (ADR). + +**Словарь слоёв один — `hae.Layers`.** Из него выводятся и порядок (`Rank` — +индекс), и перечень слоёв для выборки охватов, и проверка параметра запроса, и +текст отказа клиенту. Четыре списка не сверял бы ни компилятор, ни тест: новая +константа слоя скомпилировалась бы, получила ранг «крупнее всех», не попала бы в +выборку охватов и отвергалась бы маршрутом как незнакомая — а симптомом был бы +пустой ряд при непустых данных. Случай не гипотетический: слой `sample` +наполнится импортом родного экспорта Apple. + +**Граница с соседней задачей названа явно.** Отсюда уезжает `ETag` с областью +действия, включающей канонизированную форму запроса **и горизонт измерения**, и +`Cache-Control: private, no-cache`. Разбор `If-None-Match` и ответ `304` +остаются задаче `read-api-points-conditional`. Горизонт в метке — находка трёх +проходов ревью сразу: без него первый же `304` соседней задачи подтвердил бы +клиенту ответ, чей род уже перевернулся ходом часов, без единого коммита. + +### Решение 8. Один вход в хранилище, одна транзакция чтения, миграции нет + +Ответ снимается **одной транзакцией чтения** — как у каталога, где это +нормировано отдельным требованием. Пара проб версии противоречивое тело +обнаруживает, но не предотвращает: она снимает метку, а тело всё равно уезжает. +Поэтому вход один: `store.ReadSeries(ctx, window, pick)`. + +Правило выбора слоя при этом **остаётся в домене**: хранилище получает его +функцией-параметром `pick([]LayerSpan) string`, вызываемой внутри транзакции. +Форма не изобретена — `store.VersionedRead` уже принимает работу колбэком, а +`store.ReadCatalog` уже получает параметры правила структурой `CatalogWindow`. +Альтернатива «три метода домена под одной `VersionedRead`» отвергнута находкой +ревью; альтернатива «перенести правило в SQL» отвергнута тем же доводом, каким +измерение рода живёт в домене, а не в хранилище. + +Внутри транзакции: + +1. **Охваты слоёв** — `SELECT layer, min(first_ts), max(last_ts) FROM bucket + WHERE metric = ? AND layer IN (…) AND hour_utc BETWEEN ? AND ? GROUP BY layer`. + Отвечает по покрывающему `bucket_catalog`, содержимого не касается; словарь + слоёв приходит из домена, и пустой словарь — отказ, а не пустой ответ: + молчаливая деградация цены хуже отказа. +2. **Точки выбранного слоя** — `SELECT hour_utc, units, payload FROM bucket + WHERE metric = ? AND layer = ? AND hour_utc BETWEEN ? AND ? ORDER BY hour_utc`. + Точный префикс первичного ключа; `payload` здесь и нужен. +3. **Окно измерения** — существующие `commonHours` и `readHourPairs` по одной + метрике; второго правила отбора не заводится. + +Границы по часам берутся **шире запроса** — `[trunc(from), trunc(to)]`: точка +`10:59` живёт в объекте `10:00`. Отбор до точной границы `[from, to)` делается +по меткам точек, после разжатия. + +Схема не трогается: назначенный номер миграции `00012` не израсходован. + +## Risks / Trade-offs + +- **Ответ не ограничен ничем, а правило выбора слоя размер не минимизирует, а + максимизирует** (при равном охвате берётся самый мелкий слой) → предел вводит + соседняя задача `read-api-response-limit`; здесь цена **измеряется на трёх + режимах, включая худший** (см. `tasks.md`, шаг 5.3), а не оценивается на глаз. + Инверсия умолчания («при равном охвате брать самый крупный») — развилка, + вынесенная вопросом: она меняет правило, записанное в `docs/architecture.md` + до этой задачи, и сцеплена с соседней задачей о пределе. +- **Полный ряд собирается в памяти целиком** (`[]SeriesPoint` → `[]Point` → + `[]pointWire` + буфер энкодера) → потоковая выдача (NDJSON) — отдельная задача + беклога `ndjson-stream`; пока предел не введён, потоковая выдача сняла бы + симптом и спрятала причину. Цена измерена дважды и обе цифры названы: + на доменном слое неделя нижнего слоя (604 800 точек) стоит 1.64 с и 1375 МиБ + суммарных выделений, **под HTTP вместе с сериализацией** — 2.89 с, 279.7 МиБ + тела и 1335 МиБ живой кучи; четыре одновременных запроса дают 4322 МиБ. +- **Транзакция чтения держится всё время сборки ряда**, а собственного бюджета у + маршрута нет: `WriteTimeout` сервера контекст обработчика не отменяет + (измерено эксплуатационным проходом). Долгий читатель не даёт продвинуться + пассивному чекпойнту WAL — механизм измерен проектом раньше (51 МБ при лимите + 8 МиБ). Собственный дедлайн маршрута рамками этой задачи **исключён**: он + принадлежит задаче «Остановка и миграция» вместе с `BaseContext`. Записано + вопросом, а не замолчано. +- **Интервальная точка, начавшаяся до `from`, теряется** → названо в спеке, + вынесено вопросом владельцу; альтернатива требует неограниченного сканирования + назад или индекса по концу координаты. +- **Краевой объект без точек внутри периода может увести выбор слоя** → остаток + меры охвата, назван выше; наблюдаемый исход честен (`layer` назван, ряд пуст). +- **Выключение HTML-экранирования меняет байты всех ответов**, а не только + точек → в существующих телах (каталог, учёт приёма) этих символов не бывает по + форме данных, но изменение нормировано в `read-api` и покрыто входом с `&<>`. +- **Род в конверте точек и род в каталоге считаются в разные моменты** и могут + разойтись у клиента, сравнивающего два ответа → это свойство измерения, а не + дефект: род есть функция окна. Ровно поэтому `last_hour` едет вместе с родом. +- **Опечатка в имени метрики неотличима от пустого периода** → смягчено + `layer: null`; полностью лечится каталогом. + +## Open Questions + +Записано здесь, а не только в файле задачи: файл закрытой задачи удаляется. + +- **Инвертировать ли умолчание выбора слоя при равном охвате.** Сегодня берётся + самый мелкий — это правило `docs/architecture.md` до этой задачи, и оно + максимизирует размер ответа («пульс за неделю» в `raw` — сотни тысяч точек). + (а) оставить как есть, предел вводит `read-api-response-limit`; + (б) при равном охвате брать самый **крупный**, мелкий — только по явному + `layer`. **Рекомендация:** (а) до тех пор, пока замер не покажет, что цена + худшего режима неприемлема; решение сцеплено с формой предела и принимать его + мимоходом на первой ручке — то же, от чего отказались на каталоге. +- **Отбирать ли интервальные точки по пересечению с периодом.** Сегодня отбор по + началу, и «сон за ночь» теряет эпизод, начавшийся до полуночи. + (а) оставить (ноль стоимости, названная потеря); + (б) сканировать объекты назад на фиксированную глубину (появляется магическое + число «максимальная длительность эпизода»); + (в) индекс по концу координаты (миграция, рост витрины). + **Рекомендация:** (а) сейчас, (в) — когда появится сценарий сна как отдельная + задача; (б) отвергнуть: магическое число молча теряет длинные эпизоды. +- **Машинно-различимый код причины отказа.** Сегодня тело отказа несёт только + человекочитаемую строку, и агент не отличит «зона не указана» от «слой + незнаком» иначе, чем разбором русского текста. Правило общее для всех + маршрутов и меняет `errorWire`, то есть и контракт приёма; сюда не взято. +- **Обещание гейта расходится с его кодом.** `CLAUDE.md` объявляет, что + непокрытая изменённая строка красит гейт безусловно; `scripts/diff-coverage.py` + всегда возвращает `0`, и шаг печатает `OK` при любом покрытии (найдено + проходом `gate`, подтверждено триажем). Текущий прогон — живая демонстрация: + 30 непокрытых строк при зелёном гейте. Чинить это здесь нельзя: починка + скрипта немедленно красит гейт **этой** задачи, то есть решение о том, что + именно обещано — полное покрытие, порог или ничего, — принимает владелец. +- **Метка года 10000 (унаследовано, вне рамок).** Одна принятая доставка с датой + `9999-12-31 23:00:00 -0700` роняет каталог в `500`: `store.FormatTime` даёт + `10000-01-01T06:00:00Z`, который `ParseTime` уже не разбирает (прогнано). + Маршрут точек наследует **тот же** путь разбора времени (`ParseTime` при + разжатии `payload` и при чтении границ), то есть та же доставка уронит и его. + Чинить здесь нельзя: дефект лежит в общем слое времени и задевает свёртку и + пересборку. **Свою половину задача закрыла:** граница запроса, уезжающая за + четырёхзначный год, теперь отвергается `400`, а не отдаёт молча пустой ряд + (найдено триажем). Осталась половина на стороне приёма — доставка с такой + меткой в теле. diff --git a/openspec/changes/archive/2026-08-04-tochki-metriki-za-period/proposal.md b/openspec/changes/archive/2026-08-04-tochki-metriki-za-period/proposal.md new file mode 100644 index 0000000..bb44de3 --- /dev/null +++ b/openspec/changes/archive/2026-08-04-tochki-metriki-za-period/proposal.md @@ -0,0 +1,73 @@ +## Why + +Точки лежат в витрине и наружу не отдаются: «вес за год» достаётся только +`sqlite3` на хосте. Каталог уже отвечает, **что** есть, — но ни один из трёх +потребителей не может получить сами значения. + +Ответ обязан быть самоописательным. Метрика лежит сразу в нескольких слоях +подробности, а род её свёртки не объявлен источником, а **измерен** окном в 48 +самых свежих **общих** часов — окном, которое замирает при выключенной минутной +автоматизации HAE и продолжает объявлять род. Клиент, не видящий ни слоя, ни +границы этого окна, принимает решение по числу, происхождения которого не знает. + +## What Changes + +- Новый читающий маршрут `GET /api/v1/metrics/{name}?from&to&layer` — все точки + метрики за период, одним запросом, без доступа к файлу базы. +- Конверт ответа объявляет **фактические** параметры выдачи: `metric`, `from`, + `to`, `layer`, `bucket`, `aggregation` (род, его применимость к отданному ряду + и граница окна измерения), `points`. Поля присутствуют всегда — клиент не + выводит их наличием или отсутствием. +- Слой выбирается правилом, а не молча: параметр `layer` задаёт разрез явно, без + него берётся слой с наибольшим охватом **точек** внутри запрошенного периода, + при равенстве — самый мелкий. Смены слоя внутри одного ответа не бывает. +- Род свёртки в конверте — тот же измеренный `catalog.Style`, что у каталога, и + измеряется он тем же правилом и тем же окном. Род неизвестен — так и сказано + словом `unknown`, а не молчанием. Рядом едет применимость: `cumulative` на + нижнем слое HAE объявляется неприменимым, иначе конверт приглашает потребителя + сложить интерполяцию и завысить втрое. +- Свёртка в этом изменении **не выполняется**: `bucket` всегда `null`, а + присутствие параметра `bucket` в запросе отвергается `400`, а не игнорируется + молча. +- Ответ снимается **одной транзакцией чтения**, а метка ответа включает + канонизированную форму запроса **и горизонт измерения** — иначе соседняя + задача условного запроса подтвердит `304` на сменившемся роде. +- Форма провода — по образцу каталога (ADR + `ADR-2026-08-04-forma-provoda-prinadlezhit-transportu`): типы `*Wire` в + `internal/httpapi`, перевод присваиванием, строка в таблице образцов + `wire_internal_test.go`. + +Схема базы не трогается: обе выборки отвечают по существующим ключу и +покрывающему индексу `bucket_catalog`. + +## Capabilities + +### New Capabilities + +- `points`: точки метрики за период — форма запроса и его разбор, правило выбора + слоя, состав конверта ответа, объявление измеренного рода вместе с границей + окна измерения, дословность значений точки. + +### Modified Capabilities + +- `read-api`: добавляются два общих правила читающих маршрутов — сериализация + ответа не экранирует содержимое (иначе `&` внутри дословно сохранённой точки + уезжает как `&`) и ответ чтения помечается непригодным для разделяемого + кеша. Оба механизма общие (`writeJSON`, `setReadHeaders`), и решать их заново + на каждом маршруте — тот же второй способ. + +`catalog` (правило измерения рода) применяется новым маршрутом без изменения его +требований. + +## Impact + +- `internal/httpapi` — маршрут, разбор параметров, форма провода точек, + выключение HTML-экранирования в общем `writeJSON`. +- `internal/points` (новый) — use-case «точки метрики за период»: выбор слоя, + сборка ряда, измерение рода и метка ответа по одной метрике. +- `internal/store` — один вход чтения ряда (`ReadSeries`) в одной транзакции: + охваты слоёв в периоде, точки выбранного слоя, объекты окна измерения. +- `docs/architecture.md` — разделы «Read API» (форма ответа, правило выбора + слоя) и таблица компонентов. +- Потребители: HTTP-клиенты; следом этот же конверт копируют свёртка по сетке, + условный запрос по точкам, тренировки, записи и MCP. diff --git a/openspec/changes/archive/2026-08-04-tochki-metriki-za-period/review/triage.md b/openspec/changes/archive/2026-08-04-tochki-metriki-za-period/review/triage.md new file mode 100644 index 0000000..800293b --- /dev/null +++ b/openspec/changes/archive/2026-08-04-tochki-metriki-za-period/review/triage.md @@ -0,0 +1,268 @@ +# Триаж ревью: точки метрики за период + +## Сводка + +- **Профиль:** `deep`, режим прогона — по графу. Гейт **зелёный** (383 + изменённые исполняемые строки, 30 не покрыто, 92% — но см. находку 3: шаг + покрытия не красит по построению). +- **Расхождения состава прогона с профилем нет** (сверено с `docs/review.md`: + `deep` = 6 обязательных проходов + `reimpl` по триггеру + `rubric` только в + `design`). + +**Проходы поимённо с исходом:** + +Фаза `design` (до кода): + +- `review-specs` (режим «дизайн до кода») — отработал, находки ушли правкой спек; +- `review-rubric` (фаза 1) — отработал, свойства легли критериями в `tasks.md`; +- `review-architecture` (на предложении) — отработал, под его находки код писался. + +Фаза `deep` (по коду): + +- `review-gate` — зелёный; отдельно нашёл дефект самого гейта (находка 3); +- `review-specs` — отработал (декодирование имени, доли секунды, пустой + `?layer=`, границы в тексте ошибки — исправлено инлайн; развилка про + интервальную точку — находка 2); +- `review-code` — отработал (имя метрики в `ETag` — исправлено инлайн); +- `review-adversary` — отработал (обрыв тела, двойное декодирование — + исправлено; размер ответа — находка 1; `nosniff` — гипотеза); +- `review-ops` — отработал (предикат по слою: 13.9 мс → 0.026 мс; WARN вместо + DEBUG; `ReadOnly` не запрещает запись — исправлено/названо; транзакция без + бюджета — находка 1); +- `review-architecture` — отработал (единый словарь слоёв, `Scope` в + транспорте, `Spans` убран — исправлено инлайн); +- `review-reimpl` — **не запускался**: проектный триггер (`docs/review.md`, + «новое правило слияния, идентичности или разбора») не сработал — изменение + только читает витрину; независимый взгляд на предложение дал профиль `design`. + +**Счёт:** на вход триажа пришло 8 отложенных находок (плюс 13 позиций, +заявленных как исправленные инлайн). Все 13 инлайн-позиций **проверены по коду и +подтверждены** (`internal/httpapi/points.go`, `internal/points/points.go`, +`internal/store/series.go`, `internal/hae/hae.go`, `internal/catalog/catalog.go`, +`internal/httpapi/httpapi.go`; тесты пяти затронутых пакетов зелёные, включая +`TestТочкиНеПерехватываютКаталог` и `TestСловарьСлоёвОдин`). Из 8 отложенных: +4 в основном списке (одна — слияние двух причин), 2 понижены в гипотезы, 2 +правила ушли в promote. Ничего не выброшено молча — судьба каждой названа. + +Дедупликация: «размер ответа» найден `adversary` и `ops` независимо — это один +источник, высказавшийся дважды; приоритет поднят, `Confidence` — нет. + +## Блокирует мердж + +Пусто. Кандидат был один — ресурсный бюджет маршрута (находка 1), но обе его +половины уже разложены владельцем в задачи текущего спринта +(`read-api-response-limit`, `shutdown-and-migration-traces`), а риск +материализуется только на деплое, который в этом проекте спрашивается всегда. +Блокировать мердж значило бы отменить декомпозицию спринта решением триажа. + +## Стоит исправить сейчас + +### 1. Один широкий читатель способен уронить процесс вместе с приёмом — и доставки этого окна потеряны навсегда + +- Файл: internal/store/series.go:134-190, internal/httpapi/points.go:188, docker-compose.yml +- Severity: major +- Confidence: high +- Оракул: замеры проходов `adversary`+`ops`: неделя `raw` — 604 800 точек, + 1.75 с сборки, 1375 МиБ суммарных выделений; под HTTP — 2.89 с, тело + 279.7 МиБ, 1335 МиБ живой кучи; 4 одновременных запроса — 4322 МиБ. + `docker-compose.yml` прочитан: `mem_limit` нет (есть `restart: + unless-stopped`, что ограничивает окно простоя, но не отменяет его). + `WriteTimeout` контекст запроса не отменяет — измерено проходом `ops`. + Прецедент WAL: 51 МБ при лимите 8 МиБ. +- Последствие: авторизованный читатель (свой же агент, опрашивающий по + расписанию) четырьмя широкими запросами доводит процесс до OOM. Падает не + маршрут — падает весь бинарь, включая приём; телефон шлёт молча и не + перешлёт: доставки окна простоя теряются необратимо (`CLAUDE.md`: «Поток не + останавливается»). Побочно: перекрывающиеся долгие читатели не дают + продвинуться пассивному чекпойнту WAL. Вероятность низкая (клиенты — свои), + ущерб — высший класс: необратимая потеря данных. +- Предложение: работу не дублировать — обе половины уже в спринте + (`read-api-response-limit` — предел размера, там же форма умолчания слоя; + `shutdown-and-migration-traces` — дедлайн маршрута). Решить надо порядок: + (а) мерджить сейчас, деплой — только после `read-api-response-limit` того же + спринта (ноль работы, дисциплина деплоя и так требует вопроса к человеку); + (б) то же плюс `mem_limit` в `docker-compose.yml` одной строкой уже сейчас — + дешёвый стопор, превращающий OOM хоста в перезапуск контейнера; + (в) сцепить мердж этой ветки с веткой предела в один батч. + Сюда же слита развилка `rubric`: правило «при равном охвате — самый мелкий + слой» максимизирует размер ответа; инверсия меняет правило, записанное в + `docs/architecture.md` до этой задачи, и решается вместе с формой предела в + `read-api-response-limit`, а не здесь. +- Найдено проходом: adversary + ops (один источник дважды; оракулы — замеры) +- Действие: развилка + +### 2. Запрос «сон за ночь» молча не увидит эпизод, начавшийся до полуночи + +- Файл: internal/store/series.go:233-256; openspec/changes/tochki-metriki-za-period/specs/points/spec.md:257-260 +- Severity: major +- Confidence: high +- Оракул: положение кода (`series.go:253`: `p.Start.Before(w.From)` → + пропуск) и дословный пункт спеки: «точка-интервал, начавшаяся раньше `from`, + в ответ не входит… запрос "сон за ночь с полуночи" не увидит эпизод, + начавшийся до неё». +- Последствие: потребитель получает ряд, выглядящий полным, — эпизод сна с + 23:40 отсутствует без единого признака усечения. Класс «молчание»: сон — + профильный сценарий проекта, и решение потребителя принимается по неполным + данным. Спека цену честно называет, поэтому это не сломанное требование, а + открытая развилка дизайна. +- Предложение: вопрос владельцу, варианты: (а) оставить как есть — правило + симметрично адресации объектов, потребитель расширяет `from` сам (цена: + каждый потребитель обязан знать про максимальную длину эпизода); (б) при + выборке захватывать N часов до `from` и отбирать по пересечению интервала с + периодом (цена: магическое число глубины N); (в) индекс по концу координаты + (цена: миграция схемы и её сопровождение). Вариант (а) стоит нуля кода, но + тогда правило обязано попасть в контракт read-api так же явно, как в спеку + points. +- Найдено проходом: specs +- Действие: развилка + +### 3. Гейт обещает красить непокрытую изменённую строку — и не красит никогда + +- Файл: scripts/diff-coverage.py:88-96; scripts/gate.py:167-177; CLAUDE.md («Что красит безусловно») +- Severity: major +- Confidence: high +- Оракул: положение кода: `diff-coverage.py` `main()` возвращает `0` и при + `uncovered > 0` (строка 96), `gate.py:172-173` пишет `OK` по нулевому коду + возврата. Текущий прогон — живая демонстрация: 30 непокрытых строк, шаг + `OK`, гейт зелёный. +- Последствие: пункт `CLAUDE.md` «что красит безусловно: …непокрытая изменённая + строка» ложен. Класс — молчание конвейера, тот самый, что в журнале ревью + трижды за три дня («у проверки, которую гейт не гоняет, краснота никому не + видна» — здесь хуже: проверка гоняется и молчит). Непокрытые строки будут + копиться, а все читатели `CLAUDE.md` — включая проходы ревью — считают их + невозможными. +- Предложение: вопрос, потому что починка меняет состояние текущего прогона: + (а) `return 1` при `uncovered > 0` — гейт этой задачи немедленно красный, и + надо покрывать 30 строк веток ошибок драйвера (правки, которых никто не + заказывал) либо гнать их через `//nolint`-аналог для покрытия; (б) привести + `CLAUDE.md` к реальности: покрытие диффа — информационный шаг, печатает и не + красит; (в) красить только строки вне уже признанных классов (ветки ошибок + драйвера) — потребует механики исключений, которой нет. Дешевле всего (б) + + отдельная задача на (а); выбирать не триажу — обещание записано владельцем. +- Найдено проходом: gate +- Действие: развилка + +### 4. Граница, переваленная офсетом за 9999 год, молча опустошает ряд + +- Файл: internal/httpapi/points.go:254-265; internal/store/store.go:263 +- Severity: minor +- Confidence: high +- Оракул: прогнан тест (временный, удалён): `9999-12-31T23:00:00-08:00` → UTC + год 10000 → `FormatTime` даёт `10000-01-01T07:00:00Z`; `ParseTime` его не + разбирает (`cannot parse "0-01-01…"`); лексикографически + `'10000-…' < '2026-…'` — истина, значит `hour_utc BETWEEN '2020-…' AND + '10000-…'` пуст всегда. +- Последствие: уточнено против входной формулировки «роняет маршрут» — маршрут + не падает: запрос «с 2020 до конца времён» с `to=9999-12-31T23:00:00-08:00` + отвечает `200` и пустым рядом при непустых данных. Класс «молчание», но вход + экзотический (нужен офсет, переваливающий год), клиенты — свои. Унаследовано + от `store.FormatTime`, однако этот маршрут — первый, где клиент задаёт + временные границы, то есть первый внешний путь к дефекту. +- Предложение: в `parseBound` отвергать границу, чьё UTC-представление выходит + за год 9999 (и симметрично — раньше года 1), с `400` и текстом без значений + из запроса. Локально, однозначно, right-size. +- Найдено проходом: наследие, названо оркестратором; оракул добыт триажем +- Действие: инлайн + +## Гипотезы без доказательства + +- **`X-Content-Type-Options: nosniff` на читающих маршрутах** (adversary, + minor, понижено). Сам проход назвал это «свойством без пути»: API отдаёт + JSON под Bearer-токеном, браузерного потребителя нет, путь эксплуатации не + построен. Одна строка в `setReadHeaders` + (`internal/httpapi/conditional.go:145`) — но без пути это гигиена, а не + находка; пусть едет с первой задачей, трогающей `conditional.go`. +- **Машинно-различимый код причины отказа** (rubric, minor, понижено). Ни + одного потребителя, различающего причины программно, ещё нет, а правка + трогает общий `errorWire` — то есть контракт приёма, на который уже + завязан телефон. Цена сейчас выше пользы; момент — появление первого + программного потребителя ошибок (адаптер MCP). Правило — в promote. + +## Promote candidates + +- **«Обещание гейта подкрепляется кодом возврата шага»**: шаг, чей исход не + влияет на код возврата `gate.py`, называется в `CLAUDE.md` информационным, а + не «красящим безусловно». Кандидат в правило для `scripts/gate.py` и раздел + «Гейт» `CLAUDE.md` (следствие находки 3 — расхождение обещания и кода прожило + молча неизвестно сколько задач). +- **«Тексты отказов — не контракт»**: клиенты не парсят человекочитаемые + сообщения; при первом программном потребителе причин вводится машинный код + единым решением для всех маршрутов (следствие пониженной находки rubric). + +## Границы покрытия + +**Профиль и режим:** `deep`, по графу; фаза `design` прогнана до кода. Гейт +зелёный (с оговоркой находки 3 про шаг покрытия). + +**Не запускалось и почему:** + +- `review-reimpl` — триггер `docs/review.md` («новое правило слияния, + идентичности или разбора») не сработал: изменение читает витрину, правила + хранения не тронуты; независимый взгляд на замысел дал профиль `design`. + Если промахнёмся классом «второй способ прочитать то же самое» — искать + здесь. +- `task verify:archive` — не прогнан: в worktree нет `./data`, живой архив + основного репозитория запрещён оркестратором; триггер `CLAUDE.md` («перед + изменением правила разбора, идентичности или слияния») не сработал — + изменение сходимость журнала не трогает. Журнал ревью при этом трижды + фиксирует красноту этого прогона от роста корпуса — очередной запуск остаётся + за человеком на основной машине. +- `task verify:busy` — **прогнан вручную, зелёный** (fold 24.08 с, replay + 23.60 с): долгий читатель этой задачи не развёл живую витрину с пересборкой. + +**Что запущенные проходы не могли проверить в принципе (charter):** + +- `specs` — соответствие спеки реальности формата за пределами `testdata`; +- `code` — поведение под конкуренцией и на реальном потоке; +- `adversary` — реальный профиль нагрузки (замеры сняты на синтетике по + мотивам живых плотностей); +- `ops` — поведение на боевом железе и на горизонте недель (WAL-чекпоинт под + непрерывным опросом агента — экстраполяция из прецедента, не замер); +- `architecture` — исполнение против замысла (судит форму, не поведение); +- триаж — ничего нового не находит по определению; пропуск любого прохода — + и его пропуск тоже. + +**Непокрытые изменённые строки (30 из 383):** ветки распространения ошибок +драйвера в `internal/store/series.go` (тот же класс, что уже непокрыт в +`internal/store/catalog.go`) и ветка «ответ не подписался» (тот же класс, что в +`internal/catalog/catalog.go`). Связка с находкой 3: гейт эти строки не красил +бы в любом случае — класс признан, но признание нигде не записано, кроме этой +секции. + +**Подмена оракула критерия К1:** живой архив заменён реальными пакетами +`internal/hae/testdata` на изолированном сервисе `:18080`. Пакеты реальные, но +корпус — не архив: плотности и возраст данных другие, и оценка «на живых +данных» остаётся на человеке. + +**Не проверит ни один проход** (из `docs/review.md`): реальный профиль +нагрузки — телефон шлёт молча и непрерывно, объём и частота меряются только по +факту; поведение приложения HAE за пределами наблюдённого (расписание +автоматизаций — пожелание, не гарантия); полнота словаря переводов после +обновления iOS; секции, которых поток ещё не приносил (`symptoms`, `ecg`, +`heartRateNotifications`, `cycleTracking`, `medications`) — разбор писался +вслепую. Плюс общее: история инцидентов, поведение внешних систем в их версиях, +завязка будущих потребителей (MCP-агентов) на текущую форму ответа и вопрос +«нужна ли эта функциональность вообще». + +**Перестали проверять сознательно** (отдельным списком — при следующем промахе +первый вопрос «не тот ли это класс»): + +- `task verify:archive` / `task verify:busy` вне гейта (в этой задаче: первый + не прогнан с причиной выше, второй прогнан вручную); +- класс «в Go так не пишут» — поимённая сверка со стайлгайдами не покрыта вовсе + после упразднения `idiom`; в этой задаче никто не спросил «идиоматичен ли + колбэк `pick` внутри транзакции» — судили форму и поведение, не идиому; +- класс «чего нет в зрелой реализации такого узла» — вне профиля `design`; + здесь `design`-фаза была, так что класс покрыт частично, на замысле, не на + коде. + +**Документы проекта:** нехватки нет — все входы триажа были на месте и +непустые: инварианты с severity в `CLAUDE.md` (по ним ранжировано), «Типовые +ложноположительные» в `docs/review.md` (ни одна из 8 отложенных находок под них +не подпала — проверено поимённо), оба подраздела «Недоступно проверке» +(разнесены выше), `docs/security.md` (периметр использован находкой про +`ETag`), `docs/research/apple-health.md` (находки 34 и 36 использованы кодом). + +**Потолок:** не потребовался — в основной список вошло 4 пункта из 4 +кандидатов; слияние «умолчание самого мелкого слоя» в находку 1 и понижение +двух minor в гипотезы названы поимённо выше. diff --git a/openspec/changes/archive/2026-08-04-tochki-metriki-za-period/specs/points/spec.md b/openspec/changes/archive/2026-08-04-tochki-metriki-za-period/specs/points/spec.md new file mode 100644 index 0000000..26d3d5f --- /dev/null +++ b/openspec/changes/archive/2026-08-04-tochki-metriki-za-period/specs/points/spec.md @@ -0,0 +1,433 @@ +## ADDED Requirements + +### Requirement: Точки метрики за период отдаются одним запросом + +Система SHALL отдавать значения одной метрики за запрошенный период по +`GET /api/v1/metrics/{name}` с обязательными параметрами `from` и `to`, не +требуя от потребителя доступа к файлу базы и не требуя второго запроса за +смыслом отданных чисел. + +Имя метрики берётся из пути **ровно один раз декодированным** и далее +дословно: система его не нормализует и не сверяет со списком известных — имена +приходят из тела доставки и хранилищу не принадлежат. Повторное декодирование +MUST NOT происходить: имя, само содержащее процентную последовательность +(`a%41b`), после второго декодирования становится именем **другой** метрики, и +маршрут отвечает `200` с её данными. Имя, которое путём не +выражается (пустое), маршрутом недостижимо; каталог такую метрику показывает, и +это названная цена адресации именем в пути, а не молчание. + +Маршрут закрыт токеном чтения тем же контуром, что и каталог, и MUST NOT +перехватывать маршрут каталога `GET /api/v1/metrics`. + +#### Scenario: Период запрошен + +- **GIVEN** метрика, у которой в витрине есть объекты внутри периода +- **WHEN** потребитель запрашивает `GET /api/v1/metrics/{name}?from=…&to=…` с + действующим токеном чтения +- **THEN** ответ `200` несёт значения этой метрики за этот период + +#### Scenario: Каталог остаётся достижим + +- **WHEN** потребитель запрашивает `GET /api/v1/metrics` +- **THEN** отвечает каталог, а не маршрут точек + +#### Scenario: Имя метрики закодировано в пути + +- **GIVEN** метрика, чьё имя содержит символ, требующий процентного кодирования +- **WHEN** потребитель запрашивает её точки, закодировав имя +- **THEN** отвечают точки этой метрики, а не пустой ряд + +#### Scenario: Имя метрики само содержит процентную последовательность + +- **GIVEN** метрики с именами `a%41b` и `aAb` в витрине +- **WHEN** потребитель запрашивает `a%41b`, закодировав имя +- **THEN** отвечают точки `a%41b`, а не точки `aAb` + +#### Scenario: Токен чтения отсутствует + +- **WHEN** запрос точек приходит без действующего токена чтения при непустом + списке токенов чтения +- **THEN** ответ `401`, и тело ответа собрано объявленным типом транспорта + +### Requirement: Конверт ответа объявляет слой, род свёртки, его применимость и границу окна измерения + +Система SHALL сопровождать точки конвертом, в котором ВСЕГДА присутствуют поля +`metric`, `from`, `to`, `layer`, `bucket`, `aggregation` и `points`, а объект +`aggregation` MUST всегда нести поля `style`, `applicable` и `last_hour`. Поле, +которому нечего сообщить, MUST уезжать как `null` и MUST NOT исчезать из ответа +и MUST NOT подменяться нулевым значением своего типа. + +Смысл полей: + +- `metric` — имя метрики, как оно пришло путём после декодирования; +- `from` и `to` — фактически применённые границы периода, нормализованные к UTC + в RFC 3339; +- `layer` — слой, из которого собран ряд; +- `bucket` — сетка свёртки; `null`, когда свёртки не было; +- `aggregation.style` — измеренный род метрики (`cumulative` / `instant` / + `unknown`) тем же правилом и тем же окном, что у каталога; +- `aggregation.applicable` — применим ли объявленный род к **отданному ряду**; +- `aggregation.last_hour` — ярлык самого свежего часа окна измерения; `null`, + когда окно пусто; +- `points` — ряд, пустой коллекцией `[]`, а не `null`. + +`last_hour` обязателен именно потому, что окно измерения считается в **общих +часах**, а не в часах календаря: выключенная минутная автоматизация HAE +останавливает пополнение общих часов, окно замирает и продолжает объявлять род. +Без `last_hour` у клиента нет ни одного способа это увидеть. + +#### Scenario: Свёртки не было + +- **WHEN** маршрут отвечает на запрос без сетки +- **THEN** в ответе присутствуют `metric`, `from`, `to`, `layer`, `bucket`, + `aggregation` со всеми тремя полями и `points`, причём `bucket` равен `null` + +#### Scenario: Окно измерения замерло + +- **GIVEN** метрика, чьи самые свежие общие часы старше конца запрошенного + периода +- **WHEN** маршрут отвечает +- **THEN** `aggregation.last_hour` называет ярлык самого свежего общего часа, а + не конец периода и не текущее время + +#### Scenario: Окна измерения нет вовсе + +- **GIVEN** метрика, у которой нет ни одного общего часа минутного и часового + слоёв +- **WHEN** маршрут отвечает +- **THEN** `aggregation.style` равен `"unknown"`, а `aggregation.last_hour` + равен `null` + +### Requirement: Род, объявленный в ответе, едет вместе со своей применимостью к отданному ряду + +Система SHALL объявлять `aggregation.applicable` равным `false` всегда, когда +объявленный род нельзя применить к отданному ряду, и MUST считать неприменимым +род `cumulative` на **нижнем слое HAE** (`raw`), род `unknown` и любой род при +отсутствии выбранного слоя. Система MUST NOT досчитывать свёртку по +неизмеренному роду и MUST NOT выражать неизвестность отсутствием поля. + +Поле существует потому, что род — свойство **метрики**, а слой — свойство +**отданного ряда**, и их сочетание бывает опасным: нижний слой HAE это +интерполяция, а не сэмплы, и сумма по нему завышает втрое. Конверт, объявляющий +`cumulative` рядом с рядом из `raw` и молчащий о неприменимости, приглашает +потребителя сложить интерполяцию самостоятельно — система при этом не +складывает ничего, а решение у потребителя уже принято по завышенному числу. + +#### Scenario: Род метрики не измерен + +- **GIVEN** метрика, у которой род агрегации не измерен +- **WHEN** потребитель запрашивает её точки за период +- **THEN** ответ `200` несёт точки как есть, `aggregation.style` равен + `"unknown"`, а `aggregation.applicable` равен `false` + +#### Scenario: Накопительная метрика отдана нижним слоем + +- **GIVEN** метрика с измеренным родом `cumulative`, ряд которой собран из слоя + `raw` +- **WHEN** маршрут отвечает +- **THEN** `aggregation.style` равен `"cumulative"`, а + `aggregation.applicable` равен `false` + +#### Scenario: Род применим + +- **GIVEN** метрика с измеренным родом, ряд которой собран из слоя `minute`, + `hour` или `sample` +- **WHEN** маршрут отвечает +- **THEN** `aggregation.applicable` равен `true` + +### Requirement: Слой выбирается по охвату точек внутри периода и не меняется внутри ответа + +Система SHALL собирать ряд ровно из одного слоя и MUST NOT склеивать в одном +ответе точки разных слоёв. При отсутствии параметра `layer` система SHALL брать +слой с наибольшим **охватом** внутри запрошенного периода, а при равном охвате — +самый мелкий слой в порядке `sample`, `raw`, `minute`, `hour`, `day`. + +**Охват меряется метками точек, а не часами объектов.** Охват слоя есть длина +пересечения отрезка `[первая метка слоя, последняя метка слоя]` с запрошенным +периодом; слой с пустым пересечением из выбора MUST выбывать. Мера названа +именно так потому, что объекты адресуются часом, а ряд отбирается точной меткой: +на периоде короче часа множество «слоёв с объектами» и множество «слоёв с +точками» расходятся, и слой, выбранный по часам, отдал бы пустой ряд при +непустых данных соседнего слоя. + +Охват сравнивается между слоями, а не с запрошенным периодом: границы данных +законно короче запроса и законно имеют дыры внутри. + +Заданный параметр `layer` отменяет правило целиком: система SHALL отдавать +запрошенный разрез, в том числе пустым, и SHALL называть его в ответе. + +#### Scenario: Мелкий слой охватывает меньше крупного + +- **GIVEN** метрика, у которой внутри периода нижний слой покрывает несколько + дней, а часовой — весь период +- **WHEN** потребитель запрашивает период без параметра `layer` +- **THEN** ряд собран из часового слоя, и `layer` называет его + +#### Scenario: Охваты равны + +- **GIVEN** метрика, у которой два слоя охватывают внутри периода одно и то же +- **WHEN** потребитель запрашивает период без параметра `layer` +- **THEN** ряд собран из более мелкого слоя + +#### Scenario: Период короче часа + +- **GIVEN** период внутри одного часа, в котором у крупного слоя есть объект без + единой точки внутри периода, а у мелкого — точки внутри периода +- **WHEN** потребитель запрашивает период без параметра `layer` +- **THEN** ряд собран из мелкого слоя и не пуст + +#### Scenario: Слой задан явно + +- **WHEN** потребитель задаёт `layer` явно +- **THEN** ряд собран из этого слоя, даже если другой слой охватывает период + шире, и `layer` в ответе равен запрошенному + +#### Scenario: Имя слоя незнакомо + +- **WHEN** параметр `layer` присутствует, а его значение не является одним из + `sample`, `raw`, `minute`, `hour`, `day` +- **THEN** ответ `400`, и умолчание молча не подставляется + +#### Scenario: Значение слоя пусто + +- **WHEN** параметр `layer` присутствует с пустым значением +- **THEN** ответ `400`, а не автоматический выбор слоя + +### Requirement: Период задаётся явно и разбирается строго + +Система SHALL требовать оба параметра `from` и `to`, SHALL принимать их только в +формате RFC 3339 с явным смещением зоны и SHALL толковать период как +полуинтервал `[from, to)`. Отсутствующий параметр, неразбираемое значение, +значение без явной зоны, `from >= to` и граница, чей год после приведения к UTC +выходит за диапазон 1–9999, MUST давать `400` с человекочитаемым сообщением, +которое MUST NOT содержать значений из запроса, и MUST NOT подменяться +умолчанием. + +Граница за пределами четырёхзначного года отвергается потому, что объекты +адресуются строкой RFC 3339 и границы сравниваются лексикографически: +`9999-12-31T23:00:00-07:00` становится `10000-01-01T06:00:00Z`, который как +строка меньше любой настоящей метки, — и запрос молча отдал бы пустой ряд при +непустых данных. + +Полуинтервал взят потому, что соседние окна обязаны склеиваться без двойного +счёта граничной точки. Явная зона обязательна потому, что вопрос «в какой зоне +считать сутки» в проекте открыт: принять голую дату значило бы выбрать зону за +клиента молча. + +Параметр `bucket` этой версией маршрута не поддержан: система SHALL отвечать +`400` на его присутствие с любым значением и MUST NOT игнорировать его молча. +Молчаливое игнорирование дало бы клиенту, попросившему суточную сетку, полный +минутный ряд — зеркало того самого промаха, ради которого соседняя задача +различает «указали сетку» как информацию и как защиту. Прочие незнакомые +параметры запроса система игнорирует. + +#### Scenario: Параметр периода отсутствует + +- **WHEN** в запросе нет `from` или нет `to` +- **THEN** ответ `400`, и период умолчанием не подставляется + +#### Scenario: Метка времени без зоны + +- **WHEN** значение `from` или `to` записано без явного смещения зоны +- **THEN** ответ `400` + +#### Scenario: Границы периода вывернуты + +- **WHEN** `from` не раньше `to` +- **THEN** ответ `400` + +#### Scenario: Граница выходит за четырёхзначный год + +- **WHEN** граница периода после приведения к UTC попадает в год за пределами + диапазона 1–9999 +- **THEN** ответ `400`, а не `200` с пустым рядом + +#### Scenario: Запрошена сетка свёртки + +- **WHEN** в запросе присутствует параметр `bucket` +- **THEN** ответ `400`, называющий, что свёртка ещё не поддержана + +#### Scenario: Точка стоит ровно на границе + +- **GIVEN** точки с метками ровно в `from` и ровно в `to` +- **WHEN** маршрут отвечает +- **THEN** точка на `from` в ответе есть, а точка на `to` — нет + +### Requirement: Значение точки уезжает дословно, нормализовано только время + +Система SHALL отдавать каждую точку объектом `{ts, ts_end, tz_offset, units, +values}`, где `values` MUST быть содержимым точки ровно в том виде, в каком его +сохранило хранилище, без переименования полей, пересчёта единиц, отбрасывания +незнакомого и **без экранирования**. `ts` и `ts_end` MUST быть нормализованными +к UTC метками начала и конца координаты точки; у точки-измерения `ts_end` равен +`ts`. `tz_offset` и `units` MUST принадлежать самой точке: смещение исходной +зоны — её собственное, единицы — того объекта, из которого точка прочитана. + +Точки MUST идти по возрастанию `(ts, ts_end)`. Порядок этим определён +однозначно: пара `(начало, конец)` внутри одного слоя одной метрики есть ключ +идентичности, и двух точек с равной парой в витрине не существует. + +Принадлежность точки периоду определяется её **началом**: точка-интервал, +начавшаяся раньше `from`, в ответ не входит, даже если её конец лежит внутри +периода. Правило то же, каким час объекта берётся по началу точки; цена названа +вслух — запрос «сон за ночь с полуночи» не увидит эпизод, начавшийся до неё. + +`ts_end` присутствует потому, что идентичность точки — интервал, а не метка: под +одной меткой лежит до трёх записей сна, и конверт с одним `ts` предлагал бы +клиенту различать их, разбирая дословное содержимое. + +#### Scenario: Точка-интервал + +- **GIVEN** точка, у которой конец координаты отличается от начала +- **WHEN** маршрут отвечает +- **THEN** `ts_end` отличается от `ts` и называет конец координаты + +#### Scenario: Содержимое не переписывается + +- **WHEN** точка уезжает клиенту +- **THEN** `values` побайтово совпадает с сохранённым содержимым точки + +#### Scenario: Содержимое несёт символы, которые сериализатор склонен экранировать + +- **GIVEN** точка, чьё содержимое несёт `&`, `<` или `>` +- **WHEN** маршрут отвечает +- **THEN** эти символы уезжают как есть, а не escape-последовательностями + +### Requirement: Пустота периода — успех, а не отсутствие ресурса + +Система SHALL отвечать `200` на запрос метрики, у которой нет данных в +запрошенном периоде, и MUST отдавать `points` пустым списком. Система MUST NOT +отвечать `404` по признаку «нет данных». + +`layer` равен `null` **только** тогда, когда слой выбирала система и выбирать +было не из чего. Заданный клиентом `layer` уезжает в ответе всегда, даже когда +ряд пуст: иначе клиент, спросивший разрез поимённо, не отличил бы «этого разреза +за период нет» от «маршрут проигнорировал параметр». + +Различать опечатку в имени метрики и честную пустоту — работа каталога: список +имён маршруту точек не принадлежит. + +#### Scenario: Данных за период нет и слой не задан + +- **WHEN** у метрики нет ни одной точки внутри периода и параметр `layer` не + задан +- **THEN** ответ `200`, `points` равен `[]`, `layer` равен `null` + +#### Scenario: Данных за период нет, а слой задан + +- **WHEN** у метрики нет точек запрошенного слоя внутри периода +- **THEN** ответ `200`, `points` равен `[]`, `layer` равен запрошенному + +#### Scenario: Имени метрики в витрине нет вовсе + +- **WHEN** запрошено имя метрики, которого в витрине нет +- **THEN** ответ `200` с пустым `points`, а не `404` + +### Requirement: Ответ снимается одним снимком витрины + +Система SHALL читать охваты слоёв, точки выбранного слоя и объекты окна +измерения **в одной транзакции чтения**. Смесь «слой выбран до коммита свёртки, +точки прочитаны после» и «род измерен на третьем состоянии» дала бы ответ, +внутренне противоречивый и неотличимый от обычного свежего; пара проб версии +такой ответ обнаруживает, но не предотвращает — она снимает метку, а тело всё +равно уезжает. + +#### Scenario: Свёртка коммитит во время чтения + +- **WHEN** фоновая свёртка коммитит в витрину между выбором слоя и чтением точек +- **THEN** ответ собран из одного снимка витрины, а не из двух состояний + +### Requirement: Метка ответа включает канонизированную форму запроса и горизонт измерения + +Система SHALL помечать ответ меткой, область действия которой MUST быть +функцией канонизированной формы запроса — имени метрики, границ периода и слоя — +и которая MUST включать **горизонт измерения**, огрублённый до часа, наравне с +версией витрины. При невозможности подписать ответ система SHALL отдавать его +без метки, а не отказом. + +Область MUST быть **ограничена по длине и не выносить значения запроса наружу**. +Имя метрики приходит из чужого тела дословно и ничем не ограничено: положенное +в метку как есть, оно даёт заголовок в тысячи байт, а кавычка внутри имени по +HTTP кончает метку — разбор обрежет её там, и условный запрос по такой метрике +не сработает никогда. Тот же вход в лог уезжает обрезанным, и у метки предел +обязан быть по той же причине. + +Канонизация границ MUST сохранять точность, по которой отбираются точки: два +запроса, различающиеся долей секунды, дают разные ряды, и одна метка на них +подтвердила бы неизменность чужого набора данных. + +Горизонт входит в метку по той же причине, по какой он входит в метку каталога: +род объявляется по окну, ограниченному сверху `текущее время + запас`, и метка +из будущего, лежащая в витрине, въезжает в окно **без единого коммита**, меняя +`aggregation.style` и `last_hour`. Метка без горизонта подтвердила бы +неизменность ответа, в котором род уже перевернулся. Форма запроса входит в +область действия потому, что ответ этого маршрута есть функция параметров: метка +без них однажды подтвердила бы неизменность чужого набора данных. Род и метка +MUST сниматься с одного горизонта. + +Цена названа: один полный ответ в час на потребителя при неизменившейся витрине. + +#### Scenario: Запросы различаются параметром + +- **WHEN** два запроса при одном состоянии витрины различаются метрикой, + границей периода или слоем +- **THEN** метки их ответов различны + +#### Scenario: Запросы различаются только формой записи + +- **WHEN** два запроса при одном состоянии витрины задают одно и то же разной + записью смещения зоны +- **THEN** метки их ответов совпадают + +#### Scenario: Границы различаются долей секунды + +- **WHEN** два запроса при одном состоянии витрины различаются границей периода + на долю секунды и дают разные ряды +- **THEN** метки их ответов различны + +#### Scenario: Имя метрики длинное или содержит кавычку + +- **WHEN** запрошена метрика, чьё имя длиной в тысячи символов или содержит + кавычку +- **THEN** метка ответа остаётся короткой и не содержит имени метрики + +#### Scenario: Горизонт перешёл через час + +- **WHEN** горизонт измерения перешёл через границу часа при неизменной версии + витрины +- **THEN** метка ответа изменилась + +#### Scenario: Витрина изменилась во время чтения + +- **WHEN** пробы версии витрины разошлись +- **THEN** ответ уходит целиком и без метки + +### Requirement: Исход маршрута точек наблюдаем, а данные о здоровье в лог не уезжают + +Система SHALL писать один логирующий чекпоинт исхода на доменной границе +маршрута и MUST NOT доводить до записи лога значения точек, содержимое `values` +и границы запроса; имя метрики MUST быть обрезано по тому же пределу, что у +каталога. Отмена запроса клиентом и занятость базы MUST писаться уровнем +`DEBUG`, а настоящий отказ хранилища — уровнем `ERROR`. + +Предупреждения измерения — противоречащий род и данные, помеченные будущим, — +остаются привилегией каталога: маршрут точек их MUST NOT повторять. Агент +опрашивает по расписанию, и `WARN` на каждый опрос обесценил бы уровень ровно +так же, как обесценила бы его строка на каждый `304`. + +#### Scenario: Клиент оборвал запрос + +- **WHEN** потребитель обрывает запрос точек по своему тайм-ауту +- **THEN** запись об этом уходит уровнем `DEBUG`, а не `ERROR` + +#### Scenario: Метрика с противоречащим родом запрошена многократно + +- **WHEN** потребитель повторно запрашивает точки метрики, у которой род + противоречив +- **THEN** маршрут точек предупреждений об этом не пишет + +#### Scenario: Тело ответа оборвалось на середине + +- **WHEN** запись тела ответа отказала после того, как код ответа уже отдан +- **THEN** отказ записи оставляет собственный чекпоинт и не проходит молча под + видом успешного ответа diff --git a/openspec/changes/archive/2026-08-04-tochki-metriki-za-period/specs/read-api/spec.md b/openspec/changes/archive/2026-08-04-tochki-metriki-za-period/specs/read-api/spec.md new file mode 100644 index 0000000..a54d842 --- /dev/null +++ b/openspec/changes/archive/2026-08-04-tochki-metriki-za-period/specs/read-api/spec.md @@ -0,0 +1,40 @@ +## ADDED Requirements + +### Requirement: Сериализация ответа чтения не экранирует содержимое + +Система SHALL сериализовать тела ответов читающих маршрутов **без экранирования +HTML-символов**: `&`, `<` и `>` внутри значений MUST уезжать клиенту как есть и +MUST NOT подменяться escape-последовательностями `&`, `<`, `>`. + +Правило общее для всех читающих маршрутов, а не частное для точек, потому что +общим является механизм: тело собирает один помощник сериализации, и умолчание +`encoding/json` экранирует эти три символа молча. На маршруте, отдающем +**дословно сохранённое** содержимое, это прямо ломает обещание дословности: +имя источника приходит с телефона пользовательской строкой и законно содержит +`&`. Хранилище этот же капкан уже проходило и обезвредило тем же способом — +кодировщик с выключенным экранированием вместо `json.Marshal`. + +Проверка обязана стоять на содержимом, реально несущем эти символы: набор +фикстур, в котором их нет, зелен и будучи сломанным. + +#### Scenario: Значение несёт символ, который сериализатор склонен экранировать + +- **GIVEN** значение ответа читающего маршрута, содержащее `&`, `<` или `>` +- **WHEN** маршрут отвечает +- **THEN** эти символы присутствуют в теле ответа как есть + +### Requirement: Ответ чтения непригоден для разделяемого кеша + +Система SHALL помечать ответы читающих маршрутов заголовком +`Cache-Control: private, no-cache`. + +Правило общее, потому что цена у него одна на все маршруты чтения: с появлением +валидатора ответ становится штатно кешируемым, а при выключенной проверке +токенов — законной конфигурации для доверенной локальной сети — в запросе нет и +`Authorization`. Тогда выгрузку истории здоровья вправе сохранить любой прокси +на пути. Маршрут, решающий это заново, однажды решит иначе. + +#### Scenario: Читающий маршрут ответил + +- **WHEN** читающий маршрут отдаёт тело +- **THEN** ответ несёт `Cache-Control: private, no-cache` diff --git a/openspec/changes/archive/2026-08-04-tochki-metriki-za-period/tasks.md b/openspec/changes/archive/2026-08-04-tochki-metriki-za-period/tasks.md new file mode 100644 index 0000000..b1971b4 --- /dev/null +++ b/openspec/changes/archive/2026-08-04-tochki-metriki-za-period/tasks.md @@ -0,0 +1,140 @@ +## Критерии приёмки (из постановки, переживают удаление файла задачи) + +Задача `docs/tasks/items/read-api-points-period.md`, «Точки за период». + +- **К1.** «вес за год» отвечается одним запросом без доступа к файлу базы — + **оракул:** запрос к поднятому сервису на живом архиве. + *Подмена оракула на этой машине:* рабочая база `./data` принадлежит живому + контейнеру, читать её мимо `task up`/`task run` запрещено. Вместо неё сервис + поднимается в worktree на своём порту и своей базе, наполненной **реальными + пакетами** `internal/hae/testdata`. Подмена названа строкой и не выдаётся за + исходный оракул: она проверяет маршрут целиком (HTTP, токен, разбор + параметров, чтение витрины, форма ответа), но не объём живого архива. +- **К2.** в ответе всегда видны `layer`, `aggregation` и `last_hour` — + **оракул:** тест на форме ответа. +- **К3.** метрика, у которой род не измерен, отдаётся без свёртки и говорит об + этом, а не молчит и не досчитывает — **оракул:** тест на метрике с неизвестным + родом. + +## Приёмочные критерии из рубрики (проход `review-rubric`, профиль `design`) + +Свойства узла «HTTP-обработчик чтения временного ряда», порождённые до чтения +предложения. Проверяются наравне с К1–К3. + +- **Р1.** Род, объявленный в ответе, применим к отданному ряду: сочетания, из + которого клиент выведет разрешённой операцию, запрещённую инвариантом, в + конверте нет. +- **Р2.** Ответ есть функция того, что уже произошло, а не момента взгляда: всё, + что способно измениться **без коммита в базу** (горизонт, параметры запроса), + входит в область действия метки; повтор того же запроса при том же состоянии и + тех же часах даёт побайтово тот же ответ. +- **Р3.** Ряд собран ровно из одного слоя, слой назван всегда, правило выбора + детерминировано на любом входе; предикат выбора слоя и предикат отбора точек + используют **одну границу**. +- **Р4.** Отсутствие предела размера — осознанное решение, стоящее на замере + **того режима, ради которого предел заводится**, а не на замере доступного + корпуса. +- **Р5.** Период — полуинтервал; точка на границе попадает ровно в один из двух + соседних ответов; правило принадлежности интервальной точки названо. +- **Р6.** Пустой результат — `200`, пустая коллекция списком, метаданные на + месте; «данных нет» отличимо от «ресурса нет» без второго запроса. +- **Р7.** Невозможный запрос отвергается до чтения витрины, кодом `4xx`, и текст + отказа не содержит значений из запроса. +- **Р8.** Время нормализовано и однозначно; значения точки уезжают дословно — + без переименования, пересчёта, отбрасывания и **экранирования**. +- **Р9.** Ответ не выглядит полнее, чем он есть: «данных не было» отличимо от + «слой выбран по охвату меньше периода» без пересчёта точек. +- **Р10.** Выборка идёт по существующему индексу без полного скана витрины, + `context` протянут до драйвера, отмена клиента не считается отказом. +- **Р11.** Транспорт не несёт доменной логики; доменный тип до сериализации не + доезжает. +- **Р12.** Ни значения здоровья, ни токен не доводятся до лога выше `DEBUG` и до + тела отказа ни одним путём. + +## 1. Чтение витрины + +- [x] 1.1 `store.ReadSeries(ctx, window, pick)` — **один вход, одна транзакция + чтения**: охваты слоёв, точки выбранного слоя, объекты окна измерения. + Правило выбора слоя приходит функцией-параметром и остаётся в домене +- [x] 1.2 Охваты слоёв: `min(first_ts)`, `max(last_ts)`, охваты по + `GROUP BY layer` в границах часов `[trunc(from), trunc(to)]` — по покрывающему + индексу `bucket_catalog`, без чтения содержимого (Р10) +- [x] 1.3 Точки выбранного слоя: объекты по точному префиксу первичного ключа, + разжатие, отбор по началу координаты до `[from, to)` +- [x] 1.4 Окно измерения одной метрики: переиспользует `commonHours` и + `readHourPairs`, второго правила отбора не заводит +- [x] 1.5 Тесты хранилища: точка `10:59` из объекта `10:00` не теряется; точка + ровно на `from` есть, ровно на `to` — нет; период короче часа, где крупный + слой имеет объект без точек внутри, а мелкий — точки (Р3); пустой период + +## 2. Use-case «ряд точек» (`internal/points`) + +- [x] 2.1 Пакет `internal/points`: тип запроса, тип ответа, `Service` над + `store` и `catalog` +- [x] 2.2 Правило выбора слоя: охват = длина пересечения `[первая метка, + последняя метка]` слоя с периодом; пустое пересечение выбывает; наибольший + охват, при равенстве — самый мелкий; явный слой отменяет правило и всегда + уезжает в ответ (Р3) +- [x] 2.3 Род через `catalog.Measure`, применимость — `false` при `unknown`, при + `cumulative` на слое `raw` и при отсутствии выбранного слоя (Р1) +- [x] 2.4 Метка ответа: версия витрины + горизонт, огрублённый до часа + (`catalog.Stamp`, не второй экземпляр) + канонизированная форма запроса; род и + метка снимаются с одного горизонта (Р2) +- [x] 2.5 Логирующий чекпоинт исхода один и на доменной границе; отмена и + занятость базы — `DEBUG`, настоящий отказ — `ERROR`; значений точек и границ + запроса в записи нет, имя метрики обрезано; предупреждения измерения маршрут + точек не повторяет (Р12) +- [x] 2.6 Тесты домена: охваты различаются; охваты равны; ни одного слоя; + явный слой пуст; период короче часа; применимость рода на четырёх слоях + +## 3. Транспорт и форма провода + +- [x] 3.1 Маршрут `GET /api/v1/metrics/{name}` под токеном чтения; тест, что он + не перехватывает `GET /api/v1/metrics`; имя метрики берётся процентно + декодированным +- [x] 3.2 Разбор параметров: `from`/`to` обязательны, RFC 3339 с явной зоной, + `from < to`, `layer` из словаря, присутствие `bucket` — `400`; каждый отказ + до чтения витрины, с человекочитаемым сообщением без значений из запроса (Р7) +- [x] 3.3 Форма провода точек по образцу `catalog.go`: типы `*Wire` с + `json`-тегами, перевод присваиванием поле в поле, `values` — `json.RawMessage` +- [x] 3.4 Строка в таблице образцов `wire_internal_test.go` (Р11) +- [x] 3.5 `writeJSON` перестаёт экранировать HTML-символы (общее правило + `read-api`); тест на значении точки с `&`, `<`, `>` (Р8) +- [x] 3.6 Метка и `Cache-Control` через существующий `setReadHeaders`; тесты: + различие меток по каждому параметру по очереди, совпадение при эквивалентной + записи времени, различие при переходе горизонта через час (Р2) +- [x] 3.7 Байтовое утверждение формы ответа на **фиксированной** витрине с + часами в прошлом — ни одно поле литерала не зависит от хода часов (прецедент + `docs/review.md`, 2026-08-03) +- [x] 3.8 Тесты маршрута: **К2** (все поля конверта присутствуют всегда), + **К3** (род `unknown` назван словом, `applicable: false`), точка-интервал + (`ts_end`), дословность `values`, пустой период без слоя (`layer: null`), + пустой период с явным слоем (`layer` эхом), незнакомая метрика, `401` + +## 4. Документация + +- [x] 4.1 `docs/architecture.md`, раздел «Read API»: форма ответа точек + (`aggregation` объектом, `applicable`, `ts_end`), правило выбора слоя + формулировкой из спеки, строка маршрута с пометкой о неподдержанном `bucket`, + строка `points` в таблице компонентов +- [x] 4.2 `config.example.toml`: строка про `read_tokens` называет точки рядом с + каталогом + +## 5. Верификация + +- [x] 5.1 `task gate` зелёный +- [x] 5.2 Поведенческая верификация: свой `config.toml` в worktree (порт + `:18080`, база и архив в `./tmp/`), наполнение реальными пакетами + `internal/hae/testdata` через маршрут приёма, затем **К1** — метрика за год + одним запросом; рабочий `./data` не трогается +- [x] 5.3 Замер цены маршрута числом на **трёх** режимах, включая худший + (Р4, прецедент `docs/review.md` 2026-08-04 «замер снят на корпусе, где + измеряемого случая не бывает»): редкая метрика за год; плотная метрика за + сутки в `minute`; плотная метрика за неделю в `raw`. Корпус собирается + размножением **реальных** точек `testdata`, а не литералами; метод + записывается рядом с числом +- [x] 5.4 `task verify:busy` — зелёный (fold 24.08 с, replay 23.60 с). + `task verify:archive` **не прогнан**: в worktree нет `./data`, а живой архив + основного репозитория трогать запрещено; триггер прогона + (изменение правила разбора, идентичности или слияния) не сработал — маршрут + только читает витрину. Названо строкой в границах покрытия, а не замолчано diff --git a/openspec/specs/points/spec.md b/openspec/specs/points/spec.md new file mode 100644 index 0000000..dd31482 --- /dev/null +++ b/openspec/specs/points/spec.md @@ -0,0 +1,442 @@ +# points Specification + +## Purpose + +Ряд точек одной метрики за период — то, ради чего собирается витрина. Каталог +отвечает «что у тебя есть», этот маршрут — «дай значения». Форма запроса и его +разбор, правило выбора слоя, состав конверта и объявление измеренного рода +вместе с границей окна измерения живут здесь; общие правила читающих маршрутов — +в capability `read-api`. + +## Requirements +### Requirement: Точки метрики за период отдаются одним запросом + +Система SHALL отдавать значения одной метрики за запрошенный период по +`GET /api/v1/metrics/{name}` с обязательными параметрами `from` и `to`, не +требуя от потребителя доступа к файлу базы и не требуя второго запроса за +смыслом отданных чисел. + +Имя метрики берётся из пути **ровно один раз декодированным** и далее +дословно: система его не нормализует и не сверяет со списком известных — имена +приходят из тела доставки и хранилищу не принадлежат. Повторное декодирование +MUST NOT происходить: имя, само содержащее процентную последовательность +(`a%41b`), после второго декодирования становится именем **другой** метрики, и +маршрут отвечает `200` с её данными. Имя, которое путём не +выражается (пустое), маршрутом недостижимо; каталог такую метрику показывает, и +это названная цена адресации именем в пути, а не молчание. + +Маршрут закрыт токеном чтения тем же контуром, что и каталог, и MUST NOT +перехватывать маршрут каталога `GET /api/v1/metrics`. + +#### Scenario: Период запрошен + +- **GIVEN** метрика, у которой в витрине есть объекты внутри периода +- **WHEN** потребитель запрашивает `GET /api/v1/metrics/{name}?from=…&to=…` с + действующим токеном чтения +- **THEN** ответ `200` несёт значения этой метрики за этот период + +#### Scenario: Каталог остаётся достижим + +- **WHEN** потребитель запрашивает `GET /api/v1/metrics` +- **THEN** отвечает каталог, а не маршрут точек + +#### Scenario: Имя метрики закодировано в пути + +- **GIVEN** метрика, чьё имя содержит символ, требующий процентного кодирования +- **WHEN** потребитель запрашивает её точки, закодировав имя +- **THEN** отвечают точки этой метрики, а не пустой ряд + +#### Scenario: Имя метрики само содержит процентную последовательность + +- **GIVEN** метрики с именами `a%41b` и `aAb` в витрине +- **WHEN** потребитель запрашивает `a%41b`, закодировав имя +- **THEN** отвечают точки `a%41b`, а не точки `aAb` + +#### Scenario: Токен чтения отсутствует + +- **WHEN** запрос точек приходит без действующего токена чтения при непустом + списке токенов чтения +- **THEN** ответ `401`, и тело ответа собрано объявленным типом транспорта + +### Requirement: Конверт ответа объявляет слой, род свёртки, его применимость и границу окна измерения + +Система SHALL сопровождать точки конвертом, в котором ВСЕГДА присутствуют поля +`metric`, `from`, `to`, `layer`, `bucket`, `aggregation` и `points`, а объект +`aggregation` MUST всегда нести поля `style`, `applicable` и `last_hour`. Поле, +которому нечего сообщить, MUST уезжать как `null` и MUST NOT исчезать из ответа +и MUST NOT подменяться нулевым значением своего типа. + +Смысл полей: + +- `metric` — имя метрики, как оно пришло путём после декодирования; +- `from` и `to` — фактически применённые границы периода, нормализованные к UTC + в RFC 3339; +- `layer` — слой, из которого собран ряд; +- `bucket` — сетка свёртки; `null`, когда свёртки не было; +- `aggregation.style` — измеренный род метрики (`cumulative` / `instant` / + `unknown`) тем же правилом и тем же окном, что у каталога; +- `aggregation.applicable` — применим ли объявленный род к **отданному ряду**; +- `aggregation.last_hour` — ярлык самого свежего часа окна измерения; `null`, + когда окно пусто; +- `points` — ряд, пустой коллекцией `[]`, а не `null`. + +`last_hour` обязателен именно потому, что окно измерения считается в **общих +часах**, а не в часах календаря: выключенная минутная автоматизация HAE +останавливает пополнение общих часов, окно замирает и продолжает объявлять род. +Без `last_hour` у клиента нет ни одного способа это увидеть. + +#### Scenario: Свёртки не было + +- **WHEN** маршрут отвечает на запрос без сетки +- **THEN** в ответе присутствуют `metric`, `from`, `to`, `layer`, `bucket`, + `aggregation` со всеми тремя полями и `points`, причём `bucket` равен `null` + +#### Scenario: Окно измерения замерло + +- **GIVEN** метрика, чьи самые свежие общие часы старше конца запрошенного + периода +- **WHEN** маршрут отвечает +- **THEN** `aggregation.last_hour` называет ярлык самого свежего общего часа, а + не конец периода и не текущее время + +#### Scenario: Окна измерения нет вовсе + +- **GIVEN** метрика, у которой нет ни одного общего часа минутного и часового + слоёв +- **WHEN** маршрут отвечает +- **THEN** `aggregation.style` равен `"unknown"`, а `aggregation.last_hour` + равен `null` + +### Requirement: Род, объявленный в ответе, едет вместе со своей применимостью к отданному ряду + +Система SHALL объявлять `aggregation.applicable` равным `false` всегда, когда +объявленный род нельзя применить к отданному ряду, и MUST считать неприменимым +род `cumulative` на **нижнем слое HAE** (`raw`), род `unknown` и любой род при +отсутствии выбранного слоя. Система MUST NOT досчитывать свёртку по +неизмеренному роду и MUST NOT выражать неизвестность отсутствием поля. + +Поле существует потому, что род — свойство **метрики**, а слой — свойство +**отданного ряда**, и их сочетание бывает опасным: нижний слой HAE это +интерполяция, а не сэмплы, и сумма по нему завышает втрое. Конверт, объявляющий +`cumulative` рядом с рядом из `raw` и молчащий о неприменимости, приглашает +потребителя сложить интерполяцию самостоятельно — система при этом не +складывает ничего, а решение у потребителя уже принято по завышенному числу. + +#### Scenario: Род метрики не измерен + +- **GIVEN** метрика, у которой род агрегации не измерен +- **WHEN** потребитель запрашивает её точки за период +- **THEN** ответ `200` несёт точки как есть, `aggregation.style` равен + `"unknown"`, а `aggregation.applicable` равен `false` + +#### Scenario: Накопительная метрика отдана нижним слоем + +- **GIVEN** метрика с измеренным родом `cumulative`, ряд которой собран из слоя + `raw` +- **WHEN** маршрут отвечает +- **THEN** `aggregation.style` равен `"cumulative"`, а + `aggregation.applicable` равен `false` + +#### Scenario: Род применим + +- **GIVEN** метрика с измеренным родом, ряд которой собран из слоя `minute`, + `hour` или `sample` +- **WHEN** маршрут отвечает +- **THEN** `aggregation.applicable` равен `true` + +### Requirement: Слой выбирается по охвату точек внутри периода и не меняется внутри ответа + +Система SHALL собирать ряд ровно из одного слоя и MUST NOT склеивать в одном +ответе точки разных слоёв. При отсутствии параметра `layer` система SHALL брать +слой с наибольшим **охватом** внутри запрошенного периода, а при равном охвате — +самый мелкий слой в порядке `sample`, `raw`, `minute`, `hour`, `day`. + +**Охват меряется метками точек, а не часами объектов.** Охват слоя есть длина +пересечения отрезка `[первая метка слоя, последняя метка слоя]` с запрошенным +периодом; слой с пустым пересечением из выбора MUST выбывать. Мера названа +именно так потому, что объекты адресуются часом, а ряд отбирается точной меткой: +на периоде короче часа множество «слоёв с объектами» и множество «слоёв с +точками» расходятся, и слой, выбранный по часам, отдал бы пустой ряд при +непустых данных соседнего слоя. + +Охват сравнивается между слоями, а не с запрошенным периодом: границы данных +законно короче запроса и законно имеют дыры внутри. + +Заданный параметр `layer` отменяет правило целиком: система SHALL отдавать +запрошенный разрез, в том числе пустым, и SHALL называть его в ответе. + +#### Scenario: Мелкий слой охватывает меньше крупного + +- **GIVEN** метрика, у которой внутри периода нижний слой покрывает несколько + дней, а часовой — весь период +- **WHEN** потребитель запрашивает период без параметра `layer` +- **THEN** ряд собран из часового слоя, и `layer` называет его + +#### Scenario: Охваты равны + +- **GIVEN** метрика, у которой два слоя охватывают внутри периода одно и то же +- **WHEN** потребитель запрашивает период без параметра `layer` +- **THEN** ряд собран из более мелкого слоя + +#### Scenario: Период короче часа + +- **GIVEN** период внутри одного часа, в котором у крупного слоя есть объект без + единой точки внутри периода, а у мелкого — точки внутри периода +- **WHEN** потребитель запрашивает период без параметра `layer` +- **THEN** ряд собран из мелкого слоя и не пуст + +#### Scenario: Слой задан явно + +- **WHEN** потребитель задаёт `layer` явно +- **THEN** ряд собран из этого слоя, даже если другой слой охватывает период + шире, и `layer` в ответе равен запрошенному + +#### Scenario: Имя слоя незнакомо + +- **WHEN** параметр `layer` присутствует, а его значение не является одним из + `sample`, `raw`, `minute`, `hour`, `day` +- **THEN** ответ `400`, и умолчание молча не подставляется + +#### Scenario: Значение слоя пусто + +- **WHEN** параметр `layer` присутствует с пустым значением +- **THEN** ответ `400`, а не автоматический выбор слоя + +### Requirement: Период задаётся явно и разбирается строго + +Система SHALL требовать оба параметра `from` и `to`, SHALL принимать их только в +формате RFC 3339 с явным смещением зоны и SHALL толковать период как +полуинтервал `[from, to)`. Отсутствующий параметр, неразбираемое значение, +значение без явной зоны, `from >= to` и граница, чей год после приведения к UTC +выходит за диапазон 1–9999, MUST давать `400` с человекочитаемым сообщением, +которое MUST NOT содержать значений из запроса, и MUST NOT подменяться +умолчанием. + +Граница за пределами четырёхзначного года отвергается потому, что объекты +адресуются строкой RFC 3339 и границы сравниваются лексикографически: +`9999-12-31T23:00:00-07:00` становится `10000-01-01T06:00:00Z`, который как +строка меньше любой настоящей метки, — и запрос молча отдал бы пустой ряд при +непустых данных. + +Полуинтервал взят потому, что соседние окна обязаны склеиваться без двойного +счёта граничной точки. Явная зона обязательна потому, что вопрос «в какой зоне +считать сутки» в проекте открыт: принять голую дату значило бы выбрать зону за +клиента молча. + +Параметр `bucket` этой версией маршрута не поддержан: система SHALL отвечать +`400` на его присутствие с любым значением и MUST NOT игнорировать его молча. +Молчаливое игнорирование дало бы клиенту, попросившему суточную сетку, полный +минутный ряд — зеркало того самого промаха, ради которого соседняя задача +различает «указали сетку» как информацию и как защиту. Прочие незнакомые +параметры запроса система игнорирует. + +#### Scenario: Параметр периода отсутствует + +- **WHEN** в запросе нет `from` или нет `to` +- **THEN** ответ `400`, и период умолчанием не подставляется + +#### Scenario: Метка времени без зоны + +- **WHEN** значение `from` или `to` записано без явного смещения зоны +- **THEN** ответ `400` + +#### Scenario: Границы периода вывернуты + +- **WHEN** `from` не раньше `to` +- **THEN** ответ `400` + +#### Scenario: Граница выходит за четырёхзначный год + +- **WHEN** граница периода после приведения к UTC попадает в год за пределами + диапазона 1–9999 +- **THEN** ответ `400`, а не `200` с пустым рядом + +#### Scenario: Запрошена сетка свёртки + +- **WHEN** в запросе присутствует параметр `bucket` +- **THEN** ответ `400`, называющий, что свёртка ещё не поддержана + +#### Scenario: Точка стоит ровно на границе + +- **GIVEN** точки с метками ровно в `from` и ровно в `to` +- **WHEN** маршрут отвечает +- **THEN** точка на `from` в ответе есть, а точка на `to` — нет + +### Requirement: Значение точки уезжает дословно, нормализовано только время + +Система SHALL отдавать каждую точку объектом `{ts, ts_end, tz_offset, units, +values}`, где `values` MUST быть содержимым точки ровно в том виде, в каком его +сохранило хранилище, без переименования полей, пересчёта единиц, отбрасывания +незнакомого и **без экранирования**. `ts` и `ts_end` MUST быть нормализованными +к UTC метками начала и конца координаты точки; у точки-измерения `ts_end` равен +`ts`. `tz_offset` и `units` MUST принадлежать самой точке: смещение исходной +зоны — её собственное, единицы — того объекта, из которого точка прочитана. + +Точки MUST идти по возрастанию `(ts, ts_end)`. Порядок этим определён +однозначно: пара `(начало, конец)` внутри одного слоя одной метрики есть ключ +идентичности, и двух точек с равной парой в витрине не существует. + +Принадлежность точки периоду определяется её **началом**: точка-интервал, +начавшаяся раньше `from`, в ответ не входит, даже если её конец лежит внутри +периода. Правило то же, каким час объекта берётся по началу точки; цена названа +вслух — запрос «сон за ночь с полуночи» не увидит эпизод, начавшийся до неё. + +`ts_end` присутствует потому, что идентичность точки — интервал, а не метка: под +одной меткой лежит до трёх записей сна, и конверт с одним `ts` предлагал бы +клиенту различать их, разбирая дословное содержимое. + +#### Scenario: Точка-интервал + +- **GIVEN** точка, у которой конец координаты отличается от начала +- **WHEN** маршрут отвечает +- **THEN** `ts_end` отличается от `ts` и называет конец координаты + +#### Scenario: Содержимое не переписывается + +- **WHEN** точка уезжает клиенту +- **THEN** `values` побайтово совпадает с сохранённым содержимым точки + +#### Scenario: Содержимое несёт символы, которые сериализатор склонен экранировать + +- **GIVEN** точка, чьё содержимое несёт `&`, `<` или `>` +- **WHEN** маршрут отвечает +- **THEN** эти символы уезжают как есть, а не escape-последовательностями + +### Requirement: Пустота периода — успех, а не отсутствие ресурса + +Система SHALL отвечать `200` на запрос метрики, у которой нет данных в +запрошенном периоде, и MUST отдавать `points` пустым списком. Система MUST NOT +отвечать `404` по признаку «нет данных». + +`layer` равен `null` **только** тогда, когда слой выбирала система и выбирать +было не из чего. Заданный клиентом `layer` уезжает в ответе всегда, даже когда +ряд пуст: иначе клиент, спросивший разрез поимённо, не отличил бы «этого разреза +за период нет» от «маршрут проигнорировал параметр». + +Различать опечатку в имени метрики и честную пустоту — работа каталога: список +имён маршруту точек не принадлежит. + +#### Scenario: Данных за период нет и слой не задан + +- **WHEN** у метрики нет ни одной точки внутри периода и параметр `layer` не + задан +- **THEN** ответ `200`, `points` равен `[]`, `layer` равен `null` + +#### Scenario: Данных за период нет, а слой задан + +- **WHEN** у метрики нет точек запрошенного слоя внутри периода +- **THEN** ответ `200`, `points` равен `[]`, `layer` равен запрошенному + +#### Scenario: Имени метрики в витрине нет вовсе + +- **WHEN** запрошено имя метрики, которого в витрине нет +- **THEN** ответ `200` с пустым `points`, а не `404` + +### Requirement: Ответ снимается одним снимком витрины + +Система SHALL читать охваты слоёв, точки выбранного слоя и объекты окна +измерения **в одной транзакции чтения**. Смесь «слой выбран до коммита свёртки, +точки прочитаны после» и «род измерен на третьем состоянии» дала бы ответ, +внутренне противоречивый и неотличимый от обычного свежего; пара проб версии +такой ответ обнаруживает, но не предотвращает — она снимает метку, а тело всё +равно уезжает. + +#### Scenario: Свёртка коммитит во время чтения + +- **WHEN** фоновая свёртка коммитит в витрину между выбором слоя и чтением точек +- **THEN** ответ собран из одного снимка витрины, а не из двух состояний + +### Requirement: Метка ответа включает канонизированную форму запроса и горизонт измерения + +Система SHALL помечать ответ меткой, область действия которой MUST быть +функцией канонизированной формы запроса — имени метрики, границ периода и слоя — +и которая MUST включать **горизонт измерения**, огрублённый до часа, наравне с +версией витрины. При невозможности подписать ответ система SHALL отдавать его +без метки, а не отказом. + +Область MUST быть **ограничена по длине и не выносить значения запроса наружу**. +Имя метрики приходит из чужого тела дословно и ничем не ограничено: положенное +в метку как есть, оно даёт заголовок в тысячи байт, а кавычка внутри имени по +HTTP кончает метку — разбор обрежет её там, и условный запрос по такой метрике +не сработает никогда. Тот же вход в лог уезжает обрезанным, и у метки предел +обязан быть по той же причине. + +Канонизация границ MUST сохранять точность, по которой отбираются точки: два +запроса, различающиеся долей секунды, дают разные ряды, и одна метка на них +подтвердила бы неизменность чужого набора данных. + +Горизонт входит в метку по той же причине, по какой он входит в метку каталога: +род объявляется по окну, ограниченному сверху `текущее время + запас`, и метка +из будущего, лежащая в витрине, въезжает в окно **без единого коммита**, меняя +`aggregation.style` и `last_hour`. Метка без горизонта подтвердила бы +неизменность ответа, в котором род уже перевернулся. Форма запроса входит в +область действия потому, что ответ этого маршрута есть функция параметров: метка +без них однажды подтвердила бы неизменность чужого набора данных. Род и метка +MUST сниматься с одного горизонта. + +Цена названа: один полный ответ в час на потребителя при неизменившейся витрине. + +#### Scenario: Запросы различаются параметром + +- **WHEN** два запроса при одном состоянии витрины различаются метрикой, + границей периода или слоем +- **THEN** метки их ответов различны + +#### Scenario: Запросы различаются только формой записи + +- **WHEN** два запроса при одном состоянии витрины задают одно и то же разной + записью смещения зоны +- **THEN** метки их ответов совпадают + +#### Scenario: Границы различаются долей секунды + +- **WHEN** два запроса при одном состоянии витрины различаются границей периода + на долю секунды и дают разные ряды +- **THEN** метки их ответов различны + +#### Scenario: Имя метрики длинное или содержит кавычку + +- **WHEN** запрошена метрика, чьё имя длиной в тысячи символов или содержит + кавычку +- **THEN** метка ответа остаётся короткой и не содержит имени метрики + +#### Scenario: Горизонт перешёл через час + +- **WHEN** горизонт измерения перешёл через границу часа при неизменной версии + витрины +- **THEN** метка ответа изменилась + +#### Scenario: Витрина изменилась во время чтения + +- **WHEN** пробы версии витрины разошлись +- **THEN** ответ уходит целиком и без метки + +### Requirement: Исход маршрута точек наблюдаем, а данные о здоровье в лог не уезжают + +Система SHALL писать один логирующий чекпоинт исхода на доменной границе +маршрута и MUST NOT доводить до записи лога значения точек, содержимое `values` +и границы запроса; имя метрики MUST быть обрезано по тому же пределу, что у +каталога. Отмена запроса клиентом и занятость базы MUST писаться уровнем +`DEBUG`, а настоящий отказ хранилища — уровнем `ERROR`. + +Предупреждения измерения — противоречащий род и данные, помеченные будущим, — +остаются привилегией каталога: маршрут точек их MUST NOT повторять. Агент +опрашивает по расписанию, и `WARN` на каждый опрос обесценил бы уровень ровно +так же, как обесценила бы его строка на каждый `304`. + +#### Scenario: Клиент оборвал запрос + +- **WHEN** потребитель обрывает запрос точек по своему тайм-ауту +- **THEN** запись об этом уходит уровнем `DEBUG`, а не `ERROR` + +#### Scenario: Метрика с противоречащим родом запрошена многократно + +- **WHEN** потребитель повторно запрашивает точки метрики, у которой род + противоречив +- **THEN** маршрут точек предупреждений об этом не пишет + +#### Scenario: Тело ответа оборвалось на середине + +- **WHEN** запись тела ответа отказала после того, как код ответа уже отдан +- **THEN** отказ записи оставляет собственный чекпоинт и не проходит молча под + видом успешного ответа diff --git a/openspec/specs/read-api/spec.md b/openspec/specs/read-api/spec.md index fd097ca..de81299 100644 --- a/openspec/specs/read-api/spec.md +++ b/openspec/specs/read-api/spec.md @@ -109,3 +109,41 @@ MUST NOT подменяться нулевым значением своего - **THEN** оно уезжает как `null`, а не как нулевая метка времени, пустая строка или ноль +### Requirement: Сериализация ответа чтения не экранирует содержимое + +Система SHALL сериализовать тела ответов читающих маршрутов **без экранирования +HTML-символов**: `&`, `<` и `>` внутри значений MUST уезжать клиенту как есть и +MUST NOT подменяться escape-последовательностями `&`, `<`, `>`. + +Правило общее для всех читающих маршрутов, а не частное для точек, потому что +общим является механизм: тело собирает один помощник сериализации, и умолчание +`encoding/json` экранирует эти три символа молча. На маршруте, отдающем +**дословно сохранённое** содержимое, это прямо ломает обещание дословности: +имя источника приходит с телефона пользовательской строкой и законно содержит +`&`. Хранилище этот же капкан уже проходило и обезвредило тем же способом — +кодировщик с выключенным экранированием вместо `json.Marshal`. + +Проверка обязана стоять на содержимом, реально несущем эти символы: набор +фикстур, в котором их нет, зелен и будучи сломанным. + +#### Scenario: Значение несёт символ, который сериализатор склонен экранировать + +- **GIVEN** значение ответа читающего маршрута, содержащее `&`, `<` или `>` +- **WHEN** маршрут отвечает +- **THEN** эти символы присутствуют в теле ответа как есть + +### Requirement: Ответ чтения непригоден для разделяемого кеша + +Система SHALL помечать ответы читающих маршрутов заголовком +`Cache-Control: private, no-cache`. + +Правило общее, потому что цена у него одна на все маршруты чтения: с появлением +валидатора ответ становится штатно кешируемым, а при выключенной проверке +токенов — законной конфигурации для доверенной локальной сети — в запросе нет и +`Authorization`. Тогда выгрузку истории здоровья вправе сохранить любой прокси +на пути. Маршрут, решающий это заново, однажды решит иначе. + +#### Scenario: Читающий маршрут ответил + +- **WHEN** читающий маршрут отдаёт тело +- **THEN** ответ несёт `Cache-Control: private, no-cache`