Единый источник истины `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>
56 lines
4.1 KiB
Markdown
56 lines
4.1 KiB
Markdown
# download-tracking Specification
|
|
|
|
## ADDED Requirements
|
|
|
|
### Requirement: Легальность переходов задаётся декларативным графом
|
|
|
|
Множество легальных переходов машины состояний загрузки SHALL быть объявлено
|
|
декларативно в едином месте (`internal/store`) как отображение `from →
|
|
{разрешённые to}`, покрывающее все переходы, которые worker выполняет по всем
|
|
capability (прямой путь, `state-reconciliation`, `review`). Этот граф SHALL быть
|
|
единственным источником истины о легальности рёбер.
|
|
|
|
Запись состояния (`setState`, общая основа `SetDownloadState` и
|
|
`ActivateIfNoOtherActive`) SHALL применять переход, только если он либо объявлен
|
|
ребром графа, либо является идемпотентным самопереходом (`from == to`, переустановка
|
|
того же состояния — например, повторная запись ошибки). Переход, не удовлетворяющий
|
|
ни одному из условий, запись SHALL отклонять (0 строк UPDATE → ошибка), НЕ применяя
|
|
его.
|
|
|
|
Гейт графа SHALL быть **ортогонален** остальным гардам записи и НЕ SHALL их ослаблять:
|
|
существующий запрет молча оживить терминальную задачу (переход из терминального
|
|
состояния разрешён только через `ActivateIfNoOtherActive` с проверкой владения
|
|
хешами) и инвариант «не более одной активной загрузки на infohash» сохраняются. Как
|
|
следствие, ребро из терминального состояния (напр. `failed → downloading` при retry)
|
|
SHALL проходить только revive-путём (`ActivateIfNoOtherActive`) и SHALL отклоняться
|
|
обычным `SetDownloadState`.
|
|
|
|
Граф SHALL быть надмножеством всех переходов, которые worker уже выполняет: введение
|
|
гейта НЕ SHALL менять поведение существующих легальных переходов.
|
|
|
|
#### Scenario: Объявленный переход применяется
|
|
|
|
- **GIVEN** загрузка в состоянии `downloading`
|
|
- **WHEN** worker записывает переход `downloading → completed` (объявленное ребро)
|
|
- **THEN** состояние становится `completed`
|
|
|
|
#### Scenario: Необъявленный переход отклоняется
|
|
|
|
- **GIVEN** загрузка в состоянии `review`
|
|
- **WHEN** делается попытка записать переход `review → done` (ребра в графе нет)
|
|
- **THEN** запись отклоняется с ошибкой, состояние остаётся `review`
|
|
|
|
#### Scenario: Идемпотентная переустановка состояния разрешена
|
|
|
|
- **GIVEN** загрузка в состоянии `deferred`
|
|
- **WHEN** записывается переход `deferred → deferred` (самопереход)
|
|
- **THEN** запись проходит, состояние остаётся `deferred`
|
|
|
|
#### Scenario: Ребро из терминального состояния только через revive
|
|
|
|
- **GIVEN** загрузка в терминальном состоянии `failed`
|
|
- **WHEN** переход `failed → downloading` делается обычным `SetDownloadState`
|
|
- **THEN** запись отклоняется (терминальную задачу нельзя оживить мимо гарда владения)
|
|
- **AND** тот же переход через `ActivateIfNoOtherActive` (при свободном infohash)
|
|
проходит
|