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

79 lines
6.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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).