Files
avandClaude Opus 4.8 90fd8640ed Машина состояний: декларативный граф легальных переходов
Единый источник истины `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>
2026-07-08 09:09:17 +03:00

11 KiB

Design: декларативный граф переходов

Почему не библиотека (looplab/fsm, qmuntal/stateless)

Отклонено сознательно, три причины из нашего кода:

  1. Тяжёлую часть библиотека не заберёт — она в SQL. Настоящий инвариант («не более одной активной загрузки на infohash» + «терминальную нельзя молча оживить») держится атомарно в SQLite-транзакциях (BEGIN IMMEDIATE через _txlock): CreateDownloadIfNoActive, ActivateIfNoOtherActive, гард WHERE state='catched' в PromoteCatched. In-memory FSM физически не может участвовать в транзакции с БД — она сядет поверх реального гарда как второй, более слабый слой.

  2. У нас не событийный автомат, а reconciliation. Воркер в основном сверяет состояние БД с реальностью qBittorrent (reconcile, reconcileDesync — двумерная матрица источник×цель, reconcileRecovery). FSM-библиотеки моделируют линейный «событие → переход» хорошо, а сверку — плохо.

  3. Состояние persisted, а не в объекте. State — колонка SQLite, перечитывается каждый тик. Библиотечная FSM держит состояние в структуре; пришлось бы конструировать FSM-объект на загрузку на тик только ради валидации одного ребра.

Соразмерная альтернатива — свой декларативный граф в internal/store: 80% ценности (граф в одном месте, документирован, тестируем, роняет нелегальный переход) без зависимости, без второго слоя, без импеданса. Соответствует принципу «минимум компонентов».

Где живёт гейт: store.setState

setState — истинная точка схождения: через неё проходят и SetDownloadState (reviveOK=false), и ActivateIfNoOtherActive (reviveOK=true). Единственный обход — PromoteCatched (собственный UPDATE ... WHERE state='catched'): его переход catched → downloading объявлен ребром графа, а from-состояние жёстко фиксирует его собственный гард, поэтому он согласован по построению и в setState не заводится.

Гейт — SQL-предикатом, не Go-проверкой с предварительным чтением:

UPDATE download SET state=?, ... WHERE id=?
  AND state IN (<легальные источники для to>)     -- граф (+ сам to: самопереход)
  AND state NOT IN (<terminal>)                    -- прежний гард, только если !reviveOK и to не терминально

Так проверка остаётся атомарной (без окна между чтением и записью), естественно композится с существующими предикатами и не требует держать доп. блокировку. При 0 строк — одно диагностическое чтение текущего состояния (только на пути ошибки, редко) даёт точное сообщение: «not found» / «illegal transition from→to».

Ортогональность (ключевой инвариант дизайна)

Граф и гард терминальности независимы и оба применяются:

  • Граф говорит «ребро failed → downloading существует» (его выполняет retry).
  • Гард терминальности говорит «но обычным SetDownloadState терминальную не оживить».

Поэтому failed → downloading проходит только через ActivateIfNoOtherActive (reviveOK=true, гард терминальности снят, но проверка владения хешами добавлена), а SetDownloadState(failed → downloading) отклоняется. Гейт графа аддитивен: он ничего не ослабляет, только добавляет ещё одно необходимое условие.

Самопереходы

Правило: from == to разрешён всегда (в графе не перечисляется). Причина — идемпотентная переустановка того же состояния уже используется (напр. Defer на уже deferred-задаче; повторная запись error_msg). legalSources(to) всегда включает сам to. Это сохраняет текущую семантику (такой UPDATE успешен, трогает error_*/updated_at) и избавляет от ручного перечисления петель.

Граф (from → to), выведенный из реального кода

Источник каждого ребра — конкретный метод воркера/store (w.transitionSetDownloadState, ActivateIfNoOtherActive, PromoteCatched, reconcileToReality):

from to кто выполняет
catched downloading PromoteCatched (успех add)
catched failed processCatched таймаут (qbit_add)
catched cancelled Cancel
catched deferred Defer (любое не-терминальное)
downloading completed reconcile (classReady)
downloading failed reconcile (qbit_error), checkTimeouts (magnet_timeout)
downloading stuck checkTimeouts (stalled)
downloading cancelled Cancel
downloading deferred Defer
completed recognizing recognizeOne
completed cancelled / deferred Cancel / Defer
recognizing linking finishRecognition (авто)
recognizing review finishRecognition
recognizing cancelled / deferred Cancel / Defer (окно на время LLM)
review linking Apply
review recognizing Refine / Rerecognize / SetType
review deferred / cancelled Defer / Cancel
review orphaned / deleted reconcileToReality (preflight: источник пропал)
linking done linkPlan (успех)
linking review linkPlan (build/collision)
linking failed linkPlan (apply error)
linking cancelled / deferred Cancel / Defer (задача застряла в linking после краха процесса)
done reverted Undo
done target_missing / orphaned / deleted reconcileDesync
deferred linking Apply
deferred recognizing Refine / Rerecognize / SetType
deferred cancelled Cancel
deferred orphaned / deleted reconcileToReality (preflight: источник пропал)
stuck downloading Retry, reconcileRecovery
stuck completed reconcileRecovery (classReady)
stuck cancelled / deferred Cancel / Defer (stuck не терминально)
failed downloading Retry, reconcileRecovery
failed completed reconcileRecovery
reverted recognizing Relink
reverted orphaned / deleted reconcileToReality (Relink: источник пропал)
cancelled recognizing Relink
cancelled orphaned / deleted reconcileToReality (Relink: источник пропал)
target_missing recognizing Relink
target_missing done / orphaned / deleted reconcileDesync / reconcileToReality (heal/preflight)
orphaned done / target_missing / deleted reconcileDesync
deleted окончательно терминально; сверка его не переоценивает

Правило Cancel/Deferлюбое не-терминальное → cancelled/deferred (проверка IsTerminal на входе). Поэтому в графе cancelled и deferred — легальная цель из каждого не-терминального состояния (включая linking после краха, включая catched); это закрепляется тест-инвариантом, а не ручной аккуратностью (см. tasks.md 3.6) — именно ручное перечисление рискует пропустить состояние.

reconcileToReality (preflight-приведение к реальности перед действием ревью, когда раздача исчезла из qBittorrent) — отдельный источник рёбер → orphaned/deleted из пользовательских/ревью-состояний; из терминальных reverted/cancelled он проходит SetDownloadState, так как цель (orphaned/deleted) тоже терминальна и гард терминальности не срабатывает.

Заметки о намеренных исключениях (чтобы граф был тесным, а не «на всякий случай»):

  • failed/done → cancelled/deferred не включены: это терминальные состояния, Cancel/Defer их отвергают на входе (IsTerminal).
  • Рёбра из терминальных (failed, reverted, cancelled, target_missing, orphaned) в активные состояния (downloading/completed/recognizing) в графе есть, но проходят только через ActivateIfNoOtherActive — см. «Ортогональность».

Риск и его закрытие

Главный риск — слишком тесный граф (пропущенное ребро ломает реально работающий переход, у которого нет теста). Закрытие: (1) граф выведён построчно из кода выше; (2) весь существующий набор тестов воркера/store гоняет реальные переходы — если предикат отвергнёт хоть один, тесты покраснеют; (3) новый тест графа проверяет объявленные рёбра на проход и репрезентативные необъявленные — на отказ.