Files
jellybit/openspec/changes/archive/2026-07-08-downloading-source-gone/design.md
T
avandClaude Opus 4.8 2a5a65f2d5 Приём: пропажа источника у активной загрузки → failed(source_gone) (MAJOR-3)
Раздача активной (downloading) загрузки, исчезнувшая из qBittorrent (удалил
пользователь/другой клиент), делала задачу вечным зомби: поллинг промахивался
по torrentFor, писал Warn и continue каждый тик — состояние не менялось,
уведомления и телеметрии не было, checkTimeouts без торрента не срабатывал.
Пропажей источника у downloading не владел никто (сверка рассинхрона покрывает
только done/target_missing/orphaned, восстановление — failed/stuck).

Активный цикл Poll теперь применяет тот же дебаунс пропажи источника, что и
сверка рассинхрона (source_miss_count / source_missing_threshold): после порога
подряд идущих промахов задача уходит downloading → failed с distinct error_code
source_gone и уведомлением. До порога транзиентная недоступность qBit
(рестарт демона) задачу не роняет. source_gone восстановлению сверкой не
подлежит (удаление намеренно), но штатно retriable — Retry заново отдаёт
сохранённый источник; Retry сбрасывает source_miss_count, чтобы вернувшаяся
задача получила полное грейс-окно, а не упала снова на ближайшем тике.

Ребро downloading → failed уже было в графе, миграций/полей БД нет. Спека
download-tracking дополнена требованием, диаграмма workflow.md — ребром.
Change downloading-source-gone заархивирован.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 18:05:04 +03:00

9.7 KiB
Raw Blame History

Design — Пропажа источника у активной загрузки

Контекст

Poll под w.mu листает downloading-задачи и для каждой ищет торрент в byHash. Промах сейчас — только Warn + continue (worker.go:503-508). Механизм дебаунса пропажи источника уже есть в state-reconciliation: поле download.source_miss_count, метод debounceSource(ctx, d, sourceSeen) bool и порог [worker].source_missing_threshold (дефолт 3). Он применяется только в reconcileOneDesync. Задача — переиспользовать его в активном цикле.

Решение

Целевое состояние: failed/source_gone, а не deleted

Развилка из беклога: «downloading → deleted ИЛИ failed с distinct error_code для re-Add». Выбран failed/source_gone:

  • Восстановимость. Источник у нас сохранён (magnet source_ref или байты .torrent). Retry заново отдаёт его в qBittorrent — задача продолжится. deleted терминален навсегда и не оставляет пользователю выхода, хотя пропажа могла быть случайной.
  • Минимальная дельта графа. Ребро downloading → failed уже объявлено (allowedTransitions), нового ребра не нужно. downloading → deleted ребра нет — пришлось бы вводить.
  • Переиспользование инфраструктуры. StateFailed уже шлёт EventFailed (с дебаунсом уведомлений) и уже retriable — не нужен ни новый notify-повод, ни новая команда.
  • Distinct error_code. source_gone отличается от qbit_error (реальная ошибка qBit), magnet_timeout/stalled (наша нетерпеливость) — по нему UI/лог различает причину, и он осознанно не в наборе reconcileRecovery (ListRecoverable(magnet_timeout, stalled)): намеренно удалённый источник не должен молча воскресать, у пользователя есть явный Retry.

deriveState(false, false) = deleted (матрица «источник × цель») здесь НЕ применяется: у активной downloading-задачи цели ещё нет, и это не путь сверки рассинхрона, а отдельное правило прямого пути. Матрица по-прежнему не трогает активные состояния.

Переиспользование дебаунса в активном цикле

debounceSource(ctx, d, sourceSeen):

  • sourceSeen=true → сбрасывает счётчик в 0, возвращает true;
  • sourceSeen=false → инкремент, возвращает miss < threshold.

Активный цикл Poll перестраивается так:

t, ok := torrentFor(d, byHash)
if present := w.debounceSource(ctx, d, ok); !present {
    // порог промахов исчерпан — источник действительно пропал
    lctx := w.scoped(ctx, capIngest, d.ID, d.PrimaryInfohash())
    w.transition(lctx, d, store.StateFailed, errCodeSourceGone,
        "источник удалён из qBittorrent")
    continue
}
if !ok {
    // до порога: транзиентный промах (рестарт qBit) — ждём следующий тик
    continue
}
w.captureInfohashes(ctx, d, t)
w.captureSourceAddedAt(ctx, d, t)
w.reconcile(ctx, d, t)

