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

16 KiB
Raw Blame History

1. Опоры в хранилище

  • 1.1 Миграция 00006: частичный индекс delivery (received_at, id) WHERE parse_status = 'pending'. Комментарий объясняет, почему частичный, а не по parse_status: в установившемся режиме в нём ноль–одна строка, полный хранил бы всю таблицу ради выборки из одной.
  • 1.2 store.PendingDeliveries(ctx, after, limit) — неразобранные доставки в порядке (received_at, id), строго после курсора; отдаёт идентификатор и received_at. Сравнение курсора — row-value (received_at, id) > (?, ?): развёрнутая форма через OR даёт SCAN вместо SEARCH (проверено EXPLAIN QUERY PLAN). Нулевой курсор — нулевое время и пустой идентификатор, без ветки «первая страница».
  • 1.3 store.CountPendingDeliveries(ctx) — размер задолженности для строки INFO при старте.
  • 1.4 store.ErrBusy — доменный сентинел занятости базы; inTx оборачивает им исчерпание повторов, чтобы вызывающий не разбирал коды драйвера.
  • 1.5 Обновить docs/database.md: новый индекс в перечне индексов delivery.

2. Исход свёртки отражает доставку, а не обстоятельства

  • 2.1 fold.fail: отмена контекста и store.ErrBusy статус не меняют — доставка остаётся pending; всё прочее по-прежнему failed. Уровень лога по адресату: занятость и отмена — WARN (пройдёт само), остальное как сейчас.
  • 2.2 Тест: свёртка на занятой базе оставляет доставку pending; свёртка непонятого содержимого оставляет failed.

3. Общий проигрыватель — internal/replay

  • 3.1 replay.Player.Play(ctx, deliveryID) (Outcome, error) — свернуть одну доставку и вернуть её исход значением: ровно один классовый счётчик равен единице, плюс partial/incomparable у успешной свёртки. Классифицируется только ошибка; на контекст Player не смотрит — решение «нас остановили» принимает цикл.
  • 3.2 replay.Outcome + (*Outcome).Add(other); replay.Report встраивает Outcome, чтобы имена исходов не раздвоились. Существующие вызывающие (cmd/healthlog/reindex*.go, тесты) читают поля по-прежнему.
  • 3.3 replay.Run переводится на Player, свою ветку отмены оставляет себе. Поведение и отчёт не меняются — проверяется существующими тестами пакета.
  • 3.4 Табличный тест классификатора: ошибка → ожидаемый Outcome.

4. Воркер свёртки

  • 4.1 replay.Worker с синхронным швом: Pass(ctx) (Outcome, error) — один проход, без каналов; Run(ctx) — тонкий select поверх него по сигналу, тику и отмене; Notify() — неблокирующая отправка в канал ёмкостью 1; done закрывается в defer внутри Run.
  • 4.2 Pass: выбирать pending порциями по курсору, сворачивать через Player, курсор строго возрастает; отмена проверяется между доставками; проход конечен даже когда доставка осталась pending.
  • 4.3 Свёртка внутри прохода идёт на context.WithoutCancel от контекста прохода плюс собственный дедлайн (foldTimeout, переезжает из internal/ingest).
  • 4.4 Отказ выборки — ERROR и выход из Pass, но не из Run: воркер переживает временный отказ базы.
  • 4.5 Наблюдаемость: INFO с размером задолженности перед первым проходом; WARN «доставка ждала свёртки дольше пяти минут» — считается на выборке прохода, включается после того, как первый проход завершился. Ни значений точек, ни имён устройств.

5. Приём без свёртки

  • 5.1 ingest.Service теряет зависимость от fold: Accept пишет тело, учитывает доставку, логирует принятие и будит воркер. foldTimeout и вызов свёртки уходят.
  • 5.2 Учёт доставки — на context.WithoutCancel с коротким дедлайном: обрыв соединения после записи тела не должен оставлять тело без строки в журнале. Проверка формы тела остаётся на исходном контексте.
  • 5.3 Сигнал воркеру — параметр конструктора функцией; nil приводится к пустой функции один раз в конструкторе, как это уже делают fold.New и replay.Run со своими нулевыми значениями. Проверок на nil в местах вызова быть не должно.
  • 5.4 internal/httpapi собирается без fold; транспорт по-прежнему не логирует исход и переводит только ошибки приёма.

6. Жизненный цикл в serve.go

  • 6.1 Собрать воркер, запустить Run в горутине, передать его Notify в ingest, разбудить при старте — этим и делается подбор pending.
  • 6.2 Остановка: srv.Shutdown → отмена контекста воркера → ожидание done в остатке того же бюджета shutdownTimeout (30 с, как stop_grace_period).
  • 6.3 Контекстная ошибка ShutdownWARN, а не отказ команды: stdlib возвращает DeadlineExceeded штатно, а main печатает на любой ошибке fatal startup и выходит с кодом 1.
  • 6.4 База не закрывается, пока воркер не вышел: Close под живой транзакцией свёртки дал бы ERROR по доставке, с которой всё в порядке. Не уложились — оставляем закрытие процессу и называем это WARN.

