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,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 в гипотезы названы поимённо выше.