Files
av 29ca8d415c httpapi: точки метрики за период отдаются одним запросом
- `GET /api/v1/metrics/{name}?from&to&layer` — ряд точек за период; конверт
  объявляет слой, измеренный род, его применимость к отданному ряду и границу
  окна измерения, а сам ряд собирается из одного слоя, выбранного по охвату
  точек внутри периода
- use-case вынесен в `internal/points`, чтение — одним входом `store.ReadSeries`
  под одной транзакцией; правило выбора слоя остаётся в домене и приходит в
  хранилище колбэком
- `writeJSON` перестал экранировать HTML-символы и перестал глушить отказ
  записи: дословность содержимого точки иначе не удерживается, а оборванное
  тело уходило под видом успешного `200`
2026-08-04 18:46:45 +03:00

269 lines
24 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Триаж ревью: точки метрики за период
## Сводка
- **Профиль:** `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 в гипотезы названы поимённо выше.