Итог параллельной волны фиксов (worktree-изоляция, cherry-pick в master): - ingest-dedup-integrity (F1, F6) → спека ingest - retry-stall-basis (MAJOR-1, MAJOR-2) → спека state-reconciliation - linking-transition-robustness (MAJOR-4, MINOR-7) → спеки file-layout и state-reconciliation Дельты влиты в openspec/specs, changes перенесены в openspec/changes/archive/2026-07-08-*. Беклог не трогаю (по решению). 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).