debounceSource вызывается в ЛЮБОМ случае (и при ok, и при промахе), поэтому появление раздачи сбрасывает счётчик, накопленный ранее.

Сброс source_miss_count при Retry (иначе нет грейс-окна)

source_gone — единственный путь, оставляющий у retriable задачи ненулевой source_miss_count (у magnet_timeout/stalled источник на падающем тике присутствовал, значит счётчик уже 0). Без сброса Retry вернул бы задачу в downloading с source_miss_count == threshold: на первом же тике, если переотданная раздача ещё не видна в выдаче qBittorrent (Add без ошибки, но регистрация с задержкой), debounceSource даёт miss = threshold+1 → мгновенный повторный source_gone, минуя обещанное спекой грейс-окно.

Поэтому Retry SHALL сбрасывать source_miss_count в 0 — рядом с существующим сбросом retried_at (та же интенция «свежее окно», MAJOR-1), best-effort. Это покрывает общий случай: любая retriable-задача входит в downloading с чистым счётчиком. Путь авто-восстановления (reconcileRecovery) сброса не требует — туда source_gone не попадает, а у magnet_timeout/stalled счётчик уже 0.

Почему нет двойного учёта source_miss_count

Задача в один момент времени находится ровно в одном состоянии: либо в активном цикле (downloading), либо в reconcileDesync (done/target_missing/ orphaned) — не в обоих за тик. Поле source_miss_count используется с единой семантикой «сброс при наличии источника», поэтому пересечения нет. При переходе downloading → completed счётчик уже 0 (источник виден на том же тике).

catched-исключение сохраняется

catched-задачи в активный цикл не попадают (там нет раздачи по дизайну) — требование «catched не считается пропажей раздачи» не затрагивается: цикл листает только StateDownloading.

Альтернативы

  • downloading → deleted — отклонено: терминально без выхода, требует нового ребра, теряет ещё-скачиваемую задачу при, возможно, случайной пропаже.
  • Авто-восстановление source_gone при возврате раздачи (добавить в reconcileRecovery) — отклонено для v1: намеренное удаление не должно тихо оживать; есть явный Retry. Отложено (можно добавить позже отдельным change, если появится боль).
  • Отдельный notify-повод EventSourceGone — избыточно: EventFailed семантически покрывает «задача упала», текст уведомления берётся по состоянию.

Принятые ограничения (вне scope)

  • Дебаунс уведомлений может проглотить пинг. shouldNotifyFail дебаунсит EventFailed по download_id на 1 ч. Узкая последовательность (задача уже падала stalled/stuck с пингом < 1 ч назад → Retry → источник удалён → source_gone в то же окно) не пришлёт повторный пинг. Приемлемо: смена состояния и телеметрия всё равно фиксируются (главная боль зомби — «никто не двигает» — закрыта); отдельный пинг именно про source_gone не критичен.
  • Задача downloading с пустым Infohashes остаётся вне правила: гард len(d.Infohashes)==0 { continue } стоит до torrentFor (как и в reconcileOneDesync). В Ф1 не случается (magnet всегда с infohash); отдельный класс зомби, этим change не адресуется.
  • Пустая выдача qBittorrent при живом демоне (HTTP 200 сразу после рестарта, resume-data ещё не загружены) нарастит промахи всем активным задачам. Экспозиция предсуществующая и общая с reconcileDesync (тот массово пометил бы orphaned/deleted); change лишь распространяет её на активные загрузки. Дебаунс (source_missing_threshold) — уже имеющаяся защита; принимаем.

Тесты

  • Промах меньше порога → задача остаётся downloading (дебаунс).
  • Промах ≥ порога → downloading → failed/source_gone + EventFailed.
  • Возврат раздачи до порога → счётчик сброшен, задача жива, ушла по обычному reconcile.
  • source_gone не воскрешается reconcileRecovery (источник вернулся — задача остаётся failed до ручного Retry).