Жизненный цикл: уборка торрента при отмене во время добавления (F3/NIT-13)
Отмена задачи (catched→cancelled) в окно, пока worker вне блокировки выводит имя (LLM) и делает qbt.Add, оставляла добавленный торрент в qBittorrent без задачи-владельца: PromoteCatched корректно пропускал переход, но источник уже качался/сидировал вечно, а усыновить его назад нельзя (хеши принадлежат отменённой задаче). Спека покрывала переход состояния, но не побочный эффект. Комбинированная защита в processCatched: - re-read состояния под w.mu прямо перед qbt.Add — при отмене источник не добавляется вовсе (сужает окно гонки); - свежий листинг перед add подтверждает отсутствие infohash — признак «своего» торрента; при сбое листинга/присутствии add не делаем (усыновит следующий тик); - при отмене в окне после add (промах PromoteCatched, подтверждённый re-read'ом state != catched) — уборка добавленного нами торрента qbt.Delete(_, true); - WARN/ERROR-логи по этому пути с корреляцией по download_id, без секретов. Гарантия «удаляем только своё»: удаление-с-данными достижимо ТОЛЬКО после подтверждённого отсутствия infohash перед add, поэтому пред-существующий/чужой торрент с тем же хешем никогда не сносится (негативный инвариант). Обоснование по инварианту «источник неприкосновенен» — в design.md изменения. Дельта — download-tracking (требование «Добавление пойманной загрузки в qBittorrent»): re-read перед add, подтверждение отсутствия, уборка при отмене, негативный сценарий. Тесты покрывают все ветки (skip-before-add, cleanup после add, пред-существующий не удаляется, сбой БД не удаляет, сбой листинга не добавляет). Change archived: 2026-07-17-cancel-during-add-cleanup. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user