Слияние: Defer отклоняет catched, снято мёртвое ребро (T3)

This commit is contained in:
av
2026-07-17 21:39:41 +03:00
12 changed files with 307 additions and 23 deletions
+5 -4
View File
@@ -78,9 +78,10 @@ func (s State) IsTerminal() bool {
// (ActivateIfNoOtherActive): гейт графа ортогонален гарду терминальности в
// setState — граф говорит «ребро есть», гард «но не мимо ActivateIfNoOtherActive».
// Так, failed → downloading объявлено, но обычным SetDownloadState отклоняется.
// - deferred — легальная цель из КАЖДОГО не-терминального состояния (Defer
// проверяет лишь IsTerminal); инвариант закреплён тестом, а не ручной
// аккуратностью.
// - deferred — легальная цель из каждого не-терминального состояния, КРОМЕ
// пре-источникового catched (Defer его отклоняет: раздачи в qBittorrent ещё
// нет, откладывать нечего, а catched → deferred увёл бы задачу в лимбо —
// MAJOR-6). Инвариант закреплён тестом, а не ручной аккуратностью.
// - cancelled — легальная цель из ЛЮБОГО состояния, кроме deleted: помимо
// Cancel из не-терминальных её даёт универсальный стоп-кран Dismiss, доступный
// и из терминальных (done/failed/reverted/target_missing/orphaned) — только
@@ -90,7 +91,7 @@ func (s State) IsTerminal() bool {
// Правка воркера, вводящая новое ребро, ОБЯЗАНА отразить его здесь — иначе
// setState отклонит переход (0 строк UPDATE → ошибка).
var allowedTransitions = map[State][]State{
StateCatched: {StateDownloading, StateFailed, StateCancelled, StateDeferred},
StateCatched: {StateDownloading, StateFailed, StateCancelled},
StateDownloading: {StateCompleted, StateFailed, StateStuck, StateCancelled, StateDeferred},
StateCompleted: {StateRecognizing, StateCancelled, StateDeferred},
StateRecognizing: {StateLinking, StateReview, StateCancelled, StateDeferred},
+14 -7
View File
@@ -74,18 +74,25 @@ func TestTransitionGraphWellFormed(t *testing.T) {
}
}
// Инвариант Defer: deferred — легальная цель из КАЖДОГО не-терминального
// состояния (кроме самого deferred — это самопереход). Ловит класс дыры «забыли
// состояние» (напр. linking после краха процесса).
func TestDeferReachableFromEveryNonTerminal(t *testing.T) {
// Инвариант Defer: deferred — легальная цель из каждого не-терминального
// состояния, КРОМЕ пре-источникового catched (Defer его отклоняет: раздачи в
// qBittorrent ещё нет, откладывать нечего — MAJOR-6) и самого deferred (это
// самопереход). Ловит класс дыры «забыли состояние» (напр. linking после краха
// процесса).
func TestDeferReachableFromEveryNonTerminalButCatched(t *testing.T) {
for _, s := range allStates {
if s.IsTerminal() || s == StateDeferred {
continue // deferred → deferred покрыт самопереходом
if s.IsTerminal() || s == StateDeferred || s == StateCatched {
continue // deferred → deferred покрыт самопереходом; catched исключён
}
if !slices.Contains(transitionSources[StateDeferred], s) {
t.Errorf("%s → deferred не легально (Defer допускает любое не-терминальное)", s)
t.Errorf("%s → deferred не легально (Defer допускает любое не-терминальное, кроме catched)", s)
}
}
// Пре-источниковое catched → deferred не легально: ребро снято из графа
// заодно с гардом в Worker.Defer.
if slices.Contains(transitionSources[StateDeferred], StateCatched) {
t.Errorf("catched → deferred легально, но должно быть снято (Defer отклоняет catched)")
}
}
// Инвариант универсального стоп-крана Dismiss: cancelled — легальная цель из
+6
View File
@@ -550,6 +550,12 @@ func (w *Worker) Defer(ctx context.Context, id string) (err error) {
if d.State.IsTerminal() {
return fmt.Errorf("defer: download %s is terminal (%s): %w", id, d.State, ErrConflict)
}
// Пре-источниковое catched (торрент ещё не добавлен в qBittorrent) откладывать
// нечего: задача не дошла до ревью, а catched → deferred увёл бы её в лимбо —
// processCatched листает только catched и больше её не подхватит (MAJOR-6).
if d.State == store.StateCatched {
return fmt.Errorf("defer: источник ещё не добавлен в qBittorrent (%s), отложить можно после добавления: %w", d.State, ErrConflict)
}
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
w.transition(ctx, *d, store.StateDeferred, "", "")
return nil
+19
View File
@@ -1008,6 +1008,25 @@ func TestDefer(t *testing.T) {
}
}
// Defer из пре-источникового catched отклоняется конфликтом: торрент ещё не
// добавлен в qBittorrent, откладывать нечего, а catched → deferred увёл бы
// задачу в лимбо → необратимый deleted (MAJOR-6). Состояние не меняется.
func TestDeferRejectsCatched(t *testing.T) {
st := newMemStore()
d := completedDownload("1")
d.State = store.StateCatched
st.put(d)
w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil)
err := w.Defer(context.Background(), "1")
if !errors.Is(err, ErrConflict) {
t.Fatalf("Defer from catched = %v, want ErrConflict", err)
}
if st.downloads["1"].State != store.StateCatched {
t.Errorf("state = %q, want catched (не тронуто)", st.downloads["1"].State)
}
}
// applyFixture — реальный layouter с temp-библиотеками и исходными файлами.
type applyFixture struct {
w *Worker