Приём отвечает 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,180 @@
## 1. Опоры в хранилище
- [x] 1.1 Миграция `00006`: частичный индекс
`delivery (received_at, id) WHERE parse_status = 'pending'`. Комментарий
объясняет, почему частичный, а не по `parse_status`: в установившемся
режиме в нём ноль–одна строка, полный хранил бы всю таблицу ради выборки
из одной.
- [x] 1.2 `store.PendingDeliveries(ctx, after, limit)` — неразобранные доставки
в порядке `(received_at, id)`, строго после курсора; отдаёт идентификатор
и `received_at`. Сравнение курсора — **row-value** `(received_at, id) >
(?, ?)`: развёрнутая форма через `OR` даёт `SCAN` вместо `SEARCH`
(проверено `EXPLAIN QUERY PLAN`). Нулевой курсор — нулевое время и пустой
идентификатор, без ветки «первая страница».
- [x] 1.3 `store.CountPendingDeliveries(ctx)` — размер задолженности для
строки `INFO` при старте.
- [x] 1.4 `store.ErrBusy` — доменный сентинел занятости базы; `inTx` оборачивает
им исчерпание повторов, чтобы вызывающий не разбирал коды драйвера.
- [x] 1.5 Обновить `docs/database.md`: новый индекс в перечне индексов
`delivery`.
## 2. Исход свёртки отражает доставку, а не обстоятельства
- [x] 2.1 `fold.fail`: отмена контекста и `store.ErrBusy` статус **не меняют**
доставка остаётся `pending`; всё прочее по-прежнему `failed`. Уровень лога
по адресату: занятость и отмена — `WARN` (пройдёт само), остальное как
сейчас.
- [x] 2.2 Тест: свёртка на занятой базе оставляет доставку `pending`; свёртка
непонятого содержимого оставляет `failed`.
## 3. Общий проигрыватель — `internal/replay`
- [x] 3.1 `replay.Player.Play(ctx, deliveryID) (Outcome, error)` — свернуть одну
доставку и вернуть её исход **значением**: ровно один классовый счётчик
равен единице, плюс `partial`/`incomparable` у успешной свёртки.
Классифицируется **только ошибка**; на контекст `Player` не смотрит —
решение «нас остановили» принимает цикл.
- [x] 3.2 `replay.Outcome` + `(*Outcome).Add(other)`; `replay.Report` встраивает
`Outcome`, чтобы имена исходов не раздвоились. Существующие вызывающие
(`cmd/healthlog/reindex*.go`, тесты) читают поля по-прежнему.
- [x] 3.3 `replay.Run` переводится на `Player`, свою ветку отмены оставляет
себе. Поведение и отчёт не меняются — проверяется существующими тестами
пакета.
- [x] 3.4 Табличный тест классификатора: ошибка → ожидаемый `Outcome`.
## 4. Воркер свёртки
- [x] 4.1 `replay.Worker` с синхронным швом: `Pass(ctx) (Outcome, error)` — один
проход, без каналов; `Run(ctx)` — тонкий `select` поверх него по сигналу,
тику и отмене; `Notify()` — неблокирующая отправка в канал ёмкостью 1;
`done` закрывается в `defer` внутри `Run`.
- [x] 4.2 `Pass`: выбирать `pending` порциями по курсору, сворачивать через
`Player`, курсор строго возрастает; отмена проверяется **между**
доставками; проход конечен даже когда доставка осталась `pending`.
- [x] 4.3 Свёртка внутри прохода идёт на `context.WithoutCancel` от контекста
прохода плюс собственный дедлайн (`foldTimeout`, переезжает из
`internal/ingest`).
- [x] 4.4 Отказ выборки — `ERROR` и выход из `Pass`, но не из `Run`: воркер
переживает временный отказ базы.
- [x] 4.5 Наблюдаемость: `INFO` с размером задолженности перед первым проходом;
`WARN` «доставка ждала свёртки дольше пяти минут» — считается на выборке
прохода, включается после того, как первый проход завершился. Ни значений
точек, ни имён устройств.
## 5. Приём без свёртки
- [x] 5.1 `ingest.Service` теряет зависимость от `fold`: `Accept` пишет тело,
учитывает доставку, логирует принятие и будит воркер. `foldTimeout` и
вызов свёртки уходят.
- [x] 5.2 Учёт доставки — на `context.WithoutCancel` с коротким дедлайном:
обрыв соединения после записи тела не должен оставлять тело без строки в
журнале. Проверка формы тела остаётся на исходном контексте.
- [x] 5.3 Сигнал воркеру — параметр конструктора функцией; `nil` приводится к
пустой функции **один раз в конструкторе**, как это уже делают `fold.New`
и `replay.Run` со своими нулевыми значениями. Проверок на `nil` в местах
вызова быть не должно.
- [x] 5.4 `internal/httpapi` собирается без `fold`; транспорт по-прежнему не
логирует исход и переводит только ошибки приёма.
## 6. Жизненный цикл в `serve.go`
- [x] 6.1 Собрать воркер, запустить `Run` в горутине, передать его `Notify` в
`ingest`, разбудить при старте — этим и делается подбор `pending`.
- [x] 6.2 Остановка: `srv.Shutdown` → отмена контекста воркера → ожидание
`done` в остатке того же бюджета `shutdownTimeout` (30 с, как
`stop_grace_period`).
- [x] 6.3 Контекстная ошибка `Shutdown``WARN`, а не отказ команды: stdlib
возвращает `DeadlineExceeded` штатно, а `main` печатает на любой ошибке
`fatal startup` и выходит с кодом 1.
- [x] 6.4 База не закрывается, пока воркер не вышел: `Close` под живой
транзакцией свёртки дал бы `ERROR` по доставке, с которой всё в порядке.
Не уложились — оставляем закрытие процессу и называем это `WARN`.
## 7. Бюджет ответа маршрута приёма
- [x] 7.1 `handleIngest` перед чтением тела ставит дедлайн записи ответа через
`http.NewResponseController(w).SetWriteDeadline` на `read_timeout +
write_timeout`. `http.ErrNotSupported``DEBUG` и продолжение, а не
отказ приёма.
- [x] 7.2 Общий `write_timeout` и его умолчание не меняются; комментарий в
`config.example.toml` объясняет, что он покрывает и чтение тела и потому
приём держит собственный бюджет.
## 8. Проверки
Приёмочные критерии — рубрика ревью дизайна, перенесена сюда целиком.
- [x] 8.1 **Завершимость прохода.** Доставка, оставшаяся `pending`, не
выбирается проходом повторно; `Pass` возвращает управление.
- [x] 8.2 **Атомарность единицы работы.** Прерванная свёртка оставляет доставку
`pending` и не оставляет частично записанных объектов.
- [x] 8.3 **Идемпотентность повтора.** Двойная свёртка той же доставки даёт тот
же отпечаток витрины.
- [x] 8.4 **Сигнал не теряет работу.** Доставка, чей сигнал потерян, всё равно
подбирается — тиком или следующим проходом.
- [x] 8.5 **Тотальный порядок.** Доставки с одинаковым `received_at`
сворачиваются в порядке `id` при любом размере порции; порядок проверяется
наблюдаемым следствием — наследованием слоя.
- [x] 8.6 **Остановка.** Приём прекращается раньше воркера; после остановки нет
доставки, числящейся разобранной и записанной наполовину; исчерпание
бюджета не даёт ненулевого кода возврата.
- [x] 8.7 **Транзиентный отказ ≠ отказ доставки.** Занятость базы оставляет
`pending`, содержимое даёт `failed` (задача 2.2).
- [x] 8.8 **Отставание наблюдаемо и при отсутствии прогресса.** Метка не
срабатывает на задолженности первого прохода и срабатывает после него;
считается на выборке, а не по факту свёртки.
- [x] 8.9 **Одна классификация на оба входа.** Табличный тест `Player` (задача
3.4) плюс зелёные существующие тесты `replay`.
- [x] 8.10 **Тестируемость без сна.** Ни один тест воркера не ждёт по часам:
проверки идут через `Pass`, тест на `Run` — один, «отмена завершает цикл».
- [x] 8.11 **Приём не платит за воркер.** После `Accept` доставка числится
`pending`, обработчик не ждёт; приём с неработающим воркером отвечает
`200`.
- [x] 8.12 **Данные о здоровье не в логе.** Записи воркера несут только
идентификатор доставки, счётчики и длительности.
- [x] 8.13 `task gate` зелёный; `task verify:archive` даёт то же состояние.
## 9. Документация
- [x] 9.1 `docs/architecture.md`, раздел «Приём»: ответ отдаётся после архивации
и учёта — это **контракт**, а не деталь реализации; очередь — таблица, а
не память; порядок журнала и остановка; классификация «отказ доставки»
против «отказ обстоятельств»; бюджет ответа маршрута приёма; отвергнутые
варианты с причинами.
- [x] 9.2 Строка в задачу беклога `stats-nablyudaemost`: длина `pending`,
возраст самой старой неразобранной доставки и то, что `/healthz` их не
отражает.
## 10. Правки по ревью кода (профиль `deep`)
- [x] 10.1 `store.CreateDelivery` — через `inTx` с повторами: одиночная вставка
пересиживала только `busy_timeout`, и приём отвечал `500` по доставке,
тело которой уже на диске (измерено: окно занятости 6.55 с при широкой
свёртке против пяти секунд ожидания).
- [x] 10.2 Паника свёртки перехватывается в `fold.Fold` — у той же границы, что
пишет исход разбора: в фоновой горутине она валила процесс, а
`restart: unless-stopped` превращал дефект одной доставки в цикл
перезапуска. Писатель `parse_status` остался единственным.
- [x] 10.3 Флаг «первый проход завершён» снимается только у прохода, дошедшего
до пустой выборки: взведённый на отказе базы, он включал метку задержки
после прохода, который ничего не свернул.
- [x] 10.4 Метка отставания — одна запись на проход (число задержанных и худшее
ожидание), а не запись на доставку: задолженность в сотню тел давала бы
сотню одинаковых `WARN` каждую минуту.
- [x] 10.5 Отмена не пишется `ERROR`-ом в цикле воркера; ветка отказа `Serve` в
`serve.go` останавливает воркер прежде, чем закрыть базу.
- [x] 10.6 Значения заголовков обрезаются перед логом: `automation-name` длиной
600 КБ выдавливал из ротации всю недавнюю историю (измерено).
- [x] 10.7 Запасной `store.Now()` для метки приёма убран: он заводил второй
источник времени вопреки соседнему комментарию и был недостижим.
- [x] 10.8 `classify` неэкспортируема: вторая публичная дверь возвращала
`Outcome` без `Partial`, то есть молча занижала счётчик, по которому
решается судьба тела.
- [x] 10.9 Оракул правила «занятость — обстоятельство»: `task verify:busy`
свёртка под удерживаемой блокировкой. В гейт не входит (25 секунд), но
мутацию «убрать ветку `Transient`» убивает.
- [x] 10.10 Дельты `storage` и `reindex`: «отказ ⇒ `failed`» сужено до отказов
разбора, добавлен класс «отложено».
- [x] 10.11 Остаточный предел порядка при конкурентных приёмах назван в спеке и
в `docs/architecture.md`, вынут блокером
(`docs/backlog/poryadok-zhurnala-na-priyome.md`).