Files
jellybit/openspec/changes/archive/2026-07-10-dedup-target-missing-and-dismiss/design.md
T
avandClaude Opus 4.8 1369a9cabe Приём: дедуп по target_missing/orphaned + стоп-кран «Закрыть»
Два дубля-близнеца на один инфохэш рождались, когда повторный приём
попадал на запись в 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>
2026-07-10 20:15:37 +03:00

18 KiB
Raw Blame History

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.