Единый источник истины `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>
11 KiB
Design: декларативный граф переходов
Почему не библиотека (looplab/fsm, qmuntal/stateless)
Отклонено сознательно, три причины из нашего кода:
-
Тяжёлую часть библиотека не заберёт — она в SQL. Настоящий инвариант («не более одной активной загрузки на infohash» + «терминальную нельзя молча оживить») держится атомарно в SQLite-транзакциях (
BEGIN IMMEDIATEчерез_txlock):CreateDownloadIfNoActive,ActivateIfNoOtherActive, гардWHERE state='catched'вPromoteCatched. In-memory FSM физически не может участвовать в транзакции с БД — она сядет поверх реального гарда как второй, более слабый слой. -
У нас не событийный автомат, а reconciliation. Воркер в основном сверяет состояние БД с реальностью qBittorrent (
reconcile,reconcileDesync— двумерная матрица источник×цель,reconcileRecovery). FSM-библиотеки моделируют линейный «событие → переход» хорошо, а сверку — плохо. -
Состояние 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.transition →
SetDownloadState, 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) новый тест графа проверяет объявленные рёбра на проход и репрезентативные необъявленные — на отказ.