Приём: дедуп по 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:
@@ -74,19 +74,12 @@ func TestTransitionGraphWellFormed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Инвариант generic-команд Cancel/Defer: cancelled и deferred — легальная цель
|
||||
// из КАЖДОГО не-терминального состояния (кроме самого deferred для deferred —
|
||||
// это самопереход). Ловит класс дыры «забыли состояние» (напр. linking после
|
||||
// краха процесса).
|
||||
func TestCancelDeferReachableFromEveryNonTerminal(t *testing.T) {
|
||||
// Инвариант Defer: deferred — легальная цель из КАЖДОГО не-терминального
|
||||
// состояния (кроме самого deferred — это самопереход). Ловит класс дыры «забыли
|
||||
// состояние» (напр. linking после краха процесса).
|
||||
func TestDeferReachableFromEveryNonTerminal(t *testing.T) {
|
||||
for _, s := range allStates {
|
||||
if s.IsTerminal() {
|
||||
continue
|
||||
}
|
||||
if !slices.Contains(transitionSources[StateCancelled], s) {
|
||||
t.Errorf("%s → cancelled не легально (Cancel допускает любое не-терминальное)", s)
|
||||
}
|
||||
if s == StateDeferred {
|
||||
if s.IsTerminal() || s == StateDeferred {
|
||||
continue // deferred → deferred покрыт самопереходом
|
||||
}
|
||||
if !slices.Contains(transitionSources[StateDeferred], s) {
|
||||
@@ -95,6 +88,22 @@ func TestCancelDeferReachableFromEveryNonTerminal(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Инвариант универсального стоп-крана Dismiss: cancelled — легальная цель из
|
||||
// ЛЮБОГО состояния, кроме deleted (строго терминален) и самого cancelled
|
||||
// (самопереход). Шире инварианта Defer: покрывает и терминальные
|
||||
// done/failed/reverted/target_missing/orphaned. НЕ объединять с проверкой
|
||||
// deferred — у них разные множества источников.
|
||||
func TestCancelledReachableFromEveryStateButDeleted(t *testing.T) {
|
||||
for _, s := range allStates {
|
||||
if s == StateDeleted || s == StateCancelled {
|
||||
continue // deleted строго терминален; cancelled → cancelled — самопереход
|
||||
}
|
||||
if !slices.Contains(transitionSources[StateCancelled], s) {
|
||||
t.Errorf("%s → cancelled не легально (Dismiss/Cancel допускают любое состояние, кроме deleted)", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Объявленные не-revive рёбра проходят через SetDownloadState.
|
||||
func TestSetStateAllowsDeclaredEdges(t *testing.T) {
|
||||
edges := []struct{ from, to State }{
|
||||
@@ -110,6 +119,14 @@ func TestSetStateAllowsDeclaredEdges(t *testing.T) {
|
||||
{StateDone, StateReverted},
|
||||
{StateStuck, StateCancelled},
|
||||
{StateReview, StateDeferred},
|
||||
// Стоп-кран Dismiss: терминал → cancelled идёт обычным SetDownloadState
|
||||
// (цель cancelled терминальна → гард терминальности не мешает, revive не
|
||||
// нужен).
|
||||
{StateDone, StateCancelled},
|
||||
{StateFailed, StateCancelled},
|
||||
{StateReverted, StateCancelled},
|
||||
{StateTargetMissing, StateCancelled},
|
||||
{StateOrphaned, StateCancelled},
|
||||
}
|
||||
for i, e := range edges {
|
||||
st := newTestStore(t)
|
||||
|
||||
Reference in New Issue
Block a user