Слияние: уборка торрента при cancel во время add (T4)

# Conflicts:
#	docs/backlog/README.md
This commit is contained in:
av
2026-07-17 22:11:53 +03:00
11 changed files with 792 additions and 30 deletions
+118 -6
View File
@@ -214,9 +214,10 @@ func TestProcessCatchedTimeoutFails(t *testing.T) {
}
}
// Ре-валидация: если во время сетевых вызовов (вне блокировки) задачу отменили,
// переход в downloading не применяется — состояние остаётся cancelled.
func TestProcessCatchedCancelledDuringAddSkipsPromote(t *testing.T) {
// F3: отмена во время (медленного) вывода имени видна re-read'ом состояния ПЕРЕД
// Add — источник в qBittorrent не добавляется вовсе (раньше Add успевал пройти,
// оставляя неуправляемый торрент без владельца).
func TestProcessCatchedCancelledDuringNamerSkipsAdd(t *testing.T) {
st := catchedStore("1", catchedIH, nowStr, "ctx")
qb := &fakeQbt{}
w := newTestWorker(st, qb)
@@ -226,11 +227,122 @@ func TestProcessCatchedCancelledDuringAddSkipsPromote(t *testing.T) {
w.processCatched(context.Background())
if len(qb.added) != 1 {
t.Fatal("add должен был вызваться (сеть идёт вне замка)")
if len(qb.added) != 0 {
t.Errorf("Add не должен вызываться после отмены (re-read перед add), calls = %d", len(qb.added))
}
if len(qb.deleted) != 0 {
t.Errorf("торрент не добавляли — удалять нечего, Delete calls = %d", len(qb.deleted))
}
if st.downloads["1"].State != store.StateCancelled {
t.Errorf("ре-валидация не сработала: state = %q, want cancelled", st.downloads["1"].State)
t.Errorf("state = %q, want cancelled", st.downloads["1"].State)
}
}
// F3, scoped cleanup: отмена приходит в окне ПОСЛЕ успешного Add, но до записи
// перехода (onAdd имитирует Cancel ровно между Add и PromoteCatched). Добавленный
// НАМИ торрент удаляется из qBittorrent С ДАННЫМИ (уборка своего артефакта).
func TestProcessCatchedCancelledAfterAddRemovesTorrent(t *testing.T) {
st := catchedStore("1", catchedIH, nowStr, "ctx")
qb := &fakeQbt{}
w := newTestWorker(st, qb)
qb.onAdd = func() { st.downloads["1"].State = store.StateCancelled }
w.SetNamer(&fakeNamer{name: "X"})
w.processCatched(context.Background())
if len(qb.added) != 1 {
t.Fatalf("Add должен был вызваться, calls = %d", len(qb.added))
}
if len(qb.deleted) != 1 {
t.Fatalf("добавленный нами торрент должен быть удалён, Delete calls = %d", len(qb.deleted))
}
if !qb.deletedData[0] {
t.Error("уборка своего артефакта должна идти С ДАННЫМИ (deleteFiles=true)")
}
if len(qb.deleted[0]) == 0 || qb.deleted[0][0] != catchedIH {
t.Errorf("удаление не по infohash загрузки: %v", qb.deleted[0])
}
if st.downloads["1"].State != store.StateCancelled {
t.Errorf("state = %q, want cancelled (уборка не трогает состояние)", st.downloads["1"].State)
}
}
// F3, негативный инвариант «удаляем только своё»: тот же infohash появился в
// qBittorrent во время namer (внешний клиент, окно гонки). Свежий листинг перед
// Add видит присутствие → Add не делаем И чужой торрент С ДАННЫМИ не удаляем.
func TestProcessCatchedPreexistingTorrentNotDeleted(t *testing.T) {
st := catchedStore("1", catchedIH, nowStr, "ctx")
qb := &fakeQbt{} // снимок тика пуст → идём обычным путём добавления
w := newTestWorker(st, qb)
nm := &fakeNamer{name: "X", onCall: func() {
// внешний клиент добавил тот же торрент, пока выводилось имя
qb.torrents = []qbt.Torrent{{Hash: catchedIH, Name: "external"}}
}}
w.SetNamer(nm)
w.processCatched(context.Background())
if len(qb.added) != 0 {
t.Errorf("Add не должен вызываться: infohash уже присутствует перед add, calls = %d", len(qb.added))
}
if len(qb.deleted) != 0 {
t.Errorf("пред-существующий (чужой) торрент удалять нельзя, Delete calls = %d", len(qb.deleted))
}
if st.downloads["1"].State != store.StateCatched {
t.Errorf("state = %q, want catched (усыновление на следующем тике)", st.downloads["1"].State)
}
}
// F3, safety-critical: свежий листинг присутствия ПЕРЕД add не удался (сеть
// отвалилась между тик-снимком и проверкой). Отсутствие infohash не подтверждено
// → Add не делаем (иначе delete-с-данными стал бы небезопасен), остаёмся в
// catched. onTorrents роняет ВТОРОЙ вызов Torrents (первый — тик-снимок).
func TestProcessCatchedPresenceRecheckFailKeepsCatched(t *testing.T) {
st := catchedStore("1", catchedIH, nowStr, "ctx")
qb := &fakeQbt{}
calls := 0
qb.onTorrents = func() {
calls++
if calls == 2 { // тик-снимок (1) ок, свежий листинг перед add (2) падает
qb.torrentsErr = errors.New("connection refused")
}
}
w := newTestWorker(st, qb)
w.SetNamer(&fakeNamer{name: "X"})
w.processCatched(context.Background())
if len(qb.added) != 0 {
t.Errorf("Add не должен вызываться при неподтверждённом отсутствии, calls = %d", len(qb.added))
}
if len(qb.deleted) != 0 {
t.Errorf("Delete не должен вызываться, calls = %d", len(qb.deleted))
}
if st.downloads["1"].State != store.StateCatched {
t.Errorf("state = %q, want catched (повтор на следующем тике)", st.downloads["1"].State)
}
}
// F3, различение «отмена vs сбой БД»: PromoteCatched упал транзиентно, но задача
// ЖИВА (state остался catched). Наш торрент НЕ удаляем — переход доведётся на
// следующем тике усыновлением присутствующей раздачи.
func TestProcessCatchedPromoteDBErrorKeepsTorrent(t *testing.T) {
st := catchedStore("1", catchedIH, nowStr, "ctx")
st.promoteErr = errors.New("db is locked") // сбой записи перехода, state = catched
qb := &fakeQbt{}
w := newTestWorker(st, qb)
w.SetNamer(&fakeNamer{name: "X"})
w.processCatched(context.Background())
if len(qb.added) != 1 {
t.Fatalf("Add должен был вызваться, calls = %d", len(qb.added))
}
if len(qb.deleted) != 0 {
t.Errorf("при транзиентном сбое БД (задача жива) торрент удалять нельзя, Delete calls = %d", len(qb.deleted))
}
if st.downloads["1"].State != store.StateCatched {
t.Errorf("state = %q, want catched (повтор промоушена на следующем тике)", st.downloads["1"].State)
}
}
+62 -4
View File
@@ -476,6 +476,38 @@ func (w *Worker) processCatched(ctx context.Context) {
}
}
addReq.Rename = rename
// F3: re-read состояния прямо перед Add — вывод имени (LLM) шёл секунды вне
// замка, задачу могли отменить (catched → cancelled). Если уже не catched,
// источник в qBittorrent не добавляем вовсе (иначе остался бы неуправляемый
// торрент без задачи-владельца).
w.mu.Lock()
before, berr := w.store.GetDownload(cctx, d.ID)
stillCatched := berr == nil && before != nil && before.State == store.StateCatched
w.mu.Unlock()
if !stillCatched {
logctx.From(cctx).Info("catched add skipped before qbittorrent", "reason", "no longer catched")
continue
}
// F3, гарантия «удаляем только своё»: свежим листингом (вне замка)
// подтверждаем, что раздачи с нашим infohash в qBittorrent ЕЩЁ НЕТ. Только
// тогда торрент, появившийся под этим хешем сразу после нашего Add, — наш
// артефакт, и позднейшая уборка вправе снести его С ДАННЫМИ. Сбой листинга →
// отсутствие не подтверждено, Add не делаем (повтор на следующем тике),
// иначе delete-с-данными стал бы небезопасен. Присутствие → внешний клиент
// добавил тот же торрент в окно гонки: Add не делаем, усыновит следующий тик
// (promoteExisting); чужие данные не трогаем.
snap, ferr := w.qbt.Torrents(cctx, "")
if ferr != nil {
logctx.From(cctx).Warn("catched presence recheck failed, will retry", "error", ferr)
continue
}
if _, present := torrentFor(*before, torrentsByHash(snap)); present {
logctx.From(cctx).Info("catched torrent already present in qbittorrent, will adopt")
continue
}
addErr := w.qbt.Add(cctx, addReq)
if addErr != nil {
// Транзиентный сбой (qBit отверг/недоступен) — остаёмся в catched,
@@ -484,16 +516,42 @@ func (w *Worker) processCatched(ctx context.Context) {
continue
}
// Успех: короткий переход под w.mu с ре-валидацией state=catched
// Успех Add: короткий переход под w.mu с ре-валидацией state=catched
// (загрузку могли отменить, пока шли сетевые вызовы).
w.mu.Lock()
if err := w.store.PromoteCatched(cctx, d.ID, rename); err != nil {
logctx.From(cctx).Info("catched promote skipped", "reason", err.Error())
} else {
perr := w.store.PromoteCatched(cctx, d.ID, rename)
if perr == nil {
logctx.From(cctx).Info("state transition", "from", store.StateCatched,
"to", store.StateDownloading)
w.mu.Unlock()
continue
}
// Промоут не прошёл. Причину определяем СВЕЖИМ состоянием под тем же замком,
// а НЕ текстом ошибки (см. errors.md): PromoteCatched возвращает ошибку и при
// отмене (state != catched), и при транзиентном сбое БД (state всё ещё
// catched, задача жива).
after, aerr := w.store.GetDownload(cctx, d.ID)
cancelled := aerr == nil && after != nil && after.State != store.StateCatched
w.mu.Unlock()
if !cancelled {
// Транзиентный сбой БД (или не смогли перечитать) — торрент наш и живой,
// не удаляем: переход доведётся на следующем тике усыновлением
// присутствующей раздачи (promoteExisting).
logctx.From(cctx).Warn("catched promote failed, will retry", "error", perr)
continue
}
// F3: отмена (catched → cancelled) в окне между Add и записью перехода.
// Торрент добавлен НАМИ этим Add (отсутствие infohash подтверждено выше), а
// задачи-владельца больше нет — снимаем свой артефакт С ДАННЫМИ. Инвариант
// «источник неприкосновенен» защищает пользовательские данные, а не наш
// только что добавленный торрент; удаление идёт через API qBittorrent, не
// прямыми fs-операциями.
logctx.From(cctx).Warn("torrent left in qbittorrent after cancel, removing", "error", perr)
if delErr := w.qbt.Delete(cctx, before.HashList(), true); delErr != nil {
logctx.From(cctx).Error("cleanup added torrent after cancel failed", "error", delErr)
} else {
logctx.From(cctx).Warn("added torrent removed after cancel")
}
}
}
+11 -1
View File
@@ -24,6 +24,7 @@ type fakeStore struct {
downloads map[string]*store.Download
transitions []transition
torrents map[string][]byte // download_id → байты .torrent
promoteErr error // если задан — PromoteCatched возвращает его, НЕ меняя state (симуляция транзиентного сбоя БД)
}
type transition struct {
@@ -182,6 +183,9 @@ func (f *fakeStore) PromoteCatched(_ context.Context, id, displayName string) er
if !ok {
return fmt.Errorf("download %s not found", id)
}
if f.promoteErr != nil {
return f.promoteErr // транзиентный сбой БД: state НЕ меняем (остаётся catched)
}
if d.State != store.StateCatched {
return fmt.Errorf("promote catched %s: not in catched (%s)", id, d.State)
}
@@ -281,8 +285,10 @@ type fakeQbt struct {
onTorrents func() // вклинивается в момент листинга (симуляция гонки между снимком и re-read)
added []qbt.AddRequest
addErr error
onAdd func() // вклинивается в момент Add (симуляция отмены в окне после add)
files []qbt.File
deleted [][]string // хеши каждого вызова Delete
deletedData []bool // deleteFiles каждого вызова Delete (параллельно deleted)
deleteErr error
renamed []renameCall // каждый вызов RenameTorrent (hash, name)
renameErr error
@@ -317,6 +323,9 @@ func (f *fakeQbt) Torrents(_ context.Context, category string) ([]qbt.Torrent, e
}
func (f *fakeQbt) Add(_ context.Context, ar qbt.AddRequest) error {
if f.onAdd != nil {
f.onAdd()
}
if f.addErr != nil {
return f.addErr
}
@@ -328,11 +337,12 @@ func (f *fakeQbt) Files(_ context.Context, _ string) ([]qbt.File, error) {
return f.files, nil
}
func (f *fakeQbt) Delete(_ context.Context, hashes []string, _ bool) error {
func (f *fakeQbt) Delete(_ context.Context, hashes []string, deleteFiles bool) error {
if f.deleteErr != nil {
return f.deleteErr
}
f.deleted = append(f.deleted, hashes)
f.deletedData = append(f.deletedData, deleteFiles)
return nil
}