httpapi: точки метрики за период отдаются одним запросом

- `GET /api/v1/metrics/{name}?from&to&layer` — ряд точек за период; конверт
  объявляет слой, измеренный род, его применимость к отданному ряду и границу
  окна измерения, а сам ряд собирается из одного слоя, выбранного по охвату
  точек внутри периода
- use-case вынесен в `internal/points`, чтение — одним входом `store.ReadSeries`
  под одной транзакцией; правило выбора слоя остаётся в домене и приходит в
  хранилище колбэком
- `writeJSON` перестал экранировать HTML-символы и перестал глушить отказ
  записи: дословность содержимого точки иначе не удерживается, а оборванное
  тело уходило под видом успешного `200`
This commit is contained in:
av
2026-08-04 18:46:45 +03:00
parent b819b77f62
commit 29ca8d415c
36 changed files with 4721 additions and 58 deletions
@@ -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`, то есть и контракт приёма, — сюда не взято.
@@ -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`.
**Рекомендация:** (а). Решение сцеплено с формой предела, и принимать его
мимоходом на первой ручке — то же, от чего отказались на каталоге.
+11
View File
@@ -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`