Завершённая загрузка ложно «воскресала» из deleted в orphaned, когда её целевой путь переиспользовала другая загрузка (повторная закачка того же фильма в другом качестве): сверка проверяла лишь существование пути, не проверяя, что файл по нему — наша раскладка. Вводим инвариант «один целевой путь — один владелец»: - при успешной раскладке на освободившийся чужой путь владение переходит к новой загрузке — прежние file_link на этот путь помечаются статусом superseded и перестают считаться целью при сверке; - deleted исключён из desyncStates — терминальное состояние больше не переоценивается (источник к нему не вернётся из-за идемпотентности, цель отбирается переходом владения); - Undo снимает только реально свои разложенные ссылки (superseded пропускает — файл по пути теперь чужой хардлинк); - ошибку перехода владения трактуем как некритичную (WARN-and-continue): файлы уже разложены, рассинхрон чужих задач исправит следующий тик. Без миграции схемы (status — TEXT). Дельта влита в основную спеку, обновлены workflow.md и jellyfin-layout.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
7.9 KiB
Context
Фоновая сверка (internal/worker/reconcile.go) выводит состояние уже
разложенной задачи из матрицы «источник × цель» (deriveState). Цель
определяется targetPresent — проверкой os.Lstat по dst_path ссылок
последнего батча со «статусом раскладки» (isLaidOut: linked/copied/
exists). Источник дебаунсится, цель — нет.
Проблема: targetPresent проверяет существование пути, а не
принадлежность лежащего там файла данной загрузке. Поэтому, когда другая
загрузка разложилась по тому же dst_path (например, повторная закачка
того же фильма в другом качестве), сверка прежней загрузки видит «цель
вернулась» и ложно выводит её из deleted в orphaned (с уведомлением).
deleted уже терминален для идемпотентности (store.terminalStates
включает его — снимается idempotency_key), но при этом всё ещё
присутствует в desyncStates, т.е. сверка его переоценивает.
Обе точки раскладки (авто-апплай после распознавания и ручной Apply)
сходятся в linkPlan (internal/worker/review.go), который вызывает
store.CreateFileLinks и затем transition в done.
Goals / Non-Goals
Goals:
- Цель загрузки при сверке считается присутствующей только если файлы по её путям — её собственная раскладка (инвариант «один путь — один владелец»).
- При повторной раскладке на освободившийся чужой путь владение переходит к новой загрузке; прежние ссылки на этот путь выводятся из обращения.
deletedперестаёт переоцениваться сверкой (терминален и в этом смысле).- Документация (
docs/specs/) и спекаstate-reconciliationприведены в соответствие с графом состояний (deleted --> [*]).
Non-Goals:
- Мульти-версии Jellyfin (4K + 1080p рядом) — отдельная фича, movie-only, не входит в этот change.
- Изменение поведения коллизии (занятый путь → review) — остаётся как есть.
- Идентификация цели по inode/устройству — сознательно отвергнута (см. Decisions).
- Миграция
file-layoutв OpenSpec — вне рамок; правки раскладки идут вdocs/specs/.
Decisions
Решение 1: владение выражаем статусом file_link, а не inode
Вводим статус superseded (layout.StatusSuperseded). При успешной
раскладке в linkPlan после CreateFileLinks помечаем чужие ссылки на те
же dst_path:
UPDATE file_link SET status = 'superseded'
WHERE dst_path = ? AND download_id != ? AND status IN ('linked','copied','exists')
targetPresent/isLaidOut уже считают целью только linked/copied/
exists, поэтому superseded отсекается без изменений в логике сверки.
Помечаем только пути, которые сами реально разложили (результаты со статусом
из isLaidOut) — коллизии и пропуски владение не отбирают.
Почему не inode: завязка на номер inode — низкоуровневая, не выражает
домен, требует хранить и сверять числа, осмысленные только для ФС, и
усложняет тесты. Статус file_link остаётся в нашей доменной модели и
переиспользует существующую логику isLaidOut. Миграция схемы не нужна:
file_link.status — TEXT.
Решение 2: deleted вне сверки
Убираем store.StateDeleted из desyncStates. Завершённую начисто задачу
больше не переоценивают. Это безопасно: источник к терминальной задаче не
вернётся (идемпотентность снимается только для активных), а цель отбирается
переходом владения (Решение 1) — оба пути «воскрешения» закрыты.
Решение 3: точка вызова supersede — linkPlan
linkPlan — единственная воронка обеих раскладок (авто и ручной Apply).
Supersede вызываем там после фиксации своих ссылок и до/рядом с переходом в
done, под тем же w.mu, в той же логической операции. Отдельный метод
стора (напр. SupersedeForeignLinks(ctx, downloadID, dstPaths)).
Решение 4: приведение спеки и доков
state-reconciliation(дельта этого change): сверка не трогаетdeleted; присутствие цели — по владению; самовосстановление изdeletedубрано.docs/specs/workflow.md— в «Сверке с реальностью» уточнить, чтоdeletedтерминален (без healing); граф уже это рисует.docs/specs/jellyfin-layout.md— добавить переход владения путём при повторной раскладке на освободившийся путь.
Risks / Trade-offs
- Гонок нет: и раскладка, и сверка идут под
w.mu(per-worker), supersede иCreateFileLinks— последовательно вlinkPlan. - Частичное владение (сериал, где новая раскладка заняла лишь часть
путей прежней): прежняя загрузка теряет владение только перехваченными
путями. Пока хоть одна её неперехваченная ссылка (
isLaidOut) существует на ФС,targetPresentвозвращает true и задача остаётсяdone; вtarget_missingона уйдёт, лишь когда пропадут и собственные файлы. Это корректное отражение реальности, не регресс. - Старые данные: ранее ложно «воскрешённые» задачи в БД останутся в
своём состоянии до следующего тика; после деплоя
deleted-задачи просто перестанут трогаться, а ошибочно ставшиеorphanedисправятся вручную при необходимости (точечно, не автоматической миграцией — инцидент единичный). superseded— новое значение enum-а: учесть в местах, где статус интерпретируется (undo/листинги UI), чтобы такие ссылки не показывались как активная цель и не участвовали в undo как «снимаемые».