Files
jellybit/openspec/changes/archive/2026-06-29-reconcile-removed-source-target/design.md
T
avandClaude Opus 4.8 cc7e51b3a4 Обработка рассинхрона состояния с реальностью (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>
2026-06-29 11:07:09 +03:00

205 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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.