Files
healthlog/openspec/changes/archive/2026-08-02-otvet-i-svyortka/tasks.md
T
av 63bffe2865 Приём отвечает 200 до свёртки, свёртку ведёт фоновый воркер
- Очередью служит сама таблица: доставка ждёт свёртки в статусе `pending`,
  канал несёт только бит «есть работа». Переполнять нечего, падение процесса
  очередь не теряет, а подбор `pending` при старте — обычный проход воркера, а
  не отдельный код. Классификация исхода общая с пересборкой журнала.
- Исход разбора начал отражать доставку, а не обстоятельства: отмена и
  занятость базы статус не меняют (иначе конкуренция за базу выводила бы
  доставку из очереди навсегда), паника свёртки больше не валит процесс, а
  учёт доставки идёт через транзакцию с повторами.
- Длинный бюджет ответа выдан маршруту приёма, а не всему серверу:
  `write_timeout` в Go покрывает и чтение тела, и общий подъём снял бы защиту с
  остальных маршрутов.
2026-08-02 11:01:42 +03:00

181 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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`).