Единый источник истины `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>
145 lines
11 KiB
Markdown
145 lines
11 KiB
Markdown
# 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.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) новый тест графа проверяет
|
|
объявленные рёбра на проход и репрезентативные необъявленные — на отказ.
|