Два дубля-близнеца на один инфохэш рождались, когда повторный приём
попадал на запись в 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>
205 lines
18 KiB
Markdown
205 lines
18 KiB
Markdown
## 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.
|