Files
av 8db2ec7ff4 Цена читающего маршрута: чекпойнт WAL по таймеру и условный запрос
- рядом с воркером свёртки живёт горутина, раз в минуту разбирающая журнал
  пассивным чекпойнтом; «журнал не разбирается» видно строкой владельцу, а не
  только по `df`. Признак — пара чисел, а не флаг занятости: тот молчит под
  удерживаемым читателем (`busy=0` при 6256 страницах и пяти перенесённых), а
  при занятой блокировке отдаёт `-1` вместо ответа, и `-1 >= -1` читалось бы как
  «разобрано целиком»
- каталог отвечает `304` на `If-None-Match`, не открывая снимок витрины. Метка
  собрана из всего, от чего зависит ответ: версии витрины (`data_version` с
  закреплённого соединения плюс поколение — значение локально для соединения и
  не переживает переоткрытия), горизонта измерения и области действия ресурса.
  Версия снимается до и после сборки: снятая после пометила бы устаревший снимок
  свежим номером
- предел и дедлайн ответа отложены в задачу Read API точек вместе с измеренной
  ценой первого запроса; попутно починен флаки-тест чужой задачи, искавший
  значение точки в сыром буфере записи лога
2026-08-02 20:42:22 +03:00

46 KiB
Raw Permalink Blame History

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; элемент layerslayer, from, to, points; объект aggregationstyle, 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 предупреждение владельцу не пишется, потому что измерения не было