Раздача активной (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>
9.7 KiB
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).