# 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 () -- прежний гард, только если !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) новый тест графа проверяет объявленные рёбра на проход и репрезентативные необъявленные — на отказ.