Единый источник истины `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>
4.3 KiB
Декларативный граф переходов машины состояний
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+ граф), новый тест графа. Воркер и транспорты — без изменений (граф прозрачен для легальных переходов). - Зависимости: ноль новых (принцип «минимум компонентов» соблюдён).