Машина состояний: декларативный граф легальных переходов
Единый источник истины `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:
+107
-6
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user