Обработка рассинхрона состояния с реальностью (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,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.
|
||||
Reference in New Issue
Block a user