Цена читающего маршрута: чекпойнт WAL по таймеру и условный запрос

- рядом с воркером свёртки живёт горутина, раз в минуту разбирающая журнал
  пассивным чекпойнтом; «журнал не разбирается» видно строкой владельцу, а не
  только по `df`. Признак — пара чисел, а не флаг занятости: тот молчит под
  удерживаемым читателем (`busy=0` при 6256 страницах и пяти перенесённых), а
  при занятой блокировке отдаёт `-1` вместо ответа, и `-1 >= -1` читалось бы как
  «разобрано целиком»
- каталог отвечает `304` на `If-None-Match`, не открывая снимок витрины. Метка
  собрана из всего, от чего зависит ответ: версии витрины (`data_version` с
  закреплённого соединения плюс поколение — значение локально для соединения и
  не переживает переоткрытия), горизонта измерения и области действия ресурса.
  Версия снимается до и после сборки: снятая после пометила бы устаревший снимок
  свежим номером
- предел и дедлайн ответа отложены в задачу Read API точек вместе с измеренной
  ценой первого запроса; попутно починен флаки-тест чужой задачи, искавший
  значение точки в сыром буфере записи лога
This commit is contained in:
av
2026-08-02 20:42:22 +03:00
parent 6b729bbd2f
commit 8db2ec7ff4
37 changed files with 3575 additions and 156 deletions
@@ -0,0 +1,139 @@
## ADDED Requirements
### 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** предупреждение владельцу не пишется, потому что измерения не было