httpapi: точки метрики за период отдаются одним запросом
- `GET /api/v1/metrics/{name}?from&to&layer` — ряд точек за период; конверт
объявляет слой, измеренный род, его применимость к отданному ряду и границу
окна измерения, а сам ряд собирается из одного слоя, выбранного по охвату
точек внутри периода
- use-case вынесен в `internal/points`, чтение — одним входом `store.ReadSeries`
под одной транзакцией; правило выбора слоя остаётся в домене и приходит в
хранилище колбэком
- `writeJSON` перестал экранировать HTML-символы и перестал глушить отказ
записи: дословность содержимого точки иначе не удерживается, а оборванное
тело уходило под видом успешного `200`
This commit is contained in:
@@ -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`, а живой архив
|
||||
основного репозитория трогать запрещено; триггер прогона
|
||||
(изменение правила разбора, идентичности или слияния) не сработал — маршрут
|
||||
только читает витрину. Названо строкой в границах покрытия, а не замолчано
|
||||
Reference in New Issue
Block a user