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,104 @@
|
||||
# Design: retry/stall basis
|
||||
|
||||
## Контекст
|
||||
|
||||
Два таймаута зависания в `Worker.checkTimeouts` сегодня используют один базис —
|
||||
возраст торрента `now − added_on`:
|
||||
|
||||
- `magnet_timeout`: `metaDL` дольше порога → `failed/magnet_timeout`.
|
||||
- `stuck_after`: `stalledDL` дольше порога → `stuck/stalled`.
|
||||
|
||||
Для `magnet_timeout` возраст семантически верен (сколько торрент вообще висит
|
||||
без метаданных). Для `stuck_after` возраст НЕВЕРЕН: нас интересует **простой**
|
||||
(сколько данные не двигаются), а не общий возраст (MAJOR-2). Отдельно retry
|
||||
живого торрента не сбрасывает базис, и задача мгновенно снова падает (MAJOR-1).
|
||||
|
||||
## Дизайн-развилка: откуда брать базис простоя/таймаута
|
||||
|
||||
Ключевое решение change — где взять базис для двух мер. Рассмотрены варианты:
|
||||
|
||||
- **(a) Новые колонки БД** `retried_at` и/или `stalled_since`. Базис возраста =
|
||||
`max(added_on, retried_at)`; простой — от `stalled_since` (момент входа в
|
||||
`stalledDL`, который мы сами детектируем и пишем/сбрасываем на каждом тике).
|
||||
- **(b) Переиспользовать `last_activity` из снимка торрента** для измерения
|
||||
простоя, без колонки на stall.
|
||||
- **(c) Гибрид (ВЫБРАН):** колонка `retried_at` (только для сброса базиса при
|
||||
ручном retry) + `last_activity` qBittorrent (для измерения простоя). Колонки
|
||||
`stalled_since` НЕТ.
|
||||
|
||||
### Выбор: (c) `retried_at` (БД) + `last_activity` (qBit)
|
||||
|
||||
**Простой мерим по `last_activity`, а не по `stalled_since`-колонке.**
|
||||
qBittorrent уже отдаёт `last_activity` (Unix-время последнего движения данных)
|
||||
в том же ответе `/torrents/info` — это авторитетный источник «сколько простой»
|
||||
прямо из движка. `stalled_since` дублировал бы это состояние, требовал бы
|
||||
детектировать переход «вход в stalledDL», писать/сбрасывать колонку на КАЖДОМ
|
||||
тике (торренты мерцают `stalledDL`↔`downloading`) и рисковал бы разъездом с
|
||||
собственным взглядом qBittorrent. Простой = `now − last_activity`: торрент,
|
||||
двигавший данные секунду назад, простаивает ~0 несмотря на возраст 5ч →
|
||||
MAJOR-2 закрыт без схемы для stall. Это часть варианта (b).
|
||||
|
||||
**Сброс базиса при retry храним в `retried_at` (БД), а не в памяти.** Спека
|
||||
требует, чтобы retry давал свежее окно и задача не падала снова. `last_activity`
|
||||
этого не выражает: у по-настоящему простаивающего торрента она «часы назад», и
|
||||
возврат в `downloading` тут же дал бы `stuck` на следующем тике. Нужна
|
||||
персистентная метка «пользователь нажал retry в момент T», которая приподнимает
|
||||
пол ОБОИХ базисов: `basis = max(добавление|last_activity, retried_at)`. Она
|
||||
должна пережить интервал поллинга и рестарт процесса (retry, затем рестарт не
|
||||
должен ронять задачу), поэтому — колонка, а не in-memory map. Это часть варианта
|
||||
(a), но минимальная: одна nullable TEXT-колонка. Закрывает MAJOR-1.
|
||||
|
||||
### Почему не чистые (a) или (b)
|
||||
|
||||
- **Чистый (b) без колонки** — нельзя записать `last_activity` qBittorrent, так
|
||||
что retry не смог бы сдвинуть базис → MAJOR-1 не решается.
|
||||
- **Чистый (a) со `stalled_since`** — лишняя колонка + пер-тиковая
|
||||
бухгалтерия входа/выхода из `stalledDL`, дублирующая `last_activity`. Отвергнут
|
||||
на минимальности схемы и единственном источнике истины.
|
||||
|
||||
### Замечание об интеграции (точка человеческого вето)
|
||||
|
||||
Change читает НОВОЕ поле qBittorrent `last_activity`, но НЕ добавляет нового
|
||||
вызова API или интеграционной поверхности — поле уже приходит в ответе
|
||||
`/torrents/info`, парсим на одно поле больше. Это единственная «интеграция»
|
||||
change, и она безопасна. Более глубокая интеграция для полного решения NIT-12
|
||||
(см. ниже) СОЗНАТЕЛЬНО отложена как точка человеческого вето.
|
||||
|
||||
## Итоговая схема базисов
|
||||
|
||||
```
|
||||
magnetAge = now − max( added_on | created_at(fallback), retried_at ) // magnet_timeout
|
||||
stallIdle = now − max( last_activity | added_on|created_at(fallback), retried_at ) // stuck_after
|
||||
```
|
||||
|
||||
- `addedBasis(d,t)` — `added_on`, иначе `created_at` (NIT-10), иначе базис
|
||||
неизвестен (WARN, таймаут не срабатывает).
|
||||
- `retriedFloor(d,basis)` — приподнимает базис до `retried_at`, если он позже.
|
||||
- `retried_at` не чистится: как только данные двинулись, `last_activity`
|
||||
естественно обгоняет `retried_at`, и пол перестаёт влиять.
|
||||
|
||||
## NIT-12: retry сломанного живого торрента
|
||||
|
||||
Живой торрент в `error`/`missingFiles` (класс `classErrored`) — перецепка к
|
||||
нему бессмысленна: reconcile на ближайшем тике вернёт задачу в `failed`. Retry
|
||||
теперь считает такой торрент «неживым для целей перецепки» и идёт по ветке
|
||||
повторного `Add` (повторно отдаёт источник). Это честнее слепой перецепки:
|
||||
retry перецепляется только к ЗДОРОВОМУ живому торренту.
|
||||
|
||||
**Остаточное ограничение (отложено, точка вето):** для устойчиво сломанного
|
||||
торрента повторный `Add` того же infohash qBittorrent, как правило, дедуплицирует
|
||||
— ошибка не очистится, и следующий тик всё равно вернёт задачу в `failed`. Полное
|
||||
устранение (принудительный recheck / delete+re-add через qBittorrent) требует
|
||||
НОВОЙ интеграции с клиентом и вынесено за рамки change на человеческое решение.
|
||||
|
||||
## Тесты
|
||||
|
||||
- `TestRetryResetsTimeoutBasis` — MAJOR-1: retry живого stalledDL-торрента с
|
||||
давним `added_on` и давним `last_activity`, затем СЛЕДУЮЩИЙ тик Poll →
|
||||
остаётся `downloading` (без сброса базиса ушёл бы в `stuck`). Именно эту
|
||||
регрессию прячет `TestRetryReattachesNoReadd`.
|
||||
- `TestStallMeasuredFromLastActivity` — MAJOR-2: `stalledDL` с давним `added_on`,
|
||||
но свежим `last_activity` → `downloading`; контроль — давняя `last_activity` →
|
||||
`stuck`.
|
||||
- `TestSetRetriedAtOverwrites` — store: `retried_at` перезаписывается (в отличие
|
||||
от однократного `source_added_at`).
|
||||
Reference in New Issue
Block a user