- род метрики выводится сверкой минутного слоя с часовым: часовое значение сходится с суммой минутных — накопительная, со средним — мгновенная, иначе `unknown` и свёртка не предлагается вовсе. На живом архиве (123 доставки, 31 метрика) 7 накопительных, 9 мгновенных, противоречащих часов ноль - `GET /api/v1/metrics` под токеном чтения отдаёт единицы, слои с границами и род вместе с основанием измерения; род нигде не хранится — он функция витрины, а витрина функция журнала, устаревать в нём нечему - миграция 00009: покрывающий индекс, чтобы каталог отвечал по учётным колонкам, не разжимая содержимое объектов
36 KiB
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 в сохранённых заголовках вместо значения стоит пометка о сокрытии