Приём: дедуп по 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:
@@ -260,6 +260,71 @@ func TestFindActiveByInfohash_DesyncStatesNotActive(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Дедуп повторного приёма блокируют не только активные, но и удерживающие
|
||||
// источник desync-записи (target_missing/orphaned): по ним приём привязывается к
|
||||
// существующей, а не плодит близнеца. Прочие терминальные (done/cancelled/failed/
|
||||
// reverted/deleted) повторный приём НЕ блокируют — осознанная свежая попытка.
|
||||
func TestFindReingestBlockingByInfohash(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
blocking := []State{
|
||||
StateCatched, StateDownloading, StateReview, // активные (примеры)
|
||||
StateTargetMissing, StateOrphaned, // desync, удерживающие источник
|
||||
}
|
||||
for _, s := range blocking {
|
||||
t.Run("blocking/"+string(s), func(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ih := hashN(1)
|
||||
id := mustCreate(t, st, ih)
|
||||
forceState(t, st, id, s)
|
||||
d, err := st.FindReingestBlockingByInfohash(ctx, ih)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d == nil || d.ID != id {
|
||||
t.Fatalf("%s должна блокировать приём, получили %v", s, d)
|
||||
}
|
||||
if len(d.Infohashes) != 1 {
|
||||
t.Fatalf("хеши не подгружены: %v", d.Infohashes)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
nonBlocking := []State{
|
||||
StateDone, StateCancelled, StateFailed, StateReverted, StateDeleted,
|
||||
}
|
||||
for _, s := range nonBlocking {
|
||||
t.Run("non-blocking/"+string(s), func(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ih := hashN(2)
|
||||
id := mustCreate(t, st, ih)
|
||||
forceState(t, st, id, s)
|
||||
d, err := st.FindReingestBlockingByInfohash(ctx, ih)
|
||||
if err != nil || d != nil {
|
||||
t.Fatalf("%s блокировать приём не должна, получили (%v,%v)", s, d, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("priority-active-over-desync", func(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ih := hashN(3)
|
||||
// Старая запись ушла в target_missing (terminal освобождает хеш), затем по
|
||||
// тому же хешу завелась новая активная — инвариант «≤1 активной» это
|
||||
// допускает. Дедуп обязан присоединиться к активной, а не к desync.
|
||||
oldID := mustCreate(t, st, ih)
|
||||
forceState(t, st, oldID, StateTargetMissing)
|
||||
newID := mustCreate(t, st, ih)
|
||||
d, err := st.FindReingestBlockingByInfohash(ctx, ih)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d == nil || d.ID != newID {
|
||||
t.Fatalf("ожидалась активная %s (приоритет над desync %s), получили %v", newID, oldID, d)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Терминальное состояние освобождает infohash: тот же хеш заводится заново
|
||||
// новой задачей (повторная закачка спустя время) — активность выводится
|
||||
// только из state.
|
||||
|
||||
Reference in New Issue
Block a user