Машина состояний: декларативный граф легальных переходов
Единый источник истины `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:
@@ -0,0 +1,48 @@
|
||||
# Декларативный граф переходов машины состояний
|
||||
|
||||
## Why
|
||||
|
||||
Легальность переходов машины состояний загрузки сейчас **нигде не записана явно**.
|
||||
Чтобы понять «из `downloading` куда можно», надо прочитать весь `worker` (три файла:
|
||||
`worker.go`, `reconcile.go`, `review.go`) плюс store-методы. Единственный
|
||||
механический гард в `store.setState` — грубый: он запрещает молча оживить
|
||||
терминальную задачу (`state NOT IN terminal` без `reviveOK`) и держит инвариант
|
||||
«одна активная на infohash» (в SQL-транзакциях). Но он **не проверяет само ребро**
|
||||
перехода: `SetDownloadState(review → done)` или `linking → completed` пройдут молча,
|
||||
хотя таких переходов машина не выполняет.
|
||||
|
||||
Это оставляет класс латентных багов без страховки: будущая правка воркера может
|
||||
записать состояние, которого граф не предусматривает, и мы узнаем об этом только по
|
||||
странице задачи в неверном состоянии.
|
||||
|
||||
Внешнюю библиотеку-FSM мы сознательно НЕ берём (см. `design.md`, «Почему не
|
||||
библиотека»): настоящий инвариант держится атомарно в SQLite и in-memory FSM в
|
||||
транзакции участвовать не может; наши переходы — это в основном сверка с реальностью
|
||||
qBittorrent, а не событийный автомат. Берём соразмерное: **свой декларативный граф**
|
||||
легальных рёбер в `internal/store`, который `setState` сверяет как дополнительный
|
||||
предикат, роняя необъявленный переход громко.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **ADDED Requirement: Легальность переходов задаётся декларативным графом** —
|
||||
единый источник истины `from → {разрешённые to}` в `internal/store`; переход, не
|
||||
объявленный ребром графа (и не идемпотентный самопереход `from == to`), запись
|
||||
состояния отклоняет.
|
||||
- Гейт встраивается в `store.setState` дополнительным SQL-предикатом
|
||||
`AND state IN (<легальные источники для to>)` — атомарно, без отдельного чтения;
|
||||
существующий гард терминальности и инвариант «одна активная на infohash»
|
||||
сохраняются без изменений и остаются **ортогональны** (граф говорит «ребро есть»,
|
||||
гард терминальности — «но не мимо `ActivateIfNoOtherActive`»).
|
||||
- Тест согласованности: граф хорошо сформирован (все состояния известны), объявленные
|
||||
рёбра проходят, необъявленные — отклоняются, а рёбра из терминальных состояний
|
||||
проходят только через revive-путь (`ActivateIfNoOtherActive`), но не через
|
||||
`SetDownloadState`.
|
||||
- Поведение существующих легальных переходов НЕ меняется: граф — надмножество всего,
|
||||
что воркер уже выполняет.
|
||||
|
||||
## Impact
|
||||
|
||||
- Specs: `download-tracking` (владелец прямого пути машины состояний).
|
||||
- Код: `internal/store/download.go` (`setState` + граф), новый тест графа. Воркер и
|
||||
транспорты — без изменений (граф прозрачен для легальных переходов).
|
||||
- Зависимости: ноль новых (принцип «минимум компонентов» соблюдён).
|
||||
Reference in New Issue
Block a user