# 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`).