Files
jellybit/openspec/changes/retry-stall-basis/proposal.md
T
avandClaude Opus 4.8 8261d5b55d Retry/stall: сброс базиса таймаута + простой от last_activity (MAJOR-1, MAJOR-2)
Два связанных бага семантики таймаутов зависания и ручного retry.

MAJOR-1: Retry живого торрента не сбрасывал базис отсчёта таймаута — задача
мгновенно снова падала в stuck на ближайшем тике. Вводим колонку
download.retried_at (миграция 0010): ручной retry фиксирует момент и
приподнимает пол обоих таймаутов (max(базис, retried_at)). Хранится в БД, а
не в памяти, чтобы сброс пережил тик поллинга и рестарт.

MAJOR-2: stuck_after мерил ВОЗРАСТ торрента (от added_on), а не ПРОСТОЙ —
долго качавшийся торрент, на миг зашедший в stalledDL, ложно уходил в stuck
со «stalled for 5h». Теперь stuck_after мерит простой от qBit last_activity
(новое поле qbt.Torrent из того же ответа /torrents/info); magnet_timeout
по-прежнему мерит возраст (семантически верно). checkTimeouts разбит на
torrentAge/stallDuration/addedBasis/retriedFloor.

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

Спека: дельта state-reconciliation (MODIFIED «Восстановление зависшей
загрузки» и «Ручной повтор»), правка docs/specs/workflow.md (устранено
противоречие «возраст vs простой»), ER-схема database.md.

Тесты: TestRetryResetsTimeoutBasis (следующий тик после retry — прячется в
TestRetryReattachesNoReadd), TestStallMeasuredFromLastActivity,
TestSetRetriedAtOverwrites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 17:18:28 +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).