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

15 KiB
Raw Blame History

Context

worker ведёт FSM загрузки (см. 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.