Обработка рассинхрона состояния с реальностью (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:
av
2026-06-29 11:07:09 +03:00
co-authored by Claude Opus 4.8
parent f9d7fd9216
commit cc7e51b3a4
29 changed files with 1556 additions and 80 deletions
@@ -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.