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

40 KiB
Raw Blame History

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 формы — 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 формулировал правило как «самый мелкий слой, покрывающий весь запрошенный диапазон» и тут же оговаривал, что границы слоя — границы данных, а не обещание покрытия: внутри диапазона законно есть дыры, и слой, покрывающий диапазон целиком, может не существовать вовсе.

Взято правило, определённое на любом входе:

Охват слоя — длина пересечения отрезка [первая метка слоя, последняя метка слоя] с запрошенным периодом. Слой с пустым пересечением выбывает. Среди оставшихся берётся слой с наибольшим охватом, при равенстве — самый мелкий (порядок samplerawminutehourday).

Почему охват, а не число часов с объектами: 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 >= to400.
  • 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: []; layernull, только если выбирала система. Так отвечают и 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, а не отдаёт молча пустой ряд (найдено триажем). Осталась половина на стороне приёма — доставка с такой меткой в теле.