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,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`.
**Рекомендация:** (а). Решение сцеплено с формой предела, и принимать его
мимоходом на первой ручке — то же, от чего отказались на каталоге.