Два связанных бага семантики таймаутов зависания и ручного 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>
8.1 KiB
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_activityqBittorrent (для измерения простоя). Колонки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_activityqBittorrent, так что 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).