Приём отвечает 200 до свёртки, свёртку ведёт фоновый воркер

- Очередью служит сама таблица: доставка ждёт свёртки в статусе `pending`,
  канал несёт только бит «есть работа». Переполнять нечего, падение процесса
  очередь не теряет, а подбор `pending` при старте — обычный проход воркера, а
  не отдельный код. Классификация исхода общая с пересборкой журнала.
- Исход разбора начал отражать доставку, а не обстоятельства: отмена и
  занятость базы статус не меняют (иначе конкуренция за базу выводила бы
  доставку из очереди навсегда), паника свёртки больше не валит процесс, а
  учёт доставки идёт через транзакцию с повторами.
- Длинный бюджет ответа выдан маршруту приёма, а не всему серверу:
  `write_timeout` в Go покрывает и чтение тела, и общий подъём снял бы защиту с
  остальных маршрутов.
This commit is contained in:
av
2026-08-02 11:01:42 +03:00
parent ebd59af056
commit 63bffe2865
46 changed files with 3561 additions and 296 deletions
@@ -0,0 +1,294 @@
## ADDED Requirements
### Requirement: Ответ приёма отражает сохранность, а не разбор
Приём SHALL отвечать `200` после того, как тело записано в сырой архив и
доставка учтена строкой `delivery`, и MUST NOT ждать свёртки. Время ответа
зависеть от ширины доставки MUST NOT.
Порядок обязателен именно такой: тело на диск, затем строка учёта. Обратный дал
бы учтённую доставку без данных. Отказ на любом из двух шагов — отказ приёма, и
о нём отправителю говорится ошибкой: `400` для неразбираемой верхнеуровневой
формы, `413` для тела сверх предела, `500` для отказа записи.
Учёт доставки SHALL вестись на контексте, не отменяемом обрывом соединения:
тело к этому моменту уже на диске, и отказ вставки из-за ушедшего клиента
оставил бы тело без записи в журнале. Проверка формы тела при этом остаётся
отменяемой — там отмена уместна.
Причина разнесения измерена: свёртка 16 тысяч точек занимает 11 секунд, а
`WriteTimeout` в Go ставится до вызова обработчика и потому является общим
бюджетом на чтение тела, запись архива, учёт и свёртку. Исчерпав его, сервер
считает, что отдал `200`, клиент получает обрыв, а запись `accessLog` называет
статус `200` — то есть единственный канал наблюдаемости врёт.
#### Scenario: Ответ отдан до свёртки
- **WHEN** тело принято, записано в архив и учтено
- **THEN** ответ `200` отдан
- **AND** доставка в этот момент числится неразобранной
#### Scenario: Ширина доставки не удлиняет ответ
- **WHEN** приезжает доставка, свёртка которой занимает секунды
- **THEN** время ответа не включает время свёртки
#### Scenario: Обрыв соединения не оставляет тело без учёта
- **WHEN** соединение обрывается после того, как тело записано в архив
- **THEN** строка учёта доставки всё равно записывается
### Requirement: Несвёрнутая доставка числится неразобранной
Учтённая, но ещё не свёрнутая доставка SHALL числиться в статусе `pending`, и
этот статус SHALL быть единственным признаком того, что свёртка ещё должна
произойти. Отдельного, живущего только в памяти представления той же очереди
система иметь MUST NOT.
Отсюда следуют три свойства, и они и есть смысл требования:
- переполнять нечего — доставка ждёт свёртки в базе, а не в буфере, и «очередь
переполнена» невыразимо;
- падение процесса очереди не теряет — несвёрнутое остаётся `pending`;
- подбор `pending` не является отдельной операцией — он совпадает с обычной
работой свёртки.
#### Scenario: Принятая доставка ждёт свёртки в базе
- **WHEN** доставка учтена, но ещё не свёрнута
- **THEN** её `parse_status` равен `pending`
#### Scenario: Оборванный процесс не теряет несвёрнутое
- **WHEN** процесс прекращается до того, как свёртка доставки завершилась
- **THEN** доставка остаётся `pending`
- **AND** следующий старт сворачивает её
### Requirement: Исход свёртки отражает доставку, а не обстоятельства
Свёртка SHALL оставлять доставку в очереди — то есть **не менять** её статус, —
когда работа не сделана по причине, к самой доставке не относящейся: отмена
контекста и занятость базы после исчерпания повторов транзакции.
Все прочие отказы разбора и записи точек (непонятое содержимое, невыводимый
слой, нечитаемое или слишком большое тело, исчерпанный дедлайн свёртки, паника
самой свёртки) SHALL давать `failed`: это свойства доставки, и повторять их
бесполезно.
Отказы, случившиеся **до** чтения тела, и отказ самой записи исхода статуса не
меняют по другой причине — записать его нечем. Доставка остаётся `pending`, и
это честно: этим разбором её не досмотрели.
Паника свёртки SHALL перехватываться на той же границе, что пишет исход разбора,
и превращаться в `failed`. Иначе она валит процесс целиком — фоновая горутина
ничем не обёрнута, — а перезапуск берёт ту же доставку первой, то есть дефект
одной доставки становится циклом перезапуска, при котором приём не работает
вовсе. До разнесения ответа и свёртки ту же панику ловил транспорт, и стоила она
одного ответа.
Доставка в статусе `failed` в очередь свёртки возвращаться MUST NOT — её
подбирает только пересборка журнала. Это названная граница: обратное означало бы
бесконечный повтор заведомо безнадёжного.
Без такого различения занятость базы — а свёртка теперь конкурирует с приёмом за
неё штатно — стирала бы доставку с полки молча, и вернуть её могла бы только
ручная операция с остановкой сервиса.
#### Scenario: Занятая база не выводит доставку из очереди
- **WHEN** свёртка не прошла из-за занятости базы
- **THEN** доставка остаётся `pending`
- **AND** следующий проход пробует её снова
#### Scenario: Непонятое содержимое выводит доставку из очереди
- **WHEN** свёртка не прошла из-за содержимого тела
- **THEN** доставка получает статус `failed`
- **AND** следующий проход её не выбирает
### Requirement: Свёртку ведёт один фоновый воркер в порядке журнала
Свёртку принятых доставок SHALL вести одна горутина, обрабатывающая доставки в
порядке журнала — `(received_at, id)`, как он определён capability пересборки.
Распараллеливать свёртку MUST NOT.
Достижимая гарантия называется точно: в порядке `(received_at, id)`
сворачиваются все доставки, **видимые воркеру** на момент выборки. Доставка,
ставшая видимой позже курсора прохода, подбирается следующим проходом;
абсолютного порядка при конкурентных приёмах система не обещает.
Последствие этого предела называется вслух: доставка без плотных метрик,
свёрнутая раньше своей предшественницы, слоя не выведет и получит `failed` — то
есть её точки в витрину не попадут до пересборки. Живое состояние в этом случае
расходится с тем, что даёт `healthlog reindex`. Окно узкое (обе доставки должны
приниматься одновременно, и только у автоматизации без плотных метрик), и
изменение его сужает, а не открывает: прежде свёртка шла в порядке завершения
обработчиков. Устранение предела — отдельный вопрос, оно требует удерживать
порядок на самом приёме.
Воркер SHALL продвигаться по неразобранным доставкам строго возрастающим
курсором в пределах одного прохода. Курсор обязателен для завершимости:
доставка, у которой не удалось записать даже исход разбора, остаётся `pending`,
и проход без курсора выбирал бы её бесконечно.
Приём SHALL будить воркер после того, как доставка учтена. Потеря сигнала
отказом быть MUST NOT: доставка от этого не перестаёт числиться `pending`.
Помимо сигнала воркер SHALL просыпаться периодически — иначе доставка,
оставшаяся `pending` по причине выше, ждала бы следующей доставки, а ночью
телефон молчит часами.
Отказ отдельного прохода воркер SHALL переживать: отказ выборки пишется `ERROR`
и прекращает проход, но не цикл. Отмена работы снаружи отказом при этом
считаться MUST NOT — штатная остановка не должна писать `ERROR`. Воркер, умерший
от временного отказа базы, остановил бы свёртку до конца жизни процесса, пока
приём продолжал бы отвечать `200`.
#### Scenario: Видимые доставки сворачиваются в порядке журнала
- **GIVEN** несколько доставок числятся `pending` до начала прохода
- **WHEN** воркер делает проход
- **THEN** он сворачивает их в порядке `(received_at, id)`
- **AND** доставка без плотных метрик наследует слой предшествующей ей по этому
порядку доставки той же автоматизации, а не соседа по времени вставки
#### Scenario: Доставка, не записавшая исход, не зацикливает проход
- **WHEN** свёртка доставки не смогла записать исход разбора и оставила её
`pending`
- **THEN** проход воркера завершается, а не выбирает её повторно
#### Scenario: Доставка без входящего потока всё равно подбирается
- **GIVEN** доставка осталась `pending`, и новых доставок не приезжает
- **WHEN** наступает очередное периодическое пробуждение
- **THEN** воркер пробует свернуть её снова
### Requirement: Подбор неразобранного при старте — та же операция
При старте система SHALL сворачивать доставки, числящиеся неразобранными, тем
же путём, каким сворачивает вновь принятые: отдельного кода подбора
существовать MUST NOT.
Порядок журнала при подборе SHALL соблюдаться так же, как при обычной работе —
подбор это тот же проход воркера, а не особый режим.
Классификация исхода свёртки (свёрнуто; слой не выведен; содержимое не
разобрано; прочий отказ; частичный разбор; несравнимые наборы полей) SHALL быть
общей с пересборкой журнала: второй классификатор разошёлся бы с первым молча.
Классифицироваться SHALL только ошибка свёртки; решение «работу прекратили
снаружи» MUST NOT приниматься классификатором — у пересборки и у воркера
контекст свёртки означает разное, и общая ветка отмены дала бы одному из них
противоположный смысл.
Счётчики частичного разбора и несравнимых наборов SHALL читаться только у
успешной свёртки — у отказавшей они заполнены частично, и `partial` занижался бы,
а по нему принимается решение о судьбе тела.
#### Scenario: Доставки, оставшиеся неразобранными, подбираются при старте
- **GIVEN** в учёте есть доставки со статусом `pending`
- **WHEN** сервис стартует
- **THEN** они сворачиваются в порядке `(received_at, id)`
#### Scenario: Размер задолженности назван при старте
- **WHEN** сервис стартует и неразобранные доставки есть
- **THEN** их число попадает в лог одной записью уровня `INFO`
### Requirement: Остановка не оставляет доставку в неопределённом состоянии
Остановка сервиса SHALL сперва прекращать приём, затем останавливать воркер.
Обратный порядок оставил бы доставки, принятые после остановки воркера, никого
не разбудившими.
Инвариант остановки: после неё не существует доставки, которая числится
разобранной, а записана частично; всё несвёрнутое остаётся `pending`. Обещать,
что текущая доставка непременно досворачивается, система MUST NOT — бюджет
остановки меньше бюджета свёртки, и такое обещание исполнялось бы не всегда.
Свёртка SHALL идти на контексте, не отменяемом остановкой, а отмена SHALL
проверяться **между** доставками. Причина названа: свёртка помечает доставку
`failed` на ошибке, а `failed` воркер не подбирает — то есть отмена снаружи
превратилась бы в свойство доставки.
Исчерпание бюджета остановки отказом сервиса считаться MUST NOT: и штатное
завершение воркера, и его прерывание оставляют состояние определённым. Факт
SHALL называться предупреждением, а не ошибкой старта.
#### Scenario: Остановка не оставляет половины
- **WHEN** сервис останавливается во время свёртки доставки
- **THEN** доставка либо свёрнута целиком, либо числится `pending`
- **AND** частично записанных объектов от неё не остаётся
#### Scenario: Приём прекращается раньше воркера
- **WHEN** сервис останавливается
- **THEN** приём перестаёт принимать раньше, чем останавливается воркер
#### Scenario: Не уложились в бюджет остановки
- **WHEN** воркер не успевает выйти в отведённый бюджет
- **THEN** факт попадает в лог предупреждением
- **AND** команда не сообщает об ошибке
### Requirement: Отставание воркера видно в логе
Система SHALL писать `WARN`, когда доставка ждала свёртки дольше периода
быстрого прохода синхронизации (пять минут): дольше этого срока очередь растёт,
а не рассасывается. Ожидание считается от `received_at` до начала свёртки.
Записей SHALL быть **одна на проход**, а не одна на доставку: задолженность в
сотню тел давала бы сотню одинаковых предупреждений каждую минуту, и уровень, по
которому вмешиваются, перестал бы что-либо значить. Строка называет число
задержанных и худшее ожидание с идентификатором доставки.
Метка SHALL вычисляться на выборке прохода, а не только по факту успешной
свёртки: состояние «работа есть, прогресса нет» обязано быть отличимо от
здорового пустого потока, иначе наблюдаемость молчит ровно там, где нужна.
Задолженность, накопленную **до** старта, метка задержки помечать MUST NOT: она
названа отдельной записью `INFO` о размере задолженности, а сотня одинаковых
`WARN` при первом же старте обесценила бы уровень. Метка включается после того,
как первый проход воркера завершился.
Записи воркера значений точек и имён устройств содержать MUST NOT — как и любые
записи свёртки.
#### Scenario: Отставший воркер называет задержку
- **GIVEN** первый проход воркера завершён
- **WHEN** доставки дожидаются свёртки дольше пяти минут
- **THEN** в лог идёт одна запись `WARN` на проход с числом задержанных и
худшим ожиданием
#### Scenario: Задолженность при старте не даёт шквала предупреждений
- **GIVEN** неразобранными числятся доставки, накопленные до старта
- **WHEN** воркер сворачивает их первым проходом
- **THEN** записей `WARN` о задержке по ним нет
### Requirement: Длинный бюджет ответа принадлежит маршруту приёма
Обработчик приёма SHALL выставлять собственный дедлайн записи ответа перед
чтением тела, и этот дедлайн SHALL покрывать чтение тела вместе с отправкой
ответа. Полагаться на общий `write_timeout` сервера система MUST NOT: он
ставится до вызова обработчика и потому обрывает загрузку, идущую дольше него, —
делая `read_timeout` обещанием, которого сервер не исполняет.
Общий `write_timeout` сервера при этом расширяться MUST NOT: длинный бюджет
нужен одному маршруту, а остальные теряли бы защиту от застрявшей записи ответа.
Транспорт, не поддерживающий установки дедлайна, отказом приёма считаться MUST
NOT: факт уходит в `DEBUG`, приём продолжается.
#### Scenario: Медленная загрузка тела не обрывается
- **WHEN** тело приезжает дольше, чем общий `write_timeout` сервера, но
укладывается в `read_timeout`
- **THEN** ответ доходит до отправителя
#### Scenario: Прочие маршруты бюджета не наследуют
- **WHEN** запрос идёт не на приём
- **THEN** его бюджет записи ответа остаётся общим `write_timeout`