Files
avandClaude Opus 4.8 6b7c090ce4 Владение целевым путём при повторной раскладке (state-reconciliation)
Завершённая загрузка ложно «воскресала» из 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>
2026-06-29 18:10:05 +03:00

7.9 KiB
Raw Permalink Blame History

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

Вводим статус 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.statusTEXT.

Решение 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 как «снимаемые».