Files
jellybit/openspec/changes/archive/2026-07-08-retry-stall-basis/proposal.md
T
avandClaude Opus 4.8 4cc4de4269 OpenSpec: архивация трёх параллельных changes + синк спек
Итог параллельной волны фиксов (worktree-изоляция, cherry-pick в master):
- ingest-dedup-integrity (F1, F6) → спека ingest
- retry-stall-basis (MAJOR-1, MAJOR-2) → спека state-reconciliation
- linking-transition-robustness (MAJOR-4, MINOR-7) → спеки file-layout
  и state-reconciliation

Дельты влиты в openspec/specs, changes перенесены в
openspec/changes/archive/2026-07-08-*. Беклог не трогаю (по решению).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 17:21:22 +03:00

6.1 KiB
Raw Blame History

Why

Два связанных бага в семантике таймаутов зависания и ручного retry делают повседневные сценарии сломанными:

  • MAJOR-1 — retry живого торрента мгновенно снова падает. Worker.Retry при живой раздаче (alive=true) не переиздаёт Add, а лишь возвращает задачу в downloading. Базис отсчёта таймаута (age = now added_on) при этом НЕ сбрасывается. Если торрент давно добавлен/давно простаивает, ближайший тик снова видит stalledDL && age > stuck_after → задача опять уходит в stuck (~секунды). Спека state-reconciliation «Ручной повтор» требует сброса базиса, но код его не выполняет (комментарий «базис от added_on» верен лишь для ветки повторного Add). Существующий тест TestRetryReattachesNoReadd прячет баг, ставя added_on «минуту назад».

  • MAJOR-2 — stuck_after мерит ВОЗРАСТ, а не ПРОСТОЙ. checkTimeouts считает stalledDL-таймаут от added_on (возраст торрента). Торрент, качавшийся 5 часов и на один тик зашедший в stalledDL (нормальный проход между пирами), мгновенно получает stuck со лживым сообщением «stalled for 5h» и уведомление EventFailed. Результат — флап stuckdownloading и до-часовые ложные пинги. Спека сама противоречива: «stalledDL дольше stuck_after» (простой) против «возраст от added_on».

Дополнительно закрываются два NIT из того же ревью:

  • NIT-10 — фолбэк базиса возраста added_on → created_at (когда qBit не отдал added_on) остаётся, но теперь явно документирован и покрыт.
  • NIT-12 — retry задачи в qbit_error мгновенно откатывается: перецепка к сломанному (error/missingFiles) живому торренту бессмысленна — reconcile тут же возвращает задачу в failed. Retry перестаёт перецепляться к сломанному торренту и повторно отдаёт источник.

What Changes

  • Мера простоя вместо возраста для stuck_after. stalledDL-таймаут считается от last_activity qBittorrent (момент последнего движения данных), а не от возраста торрента. Долго качавшийся торрент со свежей активностью в stuck не уходит (MAJOR-2). magnet_timeout по-прежнему мерит возраст (metaDL без метаданных) от added_on — это семантически верно.
  • Сброс базиса таймаутов при ручном retry. Новая колонка download.retried_at (RFC 3339 UTC) фиксирует момент retry и приподнимает базис ОБОИХ таймаутов (max(базис, retried_at)). После retry задача получает свежее окно и не падает снова на ближайшем тике (MAJOR-1). Хранится в БД (не в памяти), чтобы сброс пережил интервал поллинга и рестарт процесса.
  • Retry не перецепляется к сломанному торренту. Если живой торрент в состоянии ошибки qBittorrent (error/missingFiles), retry повторно отдаёт источник вместо перецепки (NIT-12).

Capabilities

New Capabilities

Нет. Семантика таймаутов зависания относится к жизненному циклу загрузки, который пока живёт в docs/specs/workflow.md (не мигрирован в OpenSpec). Нормативная правка stuck_after/magnet_timeout вносится туда; в OpenSpec затрагивается только state-reconciliation (восстановление и ручной retry).

Modified Capabilities

  • state-reconciliation: уточняется, что предотвращение stuck для прогрессирующего торрента опирается на простой от last_activity, а не на возраст; ручной retry сбрасывает базис таймаутов через retried_at и не перецепляется к сломанному живому торренту.

Impact

  • Спеки: дельта state-reconciliation (2 MODIFIED requirements); правка семантики таймаутов и retry в docs/specs/workflow.md (источник истины по жизненному циклу до миграции).
  • Код: internal/worker/worker.gocheckTimeouts (две разные меры), torrentAge/новые stallDuration/addedBasis/retriedFloor, Retry (сброс базиса + перецепка только к здоровому торренту); internal/qbt (поле last_activity); internal/store/download.go (RetriedAt, RetriedTime, SetRetriedAt).
  • Миграции БД: 0010_retried_at.sql — колонка download.retried_at; обновление ER-схемы docs/specs/database.md.
  • qBittorrent-клиент: читается новое поле last_activity из того же ответа /torrents/info (без нового вызова API).