Распознаём ручное удаление источника (раздача в 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>
15 KiB
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/errorqBittorrent — уже ведёт в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 (картуbyHashPollуже строит). - Цель присутствует ⟺ все
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:
Lstat(dst)— нет файла → нечего снимать, пропускаем (идемпотентно).- Иначе читаем
nlinkцели.nlink <= 1означает: это единственная ссылка на inode (источник уже удалён) —unlinkсотрёт данные. Отказ: не трогаем файл, копим причину. - Доп. явный сигнал:
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.muper-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.