Машина состояний: декларативный граф легальных переходов
Единый источник истины `allowedTransitions` (from → {разрешённые to}) в
internal/store; `setState` сверяет переход дополнительным SQL-предикатом
`state IN (<легальные источники>)` — необъявленное ребро (и не самопереход)
отклоняется атомарно, с точным сообщением. Гейт ортогонален гарду
терминальности: ребро из терминального состояния проходит только через
ActivateIfNoOtherActive. Без внешней библиотеки-FSM (обоснование — design.md).
Граф выведен построчно из воркера; ревью дизайна поймало 8 preflight-рёбер
(reconcileToReality → orphaned/deleted) и linking→cancel/defer после краха.
Тест-инвариант «cancel/defer достижимы из любого не-терминального» ловит класс
пропущенного ребра. Фикстуры тестов, форсившие состояния через SetDownloadState,
переведены на прямой UPDATE (forceState).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -100,6 +100,19 @@ func mustCreate(t *testing.T, st *Store, infohash string) string {
|
||||
return d.ID
|
||||
}
|
||||
|
||||
// forceState принудительно проставляет состояние прямым UPDATE в обход гейта
|
||||
// графа переходов — для подготовки фикстур, где проверяется поведение в заданном
|
||||
// состоянии, а не путь его достижения. Реальные переходы идут через
|
||||
// SetDownloadState/ActivateIfNoOtherActive (их легальность проверяет
|
||||
// transition_test.go).
|
||||
func forceState(t *testing.T, st *Store, id string, state State) {
|
||||
t.Helper()
|
||||
if _, err := st.DB.ExecContext(context.Background(),
|
||||
`UPDATE download SET state = ? WHERE id = ?`, string(state), id); err != nil {
|
||||
t.Fatalf("force state %s: %v", state, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAndGetDownload(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
@@ -166,9 +179,7 @@ func TestFindActiveByInfohash_DesyncStatesNotActive(t *testing.T) {
|
||||
const ih = "3333333333333333333333333333333333333333"
|
||||
|
||||
id := mustCreate(t, store, ih)
|
||||
if err := store.SetDownloadState(ctx, id, st, "", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
forceState(t, store, id, st)
|
||||
if d, err := store.FindActiveByInfohash(ctx, ih); err != nil || d != nil {
|
||||
t.Fatalf("%s: активной задачи быть не должно, получили (%v,%v)", st, d, err)
|
||||
}
|
||||
@@ -291,9 +302,7 @@ func TestActivateIfNoOtherActive(t *testing.T) {
|
||||
}
|
||||
|
||||
// Владелец завершился → активация проходит.
|
||||
if err := st.SetDownloadState(ctx, id2, StateDone, "", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
forceState(t, st, id2, StateDone)
|
||||
if err := st.ActivateIfNoOtherActive(ctx, id1, StateDownloading, "", ""); err != nil {
|
||||
t.Fatalf("активация после ухода владельца: %v", err)
|
||||
}
|
||||
@@ -354,9 +363,7 @@ func TestAddInfohashesGuard(t *testing.T) {
|
||||
}
|
||||
|
||||
// Хеш терминального владельца дописывается свободно.
|
||||
if err := st.SetDownloadState(ctx, a, StateDone, "", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
forceState(t, st, a, StateDone)
|
||||
b := mustCreate(t, st, "eeee333333333333333333333333333333333333")
|
||||
if err := st.AddInfohashes(ctx, b, []string{h1}); err != nil {
|
||||
t.Fatalf("хеш терминальной задачи должен дописываться: %v", err)
|
||||
@@ -400,16 +407,14 @@ func TestSetDownloadStateRejectsRevive(t *testing.T) {
|
||||
if err := st.SetDownloadState(ctx, id, StateFailed, "x", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Терминал→активное обычным SetDownloadState отклоняется (revive-гард): ребро
|
||||
// failed → downloading в графе есть, но проходит только через гард владения.
|
||||
if err := st.SetDownloadState(ctx, id, StateDownloading, "", ""); err == nil {
|
||||
t.Fatal("терминал→активное мимо гарда должно отклоняться")
|
||||
}
|
||||
if d, _ := st.GetDownload(ctx, id); d.State != StateFailed {
|
||||
t.Fatalf("state = %s, want failed (без изменений)", d.State)
|
||||
}
|
||||
// Терминал→терминал разрешён (например, сверка double-terminal переходов).
|
||||
if err := st.SetDownloadState(ctx, id, StateDeleted, "", ""); err != nil {
|
||||
t.Fatalf("терминал→терминал должен проходить: %v", err)
|
||||
}
|
||||
// Штатный путь оживления работает.
|
||||
if err := st.ActivateIfNoOtherActive(ctx, id, StateDownloading, "", ""); err != nil {
|
||||
t.Fatalf("оживление через гард: %v", err)
|
||||
@@ -472,9 +477,7 @@ func TestExistsByInfohash(t *testing.T) {
|
||||
t.Fatalf("ожидался (false,nil), получили (%v,%v)", ok, err)
|
||||
}
|
||||
id := mustCreate(t, st, ih)
|
||||
if err := st.SetDownloadState(ctx, id, StateDone, "", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
forceState(t, st, id, StateDone)
|
||||
// Exists видит и терминальные (в отличие от FindActive).
|
||||
if ok, err := st.ExistsByInfohash(ctx, ih); err != nil || !ok {
|
||||
t.Fatalf("ожидался (true,nil), получили (%v,%v)", ok, err)
|
||||
|
||||
Reference in New Issue
Block a user