httpapi: точки метрики за период отдаются одним запросом
- `GET /api/v1/metrics/{name}?from&to&layer` — ряд точек за период; конверт
объявляет слой, измеренный род, его применимость к отданному ряду и границу
окна измерения, а сам ряд собирается из одного слоя, выбранного по охвату
точек внутри периода
- use-case вынесен в `internal/points`, чтение — одним входом `store.ReadSeries`
под одной транзакцией; правило выбора слоя остаётся в домене и приходит в
хранилище колбэком
- `writeJSON` перестал экранировать HTML-символы и перестал глушить отказ
записи: дословность содержимого точки иначе не удерживается, а оборванное
тело уходило под видом успешного `200`
This commit is contained in:
@@ -20,6 +20,7 @@ import (
|
|||||||
"git.vakhrushev.me/av/healthlog/internal/httpapi"
|
"git.vakhrushev.me/av/healthlog/internal/httpapi"
|
||||||
"git.vakhrushev.me/av/healthlog/internal/ingest"
|
"git.vakhrushev.me/av/healthlog/internal/ingest"
|
||||||
"git.vakhrushev.me/av/healthlog/internal/logging"
|
"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/replay"
|
||||||
"git.vakhrushev.me/av/healthlog/internal/store"
|
"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{
|
Handler: httpapi.New(httpapi.Options{
|
||||||
Ingest: ingest.New(arch, st, worker.Notify, log),
|
Ingest: ingest.New(arch, st, worker.Notify, log),
|
||||||
Catalog: catalog.New(st, log),
|
Catalog: catalog.New(st, log),
|
||||||
|
Points: points.New(st, log),
|
||||||
Log: log,
|
Log: log,
|
||||||
WriteTokens: cfg.Auth.WriteTokens,
|
WriteTokens: cfg.Auth.WriteTokens,
|
||||||
ReadTokens: cfg.Auth.ReadTokens,
|
ReadTokens: cfg.Auth.ReadTokens,
|
||||||
|
|||||||
+1
-1
@@ -26,7 +26,7 @@ write_timeout = "30s" # на отправку ответа прочих ма
|
|||||||
# входе, открытое чтение — выгрузку всей истории здоровья любому, кто нашёл
|
# входе, открытое чтение — выгрузку всей истории здоровья любому, кто нашёл
|
||||||
# порт. Перед выкладкой наружу `read_tokens` обязан быть непуст.
|
# порт. Перед выкладкой наружу `read_tokens` обязан быть непуст.
|
||||||
write_tokens = [] # токены на приём данных
|
write_tokens = [] # токены на приём данных
|
||||||
read_tokens = [] # токены на чтение: каталог `GET /api/v1/metrics`
|
read_tokens = [] # токены на чтение: каталог `GET /api/v1/metrics` и точки `GET /api/v1/metrics/{name}`
|
||||||
|
|
||||||
[storage]
|
[storage]
|
||||||
# ВНИМАНИЕ: умолчания в коде (./healthlog.db и ./raw) остались от прежней
|
# ВНИМАНИЕ: умолчания в коде (./healthlog.db и ./raw) остались от прежней
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|
||||||
|
**Рекомендация:** (а). Решение сцеплено с формой предела, и принимать его
|
||||||
|
мимоходом на первой ручке — то же, от чего отказались на каталоге.
|
||||||
@@ -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)
|
- [ADR-2026-08-04-forma-provoda-prinadlezhit-transportu](ADR-2026-08-04-forma-provoda-prinadlezhit-transportu.md)
|
||||||
— публичный контракт чтения объявляет транспорт, а не домен; «доменные типы и
|
— публичный контракт чтения объявляет транспорт, а не домен; «доменные типы и
|
||||||
есть форма провода» (`wtf`, Prometheus) отвергнуто фактом — поля `store.Point`
|
есть форма провода» (`wtf`, Prometheus) отвергнуто фактом — поля `store.Point`
|
||||||
|
|||||||
+61
-15
@@ -230,7 +230,8 @@ capability**, и здесь стоит ссылка, а не пересказ т
|
|||||||
| `replay` | проигрывание журнала в витрину: состав, порядок, отчёт | [`reindex`](../openspec/specs/reindex/spec.md) |
|
| `replay` | проигрывание журнала в витрину: состав, порядок, отчёт | [`reindex`](../openspec/specs/reindex/spec.md) |
|
||||||
| `catalog` | каталог разрезов и измерение рода агрегации | [`catalog`](../openspec/specs/catalog/spec.md) |
|
| `catalog` | каталог разрезов и измерение рода агрегации | [`catalog`](../openspec/specs/catalog/spec.md) |
|
||||||
| `store` | SQLite: доставки, часовые объекты, тренировки, записи | [`storage`](../openspec/specs/storage/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`). Иначе точки не сохраняются вовсе:
|
(`Minutes` → `minute`, `Hours` → `hour`). Иначе точки не сохраняются вовсе:
|
||||||
молчаливый `raw` создал бы призрачный разрез, который поедет в каталог и в
|
молчаливый `raw` создал бы призрачный разрез, который поедет в каталог и в
|
||||||
правило Read API «самый мелкий слой, покрывающий диапазон».
|
правило Read API выбора слоя (см. «Read API»).
|
||||||
|
|
||||||
Слово «предшествующей» в третьем пункте несёт вес: слой обязан быть функцией от
|
Слово «предшествующей» в третьем пункте несёт вес: слой обязан быть функцией от
|
||||||
**префикса журнала**. Наследование от последней доставки вообще делает свёртку
|
**префикса журнала**. Наследование от последней доставки вообще делает свёртку
|
||||||
@@ -1446,7 +1447,7 @@ MongoDB, и так просилось из слова «перезаписыва
|
|||||||
|
|
||||||
```
|
```
|
||||||
GET /api/v1/metrics каталог: имя, units, род, слои с диапазонами
|
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?from&to заголовки тренировок
|
||||||
GET /api/v1/workouts/{id} тренировка целиком, с маршрутом
|
GET /api/v1/workouts/{id} тренировка целиком, с маршрутом
|
||||||
GET /api/v1/records/{kind}?from&to прочие секции
|
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
|
```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": [
|
"points": [
|
||||||
{"ts": "2026-07-31T09:00:00Z", "tz_offset": 10800, "units": "count",
|
{"ts": "2026-07-31T09:00:00Z", "ts_end": "2026-07-31T09:00:00Z",
|
||||||
"values": {"qty": 812}}
|
"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
|
||||||
|
много и они разные — семантику разбирает клиент по имени метрики. Полная
|
||||||
|
нормализация означала бы, что каждая новая метрика требует правки коллектора,
|
||||||
|
а незнакомая теряется.
|
||||||
|
|
||||||
### Форма провода
|
### Форма провода
|
||||||
|
|
||||||
|
|||||||
@@ -80,3 +80,31 @@
|
|||||||
перестаёт молча. Проверено на закреплённом драйвере: одна испорченная строка
|
перестаёт молча. Проверено на закреплённом драйвере: одна испорченная строка
|
||||||
`delivery.uncovered_sections` обесценивала и сверку новизны (вечное «сверка не
|
`delivery.uncovered_sections` обесценивала и сверку новизны (вечное «сверка не
|
||||||
состоялась» на каждой доставке), и перечень целиком.
|
состоялась» на каждой доставке), и перечень целиком.
|
||||||
|
|
||||||
|
## Предикат выбора источника и предикат отбора данных — одна граница
|
||||||
|
|
||||||
|
Объекты витрины адресуются часом, а точки отбираются точной меткой. Выборка
|
||||||
|
объектов поэтому обязана быть **шире** запроса (точка `10:59` живёт в объекте
|
||||||
|
`10:00`) — и ровно здесь появляется разрыв: множество «слои, у которых есть
|
||||||
|
объекты в периоде» не совпадает с множеством «слои, у которых есть точки в
|
||||||
|
периоде».
|
||||||
|
|
||||||
|
Правило: **решение о том, откуда брать данные, принимается по той же границе, по
|
||||||
|
которой данные потом отбираются.** Иначе узел выбирает источник, в котором после
|
||||||
|
точного отбора не остаётся ничего, и отдаёт пустоту при непустых данных
|
||||||
|
соседнего источника — молча, потому что и выбор, и отбор по отдельности верны.
|
||||||
|
|
||||||
|
Прецедент: правило выбора слоя в Read API мерило охват часами объектов, а ряд
|
||||||
|
отбирало метками точек; на периоде короче часа ответ уходил пустым при непустых
|
||||||
|
минутных данных (ADR-2026-08-04-sloy-vybiraetsya-po-ohvatu-tochek).
|
||||||
|
|
||||||
|
## Значение из чужого тела имеет предел длины у КАЖДОГО адресата
|
||||||
|
|
||||||
|
Правило `docs/security.md` про предел длины читается как «в ключ, в лог, в
|
||||||
|
отчёт» — и адресаты кончаются не там. Имя метрики уезжает ещё и в заголовок
|
||||||
|
ответа: без предела `ETag` растёт вместе с именем, а кавычка внутри имени по
|
||||||
|
RFC 9110 кончает метку, и условный запрос по такой метрике не сработает никогда.
|
||||||
|
|
||||||
|
Когда предел неудобен (значение нужно целиком), его заменяет **форма**: в метку
|
||||||
|
уезжает хеш канонизированной строки, а не строка. Хеш здесь не секрет — он
|
||||||
|
ограничитель длины и экранирование разом.
|
||||||
|
|||||||
@@ -142,6 +142,13 @@
|
|||||||
|
|
||||||
**Перестали проверять сознательно.**
|
**Перестали проверять сознательно.**
|
||||||
|
|
||||||
|
- **Шаг покрытия диффа гейт не красит.** `CLAUDE.md` объявляет, что непокрытая
|
||||||
|
изменённая строка красит гейт безусловно; `scripts/diff-coverage.py` всегда
|
||||||
|
возвращает `0`, и шаг печатает `OK` при любом покрытии. То есть «гейт зелёный»
|
||||||
|
не означает «покрытие диффа полное», и разбор непокрытых строк остаётся
|
||||||
|
человеку или проходу. Найдено проходом `gate` 2026-08-04, подтверждено
|
||||||
|
триажем; чинить нельзя мимоходом — починка немедленно красит гейт задачи, в
|
||||||
|
которой её сделали.
|
||||||
- Прогон живого архива (`task verify:archive`) и свёртка под удерживаемой
|
- Прогон живого архива (`task verify:archive`) и свёртка под удерживаемой
|
||||||
блокировкой (`task verify:busy`) в гейт не входят: минута и около 50 секунд
|
блокировкой (`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`.
|
||||||
|
|
||||||
Реализованные задачи, находки ревью и решения сюда не пишутся: у них есть
|
Реализованные задачи, находки ревью и решения сюда не пишутся: у них есть
|
||||||
коммит, спека и задача. Здесь только промахи конвейера и решения о его составе.
|
коммит, спека и задача. Здесь только промахи конвейера и решения о его составе.
|
||||||
|
|
||||||
|
|||||||
+11
-3
@@ -61,9 +61,17 @@ disabled`, `read auth disabled`), но стартовать не отказыв
|
|||||||
**Ни один сегмент пути не берётся из тела или заголовков доставки** — это и
|
**Ни один сегмент пути не берётся из тела или заголовков доставки** — это и
|
||||||
есть защита от выхода за пределы каталога, и она держится ровно на этом.
|
есть защита от выхода за пределы каталога, и она держится ровно на этом.
|
||||||
- **Координатный ключ точки** — `метрика + слой + начало + конец`. Имя метрики
|
- **Координатный ключ точки** — `метрика + слой + начало + конец`. Имя метрики
|
||||||
приходит из тела и в путь на диске не попадает, но попадает в ключ, в лог и в
|
приходит из тела и в путь на диске не попадает, но попадает в ключ, в лог, в
|
||||||
ответ каталога. Любое значение из чужого JSON, попадающее в ключ, в лог или в
|
ответ каталога и — с появлением маршрута точек — **в адрес запроса и в
|
||||||
отчёт, имеет названный предел длины.
|
заголовок `ETag` ответа**. Любое значение из чужого JSON, попадающее в ключ, в
|
||||||
|
лог, в отчёт или в заголовок, имеет названный предел длины. У метки ответа
|
||||||
|
предел взят формой: в неё уезжает не имя, а хеш канонизированной формы запроса
|
||||||
|
(128 бит). Причина не только в длине — имя законно содержит кавычку, которая
|
||||||
|
по RFC 9110 кончает метку, и разбор обрезал бы её ровно там.
|
||||||
|
- **Имя метрики в адресе** декодируется из пути **ровно один раз**. Второе
|
||||||
|
декодирование превращает имя `a%41b` в имя `aAb` — то есть в имя **другой**
|
||||||
|
метрики витрины, и маршрут отвечает `200` её данными. Путь построен и прогнан
|
||||||
|
враждебным проходом ревью.
|
||||||
- **Ключ сущности** — `род секции + id` из HealthKit для `record`, `id` для
|
- **Ключ сущности** — `род секции + id` из HealthKit для `record`, `id` для
|
||||||
`workout`. `id` приходит из тела.
|
`workout`. `id` приходит из тела.
|
||||||
- **Ключ наблюдённого категориального значения** — `метрика + поле + значение`.
|
- **Ключ наблюдённого категориального значения** — `метрика + поле + значение`.
|
||||||
|
|||||||
+40
-16
@@ -147,7 +147,12 @@ func closeEnough(a, b float64) bool {
|
|||||||
return math.Abs(a-b)/math.Max(math.Abs(a), math.Abs(b)) <= tolerance
|
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 {
|
if len(metric) <= maxMetricInLog {
|
||||||
return metric
|
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)
|
s.log.DebugContext(ctx, "state version unavailable", "capability", "query", "error", err)
|
||||||
return "", 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 == "" {
|
if version == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -287,19 +318,12 @@ func New(st *store.Store, log *slog.Logger) *Service {
|
|||||||
// её хранилище — двумя пробами вокруг чтения. Порядок проб там же и объяснён:
|
// её хранилище — двумя пробами вокруг чтения. Порядок проб там же и объяснён:
|
||||||
// версия, снятая после чтения, пометила бы устаревший снимок свежей меткой.
|
// версия, снятая после чтения, пометила бы устаревший снимок свежей меткой.
|
||||||
func (s *Service) Metrics(ctx context.Context) (Snapshot, error) {
|
func (s *Service) Metrics(ctx context.Context) (Snapshot, error) {
|
||||||
horizon := store.Now().Add(horizonSlack)
|
horizon := Horizon()
|
||||||
|
|
||||||
var snap store.CatalogSnapshot
|
var snap store.CatalogSnapshot
|
||||||
version, err := s.store.VersionedRead(ctx, func(ctx context.Context) error {
|
version, err := s.store.VersionedRead(ctx, func(ctx context.Context) error {
|
||||||
var err error
|
var err error
|
||||||
snap, err = s.store.ReadCatalog(ctx, store.CatalogWindow{
|
snap, err = s.store.ReadCatalog(ctx, MeasureWindow(horizon))
|
||||||
Fine: string(hae.LayerMinute),
|
|
||||||
Coarse: string(hae.LayerHour),
|
|
||||||
Hours: Window,
|
|
||||||
Horizon: horizon,
|
|
||||||
CoarsePoints: coarsePoints,
|
|
||||||
MinFinePoints: minFinePoints,
|
|
||||||
})
|
|
||||||
return err
|
return err
|
||||||
})
|
})
|
||||||
if err != nil { //nolint:nestif // ветка одна, вложенность даёт лог по адресату
|
if err != nil { //nolint:nestif // ветка одна, вложенность даёт лог по адресату
|
||||||
@@ -331,7 +355,7 @@ func (s *Service) Metrics(ctx context.Context) (Snapshot, error) {
|
|||||||
if basis.Conflicting > 0 {
|
if basis.Conflicting > 0 {
|
||||||
s.log.WarnContext(ctx, "aggregation style conflict",
|
s.log.WarnContext(ctx, "aggregation style conflict",
|
||||||
"capability", "query",
|
"capability", "query",
|
||||||
"metric", clipMetric(group.metric),
|
"metric", ClipMetric(group.metric),
|
||||||
"hours", basis.Hours,
|
"hours", basis.Hours,
|
||||||
"compared", basis.Compared,
|
"compared", basis.Compared,
|
||||||
"agreeing", basis.Agreeing,
|
"agreeing", basis.Agreeing,
|
||||||
@@ -344,7 +368,7 @@ func (s *Service) Metrics(ctx context.Context) (Snapshot, error) {
|
|||||||
if to := group.latest(); to.After(horizon) {
|
if to := group.latest(); to.After(horizon) {
|
||||||
s.log.WarnContext(ctx, "future data",
|
s.log.WarnContext(ctx, "future data",
|
||||||
"capability", "query",
|
"capability", "query",
|
||||||
"metric", clipMetric(group.metric),
|
"metric", ClipMetric(group.metric),
|
||||||
"last_ts", store.FormatTime(to),
|
"last_ts", store.FormatTime(to),
|
||||||
"horizon", store.FormatTime(horizon))
|
"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")
|
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 {
|
type metricGroup struct {
|
||||||
|
|||||||
@@ -520,3 +520,29 @@ func TestКаталогОтдаётсяСВерсиейВитрины(t *testing
|
|||||||
t.Error("каталог собран на стоящей витрине и остался без версии")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,16 +15,16 @@ func TestГоризонтВходитВВерсиюОтвета(t *testing.T) {
|
|||||||
|
|
||||||
at := time.Date(2026, 6, 1, 10, 30, 0, 0, time.UTC)
|
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("версия не изменилась при сдвиге горизонта на два часа")
|
t.Error("версия не изменилась при сдвиге горизонта на два часа")
|
||||||
}
|
}
|
||||||
// Огрубление до часа точное, а не приблизительное: `hour_utc` объектов лежит
|
// Огрубление до часа точное, а не приблизительное: `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("версия сдвинулась внутри одного часа — метка дребезжит на месте")
|
t.Error("версия сдвинулась внутри одного часа — метка дребезжит на месте")
|
||||||
}
|
}
|
||||||
if stamp("", at) != "" {
|
if Stamp("", at) != "" {
|
||||||
t.Error("пустая версия витрины подписана горизонтом — подписывать нечем")
|
t.Error("пустая версия витрины подписана горизонтом — подписывать нечем")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-15
@@ -856,25 +856,36 @@ func headerLayer(aggregation string) Layer {
|
|||||||
|
|
||||||
// finer возвращает более мелкий из двух слоёв.
|
// finer возвращает более мелкий из двух слоёв.
|
||||||
func finer(a, b Layer) Layer {
|
func finer(a, b Layer) Layer {
|
||||||
if rank(a) < rank(b) {
|
if Rank(a) < Rank(b) {
|
||||||
return a
|
return a
|
||||||
}
|
}
|
||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
func rank(l Layer) int {
|
// Layers — ЕДИНСТВЕННЫЙ словарь слоёв, упорядоченный от самого мелкого к самому
|
||||||
switch l {
|
// крупному.
|
||||||
case LayerSample:
|
//
|
||||||
return 0
|
// Один, потому что иначе их становится четыре: порядок для разбора, перечень
|
||||||
case LayerRaw:
|
// для выборки охватов, проверка параметра запроса и текст отказа клиенту. Ни
|
||||||
return 1
|
// компилятор, ни тест их не сверяют — новая константа `Layer` скомпилировалась
|
||||||
case LayerMinute:
|
// бы, получила бы ранг «крупнее всех», не попала бы в выборку охватов и
|
||||||
return 2
|
// отвергалась бы маршрутом как незнакомая. Симптомом был бы пустой ряд при
|
||||||
case LayerHour:
|
// непустых данных. Случай не гипотетический: слой `sample` наполнится импортом
|
||||||
return 3
|
// родного экспорта Apple.
|
||||||
case LayerDay:
|
//
|
||||||
return 4
|
// Срез, а не карта: порядок здесь и есть содержание.
|
||||||
default:
|
var Layers = []Layer{LayerSample, LayerRaw, LayerMinute, LayerHour, LayerDay}
|
||||||
return 5
|
|
||||||
|
// 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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -786,3 +786,37 @@ func TestParseЧужаяПричинаОбрезается(t *testing.T) {
|
|||||||
t.Errorf("текст ошибки %d Б: литерал из тела доехал до сообщения", len(err.Error()))
|
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("незнакомый слой признан словарём")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -159,5 +159,8 @@ func (a *api) handleMetrics(w http.ResponseWriter, r *http.Request) {
|
|||||||
// ответ уходит без метки: это ровно поведение до появления условного
|
// ответ уходит без метки: это ровно поведение до появления условного
|
||||||
// запроса, то есть деградация в безопасную сторону.
|
// запроса, то есть деградация в безопасную сторону.
|
||||||
setReadHeaders(w, etag(scopeMetrics, snap.Version))
|
setReadHeaders(w, etag(scopeMetrics, snap.Version))
|
||||||
writeJSON(w, http.StatusOK, catalogWire(snap.Metrics))
|
// Обрыв записи каталога глушится: ответ здесь — три десятка строк, и
|
||||||
|
// оборваться на нём нечему. У маршрута точек ряд ничем не ограничен, и там
|
||||||
|
// отказ записи наблюдается отдельным чекпоинтом.
|
||||||
|
_ = writeJSON(w, http.StatusOK, catalogWire(snap.Metrics))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,12 +16,14 @@ import (
|
|||||||
|
|
||||||
"git.vakhrushev.me/av/healthlog/internal/catalog"
|
"git.vakhrushev.me/av/healthlog/internal/catalog"
|
||||||
"git.vakhrushev.me/av/healthlog/internal/ingest"
|
"git.vakhrushev.me/av/healthlog/internal/ingest"
|
||||||
|
"git.vakhrushev.me/av/healthlog/internal/points"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Options — зависимости и настройки транспорта.
|
// Options — зависимости и настройки транспорта.
|
||||||
type Options struct {
|
type Options struct {
|
||||||
Ingest *ingest.Service
|
Ingest *ingest.Service
|
||||||
Catalog *catalog.Service
|
Catalog *catalog.Service
|
||||||
|
Points *points.Service
|
||||||
Log *slog.Logger
|
Log *slog.Logger
|
||||||
WriteTokens []string
|
WriteTokens []string
|
||||||
ReadTokens []string
|
ReadTokens []string
|
||||||
@@ -35,6 +37,7 @@ type Options struct {
|
|||||||
type api struct {
|
type api struct {
|
||||||
ingest *ingest.Service
|
ingest *ingest.Service
|
||||||
catalog *catalog.Service
|
catalog *catalog.Service
|
||||||
|
points *points.Service
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
writeTokens []string
|
writeTokens []string
|
||||||
readTokens []string
|
readTokens []string
|
||||||
@@ -47,6 +50,7 @@ func New(o Options) http.Handler {
|
|||||||
a := &api{
|
a := &api{
|
||||||
ingest: o.Ingest,
|
ingest: o.Ingest,
|
||||||
catalog: o.Catalog,
|
catalog: o.Catalog,
|
||||||
|
points: o.Points,
|
||||||
log: o.Log,
|
log: o.Log,
|
||||||
writeTokens: o.WriteTokens,
|
writeTokens: o.WriteTokens,
|
||||||
readTokens: o.ReadTokens,
|
readTokens: o.ReadTokens,
|
||||||
@@ -62,6 +66,10 @@ func New(o Options) http.Handler {
|
|||||||
r.Route("/api/v1", func(r chi.Router) {
|
r.Route("/api/v1", func(r chi.Router) {
|
||||||
r.With(requireToken(a.writeTokens)).Post("/ingest", a.handleIngest)
|
r.With(requireToken(a.writeTokens)).Post("/ingest", a.handleIngest)
|
||||||
r.With(requireToken(a.readTokens)).Get("/metrics", a.handleMetrics)
|
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
|
return r
|
||||||
}
|
}
|
||||||
@@ -152,10 +160,27 @@ func routePattern(r *http.Request) string {
|
|||||||
return r.URL.Path
|
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.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
_ = json.NewEncoder(w).Encode(v)
|
enc := json.NewEncoder(w)
|
||||||
|
enc.SetEscapeHTML(false)
|
||||||
|
return enc.Encode(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
// errorWire — форма провода тела отказа, общая для всех маршрутов.
|
// errorWire — форма провода тела отказа, общая для всех маршрутов.
|
||||||
@@ -173,5 +198,7 @@ type errorWire struct {
|
|||||||
// writeError отдаёт человекочитаемое сообщение, а не текст ошибки: в тексте
|
// writeError отдаёт человекочитаемое сообщение, а не текст ошибки: в тексте
|
||||||
// имена колонок и форма запроса.
|
// имена колонок и форма запроса.
|
||||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||||
writeJSON(w, status, errorWire{Error: msg})
|
// Тело отказа — десятки байт: оборваться на нём нечему, и сообщать о
|
||||||
|
// таком обрыве было бы шумом.
|
||||||
|
_ = writeJSON(w, status, errorWire{Error: msg})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
"git.vakhrushev.me/av/healthlog/internal/catalog"
|
"git.vakhrushev.me/av/healthlog/internal/catalog"
|
||||||
"git.vakhrushev.me/av/healthlog/internal/httpapi"
|
"git.vakhrushev.me/av/healthlog/internal/httpapi"
|
||||||
"git.vakhrushev.me/av/healthlog/internal/ingest"
|
"git.vakhrushev.me/av/healthlog/internal/ingest"
|
||||||
|
"git.vakhrushev.me/av/healthlog/internal/points"
|
||||||
"git.vakhrushev.me/av/healthlog/internal/store"
|
"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{
|
h := httpapi.New(httpapi.Options{
|
||||||
Ingest: ingest.New(arch, st, nil, log),
|
Ingest: ingest.New(arch, st, nil, log),
|
||||||
Catalog: cat,
|
Catalog: cat,
|
||||||
|
Points: points.New(st, log),
|
||||||
Log: log,
|
Log: log,
|
||||||
WriteTokens: writeTokens,
|
WriteTokens: writeTokens,
|
||||||
ReadTokens: readTokens,
|
ReadTokens: readTokens,
|
||||||
|
|||||||
@@ -42,7 +42,9 @@ func (a *api) handleIngest(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, ingestResponse{
|
// Ответ приёма — три поля; обрыв записи на нём означает ушедшего клиента, а
|
||||||
|
// доставка уже сохранена, и это единственное, что здесь обещано.
|
||||||
|
_ = writeJSON(w, http.StatusOK, ingestResponse{
|
||||||
DeliveryID: res.DeliveryID,
|
DeliveryID: res.DeliveryID,
|
||||||
Bytes: res.Bytes,
|
Bytes: res.Bytes,
|
||||||
SHA256: res.SHA256,
|
SHA256: res.SHA256,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 <A&B>"}`
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -101,6 +101,7 @@ func foreignTypes(t reflect.Type) []string {
|
|||||||
func TestФормаПроводаДоменаНеСодержит(t *testing.T) {
|
func TestФормаПроводаДоменаНеСодержит(t *testing.T) {
|
||||||
cases := map[string]any{
|
cases := map[string]any{
|
||||||
"каталог": catalogResponse{},
|
"каталог": catalogResponse{},
|
||||||
|
"точки": pointsResponse{},
|
||||||
"тело отказа": errorWire{},
|
"тело отказа": errorWire{},
|
||||||
"учёт приёма": ingestResponse{},
|
"учёт приёма": ingestResponse{},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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("пустой словарь слоёв принят молча")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-08-04
|
||||||
@@ -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<?)`, а замер
|
||||||
|
на том же корпусе — 0.026 мс.
|
||||||
|
|
||||||
|
Смены слоя **внутри одного ответа не бывает**: ряд, склеенный из двух слоёв,
|
||||||
|
поехал бы незаметно для клиента, а вместе с ним поехала бы и будущая свёртка.
|
||||||
|
|
||||||
|
Явный `layer` отменяет правило целиком и **всегда уезжает в ответе** — в том
|
||||||
|
числе при пустом ряде: клиент, спросивший разрез поимённо, обязан отличать «за
|
||||||
|
период этого разреза нет» от «параметр проигнорирован». `layer: null` остаётся
|
||||||
|
исключительно за случаем «выбирала система, и выбирать было не из чего».
|
||||||
|
|
||||||
|
**Prior art:** **Netdata** объявляет в конверте `update_every` (разрешение
|
||||||
|
хранения) отдельно от `view_update_every` (разрешение выдачи) и рядом
|
||||||
|
`first_entry`/`last_entry` — границы того, что вообще есть в базе. Взято
|
||||||
|
разделение: `layer` (что лежит) и `bucket` (что сделано в ответе) — разные поля,
|
||||||
|
а не одно. Не взяты `first_entry`/`last_entry` в конверт точек: границы данных
|
||||||
|
метрики отдаёт каталог, а границы **отданного ряда** клиент читает по первой и
|
||||||
|
последней точке — второй их экземпляр в конверте был бы полем, вычислимым из
|
||||||
|
соседнего поля того же ответа.
|
||||||
|
|
||||||
|
### Решение 4. Период — `[from, to)`, RFC 3339, оба параметра обязательны, `bucket` отвергается
|
||||||
|
|
||||||
|
- **Полуинтервал.** Соседние окна склеиваются без двойного счёта. Prometheus
|
||||||
|
`query_range` включает оба конца, CloudWatch `GetMetricData` — левый
|
||||||
|
включительно, правый исключительно; взят второй.
|
||||||
|
- **Только RFC 3339 с явной зоной.** Голая дата (`2026-01-01`) отвергается с
|
||||||
|
`400`: у неё нет зоны, а вопрос «в какой зоне считать сутки» в проекте открыт
|
||||||
|
отдельной задачей (`day-boundary-timezone`). Принять голую дату значило бы
|
||||||
|
**материализовать нерешённое** — молча выбрать зону за клиента.
|
||||||
|
- **Оба обязательны.** Умолчание «весь год» было бы ответом, размера которого
|
||||||
|
клиент не заказывал, а предела ответа ещё нет.
|
||||||
|
- `from >= 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`, а не отдаёт молча пустой ряд
|
||||||
|
(найдено триажем). Осталась половина на стороне приёма — доставка с такой
|
||||||
|
меткой в теле.
|
||||||
@@ -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.
|
||||||
@@ -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 в гипотезы названы поимённо выше.
|
||||||
@@ -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** отказ записи оставляет собственный чекпоинт и не проходит молча под
|
||||||
|
видом успешного ответа
|
||||||
@@ -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`
|
||||||
@@ -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`, а живой архив
|
||||||
|
основного репозитория трогать запрещено; триггер прогона
|
||||||
|
(изменение правила разбора, идентичности или слияния) не сработал — маршрут
|
||||||
|
только читает витрину. Названо строкой в границах покрытия, а не замолчано
|
||||||
@@ -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** отказ записи оставляет собственный чекпоинт и не проходит молча под
|
||||||
|
видом успешного ответа
|
||||||
@@ -109,3 +109,41 @@ MUST NOT подменяться нулевым значением своего
|
|||||||
- **THEN** оно уезжает как `null`, а не как нулевая метка времени, пустая
|
- **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`
|
||||||
|
|||||||
Reference in New Issue
Block a user