Files
T
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

19 KiB
Raw Blame History

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 как ссылку (urls API /torrents/add); подсказку отображаемого имени брать из полей самой ссылки.
  • Для torrent — загружать сохранённые байты .torrent (привязанные к загрузке при приёме) и передавать их файлом (torrents API /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. Непосредственно перед 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, торрента ещё нет в 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 загрузка пропускается в этот тик (усыновление присутствующей раздачи — на следующем тике, если задача ещё активна)