Слияние: уборка торрента при cancel во время add (T4)

# Conflicts:
#	docs/backlog/README.md
This commit is contained in:
av
2026-07-17 22:11:53 +03:00
11 changed files with 792 additions and 30 deletions
+107 -7
View File
@@ -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