Слияние: уборка торрента при cancel во время add (T4)
# Conflicts: # docs/backlog/README.md
This commit is contained in:
@@ -35,7 +35,6 @@ Tududi (проект `jellybit`) больше **не** держит беклог
|
||||
- [Обучение на правках человека (few-shot из прошлых ревью)](obuchenie-na-pravkah.md) — Когда человек поправил матч, тип или нумерацию — сохранять это как пример и подмешивать…
|
||||
- [Confidence-гейт авто-раскладки: узаконить в спеке + сделать выключаемым (дефолт 0.7)](gate-confidence-spec-vs-code.md) — Решено (B): гейт оставляем как доп. проверку на ревью — выключаемый порог, дефолт 0.85→0.7, записать в спеку
|
||||
- [Внешние субтитры: пары VobSub и языковой суффикс](vneshnie-subtitry.md) — Привязка субтитр→серия уже работает; остались пары VobSub .idx+.sub и потеря Lang/Flags
|
||||
- [Cancel во время add оставляет неуправляемый торрент в qBittorrent (F3/NIT-13)](review-f3-cancel-during-add.md) — Cancel во время add оставляет неуправляемый торрент в qBittorrent _(ревью 2026-07-08)_
|
||||
|
||||
## Низкий
|
||||
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
# Cancel во время add оставляет неуправляемый торрент в qBittorrent (F3/NIT-13)
|
||||
|
||||
**Приоритет:** средний · **Теги:** review-2026-07-08, lifecycle
|
||||
|
||||
Ревью Fable 2026-07-08 (оба ревьюера: F3 + NIT-13). worker.go:368-390, discover.go:43-50.
|
||||
|
||||
Сценарий: задача catched, worker вне w.mu выводит имя (LLM, секунды) + qbt.Add (успех). Параллельно user Cancel (catched→cancelled). PromoteCatched корректно пропускает (гард state='catched', спека соблюдена). НО торрент ДОБАВЛЕН в qBit под нашей категорией, будет качаться/сидировать вечно. Усыновить назад нельзя: adopt через ExistsByInfohash (любое состояние) → хеши cancelled-задачи существуют. Торрент ест диск без видимой задачи и владельца. Спека покрывает переход состояния, но не побочный эффект. Инвариант «источник неприкосновенен» — но этот торрент добавили МЫ после cancel-намерения.
|
||||
|
||||
Фикс-опции: (a) re-read state прямо перед Add (сужает окно); (b) при promote-skip из-за cancel — WARN «torrent left in qBittorrent»; (c) scoped delete/pause только что добавленного нами.
|
||||
|
||||
Вердикт: change (нужно решение по инварианту «источник неприкосновенен»).
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-07-17
|
||||
@@ -0,0 +1,178 @@
|
||||
## Context
|
||||
|
||||
`processCatched` (`internal/worker/worker.go`) добавляет пойманные загрузки в
|
||||
qBittorrent. Медленные вызовы (namer/LLM, `qbt.Add`) идут **вне** блокировки
|
||||
переходов `w.mu`, под замком — только короткие DB-переходы. Последовательность
|
||||
на «обычном» пути добавления (торрента в снимке тика нет):
|
||||
|
||||
1. под `w.mu` re-read записи → проверка `state == catched`, актуализация
|
||||
`source_type` (текущий код: worker.go:447–453);
|
||||
2. вне замка: `namer.Derive` (секунды) → `qbt.Add`;
|
||||
3. под `w.mu`: `PromoteCatched` (гард `state='catched'`) → `catched → downloading`.
|
||||
|
||||
Гонка F3: отмена (`catched → cancelled`) приходит между шагом 1 и шагом 3. Гард
|
||||
`PromoteCatched` честно отклоняет переход (`state` уже `cancelled`), но `qbt.Add`
|
||||
на шаге 2 уже отработал — торрент добавлен под нашей категорией и остался в
|
||||
qBittorrent без задачи-владельца. `adopt` его назад не подхватит:
|
||||
`ExistsByInfohash` истинно (хеши принадлежат отменённой записи). Диск занят,
|
||||
владельца нет.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals**
|
||||
- Не добавлять источник, если отмена видна ещё до `add`.
|
||||
- Убрать добавленный нами торрент (с данными), если отмена случилась в окне после
|
||||
`add`.
|
||||
- Никогда не удалять с данными торрент, которого мы не создавали этим `add`.
|
||||
|
||||
**Non-Goals**
|
||||
- Не меняем FSM-граф и семантику `PromoteCatched`/`cancel`.
|
||||
- Не вводим новых состояний, полей БД, методов `qbt`/`store`.
|
||||
- Не закрываем окно гонки полностью (у qBittorrent нет транзакции «add+own»);
|
||||
сужаем его и гарантированно убираем последствия.
|
||||
- **Не в scope: апгрейд `source_type` во время namer.** `source_type`/`addReq`
|
||||
worker перечитывает перед namer (текущий код), а не в D1-re-read перед `add`.
|
||||
Пред-существующий зазор «magnet→torrent апгрейд случился во время вывода имени»
|
||||
этой задачей не закрывается и не ухудшается (D1 читает только состояние). D1
|
||||
специально держим дешёвым (только `state`) и не тянем чтение байтов торрента под
|
||||
`w.mu`; закрытие зазора — отдельная задача.
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1. Re-read состояния прямо перед `qbt.Add`
|
||||
|
||||
После `namer.Derive` (медленный шаг) и **непосредственно перед** `qbt.Add`
|
||||
worker берёт `w.mu`, перечитывает запись и проверяет `state == catched`. Если
|
||||
уже не `catched` (отменена во время namer) — `add` не делаем, задачу пропускаем.
|
||||
Это переносит основную защиту на самый частый сценарий: окно namer (секунды)
|
||||
куда шире окна между `add` и записью перехода (один сетевой вызов).
|
||||
|
||||
Re-read под `w.mu` перечитывает только **состояние** (дёшево, локальный SQLite).
|
||||
Актуализацию `source_type`/`addReq` он НЕ повторяет — см. «Границы блокировки» и
|
||||
«Не в scope» ниже.
|
||||
|
||||
### D2. Проверка отсутствия infohash перед `add` — признак «своего» торрента
|
||||
|
||||
Сразу перед `add` (после D1, но **вне** `w.mu` — это сетевой вызов) worker
|
||||
свежим листингом qBittorrent подтверждает, что раздачи с любым из infohash
|
||||
загрузки **ещё нет**. Реализуется переиспользованием `qbt.Torrents(ctx, "")` (тот
|
||||
же механизм, что снимок тика) — нового API не нужно.
|
||||
|
||||
- Если листинг **не удался** (сеть отвалилась) — worker `add` НЕ делает и задачу
|
||||
в этот тик пропускает (повтор на следующем). Это критично: без подтверждённого
|
||||
отсутствия «своё/чужое» неразличимо, и delete-with-data стал бы небезопасен.
|
||||
Поведение то же, что уже принято для листинга тика (сбой → не трогаем).
|
||||
- Если infohash **уже присутствует** (внешний клиент/пользователь добавил тот же
|
||||
торрент в окно гонки после снимка тика) — worker `add` НЕ делает и задачу
|
||||
пропускает: на следующем тике её штатно усыновит `promoteExisting` (ветка «уже
|
||||
присутствует»). Чужие данные не трогаются.
|
||||
- Если infohash **отсутствует** — только тогда делаем `add`. Тем самым любой
|
||||
торрент, оказавшийся под этим infohash сразу после нашего `add`, — **наш**
|
||||
артефакт.
|
||||
|
||||
Именно подтверждённое отсутствие-перед-`add` — механизм различения «своё/чужое».
|
||||
Он не завязан на семантику ответа `qbt.Add` (qBittorrent на дубль отвечает тем же
|
||||
`Ok.`, не сообщая, создал он раздачу или это был дубль).
|
||||
|
||||
### D3. Scoped cleanup при отмене в окне после `add`
|
||||
|
||||
Если `add` прошёл (D2 подтвердил отсутствие), а затем `PromoteCatched` не
|
||||
применил переход, worker принимает решение об уборке **по свежему re-read
|
||||
состояния под `w.mu`**, а не по тексту/факту ошибки `PromoteCatched`. Причина:
|
||||
`PromoteCatched` возвращает ошибку в двух разных случаях — (1) гард `n==0`
|
||||
(`state` действительно уже не `catched`, отмена) и (2) транзиентный сбой БД
|
||||
(`state` всё ещё `catched`, задача жива). Вешать delete-with-data на «любую
|
||||
ошибку промоушена» нельзя: при миге БД это снесло бы **свой же, но ещё активный**
|
||||
торрент.
|
||||
|
||||
Поэтому под тем же `w.mu` worker перечитывает запись:
|
||||
|
||||
- `state != catched` (подтверждённая отмена) → удаляем добавленный торрент из
|
||||
qBittorrent **с данными**: `qbt.Delete(ctx, hashes, deleteFiles=true)` по
|
||||
infohash загрузки. `Delete` идемпотентен (неизвестный хеш qBittorrent
|
||||
игнорирует). Сбой удаления — `ERROR`-лог, состояние задачи (`cancelled`) не
|
||||
трогаем.
|
||||
- `state == catched` (транзиентный сбой БД) или re-read сам упал → торрент НЕ
|
||||
удаляем, `WARN` «promote failed, will retry»: на следующем тике
|
||||
`promoteExisting` усыновит присутствующую (нашу же) раздачу — переход
|
||||
доведётся, ничего не потеряно.
|
||||
|
||||
Cleanup достижим ТОЛЬКО по пути, где D2 подтвердил отсутствие infohash перед
|
||||
`add`, — поэтому удаляемый торрент гарантированно создан этим `add`.
|
||||
|
||||
### Границы блокировки (сериализация переходов)
|
||||
|
||||
Порядок вокруг `add` соблюдает инвариант «медленные/сетевые вызовы вне `w.mu`»:
|
||||
|
||||
1. `[под w.mu]` re-read состояния (D1);
|
||||
2. `[вне w.mu]` свежий листинг присутствия (D2) — сетевой вызов;
|
||||
3. `[вне w.mu]` `qbt.Add`;
|
||||
4. `[под w.mu]` `PromoteCatched` + (при неуспехе) re-read состояния для решения об
|
||||
уборке (D3);
|
||||
5. `[вне w.mu]` `qbt.Delete` при подтверждённой отмене.
|
||||
|
||||
Между шагами 1–3 остаётся окно (описанный TOCTOU), но `add` защищён свежим
|
||||
подтверждением отсутствия (шаг 2), а любая пропажа гарантии деградирует к
|
||||
безопасному «не удаляем» (D3).
|
||||
|
||||
### D4. Логи
|
||||
|
||||
- `WARN "torrent left in qbittorrent after cancel, removing"` — вход в cleanup,
|
||||
поле-причина промаха `PromoteCatched`.
|
||||
- `WARN "added torrent removed after cancel"` — факт успешного удаления.
|
||||
- `ERROR` — если `qbt.Delete` не удался (мусор остался, нужен разбор).
|
||||
- D1/D2-пропуски — `INFO` (штатная развилка, не проблема).
|
||||
|
||||
Все записи несут `download_id`/`infohash` (scoped-логгер `cctx`), без секретов.
|
||||
Сам вызов `qbt.Delete`/`Torrents` логирует клиент (`ext.*`).
|
||||
|
||||
## Инвариант «источник неприкосновенен» и негативная гарантия
|
||||
|
||||
**Артикуляция решения.** Инвариант «источник неприкосновенен» (CLAUDE.md,
|
||||
architecture.md, ADR-hardlinks) защищает **пользовательские данные** под
|
||||
`paths.downloads`/существующие раздачи от НАШИХ прямых fs-операций
|
||||
(`unlink`/`rename`). Торрент, который jellybit добавил секундами ранее — уже
|
||||
после намерения отмены, — это **наш собственный артефакт**, а не пользовательские
|
||||
данные. Его удаление через API qBittorrent (не прямыми fs-операциями) —
|
||||
легитимная уборка своего мусора. Прецедент уже есть: команда «Удалить» (`delete`)
|
||||
осознанно сносит раздачу с данными через `qbt.Delete(..., true)`
|
||||
(state-reconciliation) — там обход инварианта санкционирован пользователем; здесь
|
||||
удаляется лишь то, что мы сами только что создали вопреки уже выраженной отмене.
|
||||
|
||||
**Негативная гарантия (КРИТИЧНО).** Удаление-с-данными недопустимо для торрента,
|
||||
который присутствовал в qBittorrent ДО нашего `add` (пользователь уже раздавал
|
||||
тот же infohash / внешний торрент с тем же хешем). Удалить его с данными означало
|
||||
бы снести чужие данные — прямое нарушение инварианта. Защита — D2: удаление
|
||||
достижимо только на пути, где отсутствие infohash подтверждено непосредственно
|
||||
перед `add`; при обнаруженном присутствии `add` не делается вовсе, а торрент
|
||||
уходит на усыновление. Так удаляется исключительно созданное нами этим `add`.
|
||||
|
||||
**Остаточное окно (честно).** Между проверкой присутствия (D2) и самим `add`
|
||||
остаётся микроскопический TOCTOU-зазор: внешний клиент теоретически мог добавить
|
||||
тот же infohash в этот промежуток (два последовательных сетевых вызова). У
|
||||
qBittorrent нет атомарного «create-or-fail по infohash» и `add` не сообщает,
|
||||
создал он раздачу или присоединился к дублю, — устранить зазор имеющимся API
|
||||
нельзя. Мы сознательно выбираем `add` только при подтверждённом отсутствии
|
||||
непосредственно перед ним (зазор на порядки меньше окна namer из D1) и считаем
|
||||
это санкцией на уборку. Дальнейшее сужение (напр. сверка `added_on` раздачи со
|
||||
временем нашего `add`) — возможное усиление на будущее, в этой задаче не делаем:
|
||||
базис по времени хрупок (скос часов, грубое разрешение), а вероятность
|
||||
внешнего добавления ровно в этот под-`add` зазор пренебрежимо мала.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Лишний листинг qBittorrent перед каждым `add`** пойманной загрузки. Добавления
|
||||
событийны и редки (не поллинг), стоимость незначительна; переиспользуем
|
||||
существующий `Torrents`.
|
||||
- **Cleanup зависит от доступности qBittorrent.** Если `qbt.Delete` не прошёл —
|
||||
торрент временно остаётся, но это уже отменённая задача; `ERROR`-лог фиксирует
|
||||
для разбора. Повторной авто-уборки не вводим (не усложняем): случай редкий.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
Изменение чисто поведенческое в `worker.processCatched`. Схема БД, конфиг, API
|
||||
`qbt`/`store` не меняются. Откат — возврат прежней ветки добавления.
|
||||
|
||||
## Open Questions
|
||||
|
||||
Нет.
|
||||
@@ -0,0 +1,56 @@
|
||||
## Why
|
||||
|
||||
На шаге добавления пойманной загрузки (`catched`) worker выводит имя (LLM,
|
||||
секунды) и вызывает `qbt.Add` **вне** блокировки переходов. Если в это окно
|
||||
пользователь отменяет задачу (`catched → cancelled`), запись перехода
|
||||
`PromoteCatched` корректно пропускается (гард `state='catched'`), НО источник
|
||||
уже добавлен в qBittorrent под нашей категорией. Такой торрент качается/сидирует
|
||||
вечно, ест диск, а видимой задачи-владельца нет: усыновить назад его нельзя —
|
||||
`adopt` проверяет `ExistsByInfohash` (любое состояние), а хеши уже принадлежат
|
||||
отменённой задаче. Спека покрывает переход состояния, но не этот побочный эффект
|
||||
(находка ревью F3/NIT-13).
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Re-read состояния прямо перед `qbt.Add`** (под блокировкой переходов, после
|
||||
медленного вывода имени): если задача уже не в `catched` (отменена) — источник
|
||||
в qBittorrent НЕ добавляется вовсе. Сужает окно гонки до промежутка между
|
||||
re-read и записью перехода.
|
||||
- **Scoped cleanup**: если отмена случилась в оставшемся окне (уже ПОСЛЕ
|
||||
успешного `add`, но до записи перехода), worker удаляет только что добавленный
|
||||
торрент из qBittorrent **вместе с данными** — уборка собственного мусора.
|
||||
- **Гарантия «удаляем только своё»**: удаление-с-данными допустимо ТОЛЬКО для
|
||||
торрента, который worker создал именно этим `add`. Признак — подтверждённое
|
||||
**отсутствие** infohash в qBittorrent непосредственно перед `add`. Если
|
||||
infohash уже присутствовал до нашего `add` (внешний клиент раздаёт тот же
|
||||
торрент), worker источник не добавляет и чужие данные не трогает.
|
||||
- **WARN-логи** на этом пути (торрент оставлен после отмены → удаляем; факт
|
||||
удаления), с корреляцией по `download_id`, без секретов.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
Нет.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `download-tracking`: требование «Добавление пойманной загрузки в qBittorrent» —
|
||||
добавляется re-read состояния перед `add`, подтверждение отсутствия infohash
|
||||
перед `add` как признак «своего» торрента и уборка добавленного торрента при
|
||||
отмене в окне после `add`. Сценарий «Отмена во время добавления» уточняется и
|
||||
дополняется сценариями уборки и негативного инварианта.
|
||||
|
||||
## Impact
|
||||
|
||||
- **Спеки:** дельта `download-tracking` (одно MODIFIED-требование + сценарии).
|
||||
- **Код:** `internal/worker/worker.go` — `processCatched` (re-read state и
|
||||
проверка присутствия перед `Add`; уборка добавленного при промахе
|
||||
`PromoteCatched`). Новых методов `qbt`/`store` не требуется (переиспользуем
|
||||
`Torrents`, `Delete`).
|
||||
- **Тесты:** `internal/worker/catched_test.go` — обновление сценария отмены во
|
||||
время namer (Add не вызывается) + новые: уборка после Add, негативный инвариант
|
||||
(пред-существующий торрент не удаляется с данными).
|
||||
- **Миграции БД:** нет.
|
||||
- **Инвариант «источник неприкосновенен»:** обоснование удаления-с-данными и
|
||||
негативная гарантия — в `design.md`.
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Добавление пойманной загрузки в qBittorrent
|
||||
|
||||
Worker SHALL периодически (в поллинг-цикле, под единой блокировкой переходов)
|
||||
подхватывать загрузки в состоянии `catched` и для каждой (кроме случая уже
|
||||
присутствующего в qBittorrent торрента, см. ниже): вывести отображаемое имя из
|
||||
контекста (см. `ingest` «Отображаемое имя торрента из контекста»), добавить
|
||||
источник в qBittorrent (категория `qbittorrent.category`, savepath, `rename`) и
|
||||
перевести загрузку `catched → downloading`. Отдельного состояния между `catched`
|
||||
и `downloading` быть SHALL NOT — успешный `add` сразу переводит в `downloading`
|
||||
(которое и означает «в qBit, возможно `metaDL`»).
|
||||
|
||||
Перед добавлением worker SHALL проверять, **присутствует ли торрент загрузки уже
|
||||
в qBittorrent** (по любому из её infohash), опираясь на листинг раздач того же
|
||||
тика. Если торрент уже присутствует, worker SHALL **усыновить** его: перевести
|
||||
загрузку `catched → downloading` **без повторного `add`** и без вывода имени
|
||||
через LLM (`display_name` берётся из имени присутствующей раздачи). Повторный
|
||||
`add` здесь не нужен и вреден — qBittorrent отверг бы дубль (напр. `409
|
||||
Conflict`), и загрузка зациклилась бы на ретраях. Усыновлённая раздача дальше
|
||||
идёт обычным путём отслеживания и раскладки. Проверка присутствия SHALL
|
||||
выполняться **до вывода отображаемого имени**, чтобы не тратить LLM-вызов на
|
||||
загрузку, которую добавлять не требуется.
|
||||
|
||||
Инвариант приёма («одна активная загрузка на infohash», см. `ingest`) гарантирует,
|
||||
что до этого шага доходит лишь загрузка, для которой в jellybit НЕТ другой
|
||||
активной задачи; поэтому присутствие торрента в qBittorrent worker трактует как
|
||||
«усыновить и разложить», а не как конфликт с чужой задачей.
|
||||
|
||||
Если листинг раздач qBittorrent недоступен (сетевой сбой), worker пойманную
|
||||
загрузку в этот тик трогать SHALL NOT (ни `add`, ни namer) и повторить на
|
||||
следующем; устойчивая недоступность отсекается предохранителем `catch_timeout`
|
||||
(см. «Предохранитель зависшего catched»).
|
||||
|
||||
Добавление в qBittorrent worker SHALL выполнять **по типу источника**
|
||||
(`source_type`):
|
||||
|
||||
- Для `magnet`/`url` — передавать `source_ref` как ссылку (`urls` API
|
||||
`/torrents/add`); подсказку отображаемого имени брать из полей самой ссылки.
|
||||
- Для `torrent` — загружать сохранённые байты `.torrent` (привязанные к
|
||||
загрузке при приёме) и передавать их **файлом** (`torrents` API
|
||||
`/torrents/add`), НЕ как ссылку; подсказку отображаемого имени брать из
|
||||
метаданных торрента (имя раздачи). Добавление байтами SHALL сохранять полные
|
||||
метаданные (qBittorrent стартует без докачки), поэтому воскрешать раздачу по
|
||||
magnet-хешу вместо файла система SHALL NOT.
|
||||
|
||||
`source_type` для выбора способа добавления worker SHALL перечитывать **под
|
||||
блокировкой переходов** непосредственно перед добавлением (а не полагаться на
|
||||
снимок, снятый ранее вне блокировки): иначе при точном оверлапе тика с апгрейдом
|
||||
пойманной magnet-задачи до `.torrent` (см. `ingest`) воркер добавил бы magnet из
|
||||
устаревшего снимка, хотя БД уже `torrent`.
|
||||
|
||||
Неуспешный `add` (qBittorrent временно отверг/недоступен) SHALL оставлять
|
||||
загрузку в `catched` для повторной попытки на следующем тике; переход в
|
||||
терминальное состояние по единичному сбою происходить SHALL NOT (ретраи —
|
||||
естественными тиками поллинга, отсечка — `catch_timeout`).
|
||||
|
||||
Медленные вызовы (вывод имени через LLM, `qbt.Add`) SHALL выполняться **вне**
|
||||
блокировки сериализации переходов, чтобы не задерживать команды транспортов и
|
||||
поллинг. Под блокировкой сериализуется только **запись перехода** `catched →
|
||||
downloading` (см. «Переходы состояний сериализуются воркером»), с ре-валидацией,
|
||||
что загрузка всё ещё в `catched` (иначе переход отклоняется — например, при
|
||||
параллельной отмене).
|
||||
|
||||
Вывод имени (LLM) занимает секунды и идёт вне блокировки, поэтому загрузку могут
|
||||
отменить (`catched → cancelled`) в это окно. Чтобы отменённая задача не оставила
|
||||
неуправляемый торрент в qBittorrent, worker SHALL применять комбинированную
|
||||
защиту. Порядок шагов относительно блокировки переходов: `[под блокировкой]`
|
||||
re-read состояния → `[вне блокировки]` свежий листинг присутствия → `[вне
|
||||
блокировки]` `add` → `[под блокировкой]` запись перехода и (при неуспехе) re-read
|
||||
состояния для решения об уборке → `[вне блокировки]` удаление. Сетевые вызовы
|
||||
(листинг, `add`, удаление) под блокировкой держаться SHALL NOT.
|
||||
|
||||
- **Re-read состояния перед `add`.** Непосредственно перед `qbt.Add` (после
|
||||
вывода имени) worker SHALL под блокировкой переходов перечитать запись и, если
|
||||
она уже НЕ в `catched` (отменена), НЕ вызывать `add` и загрузку в этот тик
|
||||
пропустить. Это сужает окно гонки до промежутка между re-read и записью
|
||||
перехода.
|
||||
|
||||
- **Подтверждение отсутствия торрента перед `add`.** Непосредственно перед `add`
|
||||
worker SHALL свежим листингом раздач qBittorrent подтвердить, что раздачи ни с
|
||||
одним из infohash загрузки ещё НЕТ. Если этот листинг **не удался** (сетевой
|
||||
сбой), worker `add` выполнять SHALL NOT и загрузку в этот тик пропустить (повтор
|
||||
на следующем): без подтверждённого отсутствия признак «своё/чужое» неизвестен,
|
||||
и последующее удаление-с-данными было бы небезопасным. Если торрент уже
|
||||
присутствует (внешний клиент/пользователь добавил тот же infohash в окно
|
||||
гонки), worker `add` выполнять SHALL NOT и загрузку в этот тик пропустить — на
|
||||
следующем тике её усыновит ветка «уже присутствует». Подтверждённое отсутствие
|
||||
непосредственно-перед-`add` SHALL служить признаком того, что торрент,
|
||||
оказавшийся под этим infohash сразу после `add`, создан именно этим `add` (наш
|
||||
артефакт), а не пред-существовал.
|
||||
|
||||
- **Уборка добавленного торрента при отмене в окне после `add`.** Если `add`
|
||||
прошёл успешно, а последующая запись перехода `PromoteCatched` не применилась,
|
||||
worker SHALL принимать решение об уборке по **свежему re-read состояния под
|
||||
блокировкой**, а не по факту ошибки промоушена: неуспех промоушена бывает и
|
||||
из-за отмены (`state` уже не `catched`), и из-за транзиентного сбоя хранилища
|
||||
(`state` всё ещё `catched`, задача жива). Только при подтверждённом `state !=
|
||||
catched` worker SHALL удалить только что добавленный торрент из qBittorrent
|
||||
**вместе с его данными** (`deleteFiles = true`) по infohash загрузки. Если
|
||||
повторное чтение показало `state == catched` (транзиентный сбой) либо само не
|
||||
удалось, worker торрент удалять SHALL NOT — переход доводится на следующем тике
|
||||
усыновлением присутствующей (нашей) раздачи. Удаление SHALL идти через API
|
||||
qBittorrent (`torrents/delete`), не прямыми fs-операциями. Это легитимная уборка
|
||||
**собственного** артефакта, а не пользовательских данных: инвариант «источник
|
||||
неприкосновенен» защищает существующие раздачи/файлы пользователя под
|
||||
`paths.downloads`, а здесь удаляется торрент, который сам worker добавил
|
||||
секундами ранее — уже после намерения отмены. Состояние отменённой задачи
|
||||
(`cancelled`) уборка трогать SHALL NOT; неуспех удаления SHALL логироваться
|
||||
(торрент временно остаётся, повторная авто-уборка не требуется).
|
||||
|
||||
- **Негативный инвариант (удаляем только своё).** Удаление-с-данными допустимо
|
||||
ТОЛЬКО для торрента, который worker создал именно этим `add`. Торрент, который
|
||||
присутствовал в qBittorrent ДО нашего `add` (пользователь уже раздавал тот же
|
||||
infohash / внешний торрент с тем же хешем), удалять с данными worker SHALL NOT —
|
||||
иначе снёс бы чужие данные в нарушение инварианта. Гарантию обеспечивает
|
||||
подтверждение отсутствия перед `add`: путь уборки достижим только тогда, когда
|
||||
отсутствие infohash было подтверждено непосредственно перед `add`; при
|
||||
обнаруженном присутствии (или недоступном листинге) `add` не выполняется вовсе.
|
||||
|
||||
Записи об этом пути (торрент оставлен после отмены → удаляем; факт удаления) worker
|
||||
SHALL логировать на уровне `WARN` с корреляцией по `download_id`/`infohash` и без
|
||||
секретов; неуспех удаления — на `ERROR`.
|
||||
|
||||
#### Scenario: Пойманная magnet-загрузка добавляется в qBittorrent
|
||||
|
||||
- **GIVEN** загрузка в состоянии `catched` с `source_type = magnet`, торрента
|
||||
ещё нет в qBittorrent
|
||||
- **WHEN** worker обрабатывает тик
|
||||
- **THEN** выводится отображаемое имя, ссылка добавляется в qBittorrent с
|
||||
нашей категорией и `rename`
|
||||
- **AND** загрузка переходит в `downloading`
|
||||
|
||||
#### Scenario: Пойманная .torrent-загрузка добавляется файлом
|
||||
|
||||
- **GIVEN** загрузка в состоянии `catched` с `source_type = torrent` и
|
||||
сохранёнными байтами файла, торрента ещё нет в qBittorrent
|
||||
- **WHEN** worker обрабатывает тик
|
||||
- **THEN** сохранённые байты добавляются в qBittorrent файлом (`torrents`), с
|
||||
нашей категорией и `rename`, без обращения к magnet-хешу
|
||||
- **AND** загрузка переходит в `downloading`
|
||||
|
||||
#### Scenario: Торрент уже присутствует в qBittorrent — усыновление без add
|
||||
|
||||
- **GIVEN** загрузка в состоянии `catched`, торрент которой уже присутствует в
|
||||
qBittorrent (добавлен ранее вручную/другим клиентом либо `add` прошёл на
|
||||
прошлом тике, а запись перехода не удалась)
|
||||
- **WHEN** worker обрабатывает тик
|
||||
- **THEN** worker НЕ вызывает `qbt.Add` и НЕ выводит отображаемое имя через LLM
|
||||
- **AND** `display_name` записывается из имени присутствующей раздачи
|
||||
- **AND** загрузка переходит в `downloading` и идёт обычным путём к раскладке
|
||||
|
||||
#### Scenario: qBittorrent недоступен при проверке присутствия — повтор
|
||||
|
||||
- **GIVEN** загрузка в `catched`, листинг раздач qBittorrent не удался
|
||||
- **WHEN** worker обрабатывает тик
|
||||
- **THEN** worker НЕ вызывает namer и НЕ добавляет источник
|
||||
- **AND** загрузка остаётся в `catched` и попытка повторяется на следующем тике
|
||||
|
||||
#### Scenario: Временный сбой добавления — повтор
|
||||
|
||||
- **GIVEN** загрузка в `catched`, торрента в qBittorrent нет, но `add` не удался
|
||||
- **WHEN** worker пытается добавить источник и `add` возвращает ошибку
|
||||
- **THEN** загрузка остаётся в `catched`
|
||||
- **AND** на следующем тике попытка добавления повторяется
|
||||
|
||||
#### Scenario: Свежий листинг перед add недоступен — повтор
|
||||
|
||||
- **GIVEN** загрузка в `catched`, торрента в снимке тика нет, имя выведено
|
||||
- **WHEN** свежий листинг присутствия непосредственно перед `add` не удался
|
||||
(сетевой сбой)
|
||||
- **THEN** worker `add` НЕ вызывает (отсутствие infohash не подтверждено)
|
||||
- **AND** загрузка остаётся в `catched`, попытка повторяется на следующем тике
|
||||
|
||||
#### Scenario: Отмена до add — источник не добавляется
|
||||
|
||||
- **GIVEN** загрузка в `catched`, worker выводит отображаемое имя вне блокировки
|
||||
- **WHEN** параллельно приходит команда отмены (`catched → cancelled`) во время
|
||||
вывода имени, а затем worker перечитывает состояние перед `add`
|
||||
- **THEN** re-read видит, что загрузка уже не в `catched`, и `qbt.Add` НЕ
|
||||
вызывается
|
||||
- **AND** источник в qBittorrent не добавляется, задача остаётся `cancelled`
|
||||
|
||||
#### Scenario: Отмена в окне после add — добавленный торрент удаляется с данными
|
||||
|
||||
- **GIVEN** загрузка в `catched`, отсутствие её infohash в qBittorrent
|
||||
подтверждено перед `add`, и `add` прошёл успешно
|
||||
- **WHEN** отмена (`catched → cancelled`) приходит в окне между `add` и записью
|
||||
перехода, из-за чего запись перехода не применяется, а re-read состояния под
|
||||
блокировкой показывает `state != catched`
|
||||
- **THEN** worker удаляет только что добавленный торрент из qBittorrent вместе с
|
||||
его данными (`deleteFiles = true`) по infohash загрузки
|
||||
- **AND** пишет `WARN` о том, что торрент оставлен после отмены и удалён
|
||||
- **AND** состояние задачи остаётся `cancelled`
|
||||
|
||||
#### Scenario: Сбой записи перехода без отмены — торрент не удаляется
|
||||
|
||||
- **GIVEN** загрузка в `catched`, `add` прошёл успешно, но запись перехода
|
||||
`PromoteCatched` вернула ошибку из-за транзиентного сбоя хранилища
|
||||
- **WHEN** re-read состояния под блокировкой показывает, что загрузка всё ещё в
|
||||
`catched` (отмены не было)
|
||||
- **THEN** worker торрент из qBittorrent НЕ удаляет (это наш живой торрент)
|
||||
- **AND** переход доводится на следующем тике усыновлением присутствующей раздачи
|
||||
|
||||
#### Scenario: Пред-существующий торрент не удаляется с данными
|
||||
|
||||
- **GIVEN** загрузка в `catched`, чей infohash уже присутствует в qBittorrent к
|
||||
моменту проверки перед `add` (внешний торрент/раздача пользователя с тем же
|
||||
хешем)
|
||||
- **WHEN** worker обрабатывает тик и параллельно приходит отмена
|
||||
- **THEN** worker `add` НЕ выполняет и торрент с данными НЕ удаляет (чужие данные
|
||||
неприкосновенны)
|
||||
- **AND** загрузка пропускается в этот тик (усыновление присутствующей раздачи —
|
||||
на следующем тике, если задача ещё активна)
|
||||
@@ -0,0 +1,44 @@
|
||||
## 1. Код
|
||||
|
||||
- [x] 1.1 В `processCatched` (`internal/worker/worker.go`), на обычном пути
|
||||
добавления, ПОСЛЕ вывода имени и НЕПОСРЕДСТВЕННО перед `qbt.Add`: под `w.mu`
|
||||
перечитать запись и, если `state != catched`, `add` не делать и загрузку
|
||||
пропустить (D1)
|
||||
- [x] 1.2 Там же (вне `w.mu`) добавить свежий листинг присутствия любого из
|
||||
infohash загрузки в qBittorrent перед `add` (переиспользовать `qbt.Torrents`);
|
||||
при присутствии — `add` не делать, пропустить (усыновит следующий тик); при
|
||||
СБОЕ листинга — `add` не делать, `WARN`, повтор на следующем тике (D2, Б1)
|
||||
- [x] 1.3 При неуспехе `PromoteCatched` после успешного `add` — под `w.mu`
|
||||
перечитать состояние: только если `state != catched` (подтверждённая отмена) —
|
||||
удалить добавленный торрент с данными `qbt.Delete(ctx, HashList, true)`; `WARN`
|
||||
об оставленном/удалённом торренте; `ERROR` при сбое удаления. Если `state ==
|
||||
catched` (транзиентный сбой БД) или re-read упал — НЕ удалять, `WARN`
|
||||
«promote failed, will retry» (D3, Б2). Решение по состоянию, не по тексту
|
||||
ошибки (см. errors.md)
|
||||
- [x] 1.4 Убедиться, что путь уборки достижим только после подтверждённого
|
||||
отсутствия перед `add` (негативный инвариант «удаляем только своё»)
|
||||
|
||||
## 2. Тесты
|
||||
|
||||
- [x] 2.1 Обновить `TestProcessCatchedCancelledDuringAddSkipsPromote`: отмена во
|
||||
время namer → `qbt.Add` НЕ вызывается (re-read перед add), состояние остаётся
|
||||
`cancelled` (было: Add вызывался)
|
||||
- [x] 2.2 Новый тест: отмена в окне ПОСЛЕ `add` (hook на `Add`, ставящий
|
||||
`cancelled`) → `qbt.Delete` вызван с `deleteFiles=true` по infohash загрузки,
|
||||
состояние `cancelled`
|
||||
- [x] 2.3 Новый тест (негативный инвариант): infohash появился в qBittorrent во
|
||||
время namer (hook на namer) → перед `add` листинг видит присутствие → `qbt.Add`
|
||||
НЕ вызывается, `qbt.Delete` НЕ вызывается (чужие данные не трогаем)
|
||||
- [x] 2.4 Новый тест (Б2): сбой `PromoteCatched` при `state == catched`
|
||||
(транзиентная ошибка БД, без отмены) → торрент НЕ удаляется (`qbt.Delete` не
|
||||
вызван)
|
||||
- [x] 2.6 Новый тест (Б1, safety-critical): свежий листинг перед `add` упал
|
||||
(второй вызов `Torrents`) → `Add`/`Delete` НЕ вызваны, остаётся `catched`
|
||||
- [x] 2.5 Регрессия: штатный успех (нет отмены) по-прежнему добавляет и
|
||||
промоутит (`TestProcessCatchedAddsToQbit`); усыновление присутствующего и сбой
|
||||
листинга тика (`TestProcessCatchedListErrorKeepsCatched`) — без изменений
|
||||
|
||||
## 3. Спека
|
||||
|
||||
- [x] 3.1 MODIFIED-требование «Добавление пойманной загрузки в qBittorrent» в
|
||||
`download-tracking`; `openspec validate cancel-during-add-cleanup --strict`
|
||||
@@ -154,6 +154,66 @@ downloading` (см. «Переходы состояний сериализуют
|
||||
что загрузка всё ещё в `catched` (иначе переход отклоняется — например, при
|
||||
параллельной отмене).
|
||||
|
||||
Вывод имени (LLM) занимает секунды и идёт вне блокировки, поэтому загрузку могут
|
||||
отменить (`catched → cancelled`) в это окно. Чтобы отменённая задача не оставила
|
||||
неуправляемый торрент в qBittorrent, worker SHALL применять комбинированную
|
||||
защиту. Порядок шагов относительно блокировки переходов: `[под блокировкой]`
|
||||
re-read состояния → `[вне блокировки]` свежий листинг присутствия → `[вне
|
||||
блокировки]` `add` → `[под блокировкой]` запись перехода и (при неуспехе) re-read
|
||||
состояния для решения об уборке → `[вне блокировки]` удаление. Сетевые вызовы
|
||||
(листинг, `add`, удаление) под блокировкой держаться SHALL NOT.
|
||||
|
||||
- **Re-read состояния перед `add`.** Непосредственно перед `qbt.Add` (после
|
||||
вывода имени) worker SHALL под блокировкой переходов перечитать запись и, если
|
||||
она уже НЕ в `catched` (отменена), НЕ вызывать `add` и загрузку в этот тик
|
||||
пропустить. Это сужает окно гонки до промежутка между re-read и записью
|
||||
перехода.
|
||||
|
||||
- **Подтверждение отсутствия торрента перед `add`.** Непосредственно перед `add`
|
||||
worker SHALL свежим листингом раздач qBittorrent подтвердить, что раздачи ни с
|
||||
одним из infohash загрузки ещё НЕТ. Если этот листинг **не удался** (сетевой
|
||||
сбой), worker `add` выполнять SHALL NOT и загрузку в этот тик пропустить (повтор
|
||||
на следующем): без подтверждённого отсутствия признак «своё/чужое» неизвестен,
|
||||
и последующее удаление-с-данными было бы небезопасным. Если торрент уже
|
||||
присутствует (внешний клиент/пользователь добавил тот же infohash в окно
|
||||
гонки), worker `add` выполнять SHALL NOT и загрузку в этот тик пропустить — на
|
||||
следующем тике её усыновит ветка «уже присутствует». Подтверждённое отсутствие
|
||||
непосредственно-перед-`add` SHALL служить признаком того, что торрент,
|
||||
оказавшийся под этим infohash сразу после `add`, создан именно этим `add` (наш
|
||||
артефакт), а не пред-существовал.
|
||||
|
||||
- **Уборка добавленного торрента при отмене в окне после `add`.** Если `add`
|
||||
прошёл успешно, а последующая запись перехода `PromoteCatched` не применилась,
|
||||
worker SHALL принимать решение об уборке по **свежему re-read состояния под
|
||||
блокировкой**, а не по факту ошибки промоушена: неуспех промоушена бывает и
|
||||
из-за отмены (`state` уже не `catched`), и из-за транзиентного сбоя хранилища
|
||||
(`state` всё ещё `catched`, задача жива). Только при подтверждённом `state !=
|
||||
catched` worker SHALL удалить только что добавленный торрент из qBittorrent
|
||||
**вместе с его данными** (`deleteFiles = true`) по infohash загрузки. Если
|
||||
повторное чтение показало `state == catched` (транзиентный сбой) либо само не
|
||||
удалось, worker торрент удалять SHALL NOT — переход доводится на следующем тике
|
||||
усыновлением присутствующей (нашей) раздачи. Удаление SHALL идти через API
|
||||
qBittorrent (`torrents/delete`), не прямыми fs-операциями. Это легитимная уборка
|
||||
**собственного** артефакта, а не пользовательских данных: инвариант «источник
|
||||
неприкосновенен» защищает существующие раздачи/файлы пользователя под
|
||||
`paths.downloads`, а здесь удаляется торрент, который сам worker добавил
|
||||
секундами ранее — уже после намерения отмены. Состояние отменённой задачи
|
||||
(`cancelled`) уборка трогать SHALL NOT; неуспех удаления SHALL логироваться
|
||||
(торрент временно остаётся, повторная авто-уборка не требуется).
|
||||
|
||||
- **Негативный инвариант (удаляем только своё).** Удаление-с-данными допустимо
|
||||
ТОЛЬКО для торрента, который worker создал именно этим `add`. Торрент, который
|
||||
присутствовал в qBittorrent ДО нашего `add` (пользователь уже раздавал тот же
|
||||
infohash / внешний торрент с тем же хешем), удалять с данными worker SHALL NOT —
|
||||
иначе снёс бы чужие данные в нарушение инварианта. Гарантию обеспечивает
|
||||
подтверждение отсутствия перед `add`: путь уборки достижим только тогда, когда
|
||||
отсутствие infohash было подтверждено непосредственно перед `add`; при
|
||||
обнаруженном присутствии (или недоступном листинге) `add` не выполняется вовсе.
|
||||
|
||||
Записи об этом пути (торрент оставлен после отмены → удаляем; факт удаления) worker
|
||||
SHALL логировать на уровне `WARN` с корреляцией по `download_id`/`infohash` и без
|
||||
секретов; неуспех удаления — на `ERROR`.
|
||||
|
||||
#### Scenario: Пойманная magnet-загрузка добавляется в qBittorrent
|
||||
|
||||
- **GIVEN** загрузка в состоянии `catched` с `source_type = magnet`, торрента
|
||||
@@ -196,14 +256,54 @@ downloading` (см. «Переходы состояний сериализуют
|
||||
- **THEN** загрузка остаётся в `catched`
|
||||
- **AND** на следующем тике попытка добавления повторяется
|
||||
|
||||
#### Scenario: Отмена во время добавления
|
||||
#### Scenario: Свежий листинг перед add недоступен — повтор
|
||||
|
||||
- **GIVEN** загрузка в `catched`, worker выводит имя и добавляет её вне
|
||||
блокировки
|
||||
- **WHEN** параллельно приходит команда отмены (`catched → cancelled`), а затем
|
||||
worker берёт блокировку для записи перехода
|
||||
- **THEN** ре-валидация видит, что загрузка уже не в `catched`, и переход в
|
||||
`downloading` не применяется
|
||||
- **GIVEN** загрузка в `catched`, торрента в снимке тика нет, имя выведено
|
||||
- **WHEN** свежий листинг присутствия непосредственно перед `add` не удался
|
||||
(сетевой сбой)
|
||||
- **THEN** worker `add` НЕ вызывает (отсутствие infohash не подтверждено)
|
||||
- **AND** загрузка остаётся в `catched`, попытка повторяется на следующем тике
|
||||
|
||||
#### Scenario: Отмена до add — источник не добавляется
|
||||
|
||||
- **GIVEN** загрузка в `catched`, worker выводит отображаемое имя вне блокировки
|
||||
- **WHEN** параллельно приходит команда отмены (`catched → cancelled`) во время
|
||||
вывода имени, а затем worker перечитывает состояние перед `add`
|
||||
- **THEN** re-read видит, что загрузка уже не в `catched`, и `qbt.Add` НЕ
|
||||
вызывается
|
||||
- **AND** источник в qBittorrent не добавляется, задача остаётся `cancelled`
|
||||
|
||||
#### Scenario: Отмена в окне после add — добавленный торрент удаляется с данными
|
||||
|
||||
- **GIVEN** загрузка в `catched`, отсутствие её infohash в qBittorrent
|
||||
подтверждено перед `add`, и `add` прошёл успешно
|
||||
- **WHEN** отмена (`catched → cancelled`) приходит в окне между `add` и записью
|
||||
перехода, из-за чего запись перехода не применяется, а re-read состояния под
|
||||
блокировкой показывает `state != catched`
|
||||
- **THEN** worker удаляет только что добавленный торрент из qBittorrent вместе с
|
||||
его данными (`deleteFiles = true`) по infohash загрузки
|
||||
- **AND** пишет `WARN` о том, что торрент оставлен после отмены и удалён
|
||||
- **AND** состояние задачи остаётся `cancelled`
|
||||
|
||||
#### Scenario: Сбой записи перехода без отмены — торрент не удаляется
|
||||
|
||||
- **GIVEN** загрузка в `catched`, `add` прошёл успешно, но запись перехода
|
||||
`PromoteCatched` вернула ошибку из-за транзиентного сбоя хранилища
|
||||
- **WHEN** re-read состояния под блокировкой показывает, что загрузка всё ещё в
|
||||
`catched` (отмены не было)
|
||||
- **THEN** worker торрент из qBittorrent НЕ удаляет (это наш живой торрент)
|
||||
- **AND** переход доводится на следующем тике усыновлением присутствующей раздачи
|
||||
|
||||
#### Scenario: Пред-существующий торрент не удаляется с данными
|
||||
|
||||
- **GIVEN** загрузка в `catched`, чей infohash уже присутствует в qBittorrent к
|
||||
моменту проверки перед `add` (внешний торрент/раздача пользователя с тем же
|
||||
хешем)
|
||||
- **WHEN** worker обрабатывает тик и параллельно приходит отмена
|
||||
- **THEN** worker `add` НЕ выполняет и торрент с данными НЕ удаляет (чужие данные
|
||||
неприкосновенны)
|
||||
- **AND** загрузка пропускается в этот тик (усыновление присутствующей раздачи —
|
||||
на следующем тике, если задача ещё активна)
|
||||
|
||||
### Requirement: Предохранитель зависшего catched
|
||||
|
||||
|
||||
Reference in New Issue
Block a user