Жизненный цикл: уборка торрента при отмене во время добавления (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:
av
2026-07-17 22:10:21 +03:00
co-authored by Claude Opus 4.8
parent 0354a8c96b
commit 3a00fde058
11 changed files with 792 additions and 30 deletions
@@ -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:447453);
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
Нет.