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>
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
## 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`. Результат — флап `stuck`↔`downloading` и
|
||||
до-часовые ложные пинги. Спека сама противоречива: «`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.go` — `checkTimeouts` (две разные меры),
|
||||
`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).
|
||||
Reference in New Issue
Block a user