- `GET /api/v1/metrics/{name}?from&to&layer` — ряд точек за период; конверт
объявляет слой, измеренный род, его применимость к отданному ряду и границу
окна измерения, а сам ряд собирается из одного слоя, выбранного по охвату
точек внутри периода
- use-case вынесен в `internal/points`, чтение — одним входом `store.ReadSeries`
под одной транзакцией; правило выбора слоя остаётся в домене и приходит в
хранилище колбэком
- `writeJSON` перестал экранировать HTML-символы и перестал глушить отказ
записи: дословность содержимого точки иначе не удерживается, а оборванное
тело уходило под видом успешного `200`
81 lines
6.5 KiB
Markdown
81 lines
6.5 KiB
Markdown
# Слой ответа выбирается по охвату точек внутри периода
|
||
|
||
- **Дата:** 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`.
|
||
|
||
**Рекомендация:** (а). Решение сцеплено с формой предела, и принимать его
|
||
мимоходом на первой ручке — то же, от чего отказались на каталоге.
|