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

Единый источник истины `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
+107 -6
View File
@@ -64,6 +64,71 @@ func (s State) IsTerminal() bool {
return slices.Contains(terminalStates, s)
}
// allowedTransitions — декларативный граф легальных переходов машины состояний
// (from → множество допустимых to). Единственный источник истины о легальности
// рёбер: покрывает все переходы, которые worker выполняет по всем capability
// (прямой путь download-tracking, state-reconciliation, review). Выведен построчно
// из кода воркера — таблица в change `state-transition-graph`/design.md.
//
// Правила:
// - Самопереход (from == to, идемпотентная переустановка того же состояния —
// напр. повторная запись error_msg, Defer на уже deferred) разрешён ВСЕГДА и
// здесь НЕ перечисляется (его добавляет invertTransitions).
// - Ребро может присутствовать здесь, но всё равно требовать revive-путь
// (ActivateIfNoOtherActive): гейт графа ортогонален гарду терминальности в
// setState — граф говорит «ребро есть», гард «но не мимо ActivateIfNoOtherActive».
// Так, failed → downloading объявлено, но обычным SetDownloadState отклоняется.
// - cancelled/deferred — легальная цель из КАЖДОГО не-терминального состояния
// (Cancel/Defer проверяют лишь IsTerminal); инвариант закреплён тестом, а не
// ручной аккуратностью.
//
// Правка воркера, вводящая новое ребро, ОБЯЗАНА отразить его здесь — иначе
// setState отклонит переход (0 строк UPDATE → ошибка).
var allowedTransitions = map[State][]State{
StateCatched: {StateDownloading, StateFailed, StateCancelled, StateDeferred},
StateDownloading: {StateCompleted, StateFailed, StateStuck, StateCancelled, StateDeferred},
StateCompleted: {StateRecognizing, StateCancelled, StateDeferred},
StateRecognizing: {StateLinking, StateReview, StateCancelled, StateDeferred},
StateReview: {StateLinking, StateRecognizing, StateCancelled, StateDeferred, StateOrphaned, StateDeleted},
StateLinking: {StateDone, StateReview, StateFailed, StateCancelled, StateDeferred},
StateDone: {StateReverted, StateTargetMissing, StateOrphaned, StateDeleted},
StateDeferred: {StateLinking, StateRecognizing, StateCancelled, StateOrphaned, StateDeleted},
StateStuck: {StateDownloading, StateCompleted, StateCancelled, StateDeferred},
StateFailed: {StateDownloading, StateCompleted},
StateReverted: {StateRecognizing, StateOrphaned, StateDeleted},
StateCancelled: {StateRecognizing, StateOrphaned, StateDeleted},
StateTargetMissing: {StateRecognizing, StateDone, StateOrphaned, StateDeleted},
StateOrphaned: {StateDone, StateTargetMissing, StateDeleted},
StateDeleted: nil, // окончательно терминально: сверка его не переоценивает
}
// transitionSources — обратное отображение (to → множество легальных from),
// построенное из allowedTransitions один раз при инициализации пакета. Сам to
// всегда входит в своё множество (самопереход). Основа предиката setState
// `state IN (...)`.
var transitionSources = invertTransitions(allowedTransitions)
// invertTransitions переворачивает граф from→to в to→from, добавляя каждому
// состоянию его самого (самопереход всегда легален).
func invertTransitions(fwd map[State][]State) map[State][]State {
into := make(map[State][]State, len(fwd))
ensureSelf := func(s State) {
if !slices.Contains(into[s], s) {
into[s] = append(into[s], s)
}
}
for from, tos := range fwd {
ensureSelf(from)
for _, to := range tos {
ensureSelf(to)
if !slices.Contains(into[to], from) {
into[to] = append(into[to], from)
}
}
}
return into
}
// SourceType — вид источника загрузки.
type SourceType string
@@ -505,11 +570,13 @@ func (s *Store) SetDownloadState(ctx context.Context, id string, state State, er
}
// PromoteCatched переводит пойманную загрузку catched → downloading, попутно
// записывая выведенное отображаемое имя. Гард `state = 'catched'` — это
// ре-валидация: если загрузку успели отменить (catched → cancelled) во время
// вывода имени/добавления вне блокировки переходов, UPDATE не заденет ни строки
// и вернёт ошибку, а переход не применится. Пустое имя допустимо (rename не
// задавали) — тогда display_name так и остаётся пустым.
// записывая выведенное отображаемое имя. Ребро catched → downloading объявлено в
// allowedTransitions; setState этот путь не проходит намеренно — собственный гард
// `state = 'catched'` жёстче (фиксирует ровно from=catched) и служит ре-валидацией:
// если загрузку успели отменить (catched → cancelled) во время вывода имени/
// добавления вне блокировки переходов, UPDATE не заденет ни строки и вернёт ошибку,
// а переход не применится. Пустое имя допустимо (rename не задавали) — тогда
// display_name так и остаётся пустым.
func (s *Store) PromoteCatched(ctx context.Context, id, displayName string) error {
res, err := s.DB.ExecContext(ctx, `
UPDATE download
@@ -533,6 +600,12 @@ WHERE id = ? AND state = ?`,
// (ActivateIfNoOtherActive), которому переход терминал→активное разрешён;
// иначе предикат в UPDATE не даёт молча оживить терминальную задачу.
func setState(ctx context.Context, e sqlx.ExecerContext, id string, state State, errCode, errMsg string, reviveOK bool) error {
sources := transitionSources[state]
if len(sources) == 0 {
// Целевое состояние не объявлено в графе переходов — fail-closed (это
// программная ошибка: новая State без ребра). Тест графа ловит на этапе CI.
return fmt.Errorf("set download %s state %q: target not declared in transition graph", id, state)
}
q := `
UPDATE download
SET state = ?,
@@ -541,6 +614,10 @@ SET state = ?,
updated_at = ?
WHERE id = ?`
args := []any{string(state), nullArg(errCode), nullArg(errMsg), FormatTime(Now()), id}
// Гейт графа: переход применяется, только если текущее состояние — легальный
// источник для state (объявленное ребро или самопереход from == to). Аддитивен
// к гарду терминальности ниже и его НЕ ослабляет.
q += ` AND state IN (` + placeholders(&args, sources) + `)`
if !reviveOK && !state.IsTerminal() {
q += ` AND state NOT IN (` + placeholders(&args, terminalStates) + `)`
}
@@ -553,11 +630,35 @@ WHERE id = ?`
return fmt.Errorf("set download %s state %q: %w", id, state, err)
}
if n == 0 {
return fmt.Errorf("set download %s state %q: not found or terminal (revive requires ActivateIfNoOtherActive)", id, state)
return setStateRejected(ctx, e, id, state)
}
return nil
}
// setStateRejected формирует точную ошибку отклонённого перехода (0 строк UPDATE):
// читает текущее состояние и различает «не найдено» / «нелегальное ребро» /
// «терминал без revive». Только путь ошибки (редкий), поэтому доп. чтение дёшево.
func setStateRejected(ctx context.Context, e sqlx.ExecerContext, id string, state State) error {
q, ok := e.(sqlx.QueryerContext)
if !ok {
return fmt.Errorf("set download %s state %q: rejected (not found, illegal transition, or terminal without revive)", id, state)
}
var cur State
err := sqlx.GetContext(ctx, q, &cur, `SELECT state FROM download WHERE id = ?`, id)
if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("set download %s state %q: %w", id, state, ErrNotFound)
}
if err != nil {
return fmt.Errorf("set download %s state %q: rejected, current state unreadable: %w", id, state, err)
}
if slices.Contains(transitionSources[state], cur) {
// Ребро cur → state легально — значит зарубил гард терминальности.
return fmt.Errorf("set download %s: %s → %s rejected: terminal revive requires ActivateIfNoOtherActive",
id, cur, state)
}
return fmt.Errorf("set download %s: illegal transition %s → %s (not in transition graph)", id, cur, state)
}
// SetSourceMissCount записывает счётчик пропусков источника (дебаунс сверки).
// Состояние не трогает — это отдельная от перехода фоновая отметка.
func (s *Store) SetSourceMissCount(ctx context.Context, id string, n int) error {
+19 -16
View File
@@ -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)
+1 -3
View File
@@ -24,9 +24,7 @@ func mkDownload(t *testing.T, st *Store, n int, state State, display string) str
t.Fatalf("create #%d: unexpected dedup", n)
}
if state != StateDownloading {
if err := st.SetDownloadState(ctx, d.ID, state, "", ""); err != nil {
t.Fatalf("set state #%d: %v", n, err)
}
forceState(t, st, d.ID, state)
}
return d.ID
}
+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
}