Машина состояний: декларативный граф легальных переходов

Единый источник истины `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:
av
2026-07-08 09:09:17 +03:00
co-authored by Claude Opus 4.8
parent 0d263270cb
commit 90fd8640ed
10 changed files with 716 additions and 25 deletions
+238
View File
@@ -0,0 +1,238 @@
package store
import (
"context"
"errors"
"slices"
"strconv"
"strings"
"testing"
)
// allStates — полный перечень состояний машины (для проверки хорошей
// сформированности графа). Держим локально в тесте: если добавится новое
// состояние, тест напомнит внести его сюда и в граф.
var allStates = []State{
StateCatched, StateDownloading, StateCompleted, StateRecognizing,
StateReview, StateLinking, StateDone, StateDeferred, StateStuck,
StateFailed, StateCancelled, StateReverted,
StateTargetMissing, StateOrphaned, StateDeleted,
}
// seedState заводит загрузку и приводит её к нужному состоянию кратчайшим
// доверенным путём (в обход гейта графа — через прямой UPDATE), чтобы тест
// проверял именно проверяемый переход, а не путь подготовки.
func seedState(t *testing.T, st *Store, ih string, state State) string {
t.Helper()
ctx := context.Background()
d := &Download{SourceType: SourceMagnet, SourceRef: "magnet:?xt=urn:btih:" + ih, State: StateCatched}
if _, err := st.CreateDownloadIfNoActive(ctx, d, []string{ih}); err != nil {
t.Fatalf("seed create: %v", err)
}
if state != StateCatched {
if _, err := st.DB.ExecContext(ctx,
`UPDATE download SET state = ? WHERE id = ?`, string(state), d.ID); err != nil {
t.Fatalf("seed set state %s: %v", state, err)
}
}
return d.ID
}
func stateOf(t *testing.T, st *Store, id string) State {
t.Helper()
d, err := st.GetDownload(context.Background(), id)
if err != nil {
t.Fatalf("get: %v", err)
}
return d.State
}
// Граф хорошо сформирован: все состояния из ключей и значений — известные, и
// ни один список не перечисляет сам ключ (петли не объявляются явно —
// самопереход добавляет invertTransitions).
func TestTransitionGraphWellFormed(t *testing.T) {
known := func(s State) bool { return slices.Contains(allStates, s) }
for from, tos := range allowedTransitions {
if !known(from) {
t.Errorf("неизвестное состояние-ключ: %q", from)
}
for _, to := range tos {
if !known(to) {
t.Errorf("%s → неизвестное состояние %q", from, to)
}
if to == from {
t.Errorf("%s: петля перечислена явно (самопереход неявен)", from)
}
}
}
// Каждое состояние присутствует в графе как ключ — иначе setState fail-closed
// отклонит переход в него.
for _, s := range allStates {
if _, ok := allowedTransitions[s]; !ok {
t.Errorf("состояние %q отсутствует среди ключей графа", s)
}
}
}
// Инвариант generic-команд Cancel/Defer: cancelled и deferred — легальная цель
// из КАЖДОГО не-терминального состояния (кроме самого deferred для deferred —
// это самопереход). Ловит класс дыры «забыли состояние» (напр. linking после
// краха процесса).
func TestCancelDeferReachableFromEveryNonTerminal(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 {
continue // deferred → deferred покрыт самопереходом
}
if !slices.Contains(transitionSources[StateDeferred], s) {
t.Errorf("%s → deferred не легально (Defer допускает любое не-терминальное)", s)
}
}
}
// Объявленные не-revive рёбра проходят через SetDownloadState.
func TestSetStateAllowsDeclaredEdges(t *testing.T) {
edges := []struct{ from, to State }{
{StateDownloading, StateCompleted},
{StateDownloading, StateStuck},
{StateCompleted, StateRecognizing},
{StateRecognizing, StateReview},
{StateRecognizing, StateLinking},
{StateReview, StateLinking},
{StateReview, StateRecognizing},
{StateLinking, StateDone},
{StateLinking, StateReview},
{StateDone, StateReverted},
{StateStuck, StateCancelled},
{StateReview, StateDeferred},
}
for i, e := range edges {
st := newTestStore(t)
ih := infohashN(i)
id := seedState(t, st, ih, e.from)
if err := st.SetDownloadState(context.Background(), id, e.to, "", ""); err != nil {
t.Errorf("легальное ребро %s → %s отклонено: %v", e.from, e.to, err)
continue
}
if got := stateOf(t, st, id); got != e.to {
t.Errorf("%s → %s: состояние стало %q", e.from, e.to, got)
}
}
}
// Preflight-рёбра reconcileToReality при пропавшем источнике: из ревью/
// терминальных состояний в orphaned/deleted проходят через SetDownloadState
// (цель терминальна → гард терминальности не мешает).
func TestPreflightDesyncEdges(t *testing.T) {
edges := []struct{ from, to State }{
{StateReview, StateOrphaned},
{StateReview, StateDeleted},
{StateDeferred, StateOrphaned},
{StateDeferred, StateDeleted},
{StateReverted, StateOrphaned},
{StateReverted, StateDeleted},
{StateCancelled, StateOrphaned},
{StateCancelled, StateDeleted},
}
for i, e := range edges {
st := newTestStore(t)
id := seedState(t, st, infohashN(i), e.from)
if err := st.SetDownloadState(context.Background(), id, e.to, "", ""); err != nil {
t.Errorf("preflight-ребро %s → %s отклонено: %v", e.from, e.to, err)
continue
}
if got := stateOf(t, st, id); got != e.to {
t.Errorf("%s → %s: состояние стало %q", e.from, e.to, got)
}
}
}
// Необъявленные рёбра отклоняются, состояние не меняется.
func TestSetStateRejectsUndeclaredEdges(t *testing.T) {
edges := []struct{ from, to State }{
{StateReview, StateDone},
{StateDownloading, StateDone},
{StateCompleted, StateLinking},
{StateDownloading, StateReview},
}
for i, e := range edges {
st := newTestStore(t)
id := seedState(t, st, infohashN(i), e.from)
err := st.SetDownloadState(context.Background(), id, e.to, "", "")
if err == nil {
t.Errorf("нелегальное ребро %s → %s прошло", e.from, e.to)
continue
}
if !strings.Contains(err.Error(), "illegal transition") {
t.Errorf("%s → %s: ожидалось 'illegal transition', got: %v", e.from, e.to, err)
}
if got := stateOf(t, st, id); got != e.from {
t.Errorf("%s → %s отклонён, но состояние стало %q", e.from, e.to, got)
}
}
}
// Самопереход (идемпотентная переустановка того же состояния) разрешён.
func TestSelfTransitionAllowed(t *testing.T) {
st := newTestStore(t)
id := seedState(t, st, infohashN(0), StateDeferred)
if err := st.SetDownloadState(context.Background(), id, StateDeferred, "", ""); err != nil {
t.Fatalf("самопереход deferred → deferred отклонён: %v", err)
}
if got := stateOf(t, st, id); got != StateDeferred {
t.Errorf("состояние стало %q", got)
}
}
// Ребро из терминального состояния в активное проходит только revive-путём
// (ActivateIfNoOtherActive), но не обычным SetDownloadState.
func TestTerminalReviveOnlyViaActivate(t *testing.T) {
ctx := context.Background()
// SetDownloadState (не-revive): failed → downloading отклоняется гардом
// терминальности, ошибка указывает на revive.
st := newTestStore(t)
id := seedState(t, st, infohashN(0), StateFailed)
err := st.SetDownloadState(ctx, id, StateDownloading, "", "")
if err == nil {
t.Fatal("failed → downloading через SetDownloadState прошло")
}
if !strings.Contains(err.Error(), "terminal revive") {
t.Errorf("ожидалось 'terminal revive', got: %v", err)
}
if got := stateOf(t, st, id); got != StateFailed {
t.Errorf("состояние изменилось на %q", got)
}
// ActivateIfNoOtherActive (revive): тот же переход при свободном infohash
// проходит.
st2 := newTestStore(t)
id2 := seedState(t, st2, infohashN(1), StateFailed)
if err := st2.ActivateIfNoOtherActive(ctx, id2, StateDownloading, "", ""); err != nil {
t.Fatalf("revive failed → downloading отклонён: %v", err)
}
if got := stateOf(t, st2, id2); got != StateDownloading {
t.Errorf("revive: состояние стало %q, want downloading", got)
}
}
// Отсутствующая загрузка → ErrNotFound (гейт графа не маскирует «не найдено»).
func TestSetStateNotFound(t *testing.T) {
st := newTestStore(t)
err := st.SetDownloadState(context.Background(), "01hnonexistentnonexistent", StateCompleted, "", "")
if !errors.Is(err, ErrNotFound) {
t.Errorf("ожидался ErrNotFound, got: %v", err)
}
}
// infohashN — детерминированный 40-hex инфохэш по индексу (уникальность между
// подтестами без общего состояния). Цифры и 'a'-паддинг — валидный hex.
func infohashN(n int) string {
s := strconv.Itoa(n)
return strings.Repeat("a", 40-len(s)) + s
}