Два дубля-близнеца на один инфохэш рождались, когда повторный приём
попадал на запись в target_missing: дедуп искал только активную задачу,
а target_missing терминален → заводилась новая загрузка, воркер усыновлял
уже присутствующий торрент и раскладывал его.
- Приём: критерий дедупа расширен до «блокирующей повторный приём» =
активные ∪ {target_missing, orphaned}. Повторный приём такого инфохэша
привязывается к существующей записи (спящей, без обращения к qBittorrent),
а не плодит близнеца. Прочие терминальные (done/cancelled/failed/reverted/
deleted) повторный приём не блокируют — осознанная свежая попытка. Новый
read-метод FindReingestBlockingByInfohash (приоритет активной над desync);
общий active-гард не тронут.
- Команда «Закрыть» (Dismiss) — универсальный стоп-кран из любого состояния,
кроме deleted → cancelled (error_code=user_dismiss). Только меняет статус:
файлы (в т.ч. хардлинки done/orphaned) и раздачу qBittorrent не трогает,
в отличие от «Удалить». Веб — danger-зона внизу страницы; Telegram —
кнопка с подтверждением; из cancelled — идемпотентный no-op.
- Транспорты при дедупе на desync-запись сообщают адресно (target_missing —
привязать заново/закрыть; orphaned — закрыть и добавить заново); веб при
дедупе ведёт на страницу существующей записи.
Спеки: ingest (дедуп), state-reconciliation (стоп-кран); граф переходов
допополнен рёбрами <терминал>→cancelled. OpenSpec change
dedup-target-missing-and-dismiss заархивирован.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
18 KiB
Context
Дедуп приёма (ingest) держит инвариант «не более одной активной загрузки на
infohash», где «активная» = state NOT IN terminalStates. Терминальный набор
(done, cancelled, failed, reverted, target_missing, orphaned, deleted) смешивает
две разные ситуации:
- «Отработали, забыли» —
done/cancelled/failed/reverted/deleted. Повторный приём такого инфохэша — осознанное «хочу заново», новая загрузка легитимна (ingest/spec.md, сценарий «Повторный приём после завершения»). - «Держим источник ради незакрытого намерения» —
target_missing(источник жив в qBittorrent, ждёт relink) иorphaned(источник пропал, запись держит претензию на последнюю копию данных).
Дедуп трактует обе группы одинаково: терминально → не активна → плоди новую. Во
второй группе это рождает близнеца (реальный прод-случай: target_missing +
done на один торрент). Причём усыновление раздачи воркером
(download-tracking, change catched-promote-without-readd) делает близнеца
«боевым» — он раскладывается и занимает целевой путь, из-за чего у осиротевшей
записи Привязать заново упирается в коллизию владения путём.
Отдельно: закрыть лишнюю target_missing-запись сейчас нечем. Единственная
команда, убирающая её из активного внимания, — «Удалить», но она сносит
раздачу в qBittorrent (deleteFiles=true), а этого как раз не нужно: раздача
общая, её ведёт done-близнец.
Goals / Non-Goals
Goals:
- Повторный приём инфохэша, удерживаемого записью в
target_missing/orphaned, не создаёт новую загрузку, а возвращает существующую (attach), сохраняя приём быстрым и без обращения к qBittorrent. - Пользователь может вручную закрыть любую зависшую/спорную загрузку (стоп-кран), ничего не делая с файлами и раздачей.
- Инвариант «≤1 активной на infohash» и его атомарные гарды не ослабляются.
Non-Goals:
- Авто-схлопывание/слияние уже существующих дублей фоновой сверкой — сознательно ручной путь.
- Изменение логики усыновления раздачи в воркере и общей матрицы «источник × цель» сверки.
- Авто-relink при повторном приёме (это обращение к qBittorrent — противоречит «быстрому приёму без сети»); relink остаётся отдельным явным действием.
- Разбор коллизии владения целевым путём при relink
target_missing, чьи файлы уже разложены другой записью, — эту ситуацию закрывает команда «Закрыть», а не relink. - Любое удаление/создание файлов или снятие раздачи командой «Закрыть» — для этого есть «Удалить». «Закрыть» — чисто смена статуса.
Decisions
Р1. Дедуп: reingest-blocking states = active ∪ {target_missing, orphaned}
Вводим понятие «состояний, блокирующих повторный приём»: активные состояния
плюс target_missing и orphaned. Поиск дедупа при приёме
(FindActiveByInfohash → по сути FindReingestBlockingByInfohash) ищет запись в
любом из этих состояний по любому из хешей и, найдя, возвращает её вместо
создания новой. Приоритет — активная (если вдруг есть и активная, и desync-запись
на один хеш, что допускает текущий инвариант), иначе desync-запись.
- Почему не «сделать
target_missing/orphanedактивными»: сломает семантику «активность выводится только из state» и потянет за собой сверку, healing, выборки активных. Дедуп — единственное место, которому нужна расширенная оптика; локализуем изменение там. - Атомарность: гард создания (
CreateDownloadIfNoActive) остаётся про активные — он бэкстоп инварианта «≤1 активной». Расширенная проверка — это read-ветка дедупа ДО создания; она короткозамыкает на attach. Гонка «два приёма одновременно на свежий target_missing» в худшем случае даёт одну лишнюю попытку create, которую по-прежнему отсекает активный гард; близнец наtarget_missingпри этом не создаётся, т.к. обе ветки видят одну и ту же desync-запись (она уже в БД, коммитнута ранее).
Р2. Attach для desync-записи не воскрешает и не доносит хеши сам по себе
Ветка attach для target_missing/orphaned возвращает запись как «спящую,
требует relink» (флаг в результате приёма), НЕ переводя её в активное состояние и
НЕ вызывая qBittorrent. Донесение недостающих хешей (гибридный торрент) для
desync-записи допустимо и безопасно (терминальная запись не «активна», гонки за
хеш нет), но подчиняется тому же правилу «не красть хеш у другой активной»
(ingest/spec.md, «Атомарность возврата…»). Бот/веб сообщают: запись существует,
приложите relink или закройте.
- Почему не авто-relink: relink делает синхронный source-preflight (обращение к qBittorrent) — это нарушает инвариант «синхронный приём не ходит в qBittorrent». Явный relink пользователем сохраняет разделение шагов.
Почему failed/reverted НЕ блокирующие (в отличие от target_missing/
orphaned): у них нет удерживаемого источника ради незакрытого намерения —
повторный приём осознанно трактуется как свежая попытка. Новая активная
загрузка забирает хеш, старая терминальная им не владеет; её фоновое
самовосстановление (revive failed) корректно отклонится активным гардом «infohash
занят». Дубля-призрака (как с target_missing) при этом не возникает: старая
запись остаётся терминальной и не раскладывается повторно. Реализация не
переиспользует общий active-хелпер для desync-проверки — расширенная оптика нужна
только дедуп-пред-риду (см. Р1, Б-развязка с ActivateIfNoOtherActive/
AddInfohashes).
Р3. «Закрыть» = переход в cancelled, без нового статуса (принято)
Команда «Закрыть» (dismiss) переводит запись в существующее терминальное
cancelled с error_code-дискриминатором (user_dismiss), человекочитаемой
причиной в error_msg и логом перехода.
Почему cancelled, а не новый dismissed:
- Прецедент в коде: Delete переиспользует
deleted+error_code="user_delete"и явно постулирует «новый статус вводить SHALL NOT» — терминальный набор завязан на семантику активности, любой новый статус её разъедает и тянет правки во все выборки/сверку. cancelledуже значит «пользователь отказался от этой записи, источник не трогаем», из него доступен relink — естественный safety valve, если передумал.cancelledне входит в reingest-blocking (Р1) → после «Закрыть» повторный приём заведёт свежую загрузку. Это осознанно: запись закрыта, дубля-призрака больше нет.
Различение причины отмены (закрытие стоп-краном vs. отклонение на ревью) несёт
error_code, а не отдельный статус.
Р4. «Закрыть» — универсальный стоп-кран из любого состояния, кроме deleted (принято)
Команда доступна из любого состояния, кроме deleted (строго терминально,
сверка его не переоценивает — не воскрешаем граф). На cancelled — идемпотентный
no-op (самопереход). Инвариант команды: только меняет статус, файлы под
paths.* и раздачу в qBittorrent НЕ трогает.
- Из
target_missing(исходный прод-случай) — закрытие инертно (целевых ссылок нет, источник жив и остаётся раздаваться). - Из
done/orphaned— библиотечные хардлинки сознательно остаются на месте (не удаляем: «Закрыть» ≠ «Удалить»). Запись перестаёт отслеживаться. - Из активных/
stuck/failed/deferred— источник в qBittorrent остаётся как есть (докачивается/раздаётся); мы лишь снимаем запись из внимания.
Отличие от существующего Cancel/«Отклонить» (review-флоу, только из
нетерминальных): «Закрыть» — универсальный стоп-кран, доступный и из терминальных
done/failed/reverted/target_missing/orphaned, и живёт в отдельной danger
zone внизу страницы. Оба ведут в cancelled; различаются гардом источника и
error_code.
Рёбра allowedTransitions, которые нужно добавить (у нетерминальных cancelled
как цель уже есть): done → cancelled, failed → cancelled,
reverted → cancelled, target_missing → cancelled, orphaned → cancelled.
После этого cancelled — легальная цель из любого состояния, кроме deleted.
Risks / Trade-offs
- Relink
target_missingпри существующемdone-близнеце всё ещё упрётся в коллизию пути. → Ожидаемо и допустимо: правильное действие для лишней записи — «Закрыть», а не relink; коллизия владения путём (state-reconciliation, «Занятый путь даёт коллизию») отрабатывает штатно и не портит данные. - Reingest-blocking расширен → пользователь, реально желающий переснять
target_missing-торрент заново, получит attach, а не новую загрузку. → Приемлемо: у него есть relink (вперёд) и «Закрыть» (закрыть и, при желании, переслать снова — новая загрузка заведётся изcancelled). - Гонка двух одновременных приёмов на свежую desync-запись. → Оба видят уже коммитнутую desync-запись → attach; активный гард отсекает случайный create. Близнец не рождается.
error_code=user_dismissвcancelledсмешивает две причины отмены. → Дискриминатор вerror_code+error_msg/лог различают их; телеметрия по причине доступна без нового статуса.- «Закрыть» из
done/orphanedоставляет неотслеживаемые хардлинки подpaths.movies/series, чейfile_linkпродолжает «владеть» путём (cancelledсверкой не переоценивается). → Осознанный компромисс стоп-крана «только статус»: файлы оставляем как есть, реальную зачистку делает «Удалить». Повторная закачка того же пути упрётся в штатную коллизию владения путём (state-reconciliation, «Занятый путь даёт коллизию»), а не в порчу данных. - «Закрыть» из активных состояний рвёт запись из-под воркера (напр. в
linking/downloading). → Команды сериализуются воркером под единой блокировкой (как прочие команды ревью) — «Закрыть» применяется как последняя валидная команда, а не посреди операции; частично созданные ссылки остаются, что соответствует контракту «только статус». - Восстановление
orphanedчерез приём — двухшаговое.orphaned(источник пропал) блокирует повторный приём (attach), но relink изorphanedне реализован, а приём не добавляет источник в qBittorrent. → Рабочий путь возврата источника: «Закрыть» (→cancelled) → повторный приём (уже не блокируется) → свежая активная загрузка, которую воркер добавит и разложит. Прямой приём без attach создал бы близнеца с коллизией целевого пути (файлorphanedещё на месте), поэтому attach выбран сознательно; транспорты вorphanedформулируют действие как «закройте, затем добавьте заново» (не «привяжите заново»). Прямой relink-из-orphaned— возможное будущее улучшение вне scope этого change. - danger zone скрывает завершённую (
done) запись одним действием. → Разместить «Закрыть» в отдельной danger zone внизу страницы; для необратимо выглядящих случаев (doneи прочие терминальные) UI SHOULD запрашивать подтверждение (относительно дёшево — изcancelledдоступен relink).
Migration Plan
- Схема БД не меняется (нет таблиц/столбцов/статусов). Миграции не требуются.
- Изменения — код + дельта-спеки; деплой обычным бинарём. Откат — откат бинаря; данные не мигрированы, несовместимости нет.
- Обновить граф переходов в тесте (
cancelledкак цель изdone/failed/reverted/target_missing/orphaned) и описание статусов/переходов вdocs/specs/database.md.
Open Questions
- Р3 (
cancelled+error_code) и Р4 (универсальный стоп-кран из любого состояния, кромеdeleted) — приняты. - Требует ли «Закрыть» из терминальных/
doneподтверждения в UI (см. риск) — решить на реализации веб-UI. - Тексты для транспортов: формулировка ответа приёма при attach на desync-запись («существует как #id без цели — привяжите заново или закройте») и подпись кнопки «Закрыть» в веб/Telegram.