- `GET /api/v1/metrics/{name}?from&to&layer` — ряд точек за период; конверт
объявляет слой, измеренный род, его применимость к отданному ряду и границу
окна измерения, а сам ряд собирается из одного слоя, выбранного по охвату
точек внутри периода
- use-case вынесен в `internal/points`, чтение — одним входом `store.ReadSeries`
под одной транзакцией; правило выбора слоя остаётся в домене и приходит в
хранилище колбэком
- `writeJSON` перестал экранировать HTML-символы и перестал глушить отказ
записи: дословность содержимого точки иначе не удерживается, а оборванное
тело уходило под видом успешного `200`
418 lines
40 KiB
Markdown
418 lines
40 KiB
Markdown
## 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`, а не отдаёт молча пустой ряд
|
||
(найдено триажем). Осталась половина на стороне приёма — доставка с такой
|
||
меткой в теле.
|