Приём: дедуп по 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:
+22
-15
@@ -23,9 +23,11 @@ const capIngest = "ingest"
|
||||
|
||||
// Store — нужная ingest часть хранилища.
|
||||
type Store interface {
|
||||
// FindActiveByInfohash — быстрый читающий дедуп-чек; авторитетная проверка —
|
||||
// внутри CreateDownloadIfNoActive.
|
||||
FindActiveByInfohash(ctx context.Context, hashes ...string) (*store.Download, error)
|
||||
// FindReingestBlockingByInfohash — быстрый читающий дедуп-чек: активная задача
|
||||
// ЛИБО удерживающая источник desync-запись (target_missing/orphaned). Активный
|
||||
// инвариант «≤1 активной» авторитетно держит CreateDownloadIfNoActive; desync —
|
||||
// устойчивый пред-рид, коротко замыкающий приём на возврат существующей записи.
|
||||
FindReingestBlockingByInfohash(ctx context.Context, hashes ...string) (*store.Download, error)
|
||||
// CreateDownloadIfNoActive атомарно проверяет инвариант «одна активная
|
||||
// загрузка на infohash» и заводит задачу; вернувшаяся existing ≠ nil —
|
||||
// дедуп на активную задачу (недостающие хеши вызова метод доносит сам).
|
||||
@@ -87,15 +89,18 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
|
||||
log := s.log.With("capability", capIngest, "infohash", src.infohashes[0])
|
||||
ctx = logctx.With(ctx, log)
|
||||
|
||||
// Быстрый дедуп-чек; авторитетная (атомарная) проверка — внутри
|
||||
// CreateDownloadIfNoActive ниже. Дедуп — по ЛЮБОМУ из хешей источника:
|
||||
// гибридный несёт и v1, и v2.
|
||||
if existing, err := s.store.FindActiveByInfohash(ctx, src.infohashes...); err != nil {
|
||||
// Быстрый дедуп-чек по ЛЮБОМУ из хешей источника (гибридный несёт и v1, и v2):
|
||||
// активная задача ЛИБО удерживающая источник desync-запись
|
||||
// (target_missing/orphaned) блокируют повторный приём. Для активной
|
||||
// авторитетная (атомарная) проверка — внутри CreateDownloadIfNoActive ниже;
|
||||
// desync-ветка сюда и завершается (в active-гард desync не заводим, чтобы не
|
||||
// размыть инвариант «≤1 активной»).
|
||||
if existing, err := s.store.FindReingestBlockingByInfohash(ctx, src.infohashes...); err != nil {
|
||||
// Инфраструктурный сбой (БД) — операция приёма не выполнена: ERROR.
|
||||
log.Error("ingest failed", "stage", "lookup-active", "error", err)
|
||||
return Result{}, fmt.Errorf("ingest: lookup active: %w", err)
|
||||
log.Error("ingest failed", "stage", "lookup-blocking", "error", err)
|
||||
return Result{}, fmt.Errorf("ingest: lookup blocking: %w", err)
|
||||
} else if existing != nil {
|
||||
log.Info("download attached to active", "download_id", existing.ID, "state", existing.State)
|
||||
log.Info("download attached", "download_id", existing.ID, "state", existing.State)
|
||||
return s.attached(ctx, src, existing), nil
|
||||
}
|
||||
|
||||
@@ -204,11 +209,13 @@ func mergeContext(userText, synth string) string {
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
// attached — итог дедупа на быстром чеке: присоединились к уже активной
|
||||
// задаче и доносим ей недостающие хеши источника (гибридный magnet мог
|
||||
// принести хеш, которого задача ещё не знает; guarded-путь через
|
||||
// CreateDownloadIfNoActive сюда не доходит). Донос — best-effort: конфликт
|
||||
// хеша с другой активной задачей логируется, приём не валится.
|
||||
// attached — итог дедупа на быстром чеке: присоединились к блокирующей записи
|
||||
// (активной ЛИБО удерживающей источник desync — target_missing/orphaned) и
|
||||
// доносим ей недостающие хеши источника (гибридный magnet мог принести хеш,
|
||||
// которого задача ещё не знает; guarded-путь через CreateDownloadIfNoActive сюда
|
||||
// не доходит). Для desync-записи состояние не меняем (возвращаем «спящей» — relink
|
||||
// или закрытие делает пользователь). Донос — best-effort: конфликт хеша с другой
|
||||
// активной задачей логируется, приём не валится.
|
||||
func (s *Service) attached(ctx context.Context, src parsedSource, existing *store.Download) Result {
|
||||
if len(src.infohashes) > len(existing.Infohashes) {
|
||||
if err := s.store.AddInfohashes(ctx, existing.ID, src.infohashes); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user