7. Бюджет ответа маршрута приёма

  • 7.1 handleIngest перед чтением тела ставит дедлайн записи ответа через http.NewResponseController(w).SetWriteDeadline на read_timeout + write_timeout. http.ErrNotSupportedDEBUG и продолжение, а не отказ приёма.
  • 7.2 Общий write_timeout и его умолчание не меняются; комментарий в config.example.toml объясняет, что он покрывает и чтение тела и потому приём держит собственный бюджет.

8. Проверки

Приёмочные критерии — рубрика ревью дизайна, перенесена сюда целиком.

  • 8.1 Завершимость прохода. Доставка, оставшаяся pending, не выбирается проходом повторно; Pass возвращает управление.
  • 8.2 Атомарность единицы работы. Прерванная свёртка оставляет доставку pending и не оставляет частично записанных объектов.
  • 8.3 Идемпотентность повтора. Двойная свёртка той же доставки даёт тот же отпечаток витрины.
  • 8.4 Сигнал не теряет работу. Доставка, чей сигнал потерян, всё равно подбирается — тиком или следующим проходом.
  • 8.5 Тотальный порядок. Доставки с одинаковым received_at сворачиваются в порядке id при любом размере порции; порядок проверяется наблюдаемым следствием — наследованием слоя.
  • 8.6 Остановка. Приём прекращается раньше воркера; после остановки нет доставки, числящейся разобранной и записанной наполовину; исчерпание бюджета не даёт ненулевого кода возврата.
  • 8.7 Транзиентный отказ ≠ отказ доставки. Занятость базы оставляет pending, содержимое даёт failed (задача 2.2).
  • 8.8 Отставание наблюдаемо и при отсутствии прогресса. Метка не срабатывает на задолженности первого прохода и срабатывает после него; считается на выборке, а не по факту свёртки.
  • 8.9 Одна классификация на оба входа. Табличный тест Player (задача 3.4) плюс зелёные существующие тесты replay.
  • 8.10 Тестируемость без сна. Ни один тест воркера не ждёт по часам: проверки идут через Pass, тест на Run — один, «отмена завершает цикл».
  • 8.11 Приём не платит за воркер. После Accept доставка числится pending, обработчик не ждёт; приём с неработающим воркером отвечает 200.
  • 8.12 Данные о здоровье не в логе. Записи воркера несут только идентификатор доставки, счётчики и длительности.
  • 8.13 task gate зелёный; task verify:archive даёт то же состояние.

9. Документация

  • 9.1 docs/architecture.md, раздел «Приём»: ответ отдаётся после архивации и учёта — это контракт, а не деталь реализации; очередь — таблица, а не память; порядок журнала и остановка; классификация «отказ доставки» против «отказ обстоятельств»; бюджет ответа маршрута приёма; отвергнутые варианты с причинами.
  • 9.2 Строка в задачу беклога stats-nablyudaemost: длина pending, возраст самой старой неразобранной доставки и то, что /healthz их не отражает.

10. Правки по ревью кода (профиль deep)

  • 10.1 store.CreateDelivery — через inTx с повторами: одиночная вставка пересиживала только busy_timeout, и приём отвечал 500 по доставке, тело которой уже на диске (измерено: окно занятости 6.55 с при широкой свёртке против пяти секунд ожидания).
  • 10.2 Паника свёртки перехватывается в fold.Fold — у той же границы, что пишет исход разбора: в фоновой горутине она валила процесс, а restart: unless-stopped превращал дефект одной доставки в цикл перезапуска. Писатель parse_status остался единственным.
  • 10.3 Флаг «первый проход завершён» снимается только у прохода, дошедшего до пустой выборки: взведённый на отказе базы, он включал метку задержки после прохода, который ничего не свернул.
  • 10.4 Метка отставания — одна запись на проход (число задержанных и худшее ожидание), а не запись на доставку: задолженность в сотню тел давала бы сотню одинаковых WARN каждую минуту.
  • 10.5 Отмена не пишется ERROR-ом в цикле воркера; ветка отказа Serve в serve.go останавливает воркер прежде, чем закрыть базу.
  • 10.6 Значения заголовков обрезаются перед логом: automation-name длиной 600 КБ выдавливал из ротации всю недавнюю историю (измерено).
  • 10.7 Запасной store.Now() для метки приёма убран: он заводил второй источник времени вопреки соседнему комментарию и был недостижим.
  • 10.8 classify неэкспортируема: вторая публичная дверь возвращала Outcome без Partial, то есть молча занижала счётчик, по которому решается судьба тела.
  • 10.9 Оракул правила «занятость — обстоятельство»: task verify:busy — свёртка под удерживаемой блокировкой. В гейт не входит (25 секунд), но мутацию «убрать ветку Transient» убивает.
  • 10.10 Дельты storage и reindex: «отказ ⇒ failed» сужено до отказов разбора, добавлен класс «отложено».
  • 10.11 Остаточный предел порядка при конкурентных приёмах назван в спеке и в docs/architecture.md, вынут блокером (docs/backlog/poryadok-zhurnala-na-priyome.md).