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

205 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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.