Владение целевым путём при повторной раскладке (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>
This commit is contained in:
av
2026-06-29 18:10:05 +03:00
co-authored by Claude Opus 4.8
parent 783664622c
commit 6b7c090ce4
17 changed files with 658 additions and 24 deletions
@@ -0,0 +1,108 @@
## 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`:
```sql
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 как «снимаемые».