Жизненный цикл: уборка торрента при отмене во время добавления (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:
@@ -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
|
||||
|
||||
Нет.
|
||||
Reference in New Issue
Block a user