Владение целевым путём при повторной раскладке (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:
@@ -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 как «снимаемые».
|
||||
Reference in New Issue
Block a user