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,85 @@
|
||||
# Ответ точек несёт измеренный род и его применимость к отданному ряду
|
||||
|
||||
- **Дата:** 2026-08-04
|
||||
- **Источник:** openspec/changes/archive/2026-08-04-tochki-metriki-za-period/design.md
|
||||
|
||||
## Решение
|
||||
|
||||
Конверт ответа маршрута точек несёт `aggregation` **объектом**
|
||||
`{style, applicable, last_hour}`, а не строкой с применённой свёрткой:
|
||||
|
||||
- `style` — измеренный род метрики, тот же словарь и то же имя, что у каталога;
|
||||
- `applicable` — применим ли объявленный род к **отданному ряду**;
|
||||
- `last_hour` — ярлык самого свежего часа окна измерения.
|
||||
|
||||
`docs/architecture.md` до этого изменения обещал `"aggregation": "sum"` —
|
||||
строку. Решение её **пересматривает**: строка называет применённое и молчит об
|
||||
основании.
|
||||
|
||||
Отвергнуто и названо поимённо: поле `applied` с именем применённой свёртки
|
||||
(выводится из `style` и `bucket` тем же инвариантом; как строка неверно
|
||||
описывает свёртку мгновенной метрики, у которой по архитектуре «среднее с
|
||||
`min`/`max` рядом»); полное основание каталога (`hours`, `compared`, `agreeing`,
|
||||
`conflicting`, `first_hour`) в конверте точек — второй экземпляр факта, обязанный
|
||||
сходиться с первым.
|
||||
|
||||
## Почему
|
||||
|
||||
**Род есть свойство метрики, а слой — свойство ряда, и их сочетание бывает
|
||||
опасным.** Конверт `{"layer": "raw", "style": "cumulative"}` законен и штатен:
|
||||
правило выбора слоя при равном охвате предпочитает самый мелкий. Инвариант
|
||||
«нижний слой HAE не суммируется никогда» система соблюдает, ничего не складывая,
|
||||
— но потребитель об инварианте не знает, а сумма по нижнему слою завышает втрое
|
||||
(находка 34 разведки). Разрыв построен проходом `review-rubric` на предложении,
|
||||
до кода:
|
||||
|
||||
> Конверт `{"layer": "raw", "aggregation": {"style": "cumulative"}}` законен,
|
||||
> штатен — и он прямо приглашает главного потребителя (агента с ограниченным
|
||||
> контекстом) сложить ряд самому. Система при этом свёртки не делает, инвариант
|
||||
> формально цел; результат у потребителя завышен, а решение по нему уже принято.
|
||||
|
||||
`applicable: false` — та самая оговорка, которая едет вместе с данными.
|
||||
|
||||
**`last_hour` — единственный след замершего окна.** Род считается по 48 самым
|
||||
свежим **общим** часам, а не по последним 48 часам календаря: выключенная
|
||||
минутная автоматизация HAE останавливает пополнение общих часов, окно замирает и
|
||||
продолжает объявлять род.
|
||||
|
||||
**Литература расколота, и обе стороны названы.** Род **вместе с данными**:
|
||||
Google Cloud Monitoring объявляет `metricKind` и `valueType` в каждом объекте
|
||||
`TimeSeries` ответа, а не только в дескрипторе метрики; CloudWatch
|
||||
`GetMetricData` кладёт `StatusCode` (`Complete` / `PartialData`) рядом с рядом —
|
||||
оговорка едет с данными, а не оставляется клиенту на вывод; Home Assistant
|
||||
`statistics_during_period` держит `start` и `end` в ответе **всегда**,
|
||||
независимо от запрошенных `types`. Род **отдельно от данных**: Prometheus отдаёт
|
||||
`{resultType, result}` без единого слова о типе, а тип живёт в
|
||||
`/api/v1/metadata`; Graphite render не объявляет ничего. Второе отвергнуто по
|
||||
измеримой причине: клиент обязан сделать второй запрос, а до тех пор не
|
||||
отличает «род известен» от «род не измерен», — и согласованности между двумя
|
||||
ответами всё равно нет, потому что род есть функция **окна**, а окно едет с
|
||||
часами. Принцип HealthKit `HKStatistics` («род не тот — свёртки нет») взят,
|
||||
механизм неприменим: у нас стиль источником не объявлен.
|
||||
|
||||
## Последствия
|
||||
|
||||
- `+` Потребитель видит не только число, но и на каком основании его можно
|
||||
сворачивать, без второго запроса и без знания инвариантов проекта.
|
||||
- `+` Форма объявлена **до** того, как её скопируют свёртка по сетке, порог
|
||||
неполного ведра, тренировки, записи и MCP. После копирования это была бы не
|
||||
развилка, а археология.
|
||||
- `−` Поле `applicable` избыточно по построению: клиент, знающий правило «нижний
|
||||
слой HAE не суммируется», вывел бы его из `style` и `layer`. Взято сознательно
|
||||
— правило принадлежит нам, и молчаливо перекладывать его на потребителя
|
||||
дороже, чем поле.
|
||||
- `−` Чтобы разобрать, **почему** род `unknown`, придётся спросить каталог:
|
||||
полное основание живёт там в одном экземпляре.
|
||||
- `−` Род в конверте точек и род в каталоге считаются в разные моменты и у
|
||||
клиента, сравнивающего два ответа, могут разойтись. Это свойство измерения, а
|
||||
не дефект; ровно поэтому `last_hour` едет вместе с родом.
|
||||
|
||||
## Открыто, решает владелец
|
||||
|
||||
**Машинно-различимый код причины отказа.** Тело отказа несёт только
|
||||
человекочитаемую строку, и агент не отличит «зона не указана» от «слой
|
||||
незнаком» иначе, чем разбором русского текста. Правило общее для всех маршрутов
|
||||
и меняет `errorWire`, то есть и контракт приёма, — сюда не взято.
|
||||
@@ -0,0 +1,80 @@
|
||||
# Слой ответа выбирается по охвату точек внутри периода
|
||||
|
||||
- **Дата:** 2026-08-04
|
||||
- **Источник:** openspec/changes/archive/2026-08-04-tochki-metriki-za-period/design.md
|
||||
|
||||
## Решение
|
||||
|
||||
Слой, из которого собирается ряд, выбирается так:
|
||||
|
||||
> Охват слоя — длина пересечения отрезка `[первая метка слоя, последняя метка
|
||||
> слоя]` с запрошенным периодом. Слой с пустым пересечением выбывает. Среди
|
||||
> оставшихся берётся слой с наибольшим охватом, при равенстве — самый мелкий
|
||||
> (`sample` → `raw` → `minute` → `hour` → `day`).
|
||||
|
||||
Это **пересмотр** прежнего правила, записанного в `docs/architecture.md`: «самый
|
||||
мелкий слой, покрывающий весь запрошенный диапазон».
|
||||
|
||||
## Почему
|
||||
|
||||
**Прежняя формулировка неопределена на входе, который тот же документ объявляет
|
||||
законным.** Границы слоя — границы **данных**, а не обещание покрытия: внутри
|
||||
диапазона законно есть дыры, и слоя, покрывающего диапазон целиком, может не
|
||||
существовать вовсе. Правило, не определённое на законном входе, реализатор
|
||||
доопределяет молча.
|
||||
|
||||
**Мера — охват, а не число точек.** `body_mass` в нижнем слое за три плотных дня
|
||||
даёт больше объектов, чем часовой слой за год с еженедельным взвешиванием: по
|
||||
числу точек «вес за год» вернул бы три дня, не сказав об этом ни словом.
|
||||
|
||||
**Охват меряется метками точек, а не часами объектов**, и это не придирка.
|
||||
Объекты адресуются часом, поэтому выборка обязана быть шире запроса (точка
|
||||
`10:59` живёт в объекте `10:00`), а ряд отбирается точной меткой. Путь построен
|
||||
проходом ревью на предложении:
|
||||
|
||||
> `from = 10:30`, `to = 10:45`. Слой `hour` имеет объект `10:00` с единственной
|
||||
> точкой в `10:00`, слой `minute` — объект `10:00` с точками `10:31…10:44`. По
|
||||
> часам объектов охваты равны, побеждает `hour` — и после точного отбора ответ
|
||||
> уходит пустым при непустых минутных данных.
|
||||
|
||||
Класс общий: **предикат выбора источника и предикат отбора данных обязаны
|
||||
использовать одну границу**.
|
||||
|
||||
**Цена меры измерена, и она не нулевая.** Индекс `bucket_catalog` идёт
|
||||
`(metric, layer, hour_utc, …)`, и без предиката по слою SQLite не сужает поиск по
|
||||
`hour_utc` — он просматривает все строки метрики за всю историю, а план при этом
|
||||
выглядит успешным (`SEARCH … USING COVERING INDEX`). Замер эксплуатационного
|
||||
прохода на копии схемы: 2.06 мс при 52 560 строках метрики против 13.9 мс при
|
||||
350 400, то есть цена росла бы вместе с возрастом сервиса при любой ширине
|
||||
запроса. С явным перечислением слоёв — 0.026 мс. Отсюда же следствие: **словарь
|
||||
слоёв один** (`hae.Layers`), из него выводятся и порядок, и перечень выборки, и
|
||||
проверка параметра запроса, и текст отказа клиенту.
|
||||
|
||||
## Последствия
|
||||
|
||||
- `+` Правило определено на любом входе, включая тот, где ни один слой периода
|
||||
не покрывает.
|
||||
- `+` Смены слоя внутри одного ответа не бывает: ряд, склеенный из двух слоёв,
|
||||
поехал бы незаметно для клиента, а вместе с ним поехала бы и будущая свёртка.
|
||||
- `−` Правило **максимизирует** размер ответа: при равном охвате берётся самый
|
||||
мелкий слой, то есть «пульс за неделю» без параметров это сотни тысяч точек.
|
||||
Предел ответа — соседняя задача; цена измерена и названа (см. ниже).
|
||||
- `−` Краевой объект, у которого есть точки и до, и после периода, но ни одной
|
||||
внутри, свой слой из выбора не выведет. Остаток узкий и честный: слой в ответе
|
||||
назван, а `points` пуст.
|
||||
|
||||
## Открыто, решает владелец
|
||||
|
||||
**Инвертировать ли умолчание при равном охвате.** Сегодня берётся самый мелкий —
|
||||
это правило `architecture.md` до пересмотра, и оно максимизирует размер ответа.
|
||||
Измерено на этом маршруте: неделя нижнего слоя — 604 800 точек, 1.75 с и
|
||||
1375 МиБ суммарных выделений на доменном слое; под HTTP вместе с сериализацией —
|
||||
2.89 с, 279.7 МиБ тела, 1335 МиБ живой кучи; четыре одновременных запроса дают
|
||||
4322 МиБ.
|
||||
|
||||
- **(а)** оставить как есть, предел вводит `read-api-response-limit`;
|
||||
- **(б)** при равном охвате брать самый **крупный** слой, мелкий — только по
|
||||
явному `layer`.
|
||||
|
||||
**Рекомендация:** (а). Решение сцеплено с формой предела, и принимать его
|
||||
мимоходом на первой ручке — то же, от чего отказались на каталоге.
|
||||
@@ -33,6 +33,17 @@
|
||||
| Дата | Запись | Статус |
|
||||
| --- | --- | --- |
|
||||
|
||||
- [ADR-2026-08-04-otvet-tochek-nesyot-rod-i-ego-primenimost](ADR-2026-08-04-otvet-tochek-nesyot-rod-i-ego-primenimost.md)
|
||||
— конверт точек несёт измеренный род, его **применимость к отданному ряду** и
|
||||
границу окна измерения; строка `"aggregation": "sum"` пересмотрена, поле
|
||||
`applied` отвергнуто как выводимое; род вместе с данными взят у Google Cloud
|
||||
Monitoring и CloudWatch, отдельный `/metadata` Prometheus отвергнут.
|
||||
- [ADR-2026-08-04-sloy-vybiraetsya-po-ohvatu-tochek](ADR-2026-08-04-sloy-vybiraetsya-po-ohvatu-tochek.md)
|
||||
— «самый мелкий слой, покрывающий весь диапазон» пересмотрено: правило было
|
||||
неопределено на законном входе. Охват меряется метками **точек**, а не часами
|
||||
объектов, иначе период короче часа отдаёт пустой ряд при непустых данных;
|
||||
цена меры измерена (13.9 мс против 0.026 мс) и потребовала одного словаря
|
||||
слоёв.
|
||||
- [ADR-2026-08-04-forma-provoda-prinadlezhit-transportu](ADR-2026-08-04-forma-provoda-prinadlezhit-transportu.md)
|
||||
— публичный контракт чтения объявляет транспорт, а не домен; «доменные типы и
|
||||
есть форма провода» (`wtf`, Prometheus) отвергнуто фактом — поля `store.Point`
|
||||
|
||||
+61
-15
@@ -230,7 +230,8 @@ capability**, и здесь стоит ссылка, а не пересказ т
|
||||
| `replay` | проигрывание журнала в витрину: состав, порядок, отчёт | [`reindex`](../openspec/specs/reindex/spec.md) |
|
||||
| `catalog` | каталог разрезов и измерение рода агрегации | [`catalog`](../openspec/specs/catalog/spec.md) |
|
||||
| `store` | SQLite: доставки, часовые объекты, тренировки, записи | [`storage`](../openspec/specs/storage/spec.md) |
|
||||
| `httpapi` | приём, read API и **форма провода** ответов чтения | [`ingest`](../openspec/specs/ingest/spec.md), [`catalog`](../openspec/specs/catalog/spec.md), [`read-api`](../openspec/specs/read-api/spec.md) |
|
||||
| `points` | ряд точек метрики за период: выбор слоя, применимость рода | [`points`](../openspec/specs/points/spec.md) |
|
||||
| `httpapi` | приём, read API и **форма провода** ответов чтения | [`ingest`](../openspec/specs/ingest/spec.md), [`catalog`](../openspec/specs/catalog/spec.md), [`read-api`](../openspec/specs/read-api/spec.md), [`points`](../openspec/specs/points/spec.md) |
|
||||
|
||||
## Приём
|
||||
|
||||
@@ -835,7 +836,7 @@ hour метки выровнены на час heart_rate 00:00:00
|
||||
доставки той же автоматизации; если её не было, берём **надёжный** заголовок
|
||||
(`Minutes` → `minute`, `Hours` → `hour`). Иначе точки не сохраняются вовсе:
|
||||
молчаливый `raw` создал бы призрачный разрез, который поедет в каталог и в
|
||||
правило Read API «самый мелкий слой, покрывающий диапазон».
|
||||
правило Read API выбора слоя (см. «Read API»).
|
||||
|
||||
Слово «предшествующей» в третьем пункте несёт вес: слой обязан быть функцией от
|
||||
**префикса журнала**. Наследование от последней доставки вообще делает свёртку
|
||||
@@ -1446,7 +1447,7 @@ MongoDB, и так просилось из слова «перезаписыва
|
||||
|
||||
```
|
||||
GET /api/v1/metrics каталог: имя, units, род, слои с диапазонами
|
||||
GET /api/v1/metrics/{name}?from&to&bucket&layer точки метрики, при желании свёрнутые
|
||||
GET /api/v1/metrics/{name}?from&to&layer точки метрики за период (bucket — соседняя задача, пока 400)
|
||||
GET /api/v1/workouts?from&to заголовки тренировок
|
||||
GET /api/v1/workouts/{id} тренировка целиком, с маршрутом
|
||||
GET /api/v1/records/{kind}?from&to прочие секции
|
||||
@@ -1497,9 +1498,21 @@ GET /healthz
|
||||
законно есть дыры. Поэтому правило выбора слоя опирается на фактические объекты
|
||||
запрошенного диапазона, а не на каталожную пару границ.
|
||||
|
||||
Параметр `layer` выбирает разрез. Если он не указан — берём **самый мелкий
|
||||
слой, покрывающий весь запрошенный диапазон**. Молча переключать слой на
|
||||
границе периода нельзя: ряд поедет незаметно для клиента.
|
||||
Параметр `layer` выбирает разрез. Если он не указан — берём слой с **наибольшим
|
||||
охватом внутри запрошенного периода**, а при равном охвате самый мелкий (порядок
|
||||
`sample` → `raw` → `minute` → `hour` → `day`). Молча переключать слой на границе
|
||||
периода нельзя: ряд поедет незаметно для клиента, и ряд из одного ответа всегда
|
||||
собран из одного слоя.
|
||||
|
||||
**Охват — длина пересечения** отрезка «первая метка слоя … последняя метка слоя»
|
||||
с периодом; слой с пустым пересечением выбывает. Меряется он метками **точек**,
|
||||
а не часами объектов. Почему прежняя формулировка («самый мелкий, покрывающий
|
||||
весь диапазон») пересмотрена, почему мера именно такая и во что она обошлась —
|
||||
[ADR-2026-08-04-sloy-vybiraetsya-po-ohvatu-tochek](adr/ADR-2026-08-04-sloy-vybiraetsya-po-ohvatu-tochek.md).
|
||||
|
||||
Словарь слоёв при этом **один** (`hae.Layers`): из него выводятся и порядок, и
|
||||
перечень слоёв в выборке охватов, и проверка параметра запроса, и текст отказа
|
||||
клиенту.
|
||||
|
||||
### Условный запрос
|
||||
|
||||
@@ -1578,21 +1591,54 @@ GET /healthz
|
||||
Нормализованная оболочка, сырое содержимое:
|
||||
|
||||
```json
|
||||
{"layer": "minute", "bucket": "hour", "aggregation": "sum",
|
||||
{"metric": "heart_rate",
|
||||
"from": "2026-07-31T00:00:00Z", "to": "2026-08-01T00:00:00Z",
|
||||
"layer": "minute", "bucket": null,
|
||||
"aggregation": {"style": "instant", "applicable": true,
|
||||
"last_hour": "2026-08-02T14:00:00Z"},
|
||||
"points": [
|
||||
{"ts": "2026-07-31T09:00:00Z", "tz_offset": 10800, "units": "count",
|
||||
"values": {"qty": 812}}
|
||||
{"ts": "2026-07-31T09:00:00Z", "ts_end": "2026-07-31T09:00:00Z",
|
||||
"tz_offset": 10800, "units": "count", "values": {"qty": 812}}
|
||||
]}
|
||||
```
|
||||
|
||||
`layer`, `bucket` и `aggregation` присутствуют всегда, даже когда свёртки не
|
||||
было (`"bucket": null`): клиент не должен выводить их наличием или
|
||||
отсутствием поля.
|
||||
Все поля присутствуют ВСЕГДА, даже когда сообщить нечего: клиент не должен
|
||||
выводить исход наличием или отсутствием поля. `bucket` равен `null`, когда
|
||||
свёртки не было; `layer` — `null`, когда слой выбирала система и выбирать было
|
||||
не из чего (явно запрошенный слой уезжает всегда, в том числе при пустом ряде).
|
||||
|
||||
`aggregation` — **объект, а не строка**. Строка называла бы только применённую
|
||||
свёртку, а инвариант требует, чтобы клиент видел ещё и основание (решение и
|
||||
разбор чужих API —
|
||||
[ADR-2026-08-04-otvet-tochek-nesyot-rod-i-ego-primenimost](adr/ADR-2026-08-04-otvet-tochek-nesyot-rod-i-ego-primenimost.md)):
|
||||
|
||||
- `style` — измеренный род метрики, тот же словарь, что у каталога;
|
||||
- `applicable` — применим ли род к **отданному ряду**. Род есть свойство
|
||||
метрики, слой — свойство ряда, и сочетание `{"layer": "raw", "style":
|
||||
"cumulative"}` законно и штатно: оно приглашает потребителя сложить
|
||||
интерполяцию самому и завысить втрое. Система при этом не складывает ничего —
|
||||
а потребитель об инварианте не знает;
|
||||
- `last_hour` — ярлык самого свежего часа окна измерения. Окно считается в
|
||||
**общих** часах, а не в часах календаря: выключенная минутная автоматизация
|
||||
HAE останавливает их пополнение, окно замирает и продолжает объявлять род.
|
||||
Это единственный след.
|
||||
|
||||
`ts_end` — конец координаты точки; у точки-измерения равен `ts`. Он есть потому,
|
||||
что идентичность точки — интервал, а не метка: под одной меткой лежит до трёх
|
||||
записей сна, и конверт с одним `ts` предлагал бы клиенту различать их, разбирая
|
||||
дословное содержимое.
|
||||
|
||||
Принадлежность точки периоду определяется её **началом** — тем же правилом,
|
||||
каким час объекта берётся по началу. Цена названа: «сон за ночь с полуночи» не
|
||||
увидит эпизод, начавшийся в 23:40.
|
||||
|
||||
Время приведено к единому виду, значения отданы как пришли: ни
|
||||
переименований, ни пересчёта единиц. Метрик у Apple много и они разные —
|
||||
семантику разбирает клиент по имени метрики. Полная нормализация означала бы,
|
||||
что каждая новая метрика требует правки коллектора, а незнакомая теряется.
|
||||
переименований, ни пересчёта единиц, ни экранирования (сериализатор ответа
|
||||
HTML-символы не экранирует — иначе `&` в имени источника уезжал бы как
|
||||
`\u0026`, и обещание дословности переставало быть правдой). Метрик у Apple
|
||||
много и они разные — семантику разбирает клиент по имени метрики. Полная
|
||||
нормализация означала бы, что каждая новая метрика требует правки коллектора,
|
||||
а незнакомая теряется.
|
||||
|
||||
### Форма провода
|
||||
|
||||
|
||||
@@ -80,3 +80,31 @@
|
||||
перестаёт молча. Проверено на закреплённом драйвере: одна испорченная строка
|
||||
`delivery.uncovered_sections` обесценивала и сверку новизны (вечное «сверка не
|
||||
состоялась» на каждой доставке), и перечень целиком.
|
||||
|
||||
## Предикат выбора источника и предикат отбора данных — одна граница
|
||||
|
||||
Объекты витрины адресуются часом, а точки отбираются точной меткой. Выборка
|
||||
объектов поэтому обязана быть **шире** запроса (точка `10:59` живёт в объекте
|
||||
`10:00`) — и ровно здесь появляется разрыв: множество «слои, у которых есть
|
||||
объекты в периоде» не совпадает с множеством «слои, у которых есть точки в
|
||||
периоде».
|
||||
|
||||
Правило: **решение о том, откуда брать данные, принимается по той же границе, по
|
||||
которой данные потом отбираются.** Иначе узел выбирает источник, в котором после
|
||||
точного отбора не остаётся ничего, и отдаёт пустоту при непустых данных
|
||||
соседнего источника — молча, потому что и выбор, и отбор по отдельности верны.
|
||||
|
||||
Прецедент: правило выбора слоя в Read API мерило охват часами объектов, а ряд
|
||||
отбирало метками точек; на периоде короче часа ответ уходил пустым при непустых
|
||||
минутных данных (ADR-2026-08-04-sloy-vybiraetsya-po-ohvatu-tochek).
|
||||
|
||||
## Значение из чужого тела имеет предел длины у КАЖДОГО адресата
|
||||
|
||||
Правило `docs/security.md` про предел длины читается как «в ключ, в лог, в
|
||||
отчёт» — и адресаты кончаются не там. Имя метрики уезжает ещё и в заголовок
|
||||
ответа: без предела `ETag` растёт вместе с именем, а кавычка внутри имени по
|
||||
RFC 9110 кончает метку, и условный запрос по такой метрике не сработает никогда.
|
||||
|
||||
Когда предел неудобен (значение нужно целиком), его заменяет **форма**: в метку
|
||||
уезжает хеш канонизированной строки, а не строка. Хеш здесь не секрет — он
|
||||
ограничитель длины и экранирование разом.
|
||||
|
||||
@@ -142,6 +142,13 @@
|
||||
|
||||
**Перестали проверять сознательно.**
|
||||
|
||||
- **Шаг покрытия диффа гейт не красит.** `CLAUDE.md` объявляет, что непокрытая
|
||||
изменённая строка красит гейт безусловно; `scripts/diff-coverage.py` всегда
|
||||
возвращает `0`, и шаг печатает `OK` при любом покрытии. То есть «гейт зелёный»
|
||||
не означает «покрытие диффа полное», и разбор непокрытых строк остаётся
|
||||
человеку или проходу. Найдено проходом `gate` 2026-08-04, подтверждено
|
||||
триажем; чинить нельзя мимоходом — починка немедленно красит гейт задачи, в
|
||||
которой её сделали.
|
||||
- Прогон живого архива (`task verify:archive`) и свёртка под удерживаемой
|
||||
блокировкой (`task verify:busy`) в гейт не входят: минута и около 50 секунд
|
||||
соответственно, плюс данные, которых нет ни на какой другой машине. Гоняет их
|
||||
@@ -158,6 +165,42 @@
|
||||
Запись на каждый воспроизведённый дефект, сразу, а не ретроспективно: со
|
||||
временем теряется не факт, а причина непоймания.
|
||||
|
||||
## 2026-08-04 — правило выбора слоя мерило одно, а отбор шёл по другому [пойман]
|
||||
|
||||
**Что было.** Правило выбора слоя ответа Read API мерило охват **часами
|
||||
объектов**, а ряд отбирался **точной меткой точки**. На периоде короче часа
|
||||
множества расходятся: часовой объект попадает в границы часов запроса, а его
|
||||
единственная точка в период не попадает. Ответ уходил бы пустым при непустых
|
||||
данных соседнего слоя — с непустым `layer`, то есть неотличимо от честной
|
||||
пустоты только по числу точек.
|
||||
|
||||
**Почему поймано.** Профиль `design` на предложении, до кода: и `review-specs`,
|
||||
и `review-rubric` построили один и тот же вход независимо друг от друга
|
||||
(`from = 10:30`, `to = 10:45`). На готовом коде находка стоила бы переписывания
|
||||
выборки; на предложении — абзаца.
|
||||
|
||||
**Что сделано.** Охват меряется метками точек (`first_ts`/`last_ts` уже лежат в
|
||||
покрывающем индексе). Класс промоутнут в
|
||||
`docs/conventions/storage.md` — «предикат выбора источника и предикат отбора
|
||||
данных используют одну границу»: он повторится всюду, где огрубление ради
|
||||
полноты выборки соседствует с точным фильтром.
|
||||
|
||||
## 2026-08-04 — чекпоинт, заведённый ревью, не существовал бы в проде [пойман]
|
||||
|
||||
**Что было.** Враждебный проход построил путь «ответ оборвался по `WriteTimeout`
|
||||
на середине, а `accessLog` написал `200`»: тело в 13 МиБ доехало на 2.7 МиБ,
|
||||
клиент получил нечитаемый JSON, лог сообщил успех. Чекпоинт об обрыве завели —
|
||||
и поставили ему уровень `DEBUG`.
|
||||
|
||||
**Почему поймано.** Эксплуатационный проход прочитал **боевой** конфиг
|
||||
(`config.docker.toml`, `level = "info"`) и показал, что запись уровня `DEBUG`
|
||||
не проходит фильтр `slog` никогда. То есть находка была закрыта наблюдаемостью,
|
||||
которой в проде не существует.
|
||||
|
||||
**Что сделано.** Уровень поднят до `WARN`. Правило, которое из этого следует:
|
||||
**уровень нового чекпоинта сверяется с боевым конфигом, а не с тем, что видно в
|
||||
тестах** — в тестах уровень всегда `DEBUG`.
|
||||
|
||||
Реализованные задачи, находки ревью и решения сюда не пишутся: у них есть
|
||||
коммит, спека и задача. Здесь только промахи конвейера и решения о его составе.
|
||||
|
||||
|
||||
+11
-3
@@ -61,9 +61,17 @@ disabled`, `read auth disabled`), но стартовать не отказыв
|
||||
**Ни один сегмент пути не берётся из тела или заголовков доставки** — это и
|
||||
есть защита от выхода за пределы каталога, и она держится ровно на этом.
|
||||
- **Координатный ключ точки** — `метрика + слой + начало + конец`. Имя метрики
|
||||
приходит из тела и в путь на диске не попадает, но попадает в ключ, в лог и в
|
||||
ответ каталога. Любое значение из чужого JSON, попадающее в ключ, в лог или в
|
||||
отчёт, имеет названный предел длины.
|
||||
приходит из тела и в путь на диске не попадает, но попадает в ключ, в лог, в
|
||||
ответ каталога и — с появлением маршрута точек — **в адрес запроса и в
|
||||
заголовок `ETag` ответа**. Любое значение из чужого JSON, попадающее в ключ, в
|
||||
лог, в отчёт или в заголовок, имеет названный предел длины. У метки ответа
|
||||
предел взят формой: в неё уезжает не имя, а хеш канонизированной формы запроса
|
||||
(128 бит). Причина не только в длине — имя законно содержит кавычку, которая
|
||||
по RFC 9110 кончает метку, и разбор обрезал бы её ровно там.
|
||||
- **Имя метрики в адресе** декодируется из пути **ровно один раз**. Второе
|
||||
декодирование превращает имя `a%41b` в имя `aAb` — то есть в имя **другой**
|
||||
метрики витрины, и маршрут отвечает `200` её данными. Путь построен и прогнан
|
||||
враждебным проходом ревью.
|
||||
- **Ключ сущности** — `род секции + id` из HealthKit для `record`, `id` для
|
||||
`workout`. `id` приходит из тела.
|
||||
- **Ключ наблюдённого категориального значения** — `метрика + поле + значение`.
|
||||
|
||||
Reference in New Issue
Block a user