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

205 lines
8.0 KiB
Go
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.
package worker
import (
"context"
"os"
"path/filepath"
"testing"
"git.vakhrushev.me/av/jellybit/internal/qbt"
"git.vakhrushev.me/av/jellybit/internal/store"
)
// reconcileFixture готовит done-задачу с одной разложенной ссылкой: цель —
// файл в temp-каталоге (создаётся при makeTarget), источник — наличие
// раздачи в fakeQbt (sourcePresent).
type reconcileFixture struct {
w *Worker
st *memStore
dst string
}
func newReconcileFixture(t *testing.T, state store.State, sourcePresent, makeTarget bool) reconcileFixture {
t.Helper()
dir := t.TempDir()
dst := filepath.Join(dir, "Movie (2024).mkv")
src := filepath.Join(dir, "src.mkv")
if makeTarget {
if err := os.WriteFile(dst, []byte("video"), 0o644); err != nil {
t.Fatal(err)
}
}
st := newMemStore()
d := completedDownload("1")
d.State = state
st.put(d)
st.links = append(st.links, store.FileLink{
DownloadID: "1", ApplyBatchID: "b1", SrcPath: src, DstPath: dst,
Kind: "video", Status: "linked",
})
var torrents []qbt.Torrent
if sourcePresent {
// Раздача докачана (seeding) — classReady: preflight Relink требует
// готовности источника, а не только присутствия.
torrents = []qbt.Torrent{{Hash: ihTest, State: "uploading"}}
}
w := testWorkerWith(st, &fakeQbt{torrents: torrents}, nil, nil)
w.cfg.SourceMissingThreshold = 1 // помечаем при первой же пропаже (без задержки)
return reconcileFixture{w: w, st: st, dst: dst}
}
func TestReconcileMatrix(t *testing.T) {
cases := []struct {
name string
source, target bool
want store.State
}{
{"источник+цель → done", true, true, store.StateDone},
{"цель удалена → target_missing", true, false, store.StateTargetMissing},
{"источник удалён → orphaned", false, true, store.StateOrphaned},
{"оба удалены → deleted", false, false, store.StateDeleted},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f := newReconcileFixture(t, store.StateDone, tc.source, tc.target)
if err := f.w.Poll(context.Background()); err != nil {
t.Fatalf("Poll: %v", err)
}
if got := f.st.downloads["1"].State; got != tc.want {
t.Errorf("state = %q, want %q", got, tc.want)
}
})
}
}
func TestReconcileHealing(t *testing.T) {
// Источник вернулся, цель на месте → orphaned лечится обратно в done.
f := newReconcileFixture(t, store.StateOrphaned, true, true)
if err := f.w.Poll(context.Background()); err != nil {
t.Fatalf("Poll: %v", err)
}
if got := f.st.downloads["1"].State; got != store.StateDone {
t.Errorf("state = %q, want done (healing)", got)
}
}
func TestReconcilePartialTargetLoss(t *testing.T) {
// Две ссылки, одна цель удалена → цель считается отсутствующей.
f := newReconcileFixture(t, store.StateDone, true, true)
missing := filepath.Join(filepath.Dir(f.dst), "Movie (2024).en.srt")
f.st.links = append(f.st.links, store.FileLink{
DownloadID: "1", ApplyBatchID: "b1", SrcPath: "/x.srt", DstPath: missing,
Kind: "subtitle", Status: "linked",
})
if err := f.w.Poll(context.Background()); err != nil {
t.Fatalf("Poll: %v", err)
}
if got := f.st.downloads["1"].State; got != store.StateTargetMissing {
t.Errorf("state = %q, want target_missing (частичная пропажа)", got)
}
}
func TestReconcileSkipsDeleted(t *testing.T) {
// deleted терминально: сверка его не переоценивает, даже если по бывшему
// пути снова появился файл (его забрала другая загрузка) и источника нет.
f := newReconcileFixture(t, store.StateDeleted, 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.StateDeleted {
t.Errorf("state = %q, want deleted (сверка не трогает терминальное)", got)
}
}
func TestReconcileDebounce(t *testing.T) {
// Порог 3: первые два промаха не помечают, третий — помечает orphaned;
// возврат источника лечит обратно и сбрасывает счётчик.
f := newReconcileFixture(t, store.StateDone, false, true)
f.w.cfg.SourceMissingThreshold = 3
for i := 1; i <= 2; i++ {
if err := f.w.Poll(context.Background()); err != nil {
t.Fatalf("Poll %d: %v", i, err)
}
if got := f.st.downloads["1"].State; got != store.StateDone {
t.Fatalf("tick %d: state = %q, want done (до порога)", i, got)
}
if got := f.st.downloads["1"].SourceMissCount; got != i {
t.Errorf("tick %d: miss = %d, want %d", i, got, i)
}
}
if err := f.w.Poll(context.Background()); err != nil { // третий промах
t.Fatalf("Poll 3: %v", err)
}
if got := f.st.downloads["1"].State; got != store.StateOrphaned {
t.Fatalf("tick 3: state = %q, want orphaned (порог достигнут)", got)
}
// Источник вернулся.
f.w.qbt.(*fakeQbt).torrents = []qbt.Torrent{{Hash: ihTest}}
if err := f.w.Poll(context.Background()); err != nil {
t.Fatalf("Poll heal: %v", err)
}
if got := f.st.downloads["1"].State; got != store.StateDone {
t.Errorf("state = %q, want done (источник вернулся)", got)
}
if got := f.st.downloads["1"].SourceMissCount; got != 0 {
t.Errorf("miss = %d, want 0 (сброс)", got)
}
}
func TestReconcileSkipsActiveStates(t *testing.T) {
// Матрица сверки (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.StateReview {
t.Errorf("state = %q, want review (сверка не трогает активные)", got)
}
}
func TestUndoRejectedForOrphaned(t *testing.T) {
f := newReconcileFixture(t, store.StateOrphaned, false, true)
err := f.w.Undo(context.Background(), "1")
if err == nil {
t.Fatal("ожидали отказ Undo для orphaned")
}
if got := f.st.downloads["1"].State; got != store.StateOrphaned {
t.Errorf("state = %q, want orphaned (без изменений)", got)
}
}
func TestRelinkFromTargetMissing(t *testing.T) {
// target_missing + источник на месте → relink ведёт в recognizing.
f := newReconcileFixture(t, store.StateTargetMissing, true, false)
if err := f.w.Relink(context.Background(), "1"); err != nil {
t.Fatalf("Relink: %v", err)
}
if got := f.st.downloads["1"].State; got != store.StateRecognizing {
t.Errorf("state = %q, want recognizing", got)
}
if f.st.overrides["1"][ovrForceReview] != "1" {
t.Errorf("force_review = %q, want 1", f.st.overrides["1"][ovrForceReview])
}
}
func TestPreflightFixesStaleState(t *testing.T) {
// В БД target_missing (источник якобы есть), но фактически источник пропал,
// а цель на месте: relink немедленно приводит состояние к orphaned, не
// дожидаясь фоновой сверки.
f := newReconcileFixture(t, store.StateTargetMissing, false, true)
if err := f.w.Relink(context.Background(), "1"); err == nil {
t.Fatal("ожидали отказ relink при пропавшем источнике")
}
if got := f.st.downloads["1"].State; got != store.StateOrphaned {
t.Errorf("state = %q, want orphaned (preflight привёл к реальности)", got)
}
}