Каталог разрезов и измеренный род агрегации
- род метрики выводится сверкой минутного слоя с часовым: часовое значение сходится с суммой минутных — накопительная, со средним — мгновенная, иначе `unknown` и свёртка не предлагается вовсе. На живом архиве (123 доставки, 31 метрика) 7 накопительных, 9 мгновенных, противоречащих часов ноль - `GET /api/v1/metrics` под токеном чтения отдаёт единицы, слои с границами и род вместе с основанием измерения; род нигде не хранится — он функция витрины, а витрина функция журнала, устаревать в нём нечему - миграция 00009: покрывающий индекс, чтобы каталог отвечал по учётным колонкам, не разжимая содержимое объектов
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-08-02
|
||||
@@ -0,0 +1,453 @@
|
||||
## Context
|
||||
|
||||
Витрина уже держит одну метрику в нескольких слоях одновременно
|
||||
(`sample`/`raw`/`minute`/`hour`/`day`) и своей агрегации при записи не делает.
|
||||
Read API обязан уметь свести ряд к запрошенной сетке — но правильная свёртка
|
||||
зависит от рода метрики, а рода в потоке нет:
|
||||
|
||||
- **форма точки его не выдаёт** — `Avg`/`Min`/`Max` есть только у `heart_rate`,
|
||||
заведомо мгновенные `walking_speed` и `blood_oxygen_saturation` приходят в
|
||||
`qty` ровно так же, как шаги (находка 40);
|
||||
- **заголовок доставки не описывает данные** — `automation-aggregation`
|
||||
принимает значение `Default` у трёх разных режимов (находка 31);
|
||||
- **единицы дают процентов девяносто и ломаются на краях** —
|
||||
`six_minute_walking_test_distance` в метрах складывать нельзя, а
|
||||
`walking_running_distance` в километрах можно.
|
||||
|
||||
Зато витрина содержит собственную сверку: у одной метрики есть и минутный, и
|
||||
часовой разрез за один и тот же час, и они приходят из разных автоматизаций.
|
||||
Часовое значение либо равно сумме минутных, либо их среднему — и это
|
||||
наблюдаемое различие.
|
||||
|
||||
Prior art по этому вопросу богат, но весь он про **объявление** рода, а не про
|
||||
измерение: HealthKit зашивает `HKQuantityAggregationStyle` в тип метрики, Home
|
||||
Assistant получает `state_class` от интеграции, Graphite выводит
|
||||
`aggregationMethod` регуляркой по имени метрики, Prometheus/Datadog/Splunk
|
||||
принимают тип от отправителя. Прецедента вывода рода из значений нет ни одного —
|
||||
и паспорт проекта предупреждал об этом заранее.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Род метрики (`cumulative` / `instant` / `unknown`) выводится измерением на
|
||||
накопленных данных, без ручной разметки и без списка имён в коде.
|
||||
- Потребитель одним запросом узнаёт, что в хранилище есть: метрики, единицы,
|
||||
слои с диапазонами и числом точек, род и основание, на котором он измерен.
|
||||
- Каталог отдаёт наблюдаемое состояние витрины и ничего не досчитывает: период,
|
||||
у которого верхние слои не пересобираемы, честно объявляет то, что есть.
|
||||
- Стоимость каталога не растёт вместе с историей.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- **Свёртка ряда в ответе.** Каталог отвечает «какая свёртка осмысленна», сама
|
||||
свёртка — задача Read API.
|
||||
- **Правило неполного ведра** (`xFilesFactor`). Оно нужно свёртке, а не
|
||||
измерению — см. решение 6.
|
||||
- **Тай-брейк при равной полноте точек.** Вынут блокером — решение 8.
|
||||
- **Каталог сущностей** (`workout`, `record`). Тренировки и записи адресуются
|
||||
своим `id`, слоёв у них нет; их перечисление приезжает вместе с их же
|
||||
маршрутами Read API.
|
||||
- **Хранение измеренного рода.** Решение 2.
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. Род измеряется двумя конкурирующими гипотезами и единогласием
|
||||
|
||||
Для каждого часа, за который у метрики есть **и** минутный, **и** часовой
|
||||
объект:
|
||||
|
||||
```
|
||||
час пригоден, если
|
||||
час не позже текущего времени плюс час
|
||||
единицы обоих объектов совпадают
|
||||
часовой объект несёт ровно одну точку, и она несёт значение
|
||||
метка этой точки совпадает с началом часа
|
||||
у минутного не меньше двух точек со значением
|
||||
сумма минутных и их среднее сами различимы
|
||||
|
||||
вердикт пригодного часа:
|
||||
часовое ≈ сумма минутных → cumulative
|
||||
часовое ≈ среднее минутных → instant
|
||||
иначе → час свидетельства не даёт
|
||||
```
|
||||
|
||||
Вердикт метрики: **не меньше трёх согласных часов и ни одного противоречащего**.
|
||||
Иначе — `unknown`, и свёртка по метрике не предлагается вовсе. Наличие
|
||||
противоречащих часов пишется `WARN`: род — свойство, на котором Read API строит
|
||||
арифметику года, и его смена не имеет права проходить молча.
|
||||
|
||||
**Все три сравнения — один предикат с одним допуском**, относительным, величиной
|
||||
`1e-9`. Это не аккуратность, а устранение целого класса: напиши «различимость»
|
||||
точным неравенством, а «сходимость» с допуском — появится час, подтверждающий
|
||||
обе гипотезы сразу, и его исход молча определит порядок веток `if`. При одном
|
||||
предикате такой час невыразим.
|
||||
|
||||
Величина названа числом, потому что от неё зависят счётчики основания в ответе.
|
||||
Измерено на живом корпусе: вердикты метрик одинаковы при допуске от `1e-9` до
|
||||
`1e-3`, а число согласных часов у `heart_rate` при этом меняется с 29 на 49 —
|
||||
то есть выбор не влияет на вывод, но влияет на то, что мы о нём сообщаем. Взято
|
||||
строгое: канонизация содержимого округляет числа до 12 значащих цифр, значит всё
|
||||
крупнее `1e-12` представлением не объясняется; `1e-9` оставляет три порядка
|
||||
запаса и остаётся на шесть порядков строже любого содержательного расхождения —
|
||||
сумма и среднее при `n ≥ 2` различаются не меньше чем вдвое.
|
||||
|
||||
Абсолютного порога нет намеренно: второй константы, которую пришлось бы
|
||||
объяснять, задача не заводит. Цена названа вслух — при обоих нулях предикат
|
||||
истинен, и от нулевого часа защищает не он, а проверка различимости. Полагаться
|
||||
на «около нуля не сходится» нельзя: там ровно наоборот.
|
||||
|
||||
**Горизонт закрывает подделку и сбитые часы.** Час объекта берётся из метки в
|
||||
теле доставки, а тело не наше: без верхней границы одна доставка с метками в
|
||||
будущем занимает окно целиком и подменяет измеренный род. Путь построен и
|
||||
прогнан — мгновенная метрика объявлялась накопительной при нуле противоречащих
|
||||
часов, то есть с виду безупречным основанием. Часы позже `now + час` в окно не
|
||||
входят, а сам факт данных из будущего пишется `WARN`: сбитые часы телефона и
|
||||
чужое тело в приёме лечатся не кодом.
|
||||
|
||||
**Единицы обеих сторон обязаны совпасть.** Мгновенная метрика в `count/min`
|
||||
минутным слоем и в `count/hour` часовым даёт в полном часе
|
||||
`часовое = 60 · среднее = сумма` — уверенный ложный `cumulative`, которого
|
||||
правило единогласия не ловит по построению: противоречия нет, есть молчание.
|
||||
Единицы лежат в том же покрывающем индексе, так что проверка не стоит ничего.
|
||||
|
||||
**Выравнивание часовой метки закрывает получасовые пояса.** Слой выводится по
|
||||
выравниванию метки в исходной зоне, а объект адресуется часом UTC: в зоне
|
||||
`+0530` часовая точка попадает на середину часа UTC и описывает не тот интервал,
|
||||
который покрывают минутные точки того же объекта. Сравнивать их нельзя. Условие
|
||||
стоит копейки, а закрывает класс целиком — на корпусе одной зоны его не
|
||||
воспроизвести, и потому оно и записано правилом, а не оставлено на «когда
|
||||
поедем».
|
||||
|
||||
Измерено на живом архиве (123 доставки, 31 метрика, окно — все общие часы):
|
||||
|
||||
| исход | метрик |
|
||||
|---|---|
|
||||
| `cumulative` | 7 (`active_energy`, `basal_energy_burned`, `step_count`, `walking_running_distance`, `apple_stand_time`, `apple_exercise_time`, `time_in_daylight`) |
|
||||
| `instant` | 9 (`heart_rate`, `respiratory_rate`, `blood_oxygen_saturation`, `environmental_audio_exposure`, `walking_speed`, `walking_step_length`, `walking_double_support_percentage`, `walking_asymmetry_percentage`, `stair_speed_up`) |
|
||||
| `unknown` | 15 |
|
||||
| **противоречащих часов** | **0 на всём корпусе** |
|
||||
|
||||
Числа сняты при допуске `1e-9` и окне в 48 часов. Полный обход всей истории дал
|
||||
бы `instant` ещё и `physical_effort` (5 согласных часов за всё время против 2 в
|
||||
свежем окне) — окно честно уводит редкие метрики в `unknown`, и это то же
|
||||
правило, а не издержка.
|
||||
|
||||
Три части правила стоят каждая своей причины.
|
||||
|
||||
**Фильтр различимости — не украшение.** Без него
|
||||
`walking_asymmetry_percentage` давала 4 часа «накопительная» против 3
|
||||
«мгновенная»: в нулевом часе сумма равна среднему, и «сходится с суммой»
|
||||
выполняется тождественно. Час, в котором гипотезы неразличимы, свидетельством не
|
||||
является.
|
||||
|
||||
**Порог в три часа.** Один совпавший час — свидетельство одного часа, а на роде
|
||||
потом суммируют год. Цена измерена: порог уводит в `unknown` ровно одну метрику
|
||||
(`headphone_audio_exposure`, один согласный час).
|
||||
|
||||
**Единогласие, а не большинство.** Противоречие означает, что одна из гипотез
|
||||
ложна для этой метрики; большинство голосов позволило бы объявить род при
|
||||
известном контрпримере. Измеренная цена этого решения — ноль: конфликтов нет.
|
||||
|
||||
Альтернативы отвергнуты:
|
||||
|
||||
- **Разметка руками** (так делают все, кроме нас) — она и есть то, от чего
|
||||
задача уходит: список из сотни метрик Apple, который устареет в день
|
||||
появления новой.
|
||||
- **Вывод по имени метрики** (Graphite, `pattern = \.count$`) — противоречит
|
||||
инварианту «форма Apple не транслируется» и не работает на именах HAE вовсе.
|
||||
- **Вывод по единицам** — ломается на краях (находка 40).
|
||||
- **Заголовок доставки** — врёт уже про слой, оснований верить про род нет.
|
||||
- **Детекция сброса счётчика** (Prometheus `rate`, Home Assistant
|
||||
`total_increasing` с допуском 10%) — отвечает на другой вопрос: «был ли
|
||||
рестарт у известного счётчика», а не «счётчик ли это». К данным Apple
|
||||
неприменима: монотонного накопителя в них нет, накопительная метрика приходит
|
||||
уже поинтервальными значениями.
|
||||
|
||||
### 2. Родов два, а не четыре — потому что больше нечем измерить
|
||||
|
||||
HealthKit различает четыре стиля: `cumulative`, `discreteArithmetic`,
|
||||
`discreteTemporallyWeighted` (пульс) и `discreteEquivalentContinuousLevel`
|
||||
(аудиоэкспозиция, логарифмическое усреднение по энергии). Взять весь словарь
|
||||
напрашивалось — и отвергнуто **измерением**: часовой слой HAE считается
|
||||
арифметически, а не по Apple.
|
||||
|
||||
Прямое свидетельство даёт `environmental_audio_exposure`: Apple усредняет её
|
||||
логарифмически, а часовое значение HAE сошлось с обычным арифметическим средним
|
||||
минутных в 59 часах из 62. У `heart_rate`, который Apple взвешивает по
|
||||
длительности, часовое значение сходится с арифметическим средним точно в 29
|
||||
часах из 63 и с точностью 0.1% — в 49; с суммой не сошлось ни разу.
|
||||
|
||||
Значит четвёртый и третий стили в наших данных ничем не проявляются, и ввести
|
||||
их можно было бы только разметкой руками — то есть тем, от чего задача уходит.
|
||||
Правило проекта прежнее: **род, который нечем измерить, не объявляется.**
|
||||
Появится источник, различающий больше родов (родной экспорт Apple несёт
|
||||
интервалы сэмплов), — словарь расширится тем же измерением.
|
||||
|
||||
### 3. Род не хранится, а считается на запрос по ограниченному окну
|
||||
|
||||
Хранить измеренный род означало бы завести **второе производное состояние**
|
||||
рядом с витриной: колонку, которую надо пересчитывать после каждой свёртки,
|
||||
переносить или не переносить пересборкой (перечень в `architecture.md`),
|
||||
мигрировать и объяснять, на каком составе данных она измерена. Цена ошибки
|
||||
здесь — молчаливая: устаревшее значение выглядит ровно как свежее.
|
||||
|
||||
Считанный на запрос род — по построению функция текущей витрины, а витрина есть
|
||||
функция журнала. Устареть нечему.
|
||||
|
||||
Плата — стоимость чтения, и она ограничена **окном в 48 самых свежих общих
|
||||
часов** метрики. Измерено на живом корпусе: полное измерение по всем 696 парам
|
||||
часов всех 31 метрики — 123 мс; окно даёт тот же результат и не даёт стоимости
|
||||
расти вместе с историей (за год окно ограничивает работу 1488 парами вместо
|
||||
270 тысяч).
|
||||
|
||||
Кеш в памяти сознательно не заводится: он вводит третье представление того же
|
||||
факта, а вопрос его инвалидации («изменился ли хоть один объект окна») стоит
|
||||
дороже самого измерения. Появится профиль нагрузки, показывающий обратное, —
|
||||
кеш добавится с числом в руках.
|
||||
|
||||
### 4. В измерении участвуют только `minute` и `hour`
|
||||
|
||||
Нижний слой HAE — посекундная развёртка настоящих сэмплов с инфляцией 2.4×
|
||||
(находка 20) и до 478× у базального обмена (находка 34); его сумма завышена, и
|
||||
в сверке он не сходится. Слой `sample` из родного экспорта в витрине пока пуст,
|
||||
а его точки несут собственные интервалы — их сверка с часовым слоем это другая
|
||||
задача (`healthlog import`).
|
||||
|
||||
`day` в измерении не участвует: суточная сводка сна — не разрез часов, а другая
|
||||
схема под тем же именем (находка 38).
|
||||
|
||||
### 5. Значение точки — `qty`, а при его отсутствии `Avg`
|
||||
|
||||
Единственное место, знающее, какое поле точки HAE несёт число, — пакет `hae`.
|
||||
Порядок именно такой: `qty` несут все метрики, `Avg` — только `heart_rate`
|
||||
(находка 40), и без второго кандидата самая важная метрика потока не измерялась
|
||||
бы вовсе. Точка, не несущая ни того, ни другого, в сумму не входит и число
|
||||
точек часа не увеличивает.
|
||||
|
||||
Это чтение, а не интерпретация: значение никуда не пишется и ничего не
|
||||
подменяет.
|
||||
|
||||
**Ноль — значение.** Словарь пустоты из `canon` сюда не годится и применяться не
|
||||
должен: там ноль объявлен пустотой, чтобы точка без измерений не вытесняла
|
||||
настоящее измерение при столкновении координат, — вопрос другой. Взяв его,
|
||||
измерение не увидело бы точки `{"qty":0}`, час выпал бы из счётчиков ещё до
|
||||
правила различимости, и сценарий «нулевой час свидетельством не является»
|
||||
позеленел бы по неверной причине. Поэтому разбор здесь свой: `*json.Number` для
|
||||
обоих полей, отсутствие ключа и `null` — «нет значения», ноль — значение.
|
||||
|
||||
**Бесконечность — не значение.** `json.Number("1e400").Float64()` возвращает
|
||||
`+Inf` вместе с `ErrRange`; проглоченная ошибка отравила бы и сумму, и среднее
|
||||
всего часа. Значение, не разобравшееся в конечное число, считается
|
||||
неприсланным.
|
||||
|
||||
### 6. `xFilesFactor` здесь не нужен, и это сказано вслух
|
||||
|
||||
Graphite и RRDtool закрывают вопрос «что делать со свёрткой неполного ведра»
|
||||
долей заполненности: ниже порога — не число, а пусто. Паспорт называл это
|
||||
готовым ответом на вопрос, который у нас ещё не задан.
|
||||
|
||||
Измерению порог не нужен, потому что у него **две конкурирующие гипотезы**, а
|
||||
не одна: неполный минутный час не сходится ни с суммой, ни со средним и
|
||||
свидетельства не даёт сам собой. Это видно в измерении — у `step_count` 25
|
||||
часов согласны и 17 не дали ничего; ровно эти 17 и есть неполные часы.
|
||||
|
||||
Свёртке в ответе порог понадобится, и вместе с ним — выбор полярности:
|
||||
Graphite `xFilesFactor` задаёт долю **обязательно известных** (умолчание 0.5 при
|
||||
роллапе и 0 при рендере — один параметр с двумя умолчаниями), RRDtool `xff` —
|
||||
долю **допустимо неизвестных**, то есть ровно наоборот. Обе величины будут
|
||||
выглядеть как «0.5», означая противоположное. Решение и его полярность
|
||||
принимает задача Read API; здесь оно названо, чтобы не решалось дважды.
|
||||
|
||||
### 7. Разрезы отвечают по индексу, окно читается пакетом, ответ — из одного снимка
|
||||
|
||||
Границу надо назвать точно, иначе она запрещает то, ради чего задача есть:
|
||||
**не разжимается содержимое ради разрезов и границ; объекты окна измерения
|
||||
разжимаются обязательно** — сумма минутных значений иначе невычислима. Разжатых
|
||||
объектов не больше `2 × 48` на метрику.
|
||||
|
||||
Диапазоны и число точек лежат учётными колонками объекта (`first_ts`,
|
||||
`last_ts`, `points`), но `bucket` — таблица `WITHOUT ROWID`, то есть строка
|
||||
целиком, вместе с `payload`, живёт в самом дереве первичного ключа. Обход всех
|
||||
строк ради агрегата тащил бы за собой страницы сжатого содержимого: при
|
||||
260 тысячах объектов за год это сотни мегабайт на каждый запрос каталога.
|
||||
|
||||
Поэтому миграция `00009` заводит **покрывающий индекс**
|
||||
`bucket(metric, layer, hour_utc, first_ts, last_ts, points, units)`: и
|
||||
агрегат разрезов, и поиск общих часов двух слоёв читают только его. Цена —
|
||||
около 60 байт на объект (≈16 МБ за год) и одна вставка в дерево на запись
|
||||
объекта.
|
||||
|
||||
Данных индекс не меняет, поэтому в перечне того, что не переносит пересборка,
|
||||
ему места нет.
|
||||
|
||||
**Содержимое разжимается только у часов, прошедших отбор по учётным колонкам.**
|
||||
Число точек и единицы обеих сторон лежат в покрывающем индексе, а условия
|
||||
пригодности «у крупного слоя ровно одна точка, у мелкого не меньше двух»
|
||||
проверяются по ним. Замер на раздутой витрине: 109 МиБ аллокаций при нуле
|
||||
пригодных часов — вся работа шла до того, как выяснялось, что вердикта не будет.
|
||||
Отбор ПРЕДВАРИТЕЛЬНЫЙ и строго слабее правила вердикта: числа задаёт домен,
|
||||
хранилище лишь выбирает по ним строки.
|
||||
|
||||
**Объекты окна берутся пакетом и в одной транзакции чтения со всем остальным.**
|
||||
Существующий `Store.Bucket` открывает собственную read-only транзакцию на каждый
|
||||
вызов: окно в 48 часов дало бы под сотню транзакций на метрику, а ответ
|
||||
собрался бы из смеси снимков — разрезы одного состояния витрины, род другого,
|
||||
причём под непрерывным приёмом и неотличимо от обычного свежего ответа. Проект
|
||||
уже записал это рассуждение у отпечатка витрины, и второй раз оно разошлось бы
|
||||
молча.
|
||||
|
||||
Поэтому хранилище отдаёт каталогу **один снимок**: агрегат разрезов, общие часы
|
||||
каждой метрики и объекты её окна — за одну транзакцию чтения, двумя запросами на
|
||||
метрику плюс один общий. Число обращений к базе перестаёт зависеть от размера
|
||||
окна. В WAL длинная транзакция чтения писателей не блокирует, а измеренные
|
||||
130 мс на живом корпусе — цена, которую видно.
|
||||
|
||||
### 8. Тай-брейк при равной полноте точек не трогаем — вынут блокером
|
||||
|
||||
Задача обещала доделать его «по каталогу», и измерение действительно
|
||||
подтвердило посылку: четыре из шести метрик, где тай-брейк системно берёт
|
||||
меньшее значение (находка 49), измерены как накопительные — то есть там это
|
||||
недосчёт, а у `heart_rate` (самая крупная группа) род мгновенный, и выбор
|
||||
безразличен.
|
||||
|
||||
Но сделать тай-брейк зависящим от **измеренного** рода нельзя: род есть функция
|
||||
витрины, витрина — результат слияния, и правило слияния, читающее собственную
|
||||
выдачу, повторяет ровно тот дефект, на котором свёртка уже переставала быть
|
||||
функцией префикса журнала (`docs/review-journal.md`, 2026-08-01). Остаются
|
||||
варианты, не зависящие от рода, и выбор между ними — развилка с ценой; она
|
||||
уходит блокером вместе с измеренным основанием.
|
||||
|
||||
### 9. Каталог живёт под токеном чтения
|
||||
|
||||
`GET /api/v1/metrics` — первый маршрут, который отдаёт данные наружу, поэтому
|
||||
здесь же появляется проверка `auth.read_tokens`. Правило то же, что у приёма:
|
||||
пустой список означает выключенную проверку, и о ней сервис предупреждает на
|
||||
старте. Токен приёма каталог не открывает — раздельность контуров объявлена
|
||||
архитектурой, и «пишущий умеет читать» её бы отменило.
|
||||
|
||||
Цена симметрии названа вслух, потому что она несимметрична: у приёма открытый
|
||||
контур означает мусор во входе, у чтения — выгрузку истории здоровья любому, кто
|
||||
нашёл порт. Отказ старта при пустом списке рассматривался и не взят здесь:
|
||||
сегодня оба образца конфига в репозитории идут с пустыми списками сознательно
|
||||
(доверенная локальная сеть), и такой отказ сломал бы `task up` до правки
|
||||
конфигов, заведя асимметрию с приёмом, которую пришлось бы объяснять. Вопрос
|
||||
принадлежит задаче об управлении секретами — он там уже стоит, и эта задача
|
||||
добавляет ему второй контур, а не заводит третье место для того же решения.
|
||||
|
||||
Проверка **одна на оба контура**, параметризованная списком: копия отличалась бы
|
||||
одним полем и несла бы три решения сразу — сравнение за постоянное время,
|
||||
«пустой список = выключено» и текст 401, — правка любого из них в одном месте не
|
||||
дала бы ни ошибки компиляции, ни красного теста.
|
||||
|
||||
Схема строгая: токеном считается только `Authorization: Bearer <значение>`.
|
||||
Снисходительности к голому значению у приёма нет и не было; заводить её на
|
||||
контуре чтения, клиенты которого свои, тем более не за чем.
|
||||
|
||||
Отдельно — **редакция заголовков**: сохраняемые заголовки доставки чистятся
|
||||
подстановкой по списку токенов, и сегодня в этом списке только токены приёма.
|
||||
Токен чтения, посланный заголовком с произвольным именем, осел бы в базе; список
|
||||
становится общим.
|
||||
|
||||
### 10. Форма ответа
|
||||
|
||||
```json
|
||||
{"metrics": [
|
||||
{"metric": "step_count",
|
||||
"units": ["count"],
|
||||
"aggregation": {"style": "cumulative",
|
||||
"hours": 48, "compared": 40, "agreeing": 25, "conflicting": 0,
|
||||
"first_hour": "2026-07-31T09:00:00Z",
|
||||
"last_hour": "2026-08-02T14:00:00Z"},
|
||||
"layers": [
|
||||
{"layer": "minute", "from": "2026-07-30T21:48:00Z",
|
||||
"to": "2026-08-02T14:59:00Z", "points": 1102},
|
||||
{"layer": "raw", "from": "…", "to": "…", "points": 25636}]}]}
|
||||
```
|
||||
|
||||
- `units` — **массив**: единицы метрики на живом потоке не менялись ни разу
|
||||
(находка 48), но одна форма поля для обоих случаев честнее строки, которая при
|
||||
расхождении молча выберет одно из двух. Та же форма, что у самоописания. На
|
||||
слой при этом приходится ровно один элемент `layers`: строки выборки,
|
||||
разошедшиеся единицами, схлопываются в общий диапазон и общую сумму точек, а
|
||||
различие видно множеством единиц метрики. Не поручить это схлопывание явно
|
||||
значило бы отдать клиенту два элемента с одинаковым `layer` в тот единственный
|
||||
день, ради которого `units` и сделали массивом.
|
||||
- `aggregation` — объект, а не строка: он несёт **основание**, и числа в нём
|
||||
подобраны так, чтобы их разности были осмысленны. `hours` — сколько общих
|
||||
часов попало в окно, `compared` — сколько из них оказалось пригодными,
|
||||
`agreeing` и `conflicting` — вердикты пригодных. `hours − compared` — часы,
|
||||
отброшенные проверкой пригодности; `compared − agreeing − conflicting` — часы,
|
||||
не сошедшиеся ни с одной гипотезой. Одного числа не хватало: «часов было 48, а
|
||||
пригодным не оказалось ни одного» и «часов не было вовсе» — разные события.
|
||||
- Поле называется `style`, а не `kind`: слово `kind` в проекте уже занято родом
|
||||
секции записи (`record.kind`), и два смысла под одним именем в одном API — это
|
||||
сноска в документации навсегда. `style` — слово HealthKit
|
||||
(`HKQuantityAggregationStyle`) для ровно этого понятия.
|
||||
- Значения рода остаются `cumulative` / `instant` / `unknown`. `cumulative`
|
||||
совпадает со словарём HealthKit; `instant` не совпадает ни с чьим (у Apple
|
||||
`discrete`, у Prometheus `gauge`, у Home Assistant `measurement`) — и взят
|
||||
сознательно: `discrete` описывает **природу сэмпла**, а мы называем то, что
|
||||
измерили, — свёртку средним. Архитектура пользуется словом «мгновенная» с
|
||||
самого начала, и менять словарь ради чужого сходства значило бы переименовать
|
||||
понятие, не изменив его.
|
||||
- `first_hour`/`last_hour` вместо `from`/`to` — потому что это **ярлыки часов**,
|
||||
а не метки данных: у слоя `to` — метка последней точки (`…14:59:00Z`), у окна
|
||||
— начало последнего часа окна (`…14:00:00Z`), включая непригодные. Одно имя для двух
|
||||
семантик в одном ответе стоило бы клиенту ошибки на час, заметной только
|
||||
расхождением сумм.
|
||||
- `layers` — только то, что есть. Числа часовых объектов в ответе нет: объект —
|
||||
деталь хранения, клиент про него не знает.
|
||||
- Поля присутствуют всегда, в том числе со значением `null`: клиент не должен
|
||||
выводить смысл из наличия или отсутствия ключа. Это требует внимания к
|
||||
нулевым значениям Go: nil-срез сериализуется в `null`, а нулевой `time.Time` —
|
||||
в правдоподобную метку `0001-01-01T00:00:00Z`, неотличимую от данных. Поэтому
|
||||
срезы конструируются пустыми, границы окна — указателями, а приёмочный тест
|
||||
сравнивает **байты** ответа с литералом, а не разобранную структуру с
|
||||
разобранной.
|
||||
- Порядок метрик и слоёв детерминирован: два ответа на неизменившейся витрине
|
||||
обязаны совпасть побайтово, иначе «повторный запрос не опирается на прошлый»
|
||||
нечем проверить.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Род измеряется по свежему окну, а метрика могла его сменить в прошлом** →
|
||||
окно и его границы отдаются в ответе (`from`/`to`, `compared`), то есть род
|
||||
объявлен вместе с периодом, на котором измерен. Так же поступает Home
|
||||
Assistant, признавая смену `state_class` разрушительным событием, а не
|
||||
уточнением поля.
|
||||
- **Метрика приходит только в одном слое — род не измерить никогда** → штатный
|
||||
`unknown` с `compared: 0`. Сегодня это 14 метрик из 31, в том числе
|
||||
`sleep_analysis` и `heart_rate_variability`. Лечится не кодом, а второй
|
||||
автоматизацией HAE на том же наборе метрик.
|
||||
- **Стоимость каталога растёт с числом метрик** (~4 мс на метрику на живом
|
||||
корпусе) → окно ограничивает вклад каждой; при сотне метрик это порядка
|
||||
полусекунды. Число измерено и попадёт в отчёт; кеш заводится по профилю
|
||||
нагрузки, а не заранее.
|
||||
- **Часовой слой HAE — тоже досчитываемое задним числом значение** (находка 10)
|
||||
→ свежайший час окна может быть неполным и вердикта не дать. На исход это не
|
||||
влияет: неполный час просто не свидетельствует, а окно в 48 часов заведомо
|
||||
содержит устоявшиеся.
|
||||
- **Каталог метрик не говорит о невосстановимости `stateOfMind`** — секция живёт
|
||||
в `record`, а не в метриках, и её единственный источник это доставки HAE
|
||||
(находка 46). Граница названа: за это отвечает ретеншен и перечень
|
||||
непокрытого, а не каталог разрезов.
|
||||
- **Покрывающий индекс удорожает запись объекта** → одна вставка в дерево на
|
||||
объект; широкий проход, у которого хеш сошёлся, объект не переписывает вовсе,
|
||||
поэтому цену платят только настоящие изменения.
|
||||
- **Род дребезжит вместе со скользящим окном**: час, въехавший в окно, может
|
||||
сменить `cumulative` на `unknown` без единой новой доставки за спрошенный
|
||||
период, и для агента это выглядит поломкой сервиса → следствие принято вслух и
|
||||
снабжено двумя средствами. Первое — основание измерения в ответе: клиент
|
||||
видит, что изменилось и почему. Второе — `WARN` при появлении противоречащих
|
||||
часов: событие адресовано владельцу, потому что лечится оно настройкой
|
||||
автоматизаций HAE, а не кодом. Смягчать правило долей согласных вместо
|
||||
единогласия отвергнуто: это объявление рода при известном контрпримере.
|
||||
- **Пустой список токенов чтения открывает историю здоровья** → предупреждение на
|
||||
старте и запись цены в образцах конфига; отказ старта рассмотрен и оставлен
|
||||
задаче об управлении секретами (решение 9). До выкладки наружу это домашняя
|
||||
сеть, после — блокирующее условие деплоя, и оно уже записано там.
|
||||
- **Каталог — первая ручка, где повторный запрос стоит заметного CPU** (порядка
|
||||
4 мс на метрику) → предела на размер ответа и тайм-аута у него нет, потому что
|
||||
и то, и другое — правило Read API, которое пишется следующей задачей вместе с
|
||||
остальными его маршрутами. Названо, чтобы не оказалось забытым.
|
||||
@@ -0,0 +1,59 @@
|
||||
## Why
|
||||
|
||||
Read API обязан уметь сворачивать метрику к запрошенной сетке, а род свёртки
|
||||
(сумма или среднее) HAE не присылает: `Avg`/`Min`/`Max` есть только у
|
||||
`heart_rate`, всё остальное приходит в `qty` (находка 40), заголовок доставки
|
||||
про род молчит, а единицы врут на краях. Просуммировать мгновенную метрику или
|
||||
сложить нижний слой HAE значит завысить ответ втрое — поэтому род измеряется
|
||||
**до** Read API, а не угадывается внутри него.
|
||||
|
||||
Второй пробел того же размера: потребитель не может спросить «что у тебя вообще
|
||||
есть». Слои и их диапазоны — часть контракта (после пересборки старый период
|
||||
законно теряет верхние слои), и узнать их сегодня можно только через sqlite на
|
||||
хосте.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Род агрегации измеряется сверкой минутного и часового слоёв между собой**:
|
||||
часовое значение сходится с суммой минутных — метрика накопительная, с
|
||||
арифметическим средним — мгновенная, ни с тем ни с другим или свидетельства
|
||||
противоречат — `unknown`. Измерено на живом архиве (123 доставки, 31 метрика):
|
||||
17 метрик классифицируются, 14 остаются `unknown`, **противоречивых
|
||||
свидетельств ноль**.
|
||||
- **Нижний слой (`raw`) в измерении не участвует и не суммируется никогда** — он
|
||||
посекундная развёртка, а не сэмплы (находка 34).
|
||||
- **Новая ручка `GET /api/v1/metrics`** — каталог: по каждой метрике единицы,
|
||||
измеренный род с основанием измерения и список слоёв с диапазонами и числом
|
||||
точек. Первый маршрут под токеном чтения; появляется проверка этого токена.
|
||||
- **Род нигде не хранится**: он производен от витрины и считается на запрос по
|
||||
ограниченному окну. Ни новой колонки, ни строки в перечне того, что не
|
||||
переносит пересборка.
|
||||
- Схема получает **только индекс** (`00009`): разрезы и границы обязаны
|
||||
отвечать, не разжимая содержимое объектов. Само измерение содержимое читает —
|
||||
иначе сумму минутных значений не получить, — но не больше `2 × 48` объектов на
|
||||
метрику и одной транзакцией чтения на весь ответ.
|
||||
- Тай-брейк при равной полноте точек **в этой задаче не меняется** — вынут
|
||||
блокером: род измеряется из витрины, а витрина есть результат слияния, и
|
||||
правило слияния, читающее собственную выдачу, повторяет дефект вывода слоя из
|
||||
журнала ревью.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `catalog`: каталог разрезов и измеренный род агрегации — что за метрики есть,
|
||||
в каких слоях, за какие периоды и какая свёртка по ним осмысленна.
|
||||
|
||||
### Modified Capabilities
|
||||
- `storage`: каталог отвечает по учётным полям объекта, не разжимая `payload`;
|
||||
отсюда требование к стоимости выборки разрезов.
|
||||
|
||||
## Impact
|
||||
|
||||
- Новый пакет `internal/catalog` — измерение рода и сборка каталога.
|
||||
- `internal/store` — выборка разрезов метрики и общих часов двух слоёв.
|
||||
- `internal/hae` — единственное место, знающее, какое поле точки несёт число.
|
||||
- `internal/httpapi` — маршрут каталога и проверка токена чтения.
|
||||
- `internal/store/migrations/00009_bucket_catalog.sql` — покрывающий индекс.
|
||||
- Документация: `architecture.md` (метод измерения и отвергнутые чужие решения),
|
||||
`database.md` (индекс), `local-research.md` (находка о результате измерения),
|
||||
`config.example.toml` (read_tokens перестали быть заделом на будущее).
|
||||
@@ -0,0 +1,517 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Каталог разрезов отдаёт наблюдаемое состояние витрины
|
||||
|
||||
Система SHALL отдавать каталог метрик, где по каждой метрике перечислены
|
||||
единицы и слои с границами данных и числом точек. Каталог MUST показывать
|
||||
только то, что в витрине есть: досчитывать отсутствующий слой,
|
||||
экстраполировать границы или помнить о том, чего больше нет, он MUST NOT.
|
||||
|
||||
**Границы слоя — это границы данных, а не обещание покрытия.** Внутри
|
||||
диапазона законно есть дыры: часы, за которые доставок не было, и периоды,
|
||||
верхние слои которых не пережили пересборку. Поэтому правило выбора слоя в
|
||||
Read API MUST опираться на фактические объекты запрошенного диапазона, а не
|
||||
считать каталожную пару границ доказательством непрерывности.
|
||||
|
||||
Слои — часть контракта, а не деталь хранения: без каталога вопрос «в каком
|
||||
разрезе спрашивать» не задать. Часовой объект при этом деталью остаётся, и его
|
||||
число в ответ не идёт.
|
||||
|
||||
Отсюда честность после пересборки: экспорт Apple восстанавливает только слой
|
||||
`sample`, а `minute` и `hour` за периоды с удалёнными доставками не воскресают.
|
||||
Метрика, потерявшая слой целиком, объявляет его отсутствие тем, что слоя нет в
|
||||
списке.
|
||||
|
||||
Метрика с пустым именем — законное значение колонки, и каталог MUST показывать
|
||||
её наравне с остальными: терять на границе, которая отвечает «что у тебя вообще
|
||||
есть», нельзя ничего.
|
||||
|
||||
Единицы отдаются **множеством различных значений** метрики, отсортированным и
|
||||
ограниченным потолком (пустые в множество не входят):
|
||||
на живом потоке они не менялись ни разу, но одна форма поля для обоих случаев
|
||||
честнее строки, которая при расхождении молча выберет одно из двух. На слой при
|
||||
этом приходится **ровно один** элемент списка: объекты слоя с разными единицами
|
||||
дают общий диапазон и общую сумму точек, а различие видно множеством единиц
|
||||
метрики.
|
||||
|
||||
#### Scenario: Метрика лежит в нескольких слоях
|
||||
|
||||
- **WHEN** у метрики есть объекты в слоях `raw`, `minute` и `hour`
|
||||
- **THEN** каталог перечисляет все три слоя, у каждого — границы данных и число
|
||||
точек
|
||||
|
||||
#### Scenario: Слоя за период не осталось
|
||||
|
||||
- **GIVEN** витрина пересобрана, и у метрики остались объекты только слоя
|
||||
`sample`
|
||||
- **WHEN** запрашивается каталог
|
||||
- **THEN** у метрики объявлен слой `sample` и не объявлены `minute` и `hour`
|
||||
|
||||
#### Scenario: Внутри диапазона слоя есть дыра
|
||||
|
||||
- **GIVEN** у метрики есть объекты слоя `minute` за январь и за июнь, а между
|
||||
ними нет ни одного
|
||||
- **WHEN** запрашивается каталог
|
||||
- **THEN** слой `minute` объявлен один раз с границами от января до июня, и
|
||||
каталог не утверждает, что данные есть за весь этот период
|
||||
|
||||
#### Scenario: Единицы метрики разошлись
|
||||
|
||||
- **GIVEN** объекты одной метрики несут разные единицы
|
||||
- **WHEN** запрашивается каталог
|
||||
- **THEN** множество единиц метрики содержит оба значения, а слой остаётся одним
|
||||
элементом списка с объединённым диапазоном и суммой точек
|
||||
|
||||
#### Scenario: Метрика приехала без имени
|
||||
|
||||
- **GIVEN** в витрине есть объекты метрики с пустым именем
|
||||
- **WHEN** запрашивается каталог
|
||||
- **THEN** метрика присутствует в ответе со своими слоями
|
||||
|
||||
#### Scenario: Единиц у метрики стало неправдоподобно много
|
||||
|
||||
- **GIVEN** объекты метрики несут десятки различных строк единиц
|
||||
- **WHEN** запрашивается каталог
|
||||
- **THEN** множество единиц в ответе ограничено потолком, а слой остаётся одним
|
||||
элементом
|
||||
|
||||
#### Scenario: Витрина пуста
|
||||
|
||||
- **WHEN** в витрине нет ни одного объекта
|
||||
- **THEN** каталог отдаёт пустой список метрик, а не отказ
|
||||
|
||||
### Requirement: Форма ответа каталога
|
||||
|
||||
Система SHALL отдавать каталог по маршруту `GET /api/v1/metrics` в виде объекта
|
||||
с полем `metrics`. Каждая запись MUST нести поля `metric`, `units`,
|
||||
`aggregation` и `layers`; элемент `layers` — `layer`, `from`, `to`, `points`;
|
||||
объект `aggregation` — `style`, `hours`, `compared`, `agreeing`, `conflicting`,
|
||||
`first_hour`, `last_hour`.
|
||||
|
||||
Все перечисленные поля MUST присутствовать всегда, в том числе со значением
|
||||
`null`: клиент не должен выводить смысл из наличия или отсутствия ключа. Пустой
|
||||
список MUST отдаваться как `[]`, а не как `null`, и отсутствие измеренного окна
|
||||
— как `null`, а не как нулевая метка времени: правдоподобная дата в ответе
|
||||
неотличима от настоящей.
|
||||
|
||||
Семантика границ различна, поэтому имена различны:
|
||||
|
||||
- `from`/`to` слоя — метки **первой и последней точки** слоя, включительно;
|
||||
- `first_hour`/`last_hour` — **ярлыки часов**, первого и последнего часа окна
|
||||
измерения, включительно.
|
||||
|
||||
Порядок метрик и слоёв в ответе MUST быть детерминированным, чтобы два ответа
|
||||
на одинаковом состоянии витрины совпадали побайтово.
|
||||
|
||||
Поле `style` называет род (`cumulative` / `instant` / `unknown`), а не «kind»:
|
||||
слово `kind` в проекте уже занято родом секции записи (`record.kind`), и два
|
||||
разных смысла под одним именем в одном API — вечная сноска.
|
||||
|
||||
#### Scenario: Пустая витрина отдаётся пустым списком
|
||||
|
||||
- **WHEN** каталог запрашивается на пустой витрине
|
||||
- **THEN** тело ответа — `{"metrics":[]}`
|
||||
|
||||
#### Scenario: Род не измерен
|
||||
|
||||
- **WHEN** у метрики нет общих часов двух слоёв
|
||||
- **THEN** `style` равен `unknown`, `hours` равен нулю, а `first_hour` и
|
||||
`last_hour` равны `null`
|
||||
|
||||
#### Scenario: Два запроса подряд дают один ответ
|
||||
|
||||
- **WHEN** каталог запрашивается дважды на неизменившейся витрине
|
||||
- **THEN** тела ответов совпадают побайтово
|
||||
|
||||
### Requirement: Число точки берётся из одного объявленного поля
|
||||
|
||||
Система SHALL считать числом точки значение поля `qty`, а при его отсутствии —
|
||||
значение поля `Avg`, и MUST NOT выводить число из других полей.
|
||||
|
||||
Порядок именно такой: `qty` несут все метрики, `Avg` — только `heart_rate`, и
|
||||
без второго кандидата самая важная метрика потока не измерялась бы вовсе.
|
||||
|
||||
**Ноль — значение, а не отсутствие.** Правило пустоты, принятое для сравнения
|
||||
полноты точек, здесь неприменимо: там ноль считается пустотой, чтобы точка без
|
||||
измерений не вытесняла настоящее измерение, а тут нулевой час обязан дойти до
|
||||
правила различимости и быть отброшенным им, а не исчезнуть раньше и молча.
|
||||
|
||||
Значение, которое не разбирается как конечное число (строка, `null`, объект,
|
||||
переполнение), считается неприсланным: бесконечность, попавшая в сумму,
|
||||
отравляет и сумму, и среднее всего часа.
|
||||
|
||||
Точка без числа в сумму не входит и число точек часа не увеличивает.
|
||||
|
||||
К `Avg` система переходит только при **отсутствующем или `null`** `qty`. `qty`
|
||||
не того типа означает, что форма точки изменилась, и догадываться о числе не о
|
||||
чем: точка считается не несущей значения целиком.
|
||||
|
||||
#### Scenario: Точка несёт только qty
|
||||
|
||||
- **WHEN** точка имеет вид `{"qty":72.5,"date":"…"}`
|
||||
- **THEN** её число равно `72.5`
|
||||
|
||||
#### Scenario: Точка несёт Min/Avg/Max без qty
|
||||
|
||||
- **WHEN** точка имеет вид `{"Min":60,"Avg":70,"Max":80,"date":"…"}`
|
||||
- **THEN** её число равно значению `Avg`
|
||||
|
||||
#### Scenario: Нулевое значение остаётся значением
|
||||
|
||||
- **WHEN** точка имеет вид `{"qty":0,"date":"…"}`
|
||||
- **THEN** её число равно нулю, и точка считается несущей значение
|
||||
|
||||
#### Scenario: Значение не разбирается как конечное число
|
||||
|
||||
- **WHEN** точка несёт `qty` строкой или числом вне диапазона `float64`
|
||||
- **THEN** точка считается не несущей значения и в сумму не входит
|
||||
|
||||
### Requirement: Род агрегации выводится сверкой минутного и часового слоёв
|
||||
|
||||
Система SHALL выводить род агрегации метрики (`cumulative` / `instant` /
|
||||
`unknown`) сравнением её часового слоя с минутным и MUST NOT определять его по
|
||||
имени метрики, единицам, форме точки или заголовку доставки.
|
||||
|
||||
Час **пригоден** для сверки, когда выполнено всё:
|
||||
|
||||
- у метрики есть объекты обоих слоёв за этот час;
|
||||
- час не лежит в будущем — его метка не позже текущего времени плюс запас;
|
||||
- единицы обоих объектов совпадают;
|
||||
- часовой объект несёт ровно одну точку, и она несёт значение, а её метка
|
||||
совпадает с началом часа;
|
||||
- у минутного объекта не меньше двух точек со значением;
|
||||
- сумма минутных значений **отличима** от их среднего.
|
||||
|
||||
**Горизонт обязателен, и это не защита от вредителя, а условие корректности.**
|
||||
Час объекта берётся из метки в теле доставки, а тело не наше: одна доставка с
|
||||
метками в будущем занимает окно целиком и подменяет измеренный род метрики —
|
||||
построено и прогнано, мгновенная метрика объявлялась накопительной при нуле
|
||||
противоречащих часов. Запас нужен на расхождение часов телефона и сервера.
|
||||
Данные, помеченные будущим, MUST порождать предупреждение владельцу: это либо
|
||||
сбитые часы, либо чужое тело, и оба случая лечатся не кодом.
|
||||
|
||||
**Совпадение единиц обязательно.** Мгновенная метрика, приехавшая минутным
|
||||
слоем в `count/min` и часовым в `count/hour`, даёт в полном часе
|
||||
`часовое = 60 · среднее = сумма` — то есть **уверенный ложный** `cumulative` при
|
||||
нуле противоречащих часов. Правило единогласия этот случай не ловит по
|
||||
построению: противоречия нет, есть молчание.
|
||||
|
||||
**Часовой объект несёт ровно одну точку.** Две точки за час описывают разные
|
||||
интервалы, и какая из них относится к часу целиком — неизвестно; час непригоден
|
||||
целиком, а не «по той, у которой есть значение».
|
||||
|
||||
Требование выравнивания часовой метки закрывает зоны с неполночасовым
|
||||
смещением: слой выводится по выравниванию метки в исходной зоне, а объект
|
||||
адресуется часом UTC, поэтому в зоне `+0530` часовая точка описывает не тот
|
||||
интервал, который покрывают минутные точки того же объекта. Сравнивать их
|
||||
нельзя, и такой час свидетельства не даёт.
|
||||
|
||||
Требование различимости обязательно: в часе, где все значения нули, сумма равна
|
||||
среднему, и совпадение с любой из гипотез не значит ничего.
|
||||
|
||||
Все три сравнения — «сходится с суммой», «сходится со средним», «сумма отличима
|
||||
от среднего» — MUST выполняться **одним предикатом с одним допуском**:
|
||||
относительным, величиной `1e-9`. Тогда час, подтверждающий обе гипотезы сразу,
|
||||
невыразим по построению, и исход не зависит от порядка веток.
|
||||
|
||||
Величина названа числом, потому что от неё зависят счётчики основания в ответе:
|
||||
измерено, что вердикты метрик на живом корпусе одинаковы при допуске от `1e-9`
|
||||
до `1e-3`, а число согласных часов у `heart_rate` при этом меняется с 29 на 49.
|
||||
Взято строгое значение: канонизация содержимого округляет числа до 12 значащих
|
||||
цифр, то есть всё, что крупнее `1e-12`, представлением не объясняется, а
|
||||
`1e-9` оставляет три порядка запаса и остаётся на шесть порядков строже любого
|
||||
содержательного расхождения (сумма и среднее при `n ≥ 2` различаются не меньше
|
||||
чем вдвое).
|
||||
|
||||
Абсолютного порога у сравнения нет намеренно: около нуля относительный допуск
|
||||
вырождается в сторону «не сходится», то есть даёт «свидетельства нет», а не
|
||||
ложный род.
|
||||
|
||||
Вердикт пригодного часа: часовое значение сходится с суммой минутных —
|
||||
`cumulative`, со средним — `instant`, иначе час свидетельства не даёт.
|
||||
|
||||
Сумма минутных значений MUST считаться в порядке возрастания метки точки, чтобы
|
||||
вердикт не зависел от порядка точек внутри объекта.
|
||||
|
||||
#### Scenario: Часовое значение равно сумме минутных
|
||||
|
||||
- **GIVEN** у метрики есть минутный и часовой объекты за один час
|
||||
- **WHEN** часовое значение сходится с суммой минутных значений
|
||||
- **THEN** метрика получает род `cumulative`
|
||||
|
||||
#### Scenario: Часовое значение равно среднему минутных
|
||||
|
||||
- **WHEN** часовое значение сходится со средним минутных значений
|
||||
- **THEN** метрика получает род `instant`
|
||||
|
||||
#### Scenario: Нулевой час свидетельством не является
|
||||
|
||||
- **GIVEN** все минутные значения часа равны нулю, и часовое значение тоже
|
||||
- **WHEN** измеряется род
|
||||
- **THEN** этот час непригоден и в подсчёт согласных не идёт
|
||||
|
||||
#### Scenario: Час лежит в будущем
|
||||
|
||||
- **GIVEN** доставка принесла объекты обоих слоёв с метками позже текущего
|
||||
времени
|
||||
- **WHEN** измеряется род
|
||||
- **THEN** эти часы в окно не входят, род остаётся измеренным по настоящей
|
||||
истории, и владельцу пишется предупреждение
|
||||
|
||||
#### Scenario: Единицы слоёв разошлись
|
||||
|
||||
- **GIVEN** минутный объект часа несёт одни единицы, а часовой — другие
|
||||
- **WHEN** измеряется род
|
||||
- **THEN** час непригоден и свидетельства не даёт
|
||||
|
||||
#### Scenario: Часовой объект несёт две точки
|
||||
|
||||
- **GIVEN** у метрики за час есть часовой объект с двумя точками
|
||||
- **WHEN** измеряется род
|
||||
- **THEN** час непригоден и свидетельства не даёт
|
||||
|
||||
#### Scenario: Минутный объект несёт одну точку
|
||||
|
||||
- **GIVEN** минутный объект часа несёт единственную точку
|
||||
- **WHEN** измеряется род
|
||||
- **THEN** час непригоден: сумма и среднее совпадают, различить гипотезы нечем
|
||||
|
||||
#### Scenario: Метка часовой точки не выровнена на начало часа
|
||||
|
||||
- **GIVEN** часовая точка стоит на середине часа UTC
|
||||
- **WHEN** измеряется род
|
||||
- **THEN** час непригоден и свидетельства не даёт
|
||||
|
||||
#### Scenario: Форма точки на исход не влияет
|
||||
|
||||
- **WHEN** метрика приходит только с полем `qty`, без `Avg`/`Min`/`Max`
|
||||
- **THEN** род всё равно измеряется сверкой слоёв, а не выводится из формы
|
||||
|
||||
### Requirement: Род объявляется только при единогласном свидетельстве
|
||||
|
||||
Система SHALL объявлять род метрики, только если согласных часов не меньше трёх
|
||||
и ни один час не дал противоположного вердикта. В остальных случаях род MUST
|
||||
быть `unknown`, и агрегация по такой метрике предлагаться MUST NOT.
|
||||
|
||||
Наличие противоречащих часов MUST быть записано чекпоинтом уровня `WARN` с
|
||||
именем метрики и числами основания, без значений точек: род — свойство, на
|
||||
котором Read API строит арифметику года, и его смена не имеет права проходить
|
||||
молча. На живом корпусе противоречащих часов не встретилось ни разу, поэтому
|
||||
шума правило не создаёт.
|
||||
|
||||
Единогласие, а не большинство: противоречащий час означает, что одна из гипотез
|
||||
для этой метрики ложна, и объявлять род при известном контрпримере нельзя. Порог
|
||||
в три часа — потому что на этом роде потом суммируют год, а один совпавший час
|
||||
остаётся свидетельством одного часа.
|
||||
|
||||
Следствие принято вслух: род есть функция окна, поэтому час, въехавший в окно,
|
||||
может сменить объявленный род без единой новой доставки за спрошенный период.
|
||||
Клиент, которому это важно, различает случаи по основанию измерения — оно
|
||||
отдаётся вместе с родом.
|
||||
|
||||
#### Scenario: Свидетельства противоречат
|
||||
|
||||
- **GIVEN** у метрики есть часы с вердиктом `cumulative` и часы с вердиктом
|
||||
`instant`
|
||||
- **WHEN** измеряется род
|
||||
- **THEN** род равен `unknown`, число противоречащих часов отдаётся в каталоге,
|
||||
и пишется `WARN` с именем метрики
|
||||
|
||||
#### Scenario: Свидетельств мало
|
||||
|
||||
- **WHEN** согласных часов меньше трёх
|
||||
- **THEN** род равен `unknown`
|
||||
|
||||
#### Scenario: Второго слоя нет вовсе
|
||||
|
||||
- **WHEN** метрика лежит только в одном слое
|
||||
- **THEN** род равен `unknown`, а число часов окна равно нулю
|
||||
|
||||
### Requirement: Нижний слой в измерении не участвует
|
||||
|
||||
Система SHALL измерять род только по слоям `minute` и `hour` и MUST NOT
|
||||
использовать в сверке слои `raw`, `sample` и `day`.
|
||||
|
||||
Нижний слой HAE — не сэмплы, а посекундная развёртка настоящих сэмплов с
|
||||
инфляцией до 478×: его сумма завышена и в сверке не сходится. Слой `sample`
|
||||
несёт собственные интервалы сэмплов, и его сверка с часовым слоем — другая
|
||||
задача, вместе с импортом родного экспорта. Слой `day` — суточная сводка сна,
|
||||
другая схема под тем же именем, а не разрез часов.
|
||||
|
||||
#### Scenario: Метрика есть только в нижнем слое
|
||||
|
||||
- **WHEN** у метрики есть объекты только в слое `raw`
|
||||
- **THEN** род равен `unknown`
|
||||
|
||||
#### Scenario: Нижний слой не подменяет минутный
|
||||
|
||||
- **GIVEN** у метрики есть слои `raw` и `hour`, но нет `minute`
|
||||
- **WHEN** измеряется род
|
||||
- **THEN** сверка не выполняется и род равен `unknown`
|
||||
|
||||
#### Scenario: Метрика лежит только в суточном слое
|
||||
|
||||
- **WHEN** у метрики есть объекты только слоя `day`
|
||||
- **THEN** слой объявлен в каталоге, а род равен `unknown`
|
||||
|
||||
### Requirement: Каталог отдаёт основание измерения, а не только вывод
|
||||
|
||||
Система SHALL отдавать вместе с родом четыре числа и границы окна, и клиент MUST
|
||||
иметь возможность отличить «свидетельств не было» от «свидетельства
|
||||
противоречат», не делая второго запроса.
|
||||
|
||||
Числа определены так, что их разность осмысленна:
|
||||
|
||||
- `hours` — сколько общих часов двух слоёв попало в окно;
|
||||
- `compared` — сколько из них оказалось **пригодными**;
|
||||
- `agreeing` — сколько пригодных часов дали **преобладающий** вердикт (при
|
||||
объявленном роде это он и есть);
|
||||
- `conflicting` — сколько дали другой.
|
||||
|
||||
Разложение одно и то же независимо от того, объявлен род или нет: иначе
|
||||
`agreeing` пришлось бы толковать по-разному в двух ветках, и клиент читал бы
|
||||
одно поле двумя способами.
|
||||
|
||||
Разность `compared − agreeing − conflicting` — часы, не сошедшиеся ни с одной
|
||||
гипотезой; разность `hours − compared` — часы, отброшенные проверкой
|
||||
пригодности. Без этого различения `hours` в одиночку выдавал бы «измерение шло,
|
||||
данные молчат» там, где ни один час не был пригоден вовсе.
|
||||
|
||||
`first_hour` и `last_hour` — границы окна; род объявляется вместе с периодом, на
|
||||
котором измерен, потому что окно ограничено самыми свежими общими часами, а не
|
||||
всей историей.
|
||||
|
||||
#### Scenario: Род измерен
|
||||
|
||||
- **WHEN** метрика получила род `cumulative`
|
||||
- **THEN** рядом стоят число часов окна, число пригодных, число согласных, ноль
|
||||
противоречащих и границы окна
|
||||
|
||||
#### Scenario: Часы были, но ни один не пригоден
|
||||
|
||||
- **WHEN** все часы окна отброшены проверкой пригодности
|
||||
- **THEN** `hours` больше нуля, `compared` равен нулю, род равен `unknown`
|
||||
|
||||
### Requirement: Окно измерения ограничено сорока восемью часами
|
||||
|
||||
Система SHALL измерять род по не более чем 48 самым свежим общим часам метрики
|
||||
и MUST NOT читать ради этого всю историю: стоимость каталога не имеет права
|
||||
расти вместе с журналом.
|
||||
|
||||
Число названо в спеке, а не оставлено реализации, по той же причине, что и
|
||||
порог согласных часов: от него зависят счётчики основания в ответе.
|
||||
|
||||
Измерено, что на живом корпусе окно сохраняет вердикты всех метрик, кроме
|
||||
редких: у `physical_effort` за всю историю набиралось пять согласных часов, а в
|
||||
последних сорока восьми — два, и метрика честно уходит в `unknown`. Это не
|
||||
издержка, а то же правило: свидетельств в свежем окне действительно мало.
|
||||
|
||||
Окно ограничено и сверху — часами не позже текущего времени плюс запас, см.
|
||||
правило пригодности часа.
|
||||
|
||||
#### Scenario: История длиннее окна
|
||||
|
||||
- **GIVEN** у метрики общих часов больше сорока восьми
|
||||
- **WHEN** измеряется род
|
||||
- **THEN** сравниваются только сорок восемь самых свежих, и `hours` равен
|
||||
сорока восьми
|
||||
|
||||
### Requirement: Измеренный род нигде не сохраняется
|
||||
|
||||
Система SHALL вычислять род при каждом запросе каталога и MUST NOT хранить его
|
||||
ни колонкой, ни кешем.
|
||||
|
||||
Хранимое значение было бы вторым производным состоянием рядом с витриной: его
|
||||
пришлось бы пересчитывать после каждой свёртки, переносить или не переносить
|
||||
пересборкой и объяснять, на каком составе данных оно снято; устаревшее значение
|
||||
при этом выглядит ровно как свежее. Вычисленный на запрос род есть функция
|
||||
витрины, а витрина — функция журнала, и устаревать в нём нечему.
|
||||
|
||||
#### Scenario: Новая доставка меняет род без перезапуска
|
||||
|
||||
- **GIVEN** метрика числится `unknown`, потому что общих часов было мало
|
||||
- **WHEN** приезжает доставка, добавляющая согласные часы, и каталог
|
||||
запрашивается снова
|
||||
- **THEN** ответ отдаёт новый род, и перезапуск сервиса для этого не нужен
|
||||
|
||||
### Requirement: Каталог читается одним снимком витрины
|
||||
|
||||
Система SHALL собирать ответ каталога из одного снимка базы: разрезы, границы и
|
||||
объекты окна измерения MUST читаться в одной транзакции чтения.
|
||||
|
||||
Приём идёт непрерывно, и фоновая свёртка пишет в витрину во время запроса.
|
||||
Запросы вне общей транзакции дали бы смесь «разрезы до» и «род после» — ответ,
|
||||
внутренне противоречивый и неотличимый от обычного свежего.
|
||||
|
||||
Число обращений к хранилищу на один запрос каталога MUST быть ограничено
|
||||
константой на метрику и не зависеть от размера окна: чтение объектов окна по
|
||||
одному даёт тысячи обращений там, где хватает двух на метрику.
|
||||
|
||||
#### Scenario: Доставка приезжает во время сборки каталога
|
||||
|
||||
- **GIVEN** каталог собирается, и в этот момент фоновая свёртка пишет объекты
|
||||
- **WHEN** ответ сформирован
|
||||
- **THEN** он целиком описывает одно состояние витрины
|
||||
|
||||
#### Scenario: Размер окна не умножает число запросов
|
||||
|
||||
- **WHEN** окно измерения увеличено
|
||||
- **THEN** число обращений к хранилищу на метрику не меняется
|
||||
|
||||
### Requirement: Каталог доступен по токену чтения
|
||||
|
||||
Система SHALL требовать токен чтения на маршруте каталога и MUST NOT принимать
|
||||
на нём токен приёма. Токен MUST передаваться заголовком `Authorization` со
|
||||
схемой `Bearer`; значение без этой схемы токеном не считается.
|
||||
|
||||
Пустой список токенов чтения означает выключенную проверку, и о выключенной
|
||||
проверке сервис предупреждает на старте — тем же способом, что о выключенной
|
||||
проверке приёма. Цена симметрии названа вслух: у приёма открытый контур означает
|
||||
мусор во входе, у чтения — выгрузку данных о здоровье, поэтому перед выкладкой
|
||||
наружу список обязан быть непуст. Отвечает за это отдельная задача об управлении
|
||||
секретами; здесь фиксируется, что предупреждение существует и адресовано
|
||||
владельцу.
|
||||
|
||||
Токен чтения MUST вычищаться из сохраняемых заголовков доставки наравне с
|
||||
токеном приёма: заголовок с произвольным именем иначе донесёт его до базы.
|
||||
|
||||
Контуры раздельны по архитектуре: клиент, читающий данные, писать не может, и
|
||||
обратное тоже неверно.
|
||||
|
||||
#### Scenario: Запрос без токена при заданном списке
|
||||
|
||||
- **GIVEN** список токенов чтения непуст
|
||||
- **WHEN** каталог запрашивается без заголовка `Authorization`
|
||||
- **THEN** ответ — 401, и данные не отдаются
|
||||
|
||||
#### Scenario: Токен приёма каталога не открывает
|
||||
|
||||
- **GIVEN** заданы разные списки токенов приёма и чтения
|
||||
- **WHEN** каталог запрашивается с токеном приёма
|
||||
- **THEN** ответ — 401
|
||||
|
||||
#### Scenario: Токен без схемы Bearer
|
||||
|
||||
- **GIVEN** список токенов чтения непуст
|
||||
- **WHEN** каталог запрашивается с заголовком `Authorization`, где стоит голое
|
||||
значение токена без слова `Bearer`
|
||||
- **THEN** ответ — 401
|
||||
|
||||
#### Scenario: Проверка выключена
|
||||
|
||||
- **GIVEN** список токенов чтения пуст
|
||||
- **WHEN** каталог запрашивается без заголовка `Authorization`
|
||||
- **THEN** каталог отдаётся
|
||||
|
||||
#### Scenario: О выключенной проверке предупреждают на старте
|
||||
|
||||
- **GIVEN** список токенов чтения пуст
|
||||
- **WHEN** сервис стартует
|
||||
- **THEN** в логе появляется предупреждение владельцу
|
||||
|
||||
#### Scenario: Токен чтения не оседает в учёте доставки
|
||||
|
||||
- **GIVEN** токен чтения послан на маршрут приёма заголовком с произвольным
|
||||
именем
|
||||
- **WHEN** доставка учтена
|
||||
- **THEN** в сохранённых заголовках вместо значения стоит пометка о сокрытии
|
||||
@@ -0,0 +1,49 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Перечисление разрезов не читает содержимое объектов
|
||||
|
||||
Хранилище SHALL отвечать на вопрос «какие слои есть у метрики, за какой период и
|
||||
сколько в них точек» по учётным колонкам объекта, не разжимая `payload` и не
|
||||
затрагивая страниц с содержимым. Тот же запрет действует на поиск часов, за
|
||||
которые у метрики есть объекты сразу в двух слоях.
|
||||
|
||||
Запрет ограничен именно этими двумя выборками. Измерение рода обязано прочитать
|
||||
значения точек, то есть разжать содержимое объектов окна, и требование его не
|
||||
касается — иначе оно запрещало бы то, ради чего каталог существует.
|
||||
|
||||
Причина в форме таблицы: `bucket` объявлена `WITHOUT ROWID`, то есть строка
|
||||
целиком, вместе со сжатым содержимым, живёт в дереве первичного ключа. Обход
|
||||
всех строк ради агрегата тащил бы за собой страницы содержимого — при 260 тысячах
|
||||
объектов за год это сотни мегабайт на каждый запрос каталога, притом что сам
|
||||
ответ несёт три десятка строк.
|
||||
|
||||
Поэтому колонки, по которым отвечают эти выборки, MUST быть покрыты индексом, и
|
||||
новая колонка, попадающая в ответ каталога, входит в него тем же изменением.
|
||||
|
||||
#### Scenario: Разрезы метрики за длинную историю
|
||||
|
||||
- **GIVEN** в витрине объекты за многие месяцы
|
||||
- **WHEN** запрашиваются слои метрики с границами и числом точек
|
||||
- **THEN** запрос отвечает по индексу, не читая содержимого объектов
|
||||
|
||||
#### Scenario: Общие часы двух слоёв
|
||||
|
||||
- **WHEN** запрашиваются самые свежие часы, за которые у метрики есть объекты и
|
||||
в минутном, и в часовом слое
|
||||
- **THEN** запрос отвечает по индексу и читает не больше запрошенного числа
|
||||
часов
|
||||
|
||||
### Requirement: Объекты перечисленных часов читаются пакетом
|
||||
|
||||
Хранилище SHALL уметь отдать объекты двух слоёв за перечисленные часы одной
|
||||
метрики **одним запросом**, а не по объекту за раз.
|
||||
|
||||
Чтение по одному даёт число обращений, растущее вместе с окном измерения, и
|
||||
делает каждое обращение собственной транзакцией — то есть ответ, собранный из
|
||||
разных снимков витрины под непрерывным приёмом.
|
||||
|
||||
#### Scenario: Окно из многих часов
|
||||
|
||||
- **GIVEN** запрошены объекты двух слоёв за сорок восемь часов
|
||||
- **WHEN** выполняется выборка
|
||||
- **THEN** число обращений к базе не зависит от числа часов
|
||||
@@ -0,0 +1,133 @@
|
||||
## 1. Схема
|
||||
|
||||
- [x] 1.1 Миграция `00009_bucket_catalog.sql` — покрывающий индекс
|
||||
`bucket(metric, layer, hour_utc, first_ts, last_ts, points, units)`
|
||||
- [x] 1.2 Обновить `docs/database.md`: индекс и зачем он
|
||||
|
||||
## 2. Хранилище
|
||||
|
||||
- [x] 2.1 `store.ReadCatalog(ctx, store.CatalogWindow)` — весь вход каталога
|
||||
**одной транзакцией чтения**: разрезы всех метрик, общие часы пары слоёв
|
||||
по каждой метрике, объекты окна обоих слоёв
|
||||
- [x] 2.2 Разрезы — одним запросом `metric, layer, units, min(first_ts),
|
||||
max(last_ts), sum(points)`, группировка вместе с единицами (расхождение
|
||||
видно, а не выбирается молча)
|
||||
- [x] 2.3 Общие часы — самые свежие часы с объектами обоих слоёв, от свежих к
|
||||
старым, не больше `window`
|
||||
- [x] 2.4 Объекты окна — **пакетом**, один запрос на метрику на оба слоя
|
||||
(`hour_utc IN (…)`), а не по объекту за раз
|
||||
- [x] 2.5 Тест: разрезы и общие часы отвечают по индексу (`EXPLAIN QUERY PLAN`
|
||||
не содержит обхода таблицы)
|
||||
- [x] 2.6 Тест: число обращений к базе на метрику не зависит от размера окна
|
||||
- [x] 2.7 Тест: `CommonHours` отдаёт не больше `window` и именно свежие часы
|
||||
|
||||
## 3. Значение точки
|
||||
|
||||
- [x] 3.1 `hae.PointValue(raw)` — число точки: `qty`, при его отсутствии `Avg`;
|
||||
разбор через `*json.Number`, **ноль — значение**, отсутствие ключа и
|
||||
`null` — нет значения, нечисловое и не влезающее в `float64` (`ErrRange`,
|
||||
`±Inf`) — нет значения
|
||||
- [x] 3.2 В док-комментарии сказать, что словарь пустоты `canon` сюда не
|
||||
применяется, и почему
|
||||
- [x] 3.3 Тесты на реальных формах точки из `testdata`: `heart_rate` с
|
||||
`Min`/`Avg`/`Max` без `qty`, `{"qty":0}`, точка без числового поля,
|
||||
`1e400`
|
||||
|
||||
## 4. Измерение рода
|
||||
|
||||
- [x] 4.1 Пакет `internal/catalog`: тип рода (`cumulative`/`instant`/`unknown`,
|
||||
пустое значение невыразимо) и основание измерения
|
||||
(`hours`/`compared`/`agreeing`/`conflicting`/границы окна)
|
||||
- [x] 4.2 Именованная константа допуска `1e-9` с измеренной ценой в комментарии
|
||||
и **один** предикат `close(a, b)`, которым выражены все три сравнения
|
||||
- [x] 4.3 Пригодность часа: ровно одна точка со значением у часового объекта,
|
||||
её метка совпадает с началом часа, ≥2 точек со значением у минутного,
|
||||
сумма отличима от среднего
|
||||
- [x] 4.4 Сумма минутных значений считается в порядке возрастания метки
|
||||
- [x] 4.5 Правило метрики: ≥3 согласных и 0 противоречащих, иначе `unknown`
|
||||
- [x] 4.6 Окно 48 самых свежих общих часов
|
||||
- [x] 4.7 Сборка каталога: разрезы из снимка + род из измерения; строки слоя,
|
||||
разошедшиеся единицами, схлопываются в один элемент, множество единиц
|
||||
метрики отсортировано
|
||||
- [x] 4.8 Чекпоинт `WARN` при `conflicting > 0`: имя метрики и числа основания,
|
||||
без значений точек
|
||||
- [x] 4.9 Тесты: накопительная, мгновенная, нулевой час, двухточечный часовой
|
||||
объект, одноточечный минутный, невыровненная часовая метка, противоречие,
|
||||
единственный слой, только слой `day`, история длиннее окна, разошедшиеся
|
||||
единицы, идемпотентность двух вызовов
|
||||
|
||||
## 5. HTTP
|
||||
|
||||
- [x] 5.1 Проверка токена **одна на оба контура**, параметризованная списком;
|
||||
схема строгая (`Bearer`), пустой список = выключено
|
||||
- [x] 5.2 Предупреждение на старте о выключенной проверке чтения
|
||||
- [x] 5.3 Редакция сохраняемых заголовков доставки чистит токены **обоих**
|
||||
контуров
|
||||
- [x] 5.4 `GET /api/v1/metrics` — форма ответа из дизайна: `style`, `hours`,
|
||||
`compared`, `agreeing`, `conflicting`, `first_hour`, `last_hour`; срезы
|
||||
пустые, а не nil; границы окна — указатели; порядок детерминирован
|
||||
- [x] 5.5 Тесты: 401 без токена, 401 с токеном приёма, 401 с голым значением без
|
||||
схемы, отдача при выключенной проверке, пустая витрина — **сравнением
|
||||
байтов** ответа с литералом
|
||||
- [x] 5.6 `config.example.toml` и `config.docker.toml`: `read_tokens` перестал
|
||||
быть заделом на будущее, цена пустого списка названа комментарием
|
||||
|
||||
## 6. Проверка на живом архиве
|
||||
|
||||
- [x] 6.1 Прогон измерения в `internal/replay/archive_test.go`: свойства, а не
|
||||
числа — конфликтующих свидетельств ноль; накопительные и мгновенные
|
||||
метрики разошлись по родам; ни одна метрика не измерена по слою `raw`
|
||||
- [x] 6.2 Печать измеренного рода по метрикам и стоимости каталога в `t.Logf`
|
||||
- [x] 6.3 `task verify:archive` зелёный, отпечаток витрины не изменился
|
||||
|
||||
## 7. Документация и беклог
|
||||
|
||||
- [x] 7.1 `docs/architecture.md`: метод измерения, окно, порог, допуск, почему
|
||||
род не хранится, почему родов два, а не четыре, где нужен `xFilesFactor` и
|
||||
какой у него подвох с полярностью
|
||||
- [x] 7.2 `docs/architecture.md`: форма каталога приведена к реализованной
|
||||
- [x] 7.3 `docs/local-research.md`: находка с результатом измерения на живом
|
||||
корпусе
|
||||
- [x] 7.4 Блокер «тай-брейк при равной полноте точек» в беклог, с вариантами,
|
||||
ценой и рекомендацией
|
||||
- [x] 7.5 Пометка в `docs/backlog/read-api-tochki.md`: порог неполного ведра,
|
||||
его полярность и предел размера ответа решаются там
|
||||
- [x] 7.6 Пометка в `docs/backlog/upravlenie-sekretami.md`: контуров теперь два
|
||||
- [x] 7.7 Убрать задачу из беклога, обновить индекс
|
||||
|
||||
## 8. Дозакрыто по ревью кода
|
||||
|
||||
- [x] 8.0 Горизонт окна: часы позже `now + час` в сверку не входят, данные из
|
||||
будущего пишутся `WARN`
|
||||
- [x] 8.0 Единицы обеих сторон обязаны совпасть — иначе уверенный ложный род
|
||||
- [x] 8.0 Содержимое разжимается только у часов, прошедших отбор по учётным
|
||||
колонкам
|
||||
- [x] 8.0 Имя метрики в логе обрезано, множество единиц ограничено потолком
|
||||
- [x] 8.0 Метрика с пустым именем показывается, а не выбрасывается сентинелом
|
||||
- [x] 8.0 Отмена снаружи не пишется как сбой сервиса
|
||||
- [x] 8.0 Байтовый тест непустого ответа и повтора запроса
|
||||
|
||||
## 9. Приёмочные критерии (рубрика ревью предложения)
|
||||
|
||||
- [x] 9.1 Вердикт — чистая функция состояния витрины и окна: не зависит от
|
||||
порядка строк SQL, порядка точек в объекте и момента вызова
|
||||
- [x] 9.2 Допуск назван величиной, один предикат на все сравнения, поведение
|
||||
около нуля объявлено
|
||||
- [x] 9.3 Вырожденные свидетельства исключены явно, кворум назван числом, ниже
|
||||
кворума исход — `unknown`, а не умолчание
|
||||
- [x] 9.4 `unknown` — исход первого класса, и его причины различимы клиентом без
|
||||
второго запроса
|
||||
- [x] 9.5 Измерение ничего не пишет и не кешируется скрытно
|
||||
- [x] 9.6 Стоимость ответа ограничена сверху и по числу запросов, и по числу
|
||||
прочитанных страниц; не растёт вместе с историей
|
||||
- [x] 9.7 HTTP-контракт полон: только `GET`, пустая витрина — 200 с пустым
|
||||
списком, авторизация до работы, токены и значения здоровья не в логах
|
||||
выше `DEBUG`
|
||||
- [x] 9.8 Ответ самоописателен: словарь слоёв тот же, что везде; границы
|
||||
объявляют, что метят; расхождение единиц показано, а не выбрано молча
|
||||
- [x] 9.9 Каталог отдаёт наблюдаемое, а не досчитанное; деталь хранения наружу
|
||||
не протекает
|
||||
- [x] 9.10 Нижний слой в сверке не участвует; `source` в измерение не входит
|
||||
- [x] 9.11 Смена вердикта наблюдаема чекпоинтом
|
||||
- [x] 9.12 Правило часа и правило метрики тестируются без БД; на живом архиве
|
||||
проверяются свойства, а не числа
|
||||
@@ -0,0 +1,526 @@
|
||||
# catalog Specification
|
||||
|
||||
## Purpose
|
||||
|
||||
Отвечает потребителю на два вопроса: «что у тебя вообще есть» — метрики,
|
||||
единицы, слои с границами данных и числом точек — и «какая свёртка по этой
|
||||
метрике осмысленна». Второй ответ **измеряется** сверкой минутного слоя с
|
||||
часовым, а не размечается руками: HAE рода не шлёт, и всё, что можно было бы
|
||||
объявить, пришлось бы угадать. Род неизвестен — свёртка не предлагается вовсе.
|
||||
## Requirements
|
||||
### Requirement: Каталог разрезов отдаёт наблюдаемое состояние витрины
|
||||
|
||||
Система SHALL отдавать каталог метрик, где по каждой метрике перечислены
|
||||
единицы и слои с границами данных и числом точек. Каталог MUST показывать
|
||||
только то, что в витрине есть: досчитывать отсутствующий слой,
|
||||
экстраполировать границы или помнить о том, чего больше нет, он MUST NOT.
|
||||
|
||||
**Границы слоя — это границы данных, а не обещание покрытия.** Внутри
|
||||
диапазона законно есть дыры: часы, за которые доставок не было, и периоды,
|
||||
верхние слои которых не пережили пересборку. Поэтому правило выбора слоя в
|
||||
Read API MUST опираться на фактические объекты запрошенного диапазона, а не
|
||||
считать каталожную пару границ доказательством непрерывности.
|
||||
|
||||
Слои — часть контракта, а не деталь хранения: без каталога вопрос «в каком
|
||||
разрезе спрашивать» не задать. Часовой объект при этом деталью остаётся, и его
|
||||
число в ответ не идёт.
|
||||
|
||||
Отсюда честность после пересборки: экспорт Apple восстанавливает только слой
|
||||
`sample`, а `minute` и `hour` за периоды с удалёнными доставками не воскресают.
|
||||
Метрика, потерявшая слой целиком, объявляет его отсутствие тем, что слоя нет в
|
||||
списке.
|
||||
|
||||
Метрика с пустым именем — законное значение колонки, и каталог MUST показывать
|
||||
её наравне с остальными: терять на границе, которая отвечает «что у тебя вообще
|
||||
есть», нельзя ничего.
|
||||
|
||||
Единицы отдаются **множеством различных значений** метрики, отсортированным и
|
||||
ограниченным потолком (пустые в множество не входят):
|
||||
на живом потоке они не менялись ни разу, но одна форма поля для обоих случаев
|
||||
честнее строки, которая при расхождении молча выберет одно из двух. На слой при
|
||||
этом приходится **ровно один** элемент списка: объекты слоя с разными единицами
|
||||
дают общий диапазон и общую сумму точек, а различие видно множеством единиц
|
||||
метрики.
|
||||
|
||||
#### Scenario: Метрика лежит в нескольких слоях
|
||||
|
||||
- **WHEN** у метрики есть объекты в слоях `raw`, `minute` и `hour`
|
||||
- **THEN** каталог перечисляет все три слоя, у каждого — границы данных и число
|
||||
точек
|
||||
|
||||
#### Scenario: Слоя за период не осталось
|
||||
|
||||
- **GIVEN** витрина пересобрана, и у метрики остались объекты только слоя
|
||||
`sample`
|
||||
- **WHEN** запрашивается каталог
|
||||
- **THEN** у метрики объявлен слой `sample` и не объявлены `minute` и `hour`
|
||||
|
||||
#### Scenario: Внутри диапазона слоя есть дыра
|
||||
|
||||
- **GIVEN** у метрики есть объекты слоя `minute` за январь и за июнь, а между
|
||||
ними нет ни одного
|
||||
- **WHEN** запрашивается каталог
|
||||
- **THEN** слой `minute` объявлен один раз с границами от января до июня, и
|
||||
каталог не утверждает, что данные есть за весь этот период
|
||||
|
||||
#### Scenario: Единицы метрики разошлись
|
||||
|
||||
- **GIVEN** объекты одной метрики несут разные единицы
|
||||
- **WHEN** запрашивается каталог
|
||||
- **THEN** множество единиц метрики содержит оба значения, а слой остаётся одним
|
||||
элементом списка с объединённым диапазоном и суммой точек
|
||||
|
||||
#### Scenario: Метрика приехала без имени
|
||||
|
||||
- **GIVEN** в витрине есть объекты метрики с пустым именем
|
||||
- **WHEN** запрашивается каталог
|
||||
- **THEN** метрика присутствует в ответе со своими слоями
|
||||
|
||||
#### Scenario: Единиц у метрики стало неправдоподобно много
|
||||
|
||||
- **GIVEN** объекты метрики несут десятки различных строк единиц
|
||||
- **WHEN** запрашивается каталог
|
||||
- **THEN** множество единиц в ответе ограничено потолком, а слой остаётся одним
|
||||
элементом
|
||||
|
||||
#### Scenario: Витрина пуста
|
||||
|
||||
- **WHEN** в витрине нет ни одного объекта
|
||||
- **THEN** каталог отдаёт пустой список метрик, а не отказ
|
||||
|
||||
### Requirement: Форма ответа каталога
|
||||
|
||||
Система SHALL отдавать каталог по маршруту `GET /api/v1/metrics` в виде объекта
|
||||
с полем `metrics`. Каждая запись MUST нести поля `metric`, `units`,
|
||||
`aggregation` и `layers`; элемент `layers` — `layer`, `from`, `to`, `points`;
|
||||
объект `aggregation` — `style`, `hours`, `compared`, `agreeing`, `conflicting`,
|
||||
`first_hour`, `last_hour`.
|
||||
|
||||
Все перечисленные поля MUST присутствовать всегда, в том числе со значением
|
||||
`null`: клиент не должен выводить смысл из наличия или отсутствия ключа. Пустой
|
||||
список MUST отдаваться как `[]`, а не как `null`, и отсутствие измеренного окна
|
||||
— как `null`, а не как нулевая метка времени: правдоподобная дата в ответе
|
||||
неотличима от настоящей.
|
||||
|
||||
Семантика границ различна, поэтому имена различны:
|
||||
|
||||
- `from`/`to` слоя — метки **первой и последней точки** слоя, включительно;
|
||||
- `first_hour`/`last_hour` — **ярлыки часов**, первого и последнего часа окна
|
||||
измерения, включительно.
|
||||
|
||||
Порядок метрик и слоёв в ответе MUST быть детерминированным, чтобы два ответа
|
||||
на одинаковом состоянии витрины совпадали побайтово.
|
||||
|
||||
Поле `style` называет род (`cumulative` / `instant` / `unknown`), а не «kind»:
|
||||
слово `kind` в проекте уже занято родом секции записи (`record.kind`), и два
|
||||
разных смысла под одним именем в одном API — вечная сноска.
|
||||
|
||||
#### Scenario: Пустая витрина отдаётся пустым списком
|
||||
|
||||
- **WHEN** каталог запрашивается на пустой витрине
|
||||
- **THEN** тело ответа — `{"metrics":[]}`
|
||||
|
||||
#### Scenario: Род не измерен
|
||||
|
||||
- **WHEN** у метрики нет общих часов двух слоёв
|
||||
- **THEN** `style` равен `unknown`, `hours` равен нулю, а `first_hour` и
|
||||
`last_hour` равны `null`
|
||||
|
||||
#### Scenario: Два запроса подряд дают один ответ
|
||||
|
||||
- **WHEN** каталог запрашивается дважды на неизменившейся витрине
|
||||
- **THEN** тела ответов совпадают побайтово
|
||||
|
||||
### Requirement: Число точки берётся из одного объявленного поля
|
||||
|
||||
Система SHALL считать числом точки значение поля `qty`, а при его отсутствии —
|
||||
значение поля `Avg`, и MUST NOT выводить число из других полей.
|
||||
|
||||
Порядок именно такой: `qty` несут все метрики, `Avg` — только `heart_rate`, и
|
||||
без второго кандидата самая важная метрика потока не измерялась бы вовсе.
|
||||
|
||||
**Ноль — значение, а не отсутствие.** Правило пустоты, принятое для сравнения
|
||||
полноты точек, здесь неприменимо: там ноль считается пустотой, чтобы точка без
|
||||
измерений не вытесняла настоящее измерение, а тут нулевой час обязан дойти до
|
||||
правила различимости и быть отброшенным им, а не исчезнуть раньше и молча.
|
||||
|
||||
Значение, которое не разбирается как конечное число (строка, `null`, объект,
|
||||
переполнение), считается неприсланным: бесконечность, попавшая в сумму,
|
||||
отравляет и сумму, и среднее всего часа.
|
||||
|
||||
Точка без числа в сумму не входит и число точек часа не увеличивает.
|
||||
|
||||
К `Avg` система переходит только при **отсутствующем или `null`** `qty`. `qty`
|
||||
не того типа означает, что форма точки изменилась, и догадываться о числе не о
|
||||
чем: точка считается не несущей значения целиком.
|
||||
|
||||
#### Scenario: Точка несёт только qty
|
||||
|
||||
- **WHEN** точка имеет вид `{"qty":72.5,"date":"…"}`
|
||||
- **THEN** её число равно `72.5`
|
||||
|
||||
#### Scenario: Точка несёт Min/Avg/Max без qty
|
||||
|
||||
- **WHEN** точка имеет вид `{"Min":60,"Avg":70,"Max":80,"date":"…"}`
|
||||
- **THEN** её число равно значению `Avg`
|
||||
|
||||
#### Scenario: Нулевое значение остаётся значением
|
||||
|
||||
- **WHEN** точка имеет вид `{"qty":0,"date":"…"}`
|
||||
- **THEN** её число равно нулю, и точка считается несущей значение
|
||||
|
||||
#### Scenario: Значение не разбирается как конечное число
|
||||
|
||||
- **WHEN** точка несёт `qty` строкой или числом вне диапазона `float64`
|
||||
- **THEN** точка считается не несущей значения и в сумму не входит
|
||||
|
||||
### Requirement: Род агрегации выводится сверкой минутного и часового слоёв
|
||||
|
||||
Система SHALL выводить род агрегации метрики (`cumulative` / `instant` /
|
||||
`unknown`) сравнением её часового слоя с минутным и MUST NOT определять его по
|
||||
имени метрики, единицам, форме точки или заголовку доставки.
|
||||
|
||||
Час **пригоден** для сверки, когда выполнено всё:
|
||||
|
||||
- у метрики есть объекты обоих слоёв за этот час;
|
||||
- час не лежит в будущем — его метка не позже текущего времени плюс запас;
|
||||
- единицы обоих объектов совпадают;
|
||||
- часовой объект несёт ровно одну точку, и она несёт значение, а её метка
|
||||
совпадает с началом часа;
|
||||
- у минутного объекта не меньше двух точек со значением;
|
||||
- сумма минутных значений **отличима** от их среднего.
|
||||
|
||||
**Горизонт обязателен, и это не защита от вредителя, а условие корректности.**
|
||||
Час объекта берётся из метки в теле доставки, а тело не наше: одна доставка с
|
||||
метками в будущем занимает окно целиком и подменяет измеренный род метрики —
|
||||
построено и прогнано, мгновенная метрика объявлялась накопительной при нуле
|
||||
противоречащих часов. Запас нужен на расхождение часов телефона и сервера.
|
||||
Данные, помеченные будущим, MUST порождать предупреждение владельцу: это либо
|
||||
сбитые часы, либо чужое тело, и оба случая лечатся не кодом.
|
||||
|
||||
**Совпадение единиц обязательно.** Мгновенная метрика, приехавшая минутным
|
||||
слоем в `count/min` и часовым в `count/hour`, даёт в полном часе
|
||||
`часовое = 60 · среднее = сумма` — то есть **уверенный ложный** `cumulative` при
|
||||
нуле противоречащих часов. Правило единогласия этот случай не ловит по
|
||||
построению: противоречия нет, есть молчание.
|
||||
|
||||
**Часовой объект несёт ровно одну точку.** Две точки за час описывают разные
|
||||
интервалы, и какая из них относится к часу целиком — неизвестно; час непригоден
|
||||
целиком, а не «по той, у которой есть значение».
|
||||
|
||||
Требование выравнивания часовой метки закрывает зоны с неполночасовым
|
||||
смещением: слой выводится по выравниванию метки в исходной зоне, а объект
|
||||
адресуется часом UTC, поэтому в зоне `+0530` часовая точка описывает не тот
|
||||
интервал, который покрывают минутные точки того же объекта. Сравнивать их
|
||||
нельзя, и такой час свидетельства не даёт.
|
||||
|
||||
Требование различимости обязательно: в часе, где все значения нули, сумма равна
|
||||
среднему, и совпадение с любой из гипотез не значит ничего.
|
||||
|
||||
Все три сравнения — «сходится с суммой», «сходится со средним», «сумма отличима
|
||||
от среднего» — MUST выполняться **одним предикатом с одним допуском**:
|
||||
относительным, величиной `1e-9`. Тогда час, подтверждающий обе гипотезы сразу,
|
||||
невыразим по построению, и исход не зависит от порядка веток.
|
||||
|
||||
Величина названа числом, потому что от неё зависят счётчики основания в ответе:
|
||||
измерено, что вердикты метрик на живом корпусе одинаковы при допуске от `1e-9`
|
||||
до `1e-3`, а число согласных часов у `heart_rate` при этом меняется с 29 на 49.
|
||||
Взято строгое значение: канонизация содержимого округляет числа до 12 значащих
|
||||
цифр, то есть всё, что крупнее `1e-12`, представлением не объясняется, а
|
||||
`1e-9` оставляет три порядка запаса и остаётся на шесть порядков строже любого
|
||||
содержательного расхождения (сумма и среднее при `n ≥ 2` различаются не меньше
|
||||
чем вдвое).
|
||||
|
||||
Абсолютного порога у сравнения нет намеренно: около нуля относительный допуск
|
||||
вырождается в сторону «не сходится», то есть даёт «свидетельства нет», а не
|
||||
ложный род.
|
||||
|
||||
Вердикт пригодного часа: часовое значение сходится с суммой минутных —
|
||||
`cumulative`, со средним — `instant`, иначе час свидетельства не даёт.
|
||||
|
||||
Сумма минутных значений MUST считаться в порядке возрастания метки точки, чтобы
|
||||
вердикт не зависел от порядка точек внутри объекта.
|
||||
|
||||
#### Scenario: Часовое значение равно сумме минутных
|
||||
|
||||
- **GIVEN** у метрики есть минутный и часовой объекты за один час
|
||||
- **WHEN** часовое значение сходится с суммой минутных значений
|
||||
- **THEN** метрика получает род `cumulative`
|
||||
|
||||
#### Scenario: Часовое значение равно среднему минутных
|
||||
|
||||
- **WHEN** часовое значение сходится со средним минутных значений
|
||||
- **THEN** метрика получает род `instant`
|
||||
|
||||
#### Scenario: Нулевой час свидетельством не является
|
||||
|
||||
- **GIVEN** все минутные значения часа равны нулю, и часовое значение тоже
|
||||
- **WHEN** измеряется род
|
||||
- **THEN** этот час непригоден и в подсчёт согласных не идёт
|
||||
|
||||
#### Scenario: Час лежит в будущем
|
||||
|
||||
- **GIVEN** доставка принесла объекты обоих слоёв с метками позже текущего
|
||||
времени
|
||||
- **WHEN** измеряется род
|
||||
- **THEN** эти часы в окно не входят, род остаётся измеренным по настоящей
|
||||
истории, и владельцу пишется предупреждение
|
||||
|
||||
#### Scenario: Единицы слоёв разошлись
|
||||
|
||||
- **GIVEN** минутный объект часа несёт одни единицы, а часовой — другие
|
||||
- **WHEN** измеряется род
|
||||
- **THEN** час непригоден и свидетельства не даёт
|
||||
|
||||
#### Scenario: Часовой объект несёт две точки
|
||||
|
||||
- **GIVEN** у метрики за час есть часовой объект с двумя точками
|
||||
- **WHEN** измеряется род
|
||||
- **THEN** час непригоден и свидетельства не даёт
|
||||
|
||||
#### Scenario: Минутный объект несёт одну точку
|
||||
|
||||
- **GIVEN** минутный объект часа несёт единственную точку
|
||||
- **WHEN** измеряется род
|
||||
- **THEN** час непригоден: сумма и среднее совпадают, различить гипотезы нечем
|
||||
|
||||
#### Scenario: Метка часовой точки не выровнена на начало часа
|
||||
|
||||
- **GIVEN** часовая точка стоит на середине часа UTC
|
||||
- **WHEN** измеряется род
|
||||
- **THEN** час непригоден и свидетельства не даёт
|
||||
|
||||
#### Scenario: Форма точки на исход не влияет
|
||||
|
||||
- **WHEN** метрика приходит только с полем `qty`, без `Avg`/`Min`/`Max`
|
||||
- **THEN** род всё равно измеряется сверкой слоёв, а не выводится из формы
|
||||
|
||||
### Requirement: Род объявляется только при единогласном свидетельстве
|
||||
|
||||
Система SHALL объявлять род метрики, только если согласных часов не меньше трёх
|
||||
и ни один час не дал противоположного вердикта. В остальных случаях род MUST
|
||||
быть `unknown`, и агрегация по такой метрике предлагаться MUST NOT.
|
||||
|
||||
Наличие противоречащих часов MUST быть записано чекпоинтом уровня `WARN` с
|
||||
именем метрики и числами основания, без значений точек: род — свойство, на
|
||||
котором Read API строит арифметику года, и его смена не имеет права проходить
|
||||
молча. На живом корпусе противоречащих часов не встретилось ни разу, поэтому
|
||||
шума правило не создаёт.
|
||||
|
||||
Единогласие, а не большинство: противоречащий час означает, что одна из гипотез
|
||||
для этой метрики ложна, и объявлять род при известном контрпримере нельзя. Порог
|
||||
в три часа — потому что на этом роде потом суммируют год, а один совпавший час
|
||||
остаётся свидетельством одного часа.
|
||||
|
||||
Следствие принято вслух: род есть функция окна, поэтому час, въехавший в окно,
|
||||
может сменить объявленный род без единой новой доставки за спрошенный период.
|
||||
Клиент, которому это важно, различает случаи по основанию измерения — оно
|
||||
отдаётся вместе с родом.
|
||||
|
||||
#### Scenario: Свидетельства противоречат
|
||||
|
||||
- **GIVEN** у метрики есть часы с вердиктом `cumulative` и часы с вердиктом
|
||||
`instant`
|
||||
- **WHEN** измеряется род
|
||||
- **THEN** род равен `unknown`, число противоречащих часов отдаётся в каталоге,
|
||||
и пишется `WARN` с именем метрики
|
||||
|
||||
#### Scenario: Свидетельств мало
|
||||
|
||||
- **WHEN** согласных часов меньше трёх
|
||||
- **THEN** род равен `unknown`
|
||||
|
||||
#### Scenario: Второго слоя нет вовсе
|
||||
|
||||
- **WHEN** метрика лежит только в одном слое
|
||||
- **THEN** род равен `unknown`, а число часов окна равно нулю
|
||||
|
||||
### Requirement: Нижний слой в измерении не участвует
|
||||
|
||||
Система SHALL измерять род только по слоям `minute` и `hour` и MUST NOT
|
||||
использовать в сверке слои `raw`, `sample` и `day`.
|
||||
|
||||
Нижний слой HAE — не сэмплы, а посекундная развёртка настоящих сэмплов с
|
||||
инфляцией до 478×: его сумма завышена и в сверке не сходится. Слой `sample`
|
||||
несёт собственные интервалы сэмплов, и его сверка с часовым слоем — другая
|
||||
задача, вместе с импортом родного экспорта. Слой `day` — суточная сводка сна,
|
||||
другая схема под тем же именем, а не разрез часов.
|
||||
|
||||
#### Scenario: Метрика есть только в нижнем слое
|
||||
|
||||
- **WHEN** у метрики есть объекты только в слое `raw`
|
||||
- **THEN** род равен `unknown`
|
||||
|
||||
#### Scenario: Нижний слой не подменяет минутный
|
||||
|
||||
- **GIVEN** у метрики есть слои `raw` и `hour`, но нет `minute`
|
||||
- **WHEN** измеряется род
|
||||
- **THEN** сверка не выполняется и род равен `unknown`
|
||||
|
||||
#### Scenario: Метрика лежит только в суточном слое
|
||||
|
||||
- **WHEN** у метрики есть объекты только слоя `day`
|
||||
- **THEN** слой объявлен в каталоге, а род равен `unknown`
|
||||
|
||||
### Requirement: Каталог отдаёт основание измерения, а не только вывод
|
||||
|
||||
Система SHALL отдавать вместе с родом четыре числа и границы окна, и клиент MUST
|
||||
иметь возможность отличить «свидетельств не было» от «свидетельства
|
||||
противоречат», не делая второго запроса.
|
||||
|
||||
Числа определены так, что их разность осмысленна:
|
||||
|
||||
- `hours` — сколько общих часов двух слоёв попало в окно;
|
||||
- `compared` — сколько из них оказалось **пригодными**;
|
||||
- `agreeing` — сколько пригодных часов дали **преобладающий** вердикт (при
|
||||
объявленном роде это он и есть);
|
||||
- `conflicting` — сколько дали другой.
|
||||
|
||||
Разложение одно и то же независимо от того, объявлен род или нет: иначе
|
||||
`agreeing` пришлось бы толковать по-разному в двух ветках, и клиент читал бы
|
||||
одно поле двумя способами.
|
||||
|
||||
Разность `compared − agreeing − conflicting` — часы, не сошедшиеся ни с одной
|
||||
гипотезой; разность `hours − compared` — часы, отброшенные проверкой
|
||||
пригодности. Без этого различения `hours` в одиночку выдавал бы «измерение шло,
|
||||
данные молчат» там, где ни один час не был пригоден вовсе.
|
||||
|
||||
`first_hour` и `last_hour` — границы окна; род объявляется вместе с периодом, на
|
||||
котором измерен, потому что окно ограничено самыми свежими общими часами, а не
|
||||
всей историей.
|
||||
|
||||
#### Scenario: Род измерен
|
||||
|
||||
- **WHEN** метрика получила род `cumulative`
|
||||
- **THEN** рядом стоят число часов окна, число пригодных, число согласных, ноль
|
||||
противоречащих и границы окна
|
||||
|
||||
#### Scenario: Часы были, но ни один не пригоден
|
||||
|
||||
- **WHEN** все часы окна отброшены проверкой пригодности
|
||||
- **THEN** `hours` больше нуля, `compared` равен нулю, род равен `unknown`
|
||||
|
||||
### Requirement: Окно измерения ограничено сорока восемью часами
|
||||
|
||||
Система SHALL измерять род по не более чем 48 самым свежим общим часам метрики
|
||||
и MUST NOT читать ради этого всю историю: стоимость каталога не имеет права
|
||||
расти вместе с журналом.
|
||||
|
||||
Число названо в спеке, а не оставлено реализации, по той же причине, что и
|
||||
порог согласных часов: от него зависят счётчики основания в ответе.
|
||||
|
||||
Измерено, что на живом корпусе окно сохраняет вердикты всех метрик, кроме
|
||||
редких: у `physical_effort` за всю историю набиралось пять согласных часов, а в
|
||||
последних сорока восьми — два, и метрика честно уходит в `unknown`. Это не
|
||||
издержка, а то же правило: свидетельств в свежем окне действительно мало.
|
||||
|
||||
Окно ограничено и сверху — часами не позже текущего времени плюс запас, см.
|
||||
правило пригодности часа.
|
||||
|
||||
#### Scenario: История длиннее окна
|
||||
|
||||
- **GIVEN** у метрики общих часов больше сорока восьми
|
||||
- **WHEN** измеряется род
|
||||
- **THEN** сравниваются только сорок восемь самых свежих, и `hours` равен
|
||||
сорока восьми
|
||||
|
||||
### Requirement: Измеренный род нигде не сохраняется
|
||||
|
||||
Система SHALL вычислять род при каждом запросе каталога и MUST NOT хранить его
|
||||
ни колонкой, ни кешем.
|
||||
|
||||
Хранимое значение было бы вторым производным состоянием рядом с витриной: его
|
||||
пришлось бы пересчитывать после каждой свёртки, переносить или не переносить
|
||||
пересборкой и объяснять, на каком составе данных оно снято; устаревшее значение
|
||||
при этом выглядит ровно как свежее. Вычисленный на запрос род есть функция
|
||||
витрины, а витрина — функция журнала, и устаревать в нём нечему.
|
||||
|
||||
#### Scenario: Новая доставка меняет род без перезапуска
|
||||
|
||||
- **GIVEN** метрика числится `unknown`, потому что общих часов было мало
|
||||
- **WHEN** приезжает доставка, добавляющая согласные часы, и каталог
|
||||
запрашивается снова
|
||||
- **THEN** ответ отдаёт новый род, и перезапуск сервиса для этого не нужен
|
||||
|
||||
### Requirement: Каталог читается одним снимком витрины
|
||||
|
||||
Система SHALL собирать ответ каталога из одного снимка базы: разрезы, границы и
|
||||
объекты окна измерения MUST читаться в одной транзакции чтения.
|
||||
|
||||
Приём идёт непрерывно, и фоновая свёртка пишет в витрину во время запроса.
|
||||
Запросы вне общей транзакции дали бы смесь «разрезы до» и «род после» — ответ,
|
||||
внутренне противоречивый и неотличимый от обычного свежего.
|
||||
|
||||
Число обращений к хранилищу на один запрос каталога MUST быть ограничено
|
||||
константой на метрику и не зависеть от размера окна: чтение объектов окна по
|
||||
одному даёт тысячи обращений там, где хватает двух на метрику.
|
||||
|
||||
#### Scenario: Доставка приезжает во время сборки каталога
|
||||
|
||||
- **GIVEN** каталог собирается, и в этот момент фоновая свёртка пишет объекты
|
||||
- **WHEN** ответ сформирован
|
||||
- **THEN** он целиком описывает одно состояние витрины
|
||||
|
||||
#### Scenario: Размер окна не умножает число запросов
|
||||
|
||||
- **WHEN** окно измерения увеличено
|
||||
- **THEN** число обращений к хранилищу на метрику не меняется
|
||||
|
||||
### Requirement: Каталог доступен по токену чтения
|
||||
|
||||
Система SHALL требовать токен чтения на маршруте каталога и MUST NOT принимать
|
||||
на нём токен приёма. Токен MUST передаваться заголовком `Authorization` со
|
||||
схемой `Bearer`; значение без этой схемы токеном не считается.
|
||||
|
||||
Пустой список токенов чтения означает выключенную проверку, и о выключенной
|
||||
проверке сервис предупреждает на старте — тем же способом, что о выключенной
|
||||
проверке приёма. Цена симметрии названа вслух: у приёма открытый контур означает
|
||||
мусор во входе, у чтения — выгрузку данных о здоровье, поэтому перед выкладкой
|
||||
наружу список обязан быть непуст. Отвечает за это отдельная задача об управлении
|
||||
секретами; здесь фиксируется, что предупреждение существует и адресовано
|
||||
владельцу.
|
||||
|
||||
Токен чтения MUST вычищаться из сохраняемых заголовков доставки наравне с
|
||||
токеном приёма: заголовок с произвольным именем иначе донесёт его до базы.
|
||||
|
||||
Контуры раздельны по архитектуре: клиент, читающий данные, писать не может, и
|
||||
обратное тоже неверно.
|
||||
|
||||
#### Scenario: Запрос без токена при заданном списке
|
||||
|
||||
- **GIVEN** список токенов чтения непуст
|
||||
- **WHEN** каталог запрашивается без заголовка `Authorization`
|
||||
- **THEN** ответ — 401, и данные не отдаются
|
||||
|
||||
#### Scenario: Токен приёма каталога не открывает
|
||||
|
||||
- **GIVEN** заданы разные списки токенов приёма и чтения
|
||||
- **WHEN** каталог запрашивается с токеном приёма
|
||||
- **THEN** ответ — 401
|
||||
|
||||
#### Scenario: Токен без схемы Bearer
|
||||
|
||||
- **GIVEN** список токенов чтения непуст
|
||||
- **WHEN** каталог запрашивается с заголовком `Authorization`, где стоит голое
|
||||
значение токена без слова `Bearer`
|
||||
- **THEN** ответ — 401
|
||||
|
||||
#### Scenario: Проверка выключена
|
||||
|
||||
- **GIVEN** список токенов чтения пуст
|
||||
- **WHEN** каталог запрашивается без заголовка `Authorization`
|
||||
- **THEN** каталог отдаётся
|
||||
|
||||
#### Scenario: О выключенной проверке предупреждают на старте
|
||||
|
||||
- **GIVEN** список токенов чтения пуст
|
||||
- **WHEN** сервис стартует
|
||||
- **THEN** в логе появляется предупреждение владельцу
|
||||
|
||||
#### Scenario: Токен чтения не оседает в учёте доставки
|
||||
|
||||
- **GIVEN** токен чтения послан на маршрут приёма заголовком с произвольным
|
||||
именем
|
||||
- **WHEN** доставка учтена
|
||||
- **THEN** в сохранённых заголовках вместо значения стоит пометка о сокрытии
|
||||
|
||||
@@ -1009,3 +1009,51 @@ SHALL: сегодня ровно этот случай даёт ноль и мо
|
||||
пор не пересворачивалась
|
||||
- **THEN** её число пропущенных сущностей отсутствует, а не равно нулю
|
||||
|
||||
### Requirement: Перечисление разрезов не читает содержимое объектов
|
||||
|
||||
Хранилище SHALL отвечать на вопрос «какие слои есть у метрики, за какой период и
|
||||
сколько в них точек» по учётным колонкам объекта, не разжимая `payload` и не
|
||||
затрагивая страниц с содержимым. Тот же запрет действует на поиск часов, за
|
||||
которые у метрики есть объекты сразу в двух слоях.
|
||||
|
||||
Запрет ограничен именно этими двумя выборками. Измерение рода обязано прочитать
|
||||
значения точек, то есть разжать содержимое объектов окна, и требование его не
|
||||
касается — иначе оно запрещало бы то, ради чего каталог существует.
|
||||
|
||||
Причина в форме таблицы: `bucket` объявлена `WITHOUT ROWID`, то есть строка
|
||||
целиком, вместе со сжатым содержимым, живёт в дереве первичного ключа. Обход
|
||||
всех строк ради агрегата тащил бы за собой страницы содержимого — при 260 тысячах
|
||||
объектов за год это сотни мегабайт на каждый запрос каталога, притом что сам
|
||||
ответ несёт три десятка строк.
|
||||
|
||||
Поэтому колонки, по которым отвечают эти выборки, MUST быть покрыты индексом, и
|
||||
новая колонка, попадающая в ответ каталога, входит в него тем же изменением.
|
||||
|
||||
#### Scenario: Разрезы метрики за длинную историю
|
||||
|
||||
- **GIVEN** в витрине объекты за многие месяцы
|
||||
- **WHEN** запрашиваются слои метрики с границами и числом точек
|
||||
- **THEN** запрос отвечает по индексу, не читая содержимого объектов
|
||||
|
||||
#### Scenario: Общие часы двух слоёв
|
||||
|
||||
- **WHEN** запрашиваются самые свежие часы, за которые у метрики есть объекты и
|
||||
в минутном, и в часовом слое
|
||||
- **THEN** запрос отвечает по индексу и читает не больше запрошенного числа
|
||||
часов
|
||||
|
||||
### Requirement: Объекты перечисленных часов читаются пакетом
|
||||
|
||||
Хранилище SHALL уметь отдать объекты двух слоёв за перечисленные часы одной
|
||||
метрики **одним запросом**, а не по объекту за раз.
|
||||
|
||||
Чтение по одному даёт число обращений, растущее вместе с окном измерения, и
|
||||
делает каждое обращение собственной транзакцией — то есть ответ, собранный из
|
||||
разных снимков витрины под непрерывным приёмом.
|
||||
|
||||
#### Scenario: Окно из многих часов
|
||||
|
||||
- **GIVEN** запрошены объекты двух слоёв за сорок восемь часов
|
||||
- **WHEN** выполняется выборка
|
||||
- **THEN** число обращений к базе не зависит от числа часов
|
||||
|
||||
|
||||
Reference in New Issue
Block a user