Files
jellybit/openspec/changes/retry-stall-basis/design.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

8.1 KiB
Raw Blame History

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», писать/сбрасывать колонку на КАЖДОМ тике (торренты мерцают stalledDLdownloading) и рисковал бы разъездом с собственным взглядом 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_activitydownloading; контроль — давняя last_activitystuck.
  • TestSetRetriedAtOverwrites — store: retried_at перезаписывается (в отличие от однократного source_added_at).