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

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