- рядом с воркером свёртки живёт горутина, раз в минуту разбирающая журнал пассивным чекпойнтом; «журнал не разбирается» видно строкой владельцу, а не только по `df`. Признак — пара чисел, а не флаг занятости: тот молчит под удерживаемым читателем (`busy=0` при 6256 страницах и пяти перенесённых), а при занятой блокировке отдаёт `-1` вместо ответа, и `-1 >= -1` читалось бы как «разобрано целиком» - каталог отвечает `304` на `If-None-Match`, не открывая снимок витрины. Метка собрана из всего, от чего зависит ответ: версии витрины (`data_version` с закреплённого соединения плюс поколение — значение локально для соединения и не переживает переоткрытия), горизонта измерения и области действия ресурса. Версия снимается до и после сборки: снятая после пометила бы устаревший снимок свежим номером - предел и дедлайн ответа отложены в задачу Read API точек вместе с измеренной ценой первого запроса; попутно починен флаки-тест чужой задачи, искавший значение точки в сыром буфере записи лога
665 lines
46 KiB
Markdown
665 lines
46 KiB
Markdown
# 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** в сохранённых заголовках вместо значения стоит пометка о сокрытии
|
||
|
||
### Requirement: Каталог отвечает на условный запрос
|
||
|
||
Система SHALL выставлять на ответе каталога заголовок `ETag` и SHALL отвечать
|
||
`304 Not Modified` на запрос с `If-None-Match`, чья метка совпадает с текущей
|
||
версией витрины. При совпадении снимок витрины открываться MUST NOT: смысл
|
||
условного запроса в том, что самый частый запрос потребителя — повтор
|
||
неизменившегося — не стоит ничего.
|
||
|
||
Метка MUST строиться из **всего, от чего зависит ответ**: версии витрины и
|
||
горизонта измерения. Горизонт едет вместе с часами, и метка из будущего,
|
||
лежащая в витрине, въезжает в окно сама — без единого коммита. Путь построен и
|
||
прогнан: та же версия витрины, `cumulative` против `unknown`. Значит версии
|
||
витрины для метки НЕ ДОСТАТОЧНО, и слабая форма метки этого не лечит: смена
|
||
измеренного рода — изменение семантическое, на нём Read API строит арифметику
|
||
года.
|
||
|
||
Горизонт входит в метку огрублённым до часа, и огрубление точное, а не
|
||
приблизительное: метки объектов лежат ровно на часах, поэтому отбор по горизонту
|
||
меняется ровно при переходе через час. Цена названа: один полный ответ в час на
|
||
потребителя при неизменившейся витрине.
|
||
|
||
Форма метки MUST оставаться слабой (`W/"…"`): она выведена из состояния, а не из
|
||
байтов ответа. На исход `304` это не влияет — `If-None-Match` сравнивается слабо
|
||
в любом случае.
|
||
|
||
Метка MUST выставляться, только если за всё время сборки ответа в базу никто не
|
||
коммитил; правило снятия версии принадлежит хранилищу и здесь не повторяется.
|
||
Ответ без метки — законный исход, а не отказ: клиент просто не сможет спросить
|
||
условно в следующий раз.
|
||
|
||
**Метка действительна только в пределах одного ресурса, и область действия
|
||
MUST входить в саму метку.** Маршрут, чей ответ есть функция параметров запроса
|
||
(Read API точек), и транспорт, у которого адреса нет вовсе (MCP), обязаны
|
||
подмешивать в неё канонизированную форму запроса — иначе «не изменилось»
|
||
ответит на другой набор данных. Требовать этого прозой недостаточно: правило
|
||
MUST быть выражено формой вызова, потому что забыть его — единственный путь всей
|
||
задачи, ведущий к выдаче не тех данных.
|
||
|
||
Ответ `304` MUST нести ту же метку и MUST NOT нести тела и представленческих
|
||
заголовков. Клиент, не приславший `If-None-Match`, MUST получать ровно то же,
|
||
что и до появления условного запроса.
|
||
|
||
Ответы каталога MUST быть помечены непригодными для разделяемого кеша
|
||
(`Cache-Control: private, no-cache`). До появления валидатора эвристическое
|
||
кеширование посредником было маловероятным; с меткой ответ становится штатно
|
||
кешируемым, а при выключенной проверке токенов (законная конфигурация) в
|
||
запросе нет и `Authorization` — тогда выгрузку истории здоровья вправе
|
||
сохранить любой прокси на пути.
|
||
|
||
Проверка токена чтения MUST предшествовать условному запросу: `304` без токена
|
||
подтверждал бы состояние витрины тому, кому она не открыта.
|
||
|
||
Разбор условия MUST следовать HTTP и MUST NOT превращать кривой заголовок в
|
||
отказ:
|
||
|
||
- звёздочка (`*`) совпадает с любой **существующей** меткой; метки нет —
|
||
условие не выполнено, и клиент со звёздочкой получает данные, а не вечный
|
||
`304`;
|
||
- неразбираемое значение условия не выполняет и даёт `200`, а не `400`.
|
||
|
||
**Следствие названо вслух: `304` не выполняет измерения и потому не пишет
|
||
предупреждений владельцу.** Предупреждения каталога (данные из будущего,
|
||
противоречащий род агрегации) привязаны к сборке ответа; с условным опросом они
|
||
становятся функцией смены версии витрины, а не числа запросов. Состояние при
|
||
этом не исчезает: следующая доставка меняет версию, ответ собирается, и
|
||
предупреждение пишется — а пока витрина стоит, повторять его на каждый опрос
|
||
трёх потребителей значило бы обесценить уровень.
|
||
|
||
#### Scenario: Повтор на неизменившейся витрине
|
||
|
||
- **GIVEN** клиент получил каталог и запомнил его `ETag`
|
||
- **WHEN** он повторяет запрос с `If-None-Match` этой метки, а витрина не
|
||
менялась
|
||
- **THEN** ответ — `304` без тела, с той же меткой
|
||
|
||
#### Scenario: Витрина изменилась
|
||
|
||
- **GIVEN** клиент получил каталог и запомнил его `ETag`
|
||
- **WHEN** свёртка записала объект и клиент повторяет запрос с прежней меткой
|
||
- **THEN** ответ — `200` с полным каталогом и новой меткой
|
||
|
||
#### Scenario: Горизонт сдвинулся
|
||
|
||
- **GIVEN** витрина не менялась
|
||
- **WHEN** горизонт измерения перешёл через час
|
||
- **THEN** метка отличается от прежней
|
||
|
||
#### Scenario: Метка другого ресурса
|
||
|
||
- **GIVEN** клиент присылает метку, выданную другим читающим маршрутом
|
||
- **WHEN** совпадает версия витрины
|
||
- **THEN** условие не выполнено, и ответ — `200`
|
||
|
||
#### Scenario: Клиент не спрашивает условно
|
||
|
||
- **WHEN** каталог запрашивается без `If-None-Match`
|
||
- **THEN** ответ — `200` с полным каталогом, меткой и правилом кеширования
|
||
|
||
#### Scenario: Две метки на неизменившейся витрине совпадают
|
||
|
||
- **GIVEN** витрина не менялась между двумя запросами
|
||
- **WHEN** каталог запрошен дважды
|
||
- **THEN** метки совпадают, и тела ответов совпадают побайтово
|
||
|
||
#### Scenario: Звёздочка в условии
|
||
|
||
- **WHEN** каталог запрашивается с `If-None-Match: *`
|
||
- **THEN** ответ — `304` с текущей меткой
|
||
|
||
#### Scenario: Условие нечитаемо
|
||
|
||
- **WHEN** каталог запрашивается с `If-None-Match`, который меткой не является
|
||
- **THEN** ответ — `200` с полным каталогом, а не `400` и не `304`
|
||
|
||
#### Scenario: Условный запрос без токена чтения
|
||
|
||
- **GIVEN** список токенов чтения непуст
|
||
- **WHEN** каталог запрашивается с `If-None-Match`, но без токена
|
||
- **THEN** ответ — `401`, а не `304`
|
||
|
||
#### Scenario: Витрина изменилась во время сборки ответа
|
||
|
||
- **GIVEN** между снятием версии до и после сборки в базу был коммит
|
||
- **WHEN** ответ сформирован
|
||
- **THEN** он уходит с полным телом и без заголовка `ETag`
|
||
|
||
#### Scenario: Версия витрины недоступна
|
||
|
||
- **GIVEN** версию витрины прочитать не удалось
|
||
- **WHEN** каталог запрашивается, в том числе с `If-None-Match`
|
||
- **THEN** ответ — `200` с полным каталогом и без метки, а не `500` и не `304`
|
||
|
||
#### Scenario: Условный ответ не собирает каталог
|
||
|
||
- **GIVEN** в витрине лежат данные, помеченные будущим
|
||
- **WHEN** каталог отвечает `304` по совпавшей метке
|
||
- **THEN** предупреждение владельцу не пишется, потому что измерения не было
|
||
|