Приём отвечает 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
+80 -1
View File
@@ -223,9 +223,24 @@ HRV); у накопительных — только `date`. Поэтому то
```
запрос → токен → лимит тела, gzip → проверка формы JSON
→ запись тела в архив → строка в delivery → 200
→ разбор → запись в витрину
фоновый воркер: разбор → запись в витрину
```
**Ответ отдаётся до свёртки, и это контракт, а не деталь реализации.** `200`
означает «тело сохранено и учтено»; разобрано ли оно, говорит
`delivery.parse_status`, и говорит позже. Причина измерена: свёртка 16 тысяч
точек занимает 11 секунд, а `WriteTimeout` в Go ставится в `readRequest` — то
есть до вызова обработчика — и потому является общим бюджетом на чтение тела,
запись архива, учёт и свёртку. Исчерпав его, сервер считает, что отдал `200`,
клиент получает обрыв, а `accessLog` пишет `status_code=200`: единственный канал
наблюдаемости врёт. Бьёт это по широким проходам — ровно по тем, ради которых
заведён инвариант «дыры закрываются сами».
Отсюда же второй бюджет: длинный дедлайн ответа выставляет **сам обработчик
приёма**, а не конфиг сервера. `write_timeout` глобален, и поднять его значило бы
снять защиту от застрявшей записи со всех маршрутов ради одного.
Код ответа определяется **доставкой**, не разбором:
- **400** — тело не разбирается как JSON ожидаемой верхнеуровневой формы.
@@ -236,6 +251,70 @@ HRV); у накопительных — только `date`. Поэтому то
безопасности, исход разбора виден в логе, в `delivery.parse_status` и в
`/stats`, а доразобрать их можно командой `reindex`.
#### Очередь свёртки — таблица, а не структура в памяти
Доставка ждёт свёртки в собственном статусе `pending`; канал между приёмом и
воркером несёт один бит «есть работа». Это **transactional outbox**, он же «база
как очередь заданий»: состояние задания пишется той же базой, что и факт
события, а фоновый процесс выбирает необработанные строки.
Три следствия, ради которых так и сделано:
- **переполнять нечего** — доставка `pending` всегда, пока не свёрнута, поэтому
«очередь переполнена» невыразимо;
- **падение процесса очереди не теряет** — транзакция свёртки откатывается,
статус остаётся `pending`;
- **подбор `pending` при старте не является отдельным кодом** — это обычный
проход воркера, а не особый режим.
Отвергнут **канал идентификаторов в памяти**: он вводит второе, недолговечное
представление того же факта, и эти два расходятся при каждом падении; политика
переполнения всё равно требует подбора из базы, то есть того же кода — только в
двух экземплярах. Отвергнут и **опрос по таймеру вместо сигнала**: полпериода
задержки на каждую доставку без пользы. Тик при этом взят **в дополнение** к
сигналу: доставка, оставшаяся в очереди по обстоятельствам, иначе ждала бы
следующей доставки, а ночью телефон молчит часами.
Воркер один, и порядок у него тот же, что у пересборки — `(received_at, id)`:
слой доставки без плотных метрик наследуется от предшествующей доставки той же
автоматизации, то есть является функцией префикса журнала. Обещается достижимое:
в этом порядке сворачивается всё, что **видно воркеру** на момент выборки;
абсолютного порядка при конкурентных приёмах нет и быть не может без сериализации
самого приёма.
Классификацию исхода свёртки воркер и пересборка делят (`internal/replay`):
второй классификатор разошёлся бы с первым молча, а по одному из его счётчиков
(`partial`) принимается решение о судьбе тела в архиве.
**Исход разбора отражает доставку, а не обстоятельства.** Отмена и занятость
базы статус не меняют — доставка остаётся `pending` и будет свёрнута снова;
непонятое содержимое, невыводимый слой, нечитаемое тело, исчерпанный дедлайн и
паника свёртки дают `failed`. Различение появилось не из аккуратности: `failed`
из очереди выбывает навсегда и возвращается только пересборкой, а конкуренция за
базу между приёмом и свёрткой стала штатной — без него занятость стирала бы
доставку с полки молча. По той же причине учёт доставки идёт через транзакцию с
повторами: одиночная вставка пересиживала бы только `busy_timeout`, после чего
приём ответил бы `500` по доставке, тело которой уже на диске.
**Паника свёртки перехватывается там же, где пишется исход разбора.** Пока
свёртка шла внутри обработчика, панику ловил транспорт и стоила она одного
ответа; из фоновой горутины она валит процесс, а перезапуск берёт ту же доставку
первой — дефект одной доставки становится циклом перезапуска, при котором приём
не работает вовсе.
**Предел порядка назван вслух.** Метка приёма фиксируется раньше, чем строка
учёта становится видимой, поэтому две одновременные доставки могут закоммитить
строки в обратном порядке. Доставка без плотных метрик, свёрнутая раньше своей
предшественницы, слоя не выведет и уйдёт в `failed`: её точки доедут только
пересборкой. Окно узкое, и изменение его сужает, а не открывает, — но закрытие
предела требует удерживать порядок на самом приёме, и это отдельный вопрос
(беклог, блокеры).
Остановка формулируется **инвариантом**: приём прекращается раньше воркера, и
после остановки не существует доставки, которая числится разобранной, а записана
наполовину. Обещать «текущая доставка досворачивается» нельзя — бюджет остановки
(30 с) меньше бюджета свёртки (2 мин).
#### Частичный разбор
Разбор покрывает секцию `metrics`; `workouts`, `stateOfMind`, `symptoms`, `ecg`