Завершённая загрузка ложно «воскресала» из 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>
109 lines
7.9 KiB
Markdown
109 lines
7.9 KiB
Markdown
## 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 как «снимаемые».
|