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

109 lines
7.9 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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 как «снимаемые».