Два связанных бага семантики таймаутов зависания и ручного 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>
79 lines
6.1 KiB
Markdown
79 lines
6.1 KiB
Markdown
## 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).
|