- Очередью служит сама таблица: доставка ждёт свёртки в статусе `pending`, канал несёт только бит «есть работа». Переполнять нечего, падение процесса очередь не теряет, а подбор `pending` при старте — обычный проход воркера, а не отдельный код. Классификация исхода общая с пересборкой журнала. - Исход разбора начал отражать доставку, а не обстоятельства: отмена и занятость базы статус не меняют (иначе конкуренция за базу выводила бы доставку из очереди навсегда), паника свёртки больше не валит процесс, а учёт доставки идёт через транзакцию с повторами. - Длинный бюджет ответа выдан маршруту приёма, а не всему серверу: `write_timeout` в Go покрывает и чтение тела, и общий подъём снял бы защиту с остальных маршрутов.
181 lines
16 KiB
Markdown
181 lines
16 KiB
Markdown
## 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`).
|