Обработка рассинхрона состояния с реальностью (state-reconciliation)
Распознаём ручное удаление источника (раздача в qBittorrent) и/или цели (разложенные хардлинки) и отражаем его в состоянии задачи, без автодействий. - Новая capability state-reconciliation (OpenSpec): фоновая сверка по матрице «источник × цель» → состояния target_missing/orphaned/deleted, переходы и самовосстановление (healing). - worker: reconcileDesync в Poll (только разложенные/desync-задачи), дебаунс пропажи источника (порог [worker].source_missing_threshold) и синхронный preflight перед действиями (relink/recognize/apply/undo) — не доверяем state в БД. - layout.Undo: отказ снять последнюю копию (nlink<=1 или нет источника), отказ всего батча без частичного отката (ErrLastCopy). - store: единый список terminalStates для IsTerminal и FindActiveByInfohash (иначе семантика «активности» разъезжается), столбец source_miss_count, миграция 0003. - httpapi/web и Telegram: показ новых состояний и уведомления о рассинхроне. - Доки: workflow.md, jellyfin-layout.md, database.md (+0003), config. Change заархивирован в openspec/changes/archive, дельта влита в openspec/specs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-06-29
|
||||
@@ -0,0 +1,204 @@
|
||||
## Context
|
||||
|
||||
`worker` ведёт FSM загрузки (см. [workflow.md](../../../docs/specs/workflow.md)).
|
||||
Сейчас `Poll` сверяет с qBittorrent только задачи в `downloading`
|
||||
(`ListDownloadsByState(StateDownloading)`); терминальные задачи (`done`) с
|
||||
реальностью не сверяются вовсе. Если раздача исчезла из qBittorrent, для
|
||||
активной задачи код лишь пишет `Warn("active download not found")`; для
|
||||
`done` не делает ничего.
|
||||
|
||||
Удаление просмотренного контента — штатная эксплуатационная операция, и оно
|
||||
ручное: из qBittorrent (источник + скачанные файлы) или из Jellyfin (наши
|
||||
хардлинки). Два инварианта при этом ломаются молча:
|
||||
|
||||
- **«Источник неприкосновенен»** перестаёт держаться, когда источника уже
|
||||
нет: библиотечный хардлинк становится последней копией, а `layout.Undo`
|
||||
безусловно `unlink`-ает цель.
|
||||
- **Состояние `done` правдиво** перестаёт держаться, когда файлы убрали из
|
||||
библиотеки: ссылок нет, а БД числит `done`.
|
||||
|
||||
Данные, на которые опираемся: `download.infohash` (сопоставление с qBit),
|
||||
`file_link.dst_path` + `status` (разложенные хардлинки), `file_link.src_path`
|
||||
(исходный файл раздачи). qBittorrent уже листаем целиком в `Poll`.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Распознавать ручное удаление источника и/или цели фоновой сверкой и
|
||||
отражать его в состоянии задачи — **без автоматических действий**.
|
||||
- Не допускать потери данных при `Undo`, когда источник уже удалён.
|
||||
- Сделать рассинхрон видимым (состояние в UI + уведомление автору).
|
||||
- Сохранить самовосстановление: вернулась реальность — вернулось состояние.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- Удаление средствами jellybit (path 2, «единое окно»).
|
||||
- Автоперезапуск распознавания/раскладки при рассинхроне (relink — вручную).
|
||||
- Реакция на удаление файлов **внутри** живой раздачи (это `missingFiles`/
|
||||
`error` qBittorrent — уже ведёт в `failed`).
|
||||
- Ретеншн терминальных задач.
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1. Двумерная матрица «источник × цель» → новые состояния FSM
|
||||
|
||||
Рассинхрон параметризуется двумя независимыми фактами. Для задачи, которая
|
||||
дошла до раскладки, состояние выводится из них:
|
||||
|
||||
| источник (qBit) | цель (хардлинки) | состояние |
|
||||
|-----------------|------------------|-----------------|
|
||||
| есть | есть | `done` |
|
||||
| есть | нет | `target_missing`|
|
||||
| нет | есть | `orphaned` |
|
||||
| нет | нет | `deleted` |
|
||||
|
||||
Выбрали **новые значения `state`**, а не флаги поверх состояния:
|
||||
терминальность и набор доступных команд у этих ситуаций разные, а FSM —
|
||||
единая точка правды (граф уже в `workflow.md`). Флаги размазали бы логику
|
||||
«что можно делать» по двум осям.
|
||||
|
||||
- `target_missing` — источник на месте → доступен пользовательский relink
|
||||
(та же команда «Привязать заново», что из `reverted`/`cancelled`:
|
||||
`target_missing → recognizing`). Авто-переход в `recognizing` **не**
|
||||
делаем — это нарушило бы «никаких автодействий».
|
||||
- `orphaned` — источник пропал, цель (последняя копия) на месте. Команд
|
||||
вперёд нет; `Undo` заблокирован (см. D4).
|
||||
- `deleted` — пусто и там, и там; терминально, действий нет.
|
||||
|
||||
**Альтернатива (отклонено):** одно состояние `desynced` + поле-причина.
|
||||
Хуже: разные исходы требуют разных команд и разной терминальности — проще
|
||||
развести по состояниям.
|
||||
|
||||
### D2. Какие задачи и как сверяем
|
||||
|
||||
Desync-сверка применяется только к задачам, для которых ожидаем разложенные
|
||||
файлы и осмысленную связь с источником: `done`, `target_missing`,
|
||||
`orphaned`, `deleted`. Активные (`downloading`/`recognizing`/`review`/
|
||||
`deferred`/`linking`) и пользовательски-терминальные (`reverted`/
|
||||
`cancelled`/`failed`/`stuck`) сверка **не трогает** (`reverted` = мы сами
|
||||
сняли ссылки, это не рассинхрон).
|
||||
|
||||
- **Источник присутствует** ⟺ `download.infohash` найден среди торрентов
|
||||
qBittorrent (карту `byHash` `Poll` уже строит).
|
||||
- **Цель присутствует** ⟺ все `file_link` со `status = linked` существуют на
|
||||
ФС (`os.Lstat`). Частичная пропажа (исчезла часть ссылок) трактуется как
|
||||
«цель отсутствует» → `target_missing` (библиотека сломана, лечится
|
||||
relink/слиянием).
|
||||
|
||||
Сверку делаем на том же тике `Poll` (5 с). При текущих объёмах (домашний
|
||||
сервер) `Lstat` по ссылкам терминальных задач дёшев; если объём вырастет
|
||||
(см. TODO «Ретеншн»), вынесем desync-сверку на отдельный, более редкий
|
||||
интервал. Состояние выводим и переписываем только при изменении (без
|
||||
лишних записей и логов на каждом тике).
|
||||
|
||||
### D3. Дебаунс пропажи источника
|
||||
|
||||
qBittorrent может временно пропасть из выдачи (рестарт демона, мигнул API),
|
||||
тогда как локальный `Lstat` цели надёжен. Поэтому дебаунсим **только
|
||||
отсутствие источника**:
|
||||
|
||||
- новый столбец `download.source_miss_count` (миграция goose);
|
||||
- источник отсутствует на тике → `source_miss_count++`; найден →
|
||||
сбрасываем в `0`;
|
||||
- источник считается удалённым (для вывода состояния) лишь когда
|
||||
`source_miss_count >= [worker].source_missing_threshold` (по умолчанию
|
||||
`3` → ~15 с при интервале 5 с). До порога источник трактуется как
|
||||
присутствующий — задача не дёргается.
|
||||
|
||||
Отсутствие цели в дебаунсе не нуждается (локальная ФС не «мигает»).
|
||||
|
||||
### D4. Безопасный `Undo`: не снимать последнюю копию
|
||||
|
||||
`layout.Undo` для каждой ссылки перед `unlink`:
|
||||
|
||||
1. `Lstat(dst)` — нет файла → нечего снимать, пропускаем (идемпотентно).
|
||||
2. Иначе читаем `nlink` цели. `nlink <= 1` означает: это **единственная**
|
||||
ссылка на inode (источник уже удалён) — `unlink` сотрёт данные. Отказ:
|
||||
не трогаем файл, копим причину.
|
||||
3. Доп. явный сигнал: `src_path` не существует → тоже отказ (источника нет).
|
||||
|
||||
Отказ возвращается типизированной ошибкой; задача в `reverted` **не**
|
||||
переходит, причина показывается пользователю. На командном уровне `Undo`
|
||||
для задачи в `orphaned` отклоняется сразу (по определению источник удалён →
|
||||
все ссылки — последние копии); per-file `nlink`-проверка остаётся страховкой
|
||||
на случай устаревшего состояния. Откат снимает **лишний** хардлинк, а не
|
||||
последнюю копию.
|
||||
|
||||
### D5. Синхронный preflight перед действием (не доверяем `state` в БД)
|
||||
|
||||
Фоновая сверка (D2/D3) — eventual: она отстаёт на интервал поллинга плюс
|
||||
дебаунс. Поэтому любая **команда**, которой нужен источник или цель, делает
|
||||
собственную **синхронную пробу** прямо перед действием, а не полагается на
|
||||
значение `state`:
|
||||
|
||||
- `relink`/«Распознать заново»/«Уточнить» и `Apply` (раскладка) — требуют
|
||||
источника (раздача в qBittorrent);
|
||||
- `Undo` — требует источника (чтобы не снять последнюю копию).
|
||||
|
||||
Чтобы не дублировать логику, выделяем чистый помощник
|
||||
`probe(download) → (sourcePresent, targetPresent)` и `deriveState(...)`,
|
||||
которые используют **и** фоновая сверка, **и** preflight — единая точка
|
||||
правды о том, что есть на диске и как это отображается в состояние.
|
||||
|
||||
Ключевое отличие preflight от фона: **без дебаунса** — это явное действие
|
||||
пользователя «сейчас», единичная проба. Если qBittorrent в этот момент
|
||||
недоступен, команда честно отказывает («источник недоступен») — пользователь
|
||||
повторит. Дебаунс нужен только фону, чтобы не дёргать состояние на
|
||||
транзиентных пропажах.
|
||||
|
||||
При неуспехе предусловия команда не выполняет действие, прогоняет
|
||||
`deriveState` (приводя `state` к реальности — напр. `done → orphaned`) и
|
||||
возвращает пользователю причину. Так команда сама «лечит» устаревшее
|
||||
состояние, не дожидаясь `worker`.
|
||||
|
||||
### D6. Самовосстановление (healing)
|
||||
|
||||
Состояние всегда выводится из текущей матрицы D1 (с учётом дебаунса D3), а
|
||||
не «залипает». Если источник вернулся (раздачу добавили заново) или цель
|
||||
снова на месте — следующая сверка переведёт задачу обратно
|
||||
(`orphaned/target_missing/deleted → done`). `deleted` не делаем абсорбирующим
|
||||
ради единообразия; на практике одновременный возврат маловероятен.
|
||||
|
||||
### D7. Видимость
|
||||
|
||||
При переходе в `orphaned`/`target_missing` `worker` шлёт уведомление автору
|
||||
через существующий `notifier` (новые события `EventOrphaned`/
|
||||
`EventTargetMissing`), как для `review`/`done`. Web-UI и Telegram
|
||||
отображают новые состояния; для `orphaned` кнопка `Undo` скрыта/заблокирована
|
||||
с пояснением «источник удалён — это последняя копия».
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Ложная пометка при долгом простое qBittorrent** (рестарт дольше
|
||||
`threshold × poll_interval`) → дебаунс D3 + самовосстановление D6: вернётся
|
||||
раздача — вернётся `done`. Порог настраивается.
|
||||
- **Стоимость `Lstat` на каждом тике** при росте числа терминальных задач →
|
||||
при текущих объёмах пренебрежимо; путь отхода — отдельный редкий интервал
|
||||
desync-сверки (зафиксировано в D2, делаем при необходимости).
|
||||
- **`nlink` зависит от ФС/синтаксиса `syscall.Stat_t`** (Linux-таргет,
|
||||
`CGO_ENABLED=0`, `linux/amd64`) → платформа фиксирована деплоем; copy-fallback
|
||||
раскладки (не хардлинк) даёт `nlink==1` у легитимной копии — такой `Undo`
|
||||
тоже корректно откажет (это и есть единственная копия). Приемлемо: лучше
|
||||
отказать, чем удалить данные.
|
||||
- **Гонки команда/сверка** → всё под `w.mu` per-download, как и остальные
|
||||
переходы; новых блокировок не вводим.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
- Миграция goose `0003_*`: `ALTER TABLE download ADD COLUMN
|
||||
source_miss_count INTEGER NOT NULL DEFAULT 0`. Новые значения `state` —
|
||||
данных не мигрируют (аддитивно).
|
||||
- Конфиг: новый ключ `[worker].source_missing_threshold` с дефолтом; старые
|
||||
конфиги валидны без него.
|
||||
- Откат: безопасен — состояния перестанут проставляться, существующие
|
||||
desync-задачи останутся со своим значением `state` (UI покажет как
|
||||
неизвестное/как есть). Столбец можно не удалять.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Нужны ли пользователю команды из `orphaned`, кроме как ждать
|
||||
восстановления (например явное «Забыть»/перевод в `deleted` руками)? На
|
||||
старте — нет, только пометка; добавим, если будет спрос.
|
||||
- Порог дебаунса по умолчанию (`3`) — уточнить по реальному времени
|
||||
рестарта qBittorrent на umbar.
|
||||
@@ -0,0 +1,87 @@
|
||||
## Why
|
||||
|
||||
Пришло время удалять просмотренные фильмы/сериалы, чтобы освобождать место
|
||||
под новые. Удаляют их **вручную** — из qBittorrent (источник) или из
|
||||
Jellyfin (целевые хардлинки). Сейчас jellybit этого не замечает: `worker`
|
||||
поллит только задачи в `downloading`, терминальные задачи (`done`) с
|
||||
реальностью не сверяет. Состояние в БД молча расходится с диском:
|
||||
|
||||
- **Источник пропал, цель осталась.** qBittorrent стирает скачанные файлы
|
||||
при удалении раздачи. Хардлинк в библиотеке становится **последней**
|
||||
ссылкой на inode, а обычный `Undo` (`unlink` цели) сотрёт единственную
|
||||
копию — прямая потеря данных. Инвариант «источник неприкосновенен»
|
||||
молчаливо перестаёт держаться: источника уже нет.
|
||||
- **Цель пропала, источник остался.** Файлы убрали из библиотеки, а
|
||||
jellybit по-прежнему числит загрузку `done` — состояние врёт.
|
||||
|
||||
Цель этого change — **path 1**: научить jellybit корректно **распознавать**
|
||||
ручное удаление и **отражать** его в состоянии, **не предпринимая
|
||||
автоматических действий**. Удаление средствами самого jellybit («единое
|
||||
окно», path 2) — отдельная будущая работа.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Фоновая сверка с реальностью.** `worker` расширяет периодический поллинг:
|
||||
помимо `downloading` сверяет терминальные/desync-задачи с фактом на ФС —
|
||||
присутствие раздачи в qBittorrent (источник) и существование разложенных
|
||||
хардлинков `file_link.dst_path` (цель).
|
||||
- **Новые состояния FSM** для рассинхрона (двумерная матрица «источник × цель»):
|
||||
- `target_missing` — источник на месте, цель удалена: доступен
|
||||
пользовательский флоу повторной привязки (relink); авто-действий нет.
|
||||
- `orphaned` — источник пропал, цель на месте: «осиротевшая» раздача,
|
||||
библиотечный хардлинк — единственная копия.
|
||||
- `deleted` — пропали и источник, и цель: терминально, действий больше нет.
|
||||
- Реальность «лечится» сама: если источник/цель снова появились, сверка
|
||||
возвращает задачу в согласованное состояние.
|
||||
- **Дебаунс пропажи источника.** Раздача считается удалённой только после
|
||||
`N` подряд тиков без неё (qBittorrent мог рестартовать / API мигнул);
|
||||
любое появление сбрасывает счётчик. Порог — в конфиге.
|
||||
- **Защита `Undo` от потери данных.** `Undo`/`layout.Undo` отказывается
|
||||
снимать хардлинк, если он **последняя копия** (`nlink == 1`) или исходный
|
||||
путь не существует — откат снимает лишний хардлинк, а не единственный
|
||||
файл; причина отказа сообщается явно.
|
||||
- **Уведомление** автору загрузки при переходе в `orphaned`/`target_missing`
|
||||
(через существующий `notifier`), чтобы рассинхрон не оставался незаметным.
|
||||
|
||||
Не входит в объём (Non-goals):
|
||||
|
||||
- Удаление раздач/файлов средствами самого jellybit (path 2, «единое окно»).
|
||||
- Автоматический повторный прогон распознавания/раскладки при рассинхроне —
|
||||
только пометка состояния; relink инициирует человек.
|
||||
- Сверка содержимого источника (manual delete файлов **внутри** живой
|
||||
раздачи → это `error`/`missingFiles` qBittorrent, отдельная тема).
|
||||
- Ретеншн/авточистка терминальных задач (отдельная задача в TODO).
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `state-reconciliation`: периодическая сверка записанного состояния
|
||||
загрузки с фактом на ФС (раздача в qBittorrent, разложенные хардлинки),
|
||||
состояния рассинхрона (`target_missing`/`orphaned`/`deleted`), их переходы
|
||||
и дебаунс; а также инвариант безопасного `Undo` (не снимать последнюю
|
||||
копию).
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
<!-- Граф состояний и Undo живут в docs/specs/workflow.md и
|
||||
docs/specs/jellyfin-layout.md и ещё не перенесены в OpenSpec; меняем их
|
||||
напрямую как живые спеки (см. Impact). Capability-дельт для них нет. -->
|
||||
|
||||
## Impact
|
||||
|
||||
- **Спеки:** новая `openspec/specs/state-reconciliation/`; правки живых
|
||||
`docs/specs/workflow.md` (граф состояний: +`target_missing`/`orphaned`/
|
||||
`deleted`, переходы) и `docs/specs/jellyfin-layout.md` (инвариант
|
||||
безопасного `Undo`).
|
||||
- **Код:** `internal/worker` (расширение `Poll`/`reconcile` на терминальные
|
||||
задачи, дебаунс, новые переходы, уведомления), `internal/store` (новые
|
||||
значения `state`, столбец счётчика промахов, миграция, запросы выборки
|
||||
desync-задач), `internal/layout` (`Undo` с проверкой `nlink`/наличия
|
||||
источника), `internal/qbt` (присутствие infohash — уже листаем все
|
||||
торренты), `internal/httpapi` + web-UI (отображение новых состояний,
|
||||
блокировка `Undo` для `orphaned`), `internal/config` (`[worker]` порог
|
||||
дебаунса).
|
||||
- **Конфиг:** новый ключ в `[worker]` (порог пропусков источника).
|
||||
- **Совместимость:** новые значения `state` — аддитивно; миграция goose для
|
||||
столбца счётчика.
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Периодическая сверка состояния с реальностью
|
||||
|
||||
`worker` SHALL периодически (на тике поллинга) сверять задачи, для которых
|
||||
ожидаются разложенные файлы, с фактом на файловой системе и в qBittorrent, и
|
||||
выводить состояние задачи из двух независимых признаков: присутствия
|
||||
**источника** (раздача с `download.infohash` в выдаче qBittorrent) и
|
||||
присутствия **цели** (все `file_link` со `status = linked` существуют на ФС).
|
||||
|
||||
Сверке SHALL подвергаться только состояния `done`, `target_missing`,
|
||||
`orphaned`, `deleted`. Активные (`downloading`/`recognizing`/`review`/
|
||||
`deferred`/`linking`) и пользовательски-терминальные (`reverted`/`cancelled`/
|
||||
`failed`/`stuck`) состояния сверка трогать SHALL NOT.
|
||||
|
||||
Состояние SHALL переписываться только при его изменении (без записи и логов,
|
||||
когда выведенное состояние совпадает с текущим).
|
||||
|
||||
#### Scenario: Источник и цель на месте — состояние не меняется
|
||||
|
||||
- **WHEN** для задачи в `done` раздача присутствует в qBittorrent и все её
|
||||
разложенные хардлинки существуют
|
||||
- **THEN** задача остаётся в `done`
|
||||
- **AND** запись состояния и лог перехода не выполняются
|
||||
|
||||
#### Scenario: Частичная пропажа цели считается отсутствием
|
||||
|
||||
- **WHEN** часть разложенных хардлинков задачи удалена, а источник на месте
|
||||
- **THEN** цель считается отсутствующей и задача переходит в `target_missing`
|
||||
|
||||
### Requirement: Принудительная проверка источника/цели перед действием
|
||||
|
||||
Команда workflow, требующая наличия источника или цели, SHALL синхронно
|
||||
проверять их присутствие непосредственно перед выполнением действия и SHALL
|
||||
NOT полагаться только на фоновую сверку `worker` (она отстаёт на интервал
|
||||
поллинга и дебаунс). Проверка перед действием выполняется как **единичная
|
||||
немедленная проба без дебаунса**: дебаунс применяется только к фоновому
|
||||
авто-маркированию.
|
||||
|
||||
Под это требование подпадают как минимум: повторная привязка/распознавание
|
||||
(`relink`, «Распознать заново», «Уточнить») и раскладка (`Apply`) — требуют
|
||||
**источника**; `Undo` — требует **источника** (чтобы не снять последнюю
|
||||
копию).
|
||||
|
||||
Если предусловие не выполнено, система SHALL NOT выполнять действие, SHALL
|
||||
привести состояние задачи в соответствие с реальностью (вывести состояние из
|
||||
матрицы «источник × цель», как при сверке) и SHALL сообщить причину
|
||||
пользователю.
|
||||
|
||||
#### Scenario: Relink проверяет источник перед запуском
|
||||
|
||||
- **WHEN** пользователь даёт команду, требующую источника (например
|
||||
«Привязать заново»)
|
||||
- **THEN** система синхронно проверяет наличие раздачи в qBittorrent перед
|
||||
запуском распознавания
|
||||
- **AND** если источника нет — распознавание не запускается, задача
|
||||
приводится к `orphaned` либо `deleted` (по наличию цели), причина
|
||||
сообщается
|
||||
|
||||
#### Scenario: Проверка не ждёт фоновую сверку
|
||||
|
||||
- **WHEN** источник уже удалён, а фоновая сверка ещё не отметила это (в БД
|
||||
состояние, например, `done`)
|
||||
- **THEN** команда, требующая источника, немедленно обнаруживает его
|
||||
отсутствие собственной проверкой и отказывает, не дожидаясь `worker`
|
||||
|
||||
### Requirement: Состояние target_missing и доступность повторной привязки
|
||||
|
||||
Когда у задачи источник присутствует, а цель отсутствует, система SHALL
|
||||
переводить её в состояние `target_missing` и SHALL NOT предпринимать
|
||||
автоматических действий (не запускать повторное распознавание/раскладку
|
||||
самостоятельно).
|
||||
|
||||
Из `target_missing` система SHALL предоставлять пользовательскую команду
|
||||
повторной привязки (relink) с переходом `target_missing → recognizing`, как
|
||||
из `reverted`/`cancelled`; повторная привязка идёт через `review` с ручным
|
||||
подтверждением.
|
||||
|
||||
#### Scenario: Цель удалена, источник на месте
|
||||
|
||||
- **WHEN** сверка обнаруживает, что разложенных хардлинков задачи `done`
|
||||
больше нет, но раздача в qBittorrent присутствует
|
||||
- **THEN** задача переходит в `target_missing`
|
||||
- **AND** система не запускает распознавание или раскладку автоматически
|
||||
|
||||
#### Scenario: Пользователь инициирует повторную привязку
|
||||
|
||||
- **WHEN** для задачи в `target_missing` пользователь даёт команду «Привязать
|
||||
заново»
|
||||
- **THEN** задача переходит в `recognizing` и далее проходит через `review`
|
||||
с ручным подтверждением
|
||||
|
||||
### Requirement: Состояние orphaned при пропаже источника
|
||||
|
||||
Система SHALL переводить задачу в состояние `orphaned`, когда источник
|
||||
отсутствует (с учётом дебаунса), а цель присутствует, отражая, что
|
||||
библиотечный хардлинк остался единственной копией данных.
|
||||
|
||||
#### Scenario: Источник удалён, цель на месте
|
||||
|
||||
- **WHEN** сверка устойчиво (после дебаунса) не находит раздачу задачи в
|
||||
qBittorrent, а её разложенные хардлинки существуют
|
||||
- **THEN** задача переходит в `orphaned`
|
||||
|
||||
### Requirement: Состояние deleted при пропаже источника и цели
|
||||
|
||||
Когда отсутствуют и источник (с учётом дебаунса), и цель, система SHALL
|
||||
переводить задачу в состояние `deleted`. В `deleted` действий над задачей
|
||||
больше нет.
|
||||
|
||||
#### Scenario: Источник и цель удалены
|
||||
|
||||
- **WHEN** сверка устойчиво не находит раздачу в qBittorrent и разложенных
|
||||
хардлинков задачи на ФС больше нет
|
||||
- **THEN** задача переходит в `deleted`
|
||||
|
||||
### Requirement: Дебаунс пропажи источника
|
||||
|
||||
Система SHALL дебаунсить только **отсутствие источника**, чтобы временная
|
||||
недоступность qBittorrent (рестарт демона, сбой API) не вызывала ложных
|
||||
пометок: источник считается удалённым лишь после `N` подряд тиков сверки без
|
||||
него, где `N = [worker].source_missing_threshold`. Любое обнаружение раздачи
|
||||
SHALL сбрасывать счётчик пропусков.
|
||||
|
||||
Отсутствие цели дебаунсу подвергаться SHALL NOT (локальная проверка ФС
|
||||
надёжна).
|
||||
|
||||
#### Scenario: Кратковременная пропажа источника не помечается
|
||||
|
||||
- **WHEN** раздача отсутствует в qBittorrent меньше `source_missing_threshold`
|
||||
тиков подряд
|
||||
- **THEN** источник трактуется как присутствующий и состояние задачи не
|
||||
меняется
|
||||
|
||||
#### Scenario: Возврат источника сбрасывает счётчик
|
||||
|
||||
- **WHEN** раздача снова обнаружена в qBittorrent
|
||||
- **THEN** счётчик пропусков источника сбрасывается в ноль
|
||||
|
||||
### Requirement: Самовосстановление состояния при возврате реальности
|
||||
|
||||
Система SHALL возвращать задачу в согласованное состояние, когда реальность
|
||||
восстановилась (состояние выводится из текущей матрицы «источник × цель»):
|
||||
при возврате источника и/или цели задача SHALL переходить из
|
||||
`orphaned`/`target_missing`/`deleted` обратно (в т.ч. в `done`, когда
|
||||
присутствуют оба).
|
||||
|
||||
#### Scenario: Источник вернулся
|
||||
|
||||
- **WHEN** для задачи в `orphaned` раздача снова появилась в qBittorrent, а
|
||||
цель по-прежнему на месте
|
||||
- **THEN** задача возвращается в `done`
|
||||
|
||||
### Requirement: Безопасный Undo не снимает последнюю копию
|
||||
|
||||
`Undo` (снятие созданных хардлинков) SHALL отказываться удалять целевую
|
||||
ссылку, если она является последней копией данных: целевой файл существует и
|
||||
его счётчик ссылок `nlink <= 1`, либо исходный файл (`src_path`) не
|
||||
существует. В этом случае система SHALL NOT выполнять `unlink` такого файла и
|
||||
SHALL явно сообщать причину отказа; задача в `reverted` при отказе переходить
|
||||
SHALL NOT.
|
||||
|
||||
Отсутствующую целевую ссылку (файла уже нет) `Undo` SHALL пропускать как
|
||||
успешно снятую (идемпотентность). Команда `Undo` для задачи в `orphaned`
|
||||
SHALL отклоняться сразу с пояснением, что источник удалён.
|
||||
|
||||
#### Scenario: Отказ снять единственную копию
|
||||
|
||||
- **WHEN** при `Undo` целевой хардлинк существует, но его `nlink <= 1` (или
|
||||
исходный файл отсутствует)
|
||||
- **THEN** система не удаляет файл и сообщает, что это последняя копия
|
||||
- **AND** задача остаётся в текущем состоянии (не `reverted`)
|
||||
|
||||
#### Scenario: Undo снимает лишний хардлинк при живом источнике
|
||||
|
||||
- **WHEN** при `Undo` целевой хардлинк существует, исходный файл на месте и
|
||||
`nlink > 1`
|
||||
- **THEN** система снимает целевой хардлинк, оставляя исходный файл нетронутым
|
||||
|
||||
### Requirement: Уведомление о рассинхроне
|
||||
|
||||
При переходе задачи в `orphaned` или `target_missing` система SHALL
|
||||
уведомлять автора загрузки через настроенный механизм уведомлений
|
||||
(`notifier`), чтобы рассинхрон не оставался незамеченным.
|
||||
|
||||
#### Scenario: Уведомление при потере источника
|
||||
|
||||
- **WHEN** задача переходит в `orphaned`
|
||||
- **THEN** система отправляет автору загрузки уведомление о потере источника
|
||||
@@ -0,0 +1,95 @@
|
||||
## 1. Хранилище и состояния
|
||||
|
||||
- [x] 1.1 Добавить значения состояний `target_missing`, `orphaned`, `deleted`
|
||||
в `internal/store` (константы `State*`) и в перечень допустимых состояний.
|
||||
- [x] 1.2 Миграция goose `0003_*`: `ALTER TABLE download ADD COLUMN
|
||||
source_miss_count INTEGER NOT NULL DEFAULT 0`; обновить модель `Download`.
|
||||
- [x] 1.3 Запросы в `store`: выборка desync-кандидатов
|
||||
(`done`/`target_missing`/`orphaned`/`deleted`), чтение/сброс/инкремент
|
||||
`source_miss_count`, чтение `file_link` (`status = linked`) по задаче.
|
||||
|
||||
## 2. Конфигурация
|
||||
|
||||
- [x] 2.1 Добавить `[worker].source_missing_threshold` (int, дефолт `3`) в
|
||||
`internal/config` с валидацией (`>= 1`); пробросить в `worker.Config`.
|
||||
- [x] 2.2 Отразить ключ в примере конфига и `docs/conventions/config.md`/
|
||||
README, если там перечислены ключи `[worker]`.
|
||||
|
||||
## 3. Сверка с реальностью (worker)
|
||||
|
||||
- [x] 3.1 Выделить общий помощник `probe(download) → (sourcePresent,
|
||||
targetPresent)` (источник: infohash в qBit; цель: `Lstat` всех
|
||||
`file_link.dst_path` со `status = linked`, частичная пропажа = цель
|
||||
отсутствует) и `deriveState(src, tgt) → State` — единая точка правды для
|
||||
фона и preflight.
|
||||
- [x] 3.2 В `Poll` для desync-кандидатов вызвать `probe` и реализовать
|
||||
дебаунс источника: инкремент `source_miss_count` при отсутствии, сброс при
|
||||
обнаружении; «источник удалён» только при `source_miss_count >= threshold`.
|
||||
- [x] 3.3 Применить `deriveState` и переходить только при изменении:
|
||||
`done`/`target_missing`/`orphaned`/`deleted` + healing обратно в `done`.
|
||||
Переиспользовать `transition`.
|
||||
- [x] 3.4 Не трогать фоновой сверкой активные и пользовательски-терминальные
|
||||
состояния (`reverted`/`cancelled`/`failed`/`stuck` и активные).
|
||||
|
||||
## 3a. Синхронный preflight перед действием
|
||||
|
||||
- [x] 3a.1 Перед командами, требующими источника/цели (relink, «Распознать
|
||||
заново», «Уточнить», `Apply`, `Undo`), вызывать `probe` **без дебаунса**
|
||||
(единичная немедленная проба), не доверяя `state` в БД.
|
||||
- [x] 3a.2 При неуспехе предусловия: действие не выполнять, прогнать
|
||||
`deriveState` (привести `state` к реальности, напр. `done → orphaned`),
|
||||
вернуть пользователю причину; при недоступности qBittorrent — отказ
|
||||
«источник недоступен».
|
||||
|
||||
## 4. Безопасный Undo (layout + worker)
|
||||
|
||||
- [x] 4.1 `internal/layout` `Undo`: для каждой ссылки `Lstat(dst)` (нет —
|
||||
пропустить идемпотентно), иначе проверить `nlink <= 1` и наличие
|
||||
`src_path`; при «последней копии» — отказ с типизированной ошибкой, без
|
||||
`unlink`.
|
||||
- [x] 4.2 `worker.Undo`: отклонять команду для задачи в `orphaned` сразу с
|
||||
понятным сообщением; при отказе `layout.Undo` — не переводить в `reverted`,
|
||||
пробросить причину пользователю.
|
||||
- [x] 4.3 Разрешить переход `target_missing → recognizing` в команде
|
||||
«Привязать заново» (наряду с `reverted`/`cancelled`).
|
||||
|
||||
## 5. Уведомления
|
||||
|
||||
- [x] 5.1 Добавить события `EventOrphaned`/`EventTargetMissing` и слать
|
||||
уведомление автору в `transition` при входе в эти состояния (как для
|
||||
`review`/`done`), неблокирующе и вне `w.mu`.
|
||||
|
||||
## 6. Транспорты (httpapi + web-UI)
|
||||
|
||||
- [x] 6.1 Отобразить новые состояния в списке/карточке загрузки (метки,
|
||||
пояснения «разложено, но файлов нет» / «источник удалён — последняя копия»).
|
||||
- [x] 6.2 Скрыть/заблокировать `Undo` для `orphaned`; показать команду
|
||||
«Привязать заново» для `target_missing`.
|
||||
|
||||
## 7. Тесты
|
||||
|
||||
- [x] 7.1 Таблица переходов сверки: все четыре ячейки матрицы + healing,
|
||||
частичная пропажа цели → `target_missing`.
|
||||
- [x] 7.2 Дебаунс: пропажа < порога не помечает; >= порога помечает; возврат
|
||||
сбрасывает счётчик.
|
||||
- [x] 7.3 `layout.Undo`: отказ при `nlink <= 1`/отсутствии `src_path`;
|
||||
снятие лишнего хардлинка при живом источнике; пропуск отсутствующей цели.
|
||||
- [x] 7.4 `worker.Undo` отклоняется для `orphaned`; relink из
|
||||
`target_missing` ведёт в `recognizing`.
|
||||
- [x] 7.5 Preflight: команда с устаревшим `state = done`, но удалённым
|
||||
источником немедленно отказывает и приводит состояние к `orphaned`/
|
||||
`deleted` (не дожидаясь фоновой сверки).
|
||||
|
||||
## 8. Документация и спеки
|
||||
|
||||
- [x] 8.1 Обновить `docs/specs/workflow.md`: граф состояний (+`target_missing`/
|
||||
`orphaned`/`deleted`, переходы, healing) и описания.
|
||||
- [x] 8.2 Обновить `docs/specs/jellyfin-layout.md`: инвариант безопасного
|
||||
`Undo` (не снимать последнюю копию).
|
||||
- [x] 8.2a Обновить ER-схему `docs/specs/database.md`: столбец
|
||||
`download.source_miss_count` + отметка миграции `0003` (конвенция:
|
||||
схема едет вместе с миграцией).
|
||||
- [x] 8.3 Снять пункт «Рассинхрон состояния с реальностью» (часть про
|
||||
detection/marking и undo-guard) из `docs/todo.md` или сузить до path 2.
|
||||
- [x] 8.4 `openspec validate --strict`; ревью кода; затем `opsx:archive`
|
||||
(влить дельту `state-reconciliation` в `openspec/specs/`).
|
||||
Reference in New Issue
Block a user