Приём отвечает 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,86 @@
## Why
Свёртка выполняется **внутри обработчика запроса**, поэтому время ответа равно
времени свёртки: 16 тысяч точек — 11 секунд. `WriteTimeout` в Go ставится в
`readRequest`, то есть до вызова обработчика, и его 30 секунд — общий бюджет на
всё: дочитать тело по мобильной сети, записать архив, вставить строку, свернуть.
Когда бюджет выходит, сервер считает, что отдал `200` (ошибки записи
обработчику не видно, ответ ушёл в буфер), клиент получает обрыв, а `accessLog`
пишет `status_code=200` — единственный канал наблюдаемости в этом сценарии врёт.
Бьёт это по **широким проходам** (`Today`, `Previous 7 Days`, ручной экспорт) —
ровно по тем, ради которых заведён инвариант «дыры закрываются сами».
## What Changes
- Приём отвечает `200` **после архивации тела и вставки строки `delivery`**.
Свёртка из обработчика уходит: время ответа перестаёт зависеть от ширины
доставки.
- Свёртку ведёт **фоновый воркер** — одна горутина, обработка в порядке журнала
(`received_at`, `id`) среди доставок, видимых ему на момент выборки.
- **Очередью служит сама таблица**, а не список идентификаторов в памяти:
доставка ждёт свёртки в статусе `pending`, канал несёт только сигнал «есть
работа». Отсюда три следствия: переполнять нечего (доставка и так `pending`,
это не отказ), падение процесса очередь не теряет, а «подбор `pending` при
старте» перестаёт быть отдельным кодом — это обычный проход воркера.
- Классификация исхода свёртки (`folded` / слой не выведен / содержимое не
разобрано / прочее / частичный разбор) становится **общей с пересборкой**:
один проигрыватель в `internal/replay`, а не второй рядом.
- **Исход свёртки начинает отражать доставку, а не обстоятельства.** Сегодня
`failed` пишется на любой ошибке, включая занятость базы; с воркером это
означало бы, что доставка выбывает из очереди навсегда — а конкуренция за базу
как раз становится штатной. Отмена и занятость статус больше не меняют,
доставка остаётся `pending`; всё прочее по-прежнему `failed` и возвращается
только пересборкой.
- Остановка сервиса формулируется **инвариантом**, а не обещанием досчитать:
приём прекращается раньше воркера, и после остановки нет доставки, которая
числится разобранной, а записана наполовину. Свёртка идёт на контексте,
отвязанном от остановки.
- Наблюдаемость воркера: `WARN`, когда доставка ждала свёртки дольше периода
быстрого прохода, и одна строка `INFO` о размере задолженности при старте.
Метка считается на выборке прохода, а сам проход будит не только сигнал, но и
тик — иначе «работа есть, прогресса нет» неотличимо от пустого потока.
Счётчики в `/stats` — задача `stats-nablyudaemost`, здесь только метки в логе.
- Убирается второй, оставшийся источник молчаливого обрыва: общий `write_timeout`
(30 с) меньше `read_timeout` (5 мин), а он покрывает и чтение тела — то есть
медленная загрузка 64 МиБ обрывается независимо от свёртки. Длинный бюджет
даётся **маршруту приёма** собственным дедлайном ответа; общий таймаут и
конфиг не меняются, и прочие маршруты защиту не теряют.
- Миграция: частичный индекс по неразобранным доставкам — воркер спрашивает их
чаще, чем раз в минуту, а таблица растёт на ~300 строк в сутки.
## Capabilities
### New Capabilities
- `ingest`: приём доставки как самостоятельное поведение — что делает ответ
`200` заслуженным, когда он отдаётся, кто и в каком порядке сворачивает
принятое, что происходит с несвёрнутым при остановке и рестарте.
### Modified Capabilities
- `parsing`: требование «Разбор не влияет на код ответа приёма» уточняется —
разбор идёт **после** ответа, поэтому исход становится виден не в ответе и не
сразу, а асинхронно, в `parse_status` и в логе.
- `storage`: требование «Учёт частично разобранной доставки» уточняется — отказ
обстоятельств (отмена, занятость базы) статуса не меняет вовсе, а прежняя
формулировка «ошибка ⇒ `failed`» этого не допускала.
- `reindex`: требование «Отчёт, оракул и исход команды» получает четвёртый класс
отказа — «работа отложена по обстоятельствам»: классы у пересборки и у фоновой
свёртки общие.
## Impact
- `internal/ingest` — теряет зависимость от `internal/fold`: `Accept` кладёт
тело, учитывает доставку и будит воркер.
- `internal/replay` — общий проигрыватель (свернуть доставку, классифицировать
исход) и фоновый воркер поверх него.
- `internal/store` — выборка неразобранных доставок в порядке журнала с
курсором, сентинел занятости базы; миграция `00006` с частичным индексом.
- `internal/fold` — отмена и занятость базы больше не переводят доставку в
`failed`.
- `cmd/healthlog/serve.go` — жизненный цикл воркера и согласованная остановка.
- `internal/httpapi` — сборка `ingest.Service` без свёртки; собственный дедлайн
ответа на маршруте приёма. `config.example.toml` — комментарий к
`write_timeout`.
- `docs/architecture.md`, `docs/database.md` — путь приёма и новый индекс.