Приём: дедуп по 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>
This commit is contained in:
av
2026-07-10 20:15:37 +03:00
co-authored by Claude Opus 4.8
parent b8657120fe
commit 1369a9cabe
26 changed files with 1157 additions and 68 deletions
+68 -12
View File
@@ -179,10 +179,12 @@ SHALL NOT проваливать обновление — `download.display_name
Приём SHALL быть единым **быстрым** use-case, общим для всех транспортов (HTTP,
Telegram, CLI): по источнику (Ф1 — magnet) и текстовому контексту система SHALL
синхронно извлечь инфохэши, синтезировать контекст из полей ссылки (без сети),
дедуплицировать по активной задаче и при отсутствии дубля завести загрузку
(`download` в состоянии **`catched`** + записи `download_infohash`), после чего
**сразу вернуть ответ** транспорту. Заведение загрузки и запись её хешей SHALL
выполняться атомарно (см. «Атомарность возврата загрузки в активное
дедуплицировать по **блокирующей повторный приём** задаче (активной либо
удерживающей источник ради незакрытого намерения — `target_missing`/`orphaned`;
см. «Дедупликация приёма по любому из хешей») и при отсутствии дубля завести
загрузку (`download` в состоянии **`catched`** + записи `download_infohash`),
после чего **сразу вернуть ответ** транспорту. Заведение загрузки и запись её
хешей SHALL выполняться атомарно (см. «Атомарность возврата загрузки в активное
состояние»).
Синхронный путь приёма SHALL NOT обращаться к qBittorrent и SHALL NOT выводить
@@ -238,13 +240,33 @@ btih (v1), и btmh (v2); `kind` определяется по длине hex (40
### Requirement: Дедупликация приёма по любому из хешей
При приёме система SHALL искать **активную** (нетерминальную) загрузку по
любому из известных хешей и, найдя, SHALL возвращать её вместо создания
новой. Проверка активности и вставка новой загрузки с её хешами SHALL
выполняться атомарно (в одной write-транзакции), поддерживая инвариант «не
более одной активной загрузки на infohash». Отдельного снимаемого/
восстанавливаемого ключа идемпотентности в схеме быть SHALL NOT — активность
выводится только из `state`.
При приёме система SHALL искать загрузку, **блокирующую повторный приём**, по
любому из известных хешей и, найдя, SHALL возвращать её вместо создания новой.
Блокирующими SHALL считаться загрузки в активном (нетерминальном) состоянии
**либо** удерживающие источник ради незакрытого намерения — `target_missing`
(источник жив в qBittorrent, ждёт relink) и `orphaned` (источник пропал, запись
держит претензию на последнюю копию). Прочие терминальные состояния (`done`,
`cancelled`, `failed`, `reverted`, `deleted`) блокирующими быть SHALL NOT:
повторный приём такого инфохэша — осознанное «хочу заново» и SHALL заводить
новую загрузку.
Когда найденная блокирующая загрузка терминальна (`target_missing`/`orphaned`),
приём SHALL возвращать её как существующую (`Deduplicated`) **спящей**: система
SHALL NOT переводить её в активное состояние и SHALL NOT обращаться к qBittorrent
(перепривязка — отдельное явное действие пользователя, а не побочный эффект
приёма); ответ транспорту SHALL сообщать, что запись существует и требует
перепривязки либо закрытия.
Атомарный инвариант касается **активной** составляющей: проверка отсутствия
другой активной загрузки на любом из хешей и вставка новой загрузки с её хешами
SHALL выполняться в одной write-транзакции, поддерживая «не более одной активной
загрузки на infohash» (тот же общий active-гард, что у прочих путей активации).
Расширение критерия на desync-состояния (`target_missing`/`orphaned`) SHALL быть
устойчивым пред-ридом до создания, коротко замыкающим приём на возврат
существующей записи; desync-состояния в общий active-гард заводиться SHALL NOT
(их терминальность оставляет `state`-инвариант «активности» нетронутым).
Отдельного снимаемого/восстанавливаемого ключа идемпотентности в схеме быть SHALL
NOT — активность выводится только из `state`.
#### Scenario: Повторный приём при активной загрузке
@@ -252,12 +274,46 @@ btih (v1), и btmh (v2); `kind` определяется по длине hex (40
- **WHEN** принимается magnet с тем же `h`
- **THEN** новая загрузка не создаётся, возвращается существующая
#### Scenario: Повторный приём при записи без цели
- **GIVEN** загрузка с infohash `h` в `target_missing` (источник жив, цель
удалена)
- **WHEN** принимается magnet с тем же `h`
- **THEN** новая загрузка не создаётся, возвращается существующая запись как
`Deduplicated`
- **AND** её состояние остаётся `target_missing` (в активное не переводится, к
qBittorrent обращения нет)
- **AND** ответ транспорту указывает, что запись существует и её нужно привязать
заново или закрыть
#### Scenario: Повторный приём при осиротевшей записи
- **GIVEN** загрузка с infohash `h` в `orphaned` (источник пропал)
- **WHEN** принимается magnet/torrent с тем же `h`
- **THEN** новая загрузка не создаётся, возвращается существующая запись как
`Deduplicated`
#### Scenario: Повторный приём после завершения
- **GIVEN** загрузка с infohash `h` в терминальном состоянии (`done`)
- **GIVEN** загрузка с infohash `h` в терминальном состоянии `done`
- **WHEN** принимается magnet с тем же `h`
- **THEN** создаётся новая загрузка со своим ULID и записью `h`
#### Scenario: Повторный приём после закрытия записи
- **GIVEN** загрузка с infohash `h` в `cancelled` (в т.ч. закрытая из
`target_missing`)
- **WHEN** принимается magnet с тем же `h`
- **THEN** создаётся новая загрузка со своим ULID и записью `h`
#### Scenario: Повторный приём при прочих терминальных состояниях
- **GIVEN** загрузка с infohash `h` в `failed` или `reverted` (не удерживает
источник ради незакрытого намерения)
- **WHEN** принимается magnet/torrent с тем же `h`
- **THEN** создаётся новая загрузка со своим ULID и записью `h` (повторный приём —
свежая попытка; старая терминальная запись хешем не владеет)
### Requirement: Атомарность возврата загрузки в активное состояние
Система SHALL атомарно (в одной write-транзакции) проверять на каждом пути,