- `GET /api/v1/metrics/{name}?from&to&layer` — ряд точек за период; конверт
объявляет слой, измеренный род, его применимость к отданному ряду и границу
окна измерения, а сам ряд собирается из одного слоя, выбранного по охвату
точек внутри периода
- use-case вынесен в `internal/points`, чтение — одним входом `store.ReadSeries`
под одной транзакцией; правило выбора слоя остаётся в домене и приходит в
хранилище колбэком
- `writeJSON` перестал экранировать HTML-символы и перестал глушить отказ
записи: дословность содержимого точки иначе не удерживается, а оборванное
тело уходило под видом успешного `200`
40 KiB
Context
Каталог (GET /api/v1/metrics) отвечает, что лежит в витрине. Значений он не
отдаёт: «вес за год» сегодня достаётся только sqlite3 на хосте.
Что уже решено и берётся, а не выбирается заново:
- Форма провода принадлежит транспорту —
ADR-2026-08-04.
Типы с
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" — строку с
применённой свёрткой. Этого мало: инвариант требует, чтобы клиент видел не
только применённое, но и на каком основании применять было можно.
"aggregation": {"style": "instant", "applicable": true, "last_hour": "2026-08-02T14:00:00Z"}
style— измеренный род; имя и словарь те же, что у каталога, потому что смысл тот же. Второе имя для того же понятия развело бы два маршрута молча.applicable— применим ли объявленный род к отданному ряду. Поле заведено находкой ревью и закрывает разрыв, который иначе стоил бы потребителю завышения втрое: род — свойство метрики, слой — свойство ряда, и конверт{"layer": "raw", "style": "cumulative"}законен, штатен и прямо приглашает агента сложить интерполяцию самому. Инвариант «нижний слой HAE не суммируется никогда» система соблюдает, ничего не складывая, — но потребитель об инварианте не знает, аapplicable: falseему об этом говорит. Prior art формы — CloudWatchGetMetricData, где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включает оба конца, CloudWatchGetMetricData— левый включительно, правый исключительно; взят второй. - Только 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» отвергнута тем же доводом, каким
измерение рода живёт в домене, а не в хранилище.
Внутри транзакции:
- Охваты слоёв —
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, содержимого не касается; словарь слоёв приходит из домена, и пустой словарь — отказ, а не пустой ответ: молчаливая деградация цены хуже отказа. - Точки выбранного слоя —
SELECT hour_utc, units, payload FROM bucket WHERE metric = ? AND layer = ? AND hour_utc BETWEEN ? AND ? ORDER BY hour_utc. Точный префикс первичного ключа;payloadздесь и нужен. - Окно измерения — существующие
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, а не отдаёт молча пустой ряд (найдено триажем). Осталась половина на стороне приёма — доставка с такой меткой в теле.