Отмена задачи (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>
36 KiB
download-tracking Specification
Purpose
Отслеживание скачивания и прямой путь машины состояний загрузки: поллинг
qBittorrent и сопоставление его состояний (downloading → completed; готовность
только когда файлы на месте), таймауты-предохранители (magnet_timeout/
stuck_after), ошибка qBit → failed, усыновление раздач по категории/тегу и
переходы под per-download блокировкой. Сверка уже разложенного с реальностью —
в state-reconciliation.
Requirements
Requirement: Поллинг qBittorrent и сопоставление состояний
Worker SHALL периодически (worker.poll_interval, дефолт 5 с) опрашивать
qBittorrent и сопоставлять его состояния раздачи с состоянием загрузки в БД.
Готовые к раскладке состояния (uploading/stalledUP/pausedUP/stoppedUP/
queuedUP/forcedUP, с учётом различий имён между qBit v4 и v5) SHALL переводить
загрузку в completed. Ещё качающиеся состояния (downloading/stalledDL/
metaDL/…) SHALL оставлять её в downloading.
Scenario: Раздача завершилась
- GIVEN загрузка в
downloading - WHEN qBittorrent сообщает состояние
stalledUPи файлы на месте - THEN загрузка переходит в
completed
Requirement: Готовность только когда файлы на месте
Переходные состояния qBittorrent система SHALL трактовать как «ждём»
(moving/checkingUP/checkingResumeData/allocating): оставаться в
downloading и НЕ объявлять готовность, даже если выставлены флаги UP, пока
qBit не завершит перенос/проверку. Финальные пути файлов система SHALL брать из
API после завершения переноса.
Scenario: Ждём завершения переноса
- GIVEN загрузка, у которой qBittorrent в состоянии
moving - WHEN идёт тик поллинга
- THEN загрузка остаётся в
downloading, готовность не объявляется
Requirement: Таймауты-предохранители downloading
Система SHALL переводить metaDL/forcedMetaDL дольше magnet_timeout (дефолт
24h, редкий предохранитель) в failed (error_code magnet_timeout), а
stalledDL дольше stuck_after — в stuck (error_code
stalled). Возраст система SHALL считать от времени добавления в qBittorrent
(added_on), а не от создания задачи, чтобы базис переживал retry и усыновление.
Долгий metaDL система НЕ SHALL убивать агрессивно (медленные трекеры — норма).
Scenario: Завис на метаданных дольше таймаута
- GIVEN раздача в
metaDLдольшеmagnet_timeoutотadded_on - WHEN идёт тик поллинга
- THEN загрузка переходит в
failedсerror_codemagnet_timeout
Requirement: Ошибка qBittorrent переводит в failed
Состояния error/missingFiles система SHALL трактовать как настоящий провал и
переводить загрузку в failed (error_code qbit_error) — в отличие от
таймаутов-предохранителей, такой провал сверкой не воскрешается.
Scenario: qBit сообщает об ошибке
- GIVEN раздача в состоянии
missingFiles - WHEN идёт тик поллинга
- THEN загрузка переходит в
failedсerror_codeqbit_error
Requirement: Усыновление раздач по категории или тегу
Worker SHALL периодически сверять раздачи qBittorrent с БД и усыновлять те, у
которых наша категория (qbittorrent.category) ИЛИ тег (qbittorrent.tag), а
записи в БД ещё нет, заводя для них загрузку в состоянии downloading. Категория
ставится на добавляемые нами раздачи (push); тег позволяет подхватить уже
существующую раздачу (pull), не трогая её категорию и файлы.
Scenario: Подхват существующей раздачи по тегу
- GIVEN в qBittorrent есть раздача с тегом
qbittorrent.tag, которой нет в БД - WHEN worker сверяет qBittorrent с БД
- THEN для раздачи заводится загрузка в состоянии
downloading
Requirement: Переходы состояний сериализуются воркером
Все переходы состояний загрузки SHALL сериализоваться worker'ом под единой
блокировкой (поллинг-цикл и команды всех транспортов проходят через неё), чтобы
два источника перехода не гонялись за одно состояние. Состояние SHALL быть
персистентным в SQLite; активность загрузки SHALL выводиться только из state,
без отдельного флага.
Scenario: Команды сериализуются
- GIVEN две одновременные команды к одной загрузке из разных транспортов
- WHEN они обрабатываются
- THEN переходы применяются последовательно под блокировкой, без гонки
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 загрузка пропускается в этот тик (усыновление присутствующей раздачи — на следующем тике, если задача ещё активна)
Requirement: Предохранитель зависшего catched
Система SHALL переводить загрузку, задержавшуюся в catched дольше
catch_timeout (конфигурируемый предохранитель, дефолт консервативный), в
failed (error_code qbit_add) и уведомлять автора. Возраст SHALL считать
от времени попадания в catched (создания загрузки). Предохранитель —
редкий страховочный механизм на случай устойчивой недоступности qBittorrent, а
не штатный путь.
Scenario: catched висит дольше таймаута
- GIVEN загрузка в
catchedдольшеcatch_timeout - WHEN идёт тик поллинга
- THEN загрузка переходит в
failedсerror_codeqbit_add - AND автор загрузки уведомляется
Requirement: catched не считается пропажей раздачи
Система SHALL исключать состояние catched из проверок «раздача не найдена в
qBittorrent» — как в поллинге активных загрузок, так и в сверке рассинхрона
(state-reconciliation). У пойманной загрузки раздачи в qBittorrent ещё нет по
дизайну, поэтому её отсутствие система SHALL NOT трактовать как рассинхрон,
orphaned или пропажу источника.
Scenario: Отсутствие раздачи у catched — не рассинхрон
- GIVEN загрузка в
catched(раздачи в qBittorrent ещё нет) - WHEN идёт тик поллинга и сверки
- THEN загрузка не считается пропавшей/рассинхронизированной и остаётся в
catched(до добавления воркером или срабатыванияcatch_timeout)
Requirement: Легальность переходов задаётся декларативным графом
Множество легальных переходов машины состояний загрузки SHALL быть объявлено
декларативно в едином месте (internal/store) как отображение from → {разрешённые to}, покрывающее все переходы, которые worker выполняет по всем
capability (прямой путь, state-reconciliation, review). Этот граф SHALL быть
единственным источником истины о легальности рёбер.
Запись состояния (setState, общая основа SetDownloadState и
ActivateIfNoOtherActive) SHALL применять переход, только если он либо объявлен
ребром графа, либо является идемпотентным самопереходом (from == to, переустановка
того же состояния — например, повторная запись ошибки). Переход, не удовлетворяющий
ни одному из условий, запись SHALL отклонять (0 строк UPDATE → ошибка), НЕ применяя
его.
Гейт графа SHALL быть ортогонален остальным гардам записи и НЕ SHALL их ослаблять:
существующий запрет молча оживить терминальную задачу (переход из терминального
состояния разрешён только через ActivateIfNoOtherActive с проверкой владения
хешами) и инвариант «не более одной активной загрузки на infohash» сохраняются. Как
следствие, ребро из терминального состояния (напр. failed → downloading при retry)
SHALL проходить только revive-путём (ActivateIfNoOtherActive) и SHALL отклоняться
обычным SetDownloadState.
Граф SHALL быть надмножеством всех переходов, которые worker уже выполняет: введение гейта НЕ SHALL менять поведение существующих легальных переходов.
Scenario: Объявленный переход применяется
- GIVEN загрузка в состоянии
downloading - WHEN worker записывает переход
downloading → completed(объявленное ребро) - THEN состояние становится
completed
Scenario: Необъявленный переход отклоняется
- GIVEN загрузка в состоянии
review - WHEN делается попытка записать переход
review → done(ребра в графе нет) - THEN запись отклоняется с ошибкой, состояние остаётся
review
Scenario: Идемпотентная переустановка состояния разрешена
- GIVEN загрузка в состоянии
deferred - WHEN записывается переход
deferred → deferred(самопереход) - THEN запись проходит, состояние остаётся
deferred
Scenario: Ребро из терминального состояния только через revive
- GIVEN загрузка в терминальном состоянии
failed - WHEN переход
failed → downloadingделается обычнымSetDownloadState - THEN запись отклоняется (терминальную задачу нельзя оживить мимо гарда владения)
- AND тот же переход через
ActivateIfNoOtherActive(при свободном infohash) проходит
Requirement: Пропажа источника у активной загрузки
Поллинг активных загрузок (downloading) SHALL обнаруживать пропажу источника:
если раздача, совпадающая с любым из известных хешей загрузки, отсутствует в
выдаче qBittorrent, система SHALL применять тот же дебаунс пропажи источника,
что и сверка рассинхрона (счётчик source_miss_count, порог
[worker].source_missing_threshold; см. state-reconciliation «Дебаунс пропажи
источника»). Любое обнаружение раздачи SHALL сбрасывать счётчик.
После N подряд идущих тиков без раздачи (N = [worker].source_missing_threshold) система SHALL переводить загрузку
downloading → failed с error_code source_gone и уведомлять автора. До
достижения порога загрузка SHALL оставаться в downloading (транзиентная
недоступность qBittorrent, например рестарт демона, не должна ронять задачу).
source_gone система SHALL трактовать как отдельную причину, отличную от
qbit_error (реальная ошибка qBittorrent) и от magnet_timeout/stalled (наша
нетерпеливость). Восстановлению сверкой (reconcileRecovery) source_gone
подлежать SHALL NOT — удаление источника из qBittorrent намеренно, молча
воскрешать задачу нельзя. Задача SHALL оставаться штатно восстановимой вручную
(Retry заново отдаёт сохранённый источник в qBittorrent).
Состояние catched этим правилом затрагиваться SHALL NOT: у пойманной загрузки
раздачи в qBittorrent ещё нет по дизайну (см. «catched не считается пропажей
раздачи»), а цикл активных загрузок листает только downloading.
Scenario: Источник пропал у активной загрузки дольше порога
- GIVEN загрузка в
downloading, чья раздача удалена из qBittorrent - WHEN раздача отсутствует
source_missing_thresholdподряд идущих тиков - THEN загрузка переходит в
failedсerror_codesource_gone - AND автор загрузки уведомляется
Scenario: Кратковременная пропажа источника не роняет задачу
- GIVEN загрузка в
downloading - WHEN раздача отсутствует в qBittorrent меньше
source_missing_thresholdтиков подряд - THEN загрузка остаётся в
downloading
Scenario: Возврат раздачи сбрасывает счётчик
- GIVEN загрузка в
downloadingс накопленными промахами источника (меньше порога) - WHEN раздача снова обнаружена в qBittorrent
- THEN счётчик промахов сбрасывается в ноль и загрузка ведётся обычной сверкой состояния
Scenario: source_gone не воскрешается сверкой
- GIVEN загрузка в
failedсerror_codesource_gone - WHEN её раздача снова появляется в qBittorrent и продвигается
- THEN сверка восстановления её не трогает — задача остаётся в
failedдо ручногоRetry