Отмена задачи (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>
19 KiB
MODIFIED Requirements
Requirement: Добавление пойманной загрузки в qBittorrent
Worker SHALL периодически (в поллинг-цикле, под единой блокировкой переходов)
подхватывать загрузки в состоянии catched и для каждой (кроме случая уже
присутствующего в qBittorrent торрента, см. ниже): вывести отображаемое имя из
контекста (см. ingest «Отображаемое имя торрента из контекста»), добавить
источник в qBittorrent (категория qbittorrent.category, savepath, rename) и
перевести загрузку catched → downloading. Отдельного состояния между catched
и downloading быть SHALL NOT — успешный add сразу переводит в downloading
(которое и означает «в qBit, возможно metaDL»).
Перед добавлением worker SHALL проверять, присутствует ли торрент загрузки уже
в qBittorrent (по любому из её infohash), опираясь на листинг раздач того же
тика. Если торрент уже присутствует, worker SHALL усыновить его: перевести
загрузку catched → downloading без повторного add и без вывода имени
через LLM (display_name берётся из имени присутствующей раздачи). Повторный
add здесь не нужен и вреден — qBittorrent отверг бы дубль (напр. 409 Conflict), и загрузка зациклилась бы на ретраях. Усыновлённая раздача дальше
идёт обычным путём отслеживания и раскладки. Проверка присутствия SHALL
выполняться до вывода отображаемого имени, чтобы не тратить LLM-вызов на
загрузку, которую добавлять не требуется.
Инвариант приёма («одна активная загрузка на infohash», см. ingest) гарантирует,
что до этого шага доходит лишь загрузка, для которой в jellybit НЕТ другой
активной задачи; поэтому присутствие торрента в qBittorrent worker трактует как
«усыновить и разложить», а не как конфликт с чужой задачей.
Если листинг раздач qBittorrent недоступен (сетевой сбой), worker пойманную
загрузку в этот тик трогать SHALL NOT (ни add, ни namer) и повторить на
следующем; устойчивая недоступность отсекается предохранителем catch_timeout
(см. «Предохранитель зависшего catched»).
Добавление в qBittorrent worker SHALL выполнять по типу источника
(source_type):
- Для
magnet/url— передаватьsource_refкак ссылку (urlsAPI/torrents/add); подсказку отображаемого имени брать из полей самой ссылки. - Для
torrent— загружать сохранённые байты.torrent(привязанные к загрузке при приёме) и передавать их файлом (torrentsAPI/torrents/add), НЕ как ссылку; подсказку отображаемого имени брать из метаданных торрента (имя раздачи). Добавление байтами SHALL сохранять полные метаданные (qBittorrent стартует без докачки), поэтому воскрешать раздачу по magnet-хешу вместо файла система SHALL NOT.
source_type для выбора способа добавления worker SHALL перечитывать под
блокировкой переходов непосредственно перед добавлением (а не полагаться на
снимок, снятый ранее вне блокировки): иначе при точном оверлапе тика с апгрейдом
пойманной magnet-задачи до .torrent (см. ingest) воркер добавил бы magnet из
устаревшего снимка, хотя БД уже torrent.
Неуспешный add (qBittorrent временно отверг/недоступен) SHALL оставлять
загрузку в catched для повторной попытки на следующем тике; переход в
терминальное состояние по единичному сбою происходить SHALL NOT (ретраи —
естественными тиками поллинга, отсечка — catch_timeout).
Медленные вызовы (вывод имени через LLM, qbt.Add) SHALL выполняться вне
блокировки сериализации переходов, чтобы не задерживать команды транспортов и
поллинг. Под блокировкой сериализуется только запись перехода catched → 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. Непосредственно передaddworker SHALL свежим листингом раздач qBittorrent подтвердить, что раздачи ни с одним из infohash загрузки ещё НЕТ. Если этот листинг не удался (сетевой сбой), workeraddвыполнять SHALL NOT и загрузку в этот тик пропустить (повтор на следующем): без подтверждённого отсутствия признак «своё/чужое» неизвестен, и последующее удаление-с-данными было бы небезопасным. Если торрент уже присутствует (внешний клиент/пользователь добавил тот же infohash в окно гонки), workeraddвыполнять SHALL NOT и загрузку в этот тик пропустить — на следующем тике её усыновит ветка «уже присутствует». Подтверждённое отсутствие непосредственно-перед-addSHALL служить признаком того, что торрент, оказавшийся под этим infohash сразу послеadd, создан именно этимadd(наш артефакт), а не пред-существовал. -
Уборка добавленного торрента при отмене в окне после
add. Еслиaddпрошёл успешно, а последующая запись переходаPromoteCatchedне применилась, worker SHALL принимать решение об уборке по свежему re-read состояния под блокировкой, а не по факту ошибки промоушена: неуспех промоушена бывает и из-за отмены (stateуже неcatched), и из-за транзиентного сбоя хранилища (stateвсё ещёcatched, задача жива). Только при подтверждённомstate != catchedworker 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, торрента ещё нет в qBittorrent - WHEN worker обрабатывает тик
- THEN выводится отображаемое имя, ссылка добавляется в qBittorrent с
нашей категорией и
rename - AND загрузка переходит в
downloading
Scenario: Пойманная .torrent-загрузка добавляется файлом
- GIVEN загрузка в состоянии
catchedсsource_type = torrentи сохранёнными байтами файла, торрента ещё нет в qBittorrent - WHEN worker обрабатывает тик
- THEN сохранённые байты добавляются в qBittorrent файлом (
torrents), с нашей категорией иrename, без обращения к magnet-хешу - AND загрузка переходит в
downloading
Scenario: Торрент уже присутствует в qBittorrent — усыновление без add
- GIVEN загрузка в состоянии
catched, торрент которой уже присутствует в qBittorrent (добавлен ранее вручную/другим клиентом либоaddпрошёл на прошлом тике, а запись перехода не удалась) - WHEN worker обрабатывает тик
- THEN worker НЕ вызывает
qbt.Addи НЕ выводит отображаемое имя через LLM - AND
display_nameзаписывается из имени присутствующей раздачи - AND загрузка переходит в
downloadingи идёт обычным путём к раскладке
Scenario: qBittorrent недоступен при проверке присутствия — повтор
- GIVEN загрузка в
catched, листинг раздач qBittorrent не удался - WHEN worker обрабатывает тик
- THEN worker НЕ вызывает namer и НЕ добавляет источник
- AND загрузка остаётся в
catchedи попытка повторяется на следующем тике
Scenario: Временный сбой добавления — повтор
- GIVEN загрузка в
catched, торрента в qBittorrent нет, ноaddне удался - WHEN worker пытается добавить источник и
addвозвращает ошибку - THEN загрузка остаётся в
catched - AND на следующем тике попытка добавления повторяется
Scenario: Свежий листинг перед add недоступен — повтор
- 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 загрузка пропускается в этот тик (усыновление присутствующей раздачи — на следующем тике, если задача ещё активна)