Files
avandClaude Opus 4.8 3a00fde058 Жизненный цикл: уборка торрента при отмене во время добавления (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>
2026-07-17 22:10:21 +03:00

179 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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
Нет.