Files
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

136 lines
9.7 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` перестраивается так:
```go
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`).