From 2a5a65f2d585cf34714dd6da3a8efb29d4f4ca76 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Wed, 8 Jul 2026 18:05:04 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D1=80=D0=B8=D1=91=D0=BC:=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=BF=D0=B0=D0=B6=D0=B0=20=D0=B8=D1=81=D1=82=D0=BE=D1=87?= =?UTF-8?q?=D0=BD=D0=B8=D0=BA=D0=B0=20=D1=83=20=D0=B0=D0=BA=D1=82=D0=B8?= =?UTF-8?q?=D0=B2=D0=BD=D0=BE=D0=B9=20=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7?= =?UTF-8?q?=D0=BA=D0=B8=20=E2=86=92=20failed(source=5Fgone)=20(MAJOR-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раздача активной (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) --- docs/backlog/README.md | 1 - .../review-major3-zombie-downloading.md | 11 -- docs/specs/workflow.md | 11 +- internal/worker/reconcile_test.go | 12 +- internal/worker/recovery_test.go | 134 +++++++++++++++++ internal/worker/worker.go | 33 ++++- internal/worker/worker_test.go | 8 +- .../.openspec.yaml | 2 + .../design.md | 135 ++++++++++++++++++ .../proposal.md | 47 ++++++ .../specs/download-tracking/spec.md | 58 ++++++++ .../tasks.md | 38 +++++ openspec/specs/download-tracking/spec.md | 55 +++++++ 13 files changed, 524 insertions(+), 21 deletions(-) delete mode 100644 docs/backlog/review-major3-zombie-downloading.md create mode 100644 openspec/changes/archive/2026-07-08-downloading-source-gone/.openspec.yaml create mode 100644 openspec/changes/archive/2026-07-08-downloading-source-gone/design.md create mode 100644 openspec/changes/archive/2026-07-08-downloading-source-gone/proposal.md create mode 100644 openspec/changes/archive/2026-07-08-downloading-source-gone/specs/download-tracking/spec.md create mode 100644 openspec/changes/archive/2026-07-08-downloading-source-gone/tasks.md diff --git a/docs/backlog/README.md b/docs/backlog/README.md index c22344a..a4f47b3 100644 --- a/docs/backlog/README.md +++ b/docs/backlog/README.md @@ -21,7 +21,6 @@ Tududi (проект `jellybit`) больше **не** держит беклог - [Полное удаление загрузки из jellybit («единое окно», path 2)](udalenie-edinoe-okno.md) — Действие «Удалить»: снять хардлинки + снести раздачу+файлы из qBittorrent → освободить место (отдельно от undo) - [Ретеншн и очистка БД](retention-ochistka-bd.md) — Терминальные задачи (done/cancelled/failed/reverted), их попытки recognition с сырыми… - [Eval-харнес распознавания (корпус кейсов + метрика точности)](eval-harness-raspoznavaniya.md) — Распознавание — ядро продукта, но смена модели или правка промпта сейчас вслепую… -- [Восстановление zombie downloading при пропаже источника из qBittorrent (MAJOR-3)](review-major3-zombie-downloading.md) — торрент пропал из qBittorrent в downloading → задача вечный зомби, никто не двигает _(ревью 2026-07-08)_ ## Средний diff --git a/docs/backlog/review-major3-zombie-downloading.md b/docs/backlog/review-major3-zombie-downloading.md deleted file mode 100644 index f62d2e8..0000000 --- a/docs/backlog/review-major3-zombie-downloading.md +++ /dev/null @@ -1,11 +0,0 @@ -# Восстановление zombie downloading при пропаже источника из qBittorrent (MAJOR-3) - -**Приоритет:** высокий · **Теги:** review-2026-07-08, lifecycle - -Ревью Fable 2026-07-08 (жизненный цикл). worker.go:472-477. Подтверждено чтением кода. - -Сценарий: торрент удалён из qBittorrent (юзером/другим клиентом), пока задача в downloading. Poll: torrentFor промах → Warn «active download not found in qbittorrent» → continue. Каждый тик, вечно. reconcileDesync покрывает только done/target_missing/orphaned; reconcileRecovery — failed/stuck; дебаунса для этого случая НЕТ, состояние не меняется, уведомления нет, checkTimeouts требует торрент. Задача — вечный зомби, активна в UI без телеметрии; выход только Cancel/Defer. Тот же зомби при провале отката Retry (worker.go:716-729). Спека намеренно исключает активные из матрицы source×target, но «источник исчез в downloading» не владеет НИКТО — дыра спеки (сравн.: та же пропажа в completed/recognizing деградирует штатно). - -Фикс: расширить дебаунс пропажи источника (SourceMissCount) на downloading → после порога downloading→deleted (или failed с distinct error_code для re-Add) + уведомление. Нужно ребро графа. - -Вердикт: полноценный change. Классический «застрявшее состояние, которое никто не двигает». diff --git a/docs/specs/workflow.md b/docs/specs/workflow.md index 476fdd3..c06beb7 100644 --- a/docs/specs/workflow.md +++ b/docs/specs/workflow.md @@ -22,6 +22,7 @@ stateDiagram-v2 downloading --> completed: файлы на месте downloading --> stuck: stalledDL дольше stuck_after downloading --> failed: metaDL дольше magnet_timeout (страховка) / error + downloading --> failed: источник пропал из qBittorrent (source_gone, после дебаунса) completed --> recognizing @@ -170,6 +171,13 @@ SQLite; `worker` периодически сверяет qBittorrent с БД и в `downloading` не ронял задачу снова на ближайшем тике. - **ошибка:** `error`/`missingFiles` → `failed` (`error_code` `qbit_error`) — это настоящий провал, в отличие от таймаута. +- **источник пропал:** раздача активной загрузки устойчиво (после дебаунса + `source_missing_threshold`, тот же счётчик, что и сверка рассинхрона) исчезла + из qBittorrent (удалил пользователь/другой клиент) → `failed` (`error_code` + `source_gone`). Иначе `downloading` без раздачи оставался бы вечным зомби, + которого никто не двигает (MAJOR-3). В отличие от таймаутов, сверка + `source_gone` **не воскрешает** (удаление намеренно) — но задача штатно + retriable: `Retry` заново отдаёт сохранённый источник. ### Уведомление и восстановление @@ -184,7 +192,8 @@ SQLite; `worker` периодически сверяет qBittorrent с БД и только источник в qBittorrent ожил и продвинулся за условие падения (получил метаданные → `downloading`; уже готов → `completed`). Пока торрент всё ещё в `metaDL`/`stalledDL`, задача остаётся упавшей (без зацикливания). - Настоящие провалы (`qbit_error`) сверкой не воскрешаются. + Настоящие провалы (`qbit_error`) и намеренная пропажа источника + (`source_gone`) сверкой не воскрешаются — только ручной retry. - Дополнительно доступен **ручной retry** из веб-UI и Telegram (не только REST): возвращает в `downloading`, перецепляясь к живому **здоровому** торренту без повторного `Add` (к сломанному — `error`/`missingFiles` — не diff --git a/internal/worker/reconcile_test.go b/internal/worker/reconcile_test.go index b0a793d..1bed206 100644 --- a/internal/worker/reconcile_test.go +++ b/internal/worker/reconcile_test.go @@ -151,13 +151,17 @@ func TestReconcileDebounce(t *testing.T) { } func TestReconcileSkipsActiveStates(t *testing.T) { - // downloading сверкой не трогаем, даже если раздачи нет в qBittorrent. - f := newReconcileFixture(t, store.StateDownloading, false, true) + // Матрица сверки (reconcileDesync) не трогает активные состояния, даже если + // раздачи нет в qBittorrent. Берём review (не downloading): активный цикл + // поллинга листает только downloading, поэтому review остаётся нетронутым + // — пропажу источника у downloading отдельно ведёт правило source_gone + // (см. recovery_test: TestSourceGone*). + f := newReconcileFixture(t, store.StateReview, false, true) if err := f.w.Poll(context.Background()); err != nil { t.Fatalf("Poll: %v", err) } - if got := f.st.downloads["1"].State; got != store.StateDownloading { - t.Errorf("state = %q, want downloading (сверка не трогает активные)", got) + if got := f.st.downloads["1"].State; got != store.StateReview { + t.Errorf("state = %q, want review (сверка не трогает активные)", got) } } diff --git a/internal/worker/recovery_test.go b/internal/worker/recovery_test.go index 42df921..3f5e643 100644 --- a/internal/worker/recovery_test.go +++ b/internal/worker/recovery_test.go @@ -260,3 +260,137 @@ func TestStallIgnoresFutureLastActivity(t *testing.T) { t.Errorf("last_activity из будущего игнорируется (фолбэк на возраст 5ч > stuck_after 1ч → stuck), got %q", got) } } + +// --- MAJOR-3: пропажа источника у активной (downloading) загрузки --- + +// Источник активной загрузки устойчиво пропал из qBittorrent → задача уходит в +// failed(source_gone) с уведомлением, а не остаётся вечным зомби. +func TestSourceGoneMarksFailed(t *testing.T) { + const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6" + st := oneDownloading(ih, timeRecent) + w := newTestWorker(st, &fakeQbt{torrents: nil}) // раздачи нет + w.cfg.SourceMissingThreshold = 1 // помечаем при первой же пропаже + n := &recordingNotifier{ch: make(chan notifyEvent, 4)} + w.SetNotifier(n) + + if err := w.Poll(context.Background()); err != nil { + t.Fatalf("Poll: %v", err) + } + d := st.downloads["1"] + if d.State != store.StateFailed { + t.Fatalf("state = %q, want failed", d.State) + } + if got := d.ErrorCode.String; got != errCodeSourceGone { + t.Errorf("error_code = %q, want %q", got, errCodeSourceGone) + } + if e := waitNotify(t, n); e.ev != EventFailed { + t.Errorf("пинг: ev=%v, want failed", e.ev) + } +} + +// Кратковременная пропажа (меньше порога) не роняет задачу — дебаунс терпит +// транзиентную недоступность qBittorrent (рестарт демона). +func TestSourceGoneDebounced(t *testing.T) { + const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6" + st := oneDownloading(ih, timeRecent) + w := newTestWorker(st, &fakeQbt{torrents: nil}) + w.cfg.SourceMissingThreshold = 3 + + for i := 1; i <= 2; i++ { + if err := w.Poll(context.Background()); err != nil { + t.Fatalf("Poll %d: %v", i, err) + } + if got := st.downloads["1"].State; got != store.StateDownloading { + t.Fatalf("tick %d: state = %q, want downloading (до порога)", i, got) + } + if got := st.downloads["1"].SourceMissCount; got != i { + t.Errorf("tick %d: miss = %d, want %d", i, got, i) + } + } + if err := w.Poll(context.Background()); err != nil { // третий промах + t.Fatalf("Poll 3: %v", err) + } + if got := st.downloads["1"].State; got != store.StateFailed { + t.Fatalf("tick 3: state = %q, want failed (порог достигнут)", got) + } + if got := st.downloads["1"].ErrorCode.String; got != errCodeSourceGone { + t.Errorf("error_code = %q, want %q", got, errCodeSourceGone) + } +} + +// Возврат раздачи до порога сбрасывает счётчик промахов и задача ведётся +// обычной сверкой (не падает). +func TestSourceGoneResetOnReturn(t *testing.T) { + const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6" + st := oneDownloading(ih, timeRecent) + qb := &fakeQbt{torrents: nil} + w := newTestWorker(st, qb) + w.cfg.SourceMissingThreshold = 3 + + if err := w.Poll(context.Background()); err != nil { // промах 1 + t.Fatalf("Poll miss: %v", err) + } + if got := st.downloads["1"].SourceMissCount; got != 1 { + t.Fatalf("miss = %d, want 1", got) + } + // Раздача вернулась (свежий metaDL — не падает по таймауту). + qb.torrents = []qbt.Torrent{{Hash: ih, State: "metaDL", AddedOn: addedRecent}} + if err := w.Poll(context.Background()); err != nil { + t.Fatalf("Poll return: %v", err) + } + if got := st.downloads["1"].State; got != store.StateDownloading { + t.Errorf("state = %q, want downloading (источник вернулся)", got) + } + if got := st.downloads["1"].SourceMissCount; got != 0 { + t.Errorf("miss = %d, want 0 (сброс)", got) + } +} + +// source_gone восстановлению сверкой не подлежит: даже если раздача снова +// появилась и продвинулась, reconcileRecovery её не воскрешает — нужен ручной +// Retry. +func TestSourceGoneNotAutoRecovered(t *testing.T) { + const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6" + st := oneFailed(store.StateFailed, errCodeSourceGone, ih, timeOld) + qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ih, State: "downloading", AddedOn: addedRecent}}} + w := newTestWorker(st, qb) + if err := w.Poll(context.Background()); err != nil { + t.Fatalf("Poll: %v", err) + } + if got := st.downloads["1"].State; got != store.StateFailed { + t.Errorf("state = %q, want failed (source_gone сверкой не воскрешается)", got) + } +} + +// Retry задачи source_gone сбрасывает source_miss_count, чтобы вернувшаяся в +// downloading задача получила полное грейс-окно, а не упала снова на ближайшем +// тике, если переотданная раздача ещё не видна в qBittorrent. +func TestRetryResetsSourceMissCount(t *testing.T) { + const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6" + st := oneFailed(store.StateFailed, errCodeSourceGone, ih, timeRecent) + st.downloads["1"].SourceMissCount = 3 // задача упала по порогу + qb := &fakeQbt{torrents: nil} // раздачи всё ещё нет (Add вслепую) + w := newTestWorker(st, qb) + w.cfg.SourceMissingThreshold = 3 + + if err := w.Retry(context.Background(), "1"); err != nil { + t.Fatalf("Retry: %v", err) + } + if got := st.downloads["1"].SourceMissCount; got != 0 { + t.Fatalf("после retry miss = %d, want 0 (сброс)", got) + } + if len(qb.added) != 1 { + t.Errorf("retry без живой раздачи должен переотдать источник, got %d Add", len(qb.added)) + } + // Грейс-окно: раздача ещё не видна — задача НЕ падает на ближайшем тике + // (miss стал бы 1 < 3, а не 4 при несброшенном счётчике). + if err := w.Poll(context.Background()); err != nil { + t.Fatalf("Poll after retry: %v", err) + } + if got := st.downloads["1"].State; got != store.StateDownloading { + t.Errorf("state = %q, want downloading (грейс-окно не съедено)", got) + } + if got := st.downloads["1"].SourceMissCount; got != 1 { + t.Errorf("miss = %d, want 1 (один промах после сброса)", got) + } +} diff --git a/internal/worker/worker.go b/internal/worker/worker.go index a7b1b2b..2888875 100644 --- a/internal/worker/worker.go +++ b/internal/worker/worker.go @@ -133,6 +133,12 @@ const ( // catch_timeout (устойчивая недоступность qBit). Раздачи в qBittorrent нет, // восстановлению сверкой не подлежит. errCodeQbitAdd = "qbit_add" + // errCodeSourceGone — раздача активной (downloading) загрузки устойчиво (после + // дебаунса source_missing_threshold) пропала из qBittorrent: пользователь/другой + // клиент её удалил. Distinct-код, отличный от qbit_error (реальная ошибка qBit) и + // magnet_timeout/stalled (наша нетерпеливость). Восстановлению сверкой НЕ подлежит + // (удаление намеренно) — но задача штатно retriable: Retry заново отдаёт источник. + errCodeSourceGone = "source_gone" ) // Notifier — исходящие пинги (Telegram). Вызывается неблокирующе. @@ -501,9 +507,22 @@ func (w *Worker) Poll(ctx context.Context) error { continue // нечем сопоставить (в Ф1 не случается: magnet всегда с infohash) } t, ok := torrentFor(d, byHash) + // Дебаунс пропажи источника у активной загрузки (тот же счётчик, что и + // сверка рассинхрона): промах наращивает source_miss_count, появление + // раздачи сбрасывает его. Устойчивая пропажа (после порога) уводит задачу + // в failed(source_gone) — иначе downloading без раздачи в qBittorrent + // оставался бы вечным зомби (MAJOR-3). + if present := w.debounceSource(ctx, d, ok); !present { + lctx := w.scoped(ctx, capIngest, d.ID, d.PrimaryInfohash()) + logctx.From(lctx).Warn("active download source gone from qbittorrent", + "miss_count", d.SourceMissCount+1) + w.transition(lctx, d, store.StateFailed, errCodeSourceGone, + "источник удалён из qBittorrent") + continue + } if !ok { - w.log.Warn("active download not found in qbittorrent", - "capability", capIngest, "download_id", d.ID, "infohash", d.PrimaryInfohash()) + // До порога: транзиентный промах (например рестарт демона qBit) — ждём + // следующий тик, задачу не трогаем. continue } w.captureInfohashes(ctx, d, t) @@ -839,6 +858,16 @@ func (w *Worker) Retry(ctx context.Context, id string) error { w.log.Warn("retry basis reset failed", "capability", capReview, "download_id", id, "error", err) } + // Сброс счётчика пропусков источника: retried source_gone-задача иначе вошла бы + // в downloading с source_miss_count == threshold и упала бы снова на ближайшем + // тике, если переотданная раздача ещё не видна в выдаче qBittorrent — без + // обещанного грейс-окна (MAJOR-3). Best-effort: сбой лишь лишает свежего окна. + if d.SourceMissCount != 0 { + if err := w.store.SetSourceMissCount(ctx, id, 0); err != nil { + w.log.Warn("retry miss count reset failed", + "capability", capReview, "download_id", id, "error", err) + } + } logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("download retried", "from", d.State) return nil } diff --git a/internal/worker/worker_test.go b/internal/worker/worker_test.go index 6d7cd88..b0211d4 100644 --- a/internal/worker/worker_test.go +++ b/internal/worker/worker_test.go @@ -370,15 +370,19 @@ func TestPollMatchesByInfohashV2(t *testing.T) { } } -func TestPollIgnoresMissingTorrent(t *testing.T) { +// Единичный транзиентный промах (торрента нет в этом тике) при пороге дебаунса +// > 1 не двигает состояние — устойчивую пропажу и переход в source_gone проверяют +// TestSourceGone* в recovery_test. +func TestPollToleratesTransientMissingTorrent(t *testing.T) { st := oneDownloading("541adcff3b6dd5dba7088ea83317d9d6fac331d6", timeRecent) qb := &fakeQbt{torrents: nil} // торрента в qBittorrent нет w := newTestWorker(st, qb) + w.cfg.SourceMissingThreshold = 3 // порог не достигнут за один тик if err := w.Poll(context.Background()); err != nil { t.Fatal(err) } if st.downloads["1"].State != store.StateDownloading { - t.Errorf("без торрента состояние не должно меняться, got %q", st.downloads["1"].State) + t.Errorf("единичный промах не должен менять состояние, got %q", st.downloads["1"].State) } } diff --git a/openspec/changes/archive/2026-07-08-downloading-source-gone/.openspec.yaml b/openspec/changes/archive/2026-07-08-downloading-source-gone/.openspec.yaml new file mode 100644 index 0000000..8cceb8d --- /dev/null +++ b/openspec/changes/archive/2026-07-08-downloading-source-gone/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-08 diff --git a/openspec/changes/archive/2026-07-08-downloading-source-gone/design.md b/openspec/changes/archive/2026-07-08-downloading-source-gone/design.md new file mode 100644 index 0000000..6c75d99 --- /dev/null +++ b/openspec/changes/archive/2026-07-08-downloading-source-gone/design.md @@ -0,0 +1,135 @@ +# 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`). diff --git a/openspec/changes/archive/2026-07-08-downloading-source-gone/proposal.md b/openspec/changes/archive/2026-07-08-downloading-source-gone/proposal.md new file mode 100644 index 0000000..1578d87 --- /dev/null +++ b/openspec/changes/archive/2026-07-08-downloading-source-gone/proposal.md @@ -0,0 +1,47 @@ +# Пропажа источника у активной загрузки (MAJOR-3) + +## Why + +Если раздача исчезает из qBittorrent (пользователь или другой клиент удалил её), +пока задача в `downloading`, задача становится **вечным зомби**. Поллинг активных +загрузок промахивается по `torrentFor`, пишет `Warn "active download not found in +qbittorrent"` и делает `continue` — и так каждый тик, бесконечно. Состояние не +меняется, уведомления нет, телеметрии нет, `checkTimeouts` требует торрент (значит +таймауты-предохранители не срабатывают). Единственный выход — ручной Cancel/Defer. + +Дыра: сверка рассинхрона (`state-reconciliation`) покрывает пропажу источника +только для уже разложенных состояний (`done`/`target_missing`/`orphaned`), а +восстановление (`reconcileRecovery`) — только `failed`/`stuck`. Для активного +`downloading` пропажей источника не владеет НИКТО. Сравни: та же пропажа в +`completed`/`recognizing`/`review` деградирует штатно (там источник уже не нужен +или его отсутствие ведёт через матрицу). + +## What Changes + +- Поллинг активных загрузок SHALL применять **дебаунс пропажи источника** (тот же + `SourceMissCount` / `[worker].source_missing_threshold`, что и сверка + рассинхрона): промах `torrentFor` наращивает счётчик, любое появление раздачи + его сбрасывает. +- После порога подряд идущих промахов задача SHALL переходить `downloading → + failed` с новым отдельным `error_code` `source_gone` и уведомлять автора + (`EventFailed`). +- `source_gone` **восстановлению сверкой не подлежит** (не входит в набор + `reconcileRecovery`): удаление источника из qBittorrent — намеренное действие, + молча воскрешать задачу нельзя. Задача остаётся штатно **retriable**: `Retry` + заново отдаёт источник (у нас сохранены magnet/`.torrent`-байты). + +Ребро графа `downloading → failed` уже объявлено — нового ребра не требуется. +Меняется только набор `error_code` и поведение поллинга активных загрузок. + +## Capabilities + +- `download-tracking` — ADDED: «Пропажа источника у активной загрузки». + +## Impact + +- Код: `internal/worker/worker.go` (поллинг активных загрузок, новый + `errCodeSourceGone`), тесты воркера. +- Спека: `download-tracking` (новое требование). Диаграмма `docs/specs/workflow.md` + (миррор FSM) — добавить ребро `downloading → failed (source_gone)`. +- Миграции БД нет: `source_miss_count` уже существует, новых полей не вводим. +- Конфиг без изменений: переиспользуем `source_missing_threshold`. diff --git a/openspec/changes/archive/2026-07-08-downloading-source-gone/specs/download-tracking/spec.md b/openspec/changes/archive/2026-07-08-downloading-source-gone/specs/download-tracking/spec.md new file mode 100644 index 0000000..d4b7e70 --- /dev/null +++ b/openspec/changes/archive/2026-07-08-downloading-source-gone/specs/download-tracking/spec.md @@ -0,0 +1,58 @@ +# download-tracking Specification + +## ADDED Requirements + +### Requirement: Пропажа источника у активной загрузки + +Поллинг активных загрузок (`downloading`) SHALL обнаруживать пропажу источника: +если раздача, совпадающая с любым из известных хешей загрузки, отсутствует в +выдаче qBittorrent, система SHALL применять **тот же дебаунс пропажи источника**, +что и сверка рассинхрона (счётчик `source_miss_count`, порог +`[worker].source_missing_threshold`; см. `state-reconciliation` «Дебаунс пропажи +источника»). Любое обнаружение раздачи SHALL сбрасывать счётчик. + +После `N` подряд идущих тиков без раздачи (`N = +[worker].source_missing_threshold`) система SHALL переводить загрузку +`downloading → failed` с `error_code` `source_gone` и уведомлять автора. До +достижения порога загрузка SHALL оставаться в `downloading` (транзиентная +недоступность qBittorrent, например рестарт демона, не должна ронять задачу). + +`source_gone` система SHALL трактовать как отдельную причину, отличную от +`qbit_error` (реальная ошибка qBittorrent) и от `magnet_timeout`/`stalled` (наша +нетерпеливость). Восстановлению сверкой (`reconcileRecovery`) `source_gone` +подлежать SHALL NOT — удаление источника из qBittorrent намеренно, молча +воскрешать задачу нельзя. Задача SHALL оставаться штатно восстановимой вручную +(`Retry` заново отдаёт сохранённый источник в qBittorrent). + +Состояние `catched` этим правилом затрагиваться SHALL NOT: у пойманной загрузки +раздачи в qBittorrent ещё нет по дизайну (см. «catched не считается пропажей +раздачи»), а цикл активных загрузок листает только `downloading`. + +#### Scenario: Источник пропал у активной загрузки дольше порога + +- **GIVEN** загрузка в `downloading`, чья раздача удалена из qBittorrent +- **WHEN** раздача отсутствует `source_missing_threshold` подряд идущих тиков +- **THEN** загрузка переходит в `failed` с `error_code` `source_gone` +- **AND** автор загрузки уведомляется + +#### Scenario: Кратковременная пропажа источника не роняет задачу + +- **GIVEN** загрузка в `downloading` +- **WHEN** раздача отсутствует в qBittorrent меньше `source_missing_threshold` + тиков подряд +- **THEN** загрузка остаётся в `downloading` + +#### Scenario: Возврат раздачи сбрасывает счётчик + +- **GIVEN** загрузка в `downloading` с накопленными промахами источника (меньше + порога) +- **WHEN** раздача снова обнаружена в qBittorrent +- **THEN** счётчик промахов сбрасывается в ноль и загрузка ведётся обычной + сверкой состояния + +#### Scenario: source_gone не воскрешается сверкой + +- **GIVEN** загрузка в `failed` с `error_code` `source_gone` +- **WHEN** её раздача снова появляется в qBittorrent и продвигается +- **THEN** сверка восстановления её не трогает — задача остаётся в `failed` до + ручного `Retry` diff --git a/openspec/changes/archive/2026-07-08-downloading-source-gone/tasks.md b/openspec/changes/archive/2026-07-08-downloading-source-gone/tasks.md new file mode 100644 index 0000000..20bda52 --- /dev/null +++ b/openspec/changes/archive/2026-07-08-downloading-source-gone/tasks.md @@ -0,0 +1,38 @@ +# Tasks — Пропажа источника у активной загрузки + +## 1. Код воркера + +- [x] 1.1 Ввести `errCodeSourceGone = "source_gone"` в блок кодов ошибок + (`internal/worker/worker.go`) с комментарием: distinct-код, восстановлению + сверкой не подлежит, retriable вручную. +- [x] 1.2 Перестроить активный цикл в `Poll`: при промахе `torrentFor` прогонять + `debounceSource`; при исчерпании порога — `transition downloading → failed` + (`source_gone`); до порога — `continue`; при наличии источника — сброс счётчика + и обычная сверка (`captureInfohashes`/`captureSourceAddedAt`/`reconcile`). +- [x] 1.3 Убедиться, что `debounceSource` вызывается и при наличии источника + (сброс накопленных промахов). +- [x] 1.4 `Retry` сбрасывает `source_miss_count` в 0 (рядом с `SetRetriedAt`), + чтобы retried `source_gone`-задача получила полное грейс-окно, а не падала + сразу (см. design «Сброс source_miss_count при Retry»). + +## 2. Тесты + +- [x] 2.1 Промах < порога → задача остаётся `downloading`. +- [x] 2.2 Промах ≥ порога → `downloading → failed`/`source_gone` + `EventFailed`. +- [x] 2.3 Возврат раздачи до порога → счётчик сброшен, задача ведётся обычной + сверкой. +- [x] 2.4 `source_gone` не воскрешается `reconcileRecovery` (источник вернулся — + задача остаётся `failed`). +- [x] 2.5 `Retry` задачи `source_gone` сбрасывает `source_miss_count`: после + retry с ещё-не-видимой раздачей задача остаётся `downloading` полный порог + тиков (грейс-окно не съедено). + +## 3. Документация + +- [x] 3.1 `docs/specs/workflow.md`: добавить ребро `downloading → failed` + (`source_gone`, пропажа источника) в диаграмму и список переходов. + +## 4. Верификация + +- [x] 4.1 `task test` / `task lint` зелёные. +- [x] 4.2 `openspec validate downloading-source-gone --strict`. diff --git a/openspec/specs/download-tracking/spec.md b/openspec/specs/download-tracking/spec.md index c6c39b6..ce5bf8b 100644 --- a/openspec/specs/download-tracking/spec.md +++ b/openspec/specs/download-tracking/spec.md @@ -242,3 +242,58 @@ SHALL проходить только revive-путём (`ActivateIfNoOtherActiv - **AND** тот же переход через `ActivateIfNoOtherActive` (при свободном infohash) проходит +### Requirement: Пропажа источника у активной загрузки + +Поллинг активных загрузок (`downloading`) SHALL обнаруживать пропажу источника: +если раздача, совпадающая с любым из известных хешей загрузки, отсутствует в +выдаче qBittorrent, система SHALL применять **тот же дебаунс пропажи источника**, +что и сверка рассинхрона (счётчик `source_miss_count`, порог +`[worker].source_missing_threshold`; см. `state-reconciliation` «Дебаунс пропажи +источника»). Любое обнаружение раздачи SHALL сбрасывать счётчик. + +После `N` подряд идущих тиков без раздачи (`N = +[worker].source_missing_threshold`) система SHALL переводить загрузку +`downloading → failed` с `error_code` `source_gone` и уведомлять автора. До +достижения порога загрузка SHALL оставаться в `downloading` (транзиентная +недоступность qBittorrent, например рестарт демона, не должна ронять задачу). + +`source_gone` система SHALL трактовать как отдельную причину, отличную от +`qbit_error` (реальная ошибка qBittorrent) и от `magnet_timeout`/`stalled` (наша +нетерпеливость). Восстановлению сверкой (`reconcileRecovery`) `source_gone` +подлежать SHALL NOT — удаление источника из qBittorrent намеренно, молча +воскрешать задачу нельзя. Задача SHALL оставаться штатно восстановимой вручную +(`Retry` заново отдаёт сохранённый источник в qBittorrent). + +Состояние `catched` этим правилом затрагиваться SHALL NOT: у пойманной загрузки +раздачи в qBittorrent ещё нет по дизайну (см. «catched не считается пропажей +раздачи»), а цикл активных загрузок листает только `downloading`. + +#### Scenario: Источник пропал у активной загрузки дольше порога + +- **GIVEN** загрузка в `downloading`, чья раздача удалена из qBittorrent +- **WHEN** раздача отсутствует `source_missing_threshold` подряд идущих тиков +- **THEN** загрузка переходит в `failed` с `error_code` `source_gone` +- **AND** автор загрузки уведомляется + +#### Scenario: Кратковременная пропажа источника не роняет задачу + +- **GIVEN** загрузка в `downloading` +- **WHEN** раздача отсутствует в qBittorrent меньше `source_missing_threshold` + тиков подряд +- **THEN** загрузка остаётся в `downloading` + +#### Scenario: Возврат раздачи сбрасывает счётчик + +- **GIVEN** загрузка в `downloading` с накопленными промахами источника (меньше + порога) +- **WHEN** раздача снова обнаружена в qBittorrent +- **THEN** счётчик промахов сбрасывается в ноль и загрузка ведётся обычной + сверкой состояния + +#### Scenario: source_gone не воскрешается сверкой + +- **GIVEN** загрузка в `failed` с `error_code` `source_gone` +- **WHEN** её раздача снова появляется в qBittorrent и продвигается +- **THEN** сверка восстановления её не трогает — задача остаётся в `failed` до + ручного `Retry` +