Владение целевым путём при повторной раскладке (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,2 @@
schema: spec-driven
created: 2026-06-29
@@ -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 как «снимаемые».
@@ -0,0 +1,70 @@
## Why
Фоновая сверка может ложно «воскресить» завершённую загрузку, если её
целевой путь переиспользовала другая загрузка. Реальный инцидент: фильм
скачали в 4K (download A), затем удалили его из qBittorrent и Jellyfin —
сверка увела A в терминальный `deleted` (нет ни источника, ни цели). После
этого тот же фильм скачали в 1080p (download B); он распознался как тот же
фильм и сделал хардлинк по **тому же** `dst_path`. На следующем тике сверка
для A увидела, что файл по пути снова существует (хотя это файл B), и
вывела `deleted → orphaned` с ложным уведомлением «источник потерян».
Корень: `targetPresent` считает цель присутствующей по факту существования
**пути**, не проверяя, что лежащая там раскладка принадлежит **этой**
загрузке. Один `dst_path` может оказаться «своим» сразу для двух загрузок.
## What Changes
- Вводим инвариант **«один целевой путь — один владелец»**: цель загрузки
считается присутствующей, только если разложенные по её путям ссылки всё
ещё принадлежат именно ей. Когда новая раскладка ложится на путь, ранее
занятый другой загрузкой (путь к тому моменту свободен — иначе была бы
коллизия → review), владение переходит к новой загрузке, а ссылки прежней
на этот путь помечаются вышедшими из обращения (новый статус `file_link`
`superseded`).
- Делаем `deleted` действительно **терминальным** для сверки: исключаем его
из набора сверяемых состояний — завершённую начисто задачу больше не
переоценивают (источник к ней не вернётся из-за идемпотентности
терминальных задач, а цель отбирается переходом владения).
- Приводим спеку в соответствие с графом состояний: убираем из требования
«самовосстановление» возврат из `deleted` (он противоречил диаграмме
`deleted --> [*]` в `workflow.md` и под новым инвариантом нереализуем).
- Коллизия остаётся как есть: если файл прежней загрузки **всё ещё на
месте**, новая раскладка не перезаписывает его, а уходит в review.
## Capabilities
### New Capabilities
(нет)
### Modified Capabilities
- `state-reconciliation`: присутствие цели определяется по **владению**, а
не по факту существования пути; вводится переход владения путём при
повторной раскладке; `deleted` исключается из сверки и из
самовосстановления.
## Impact
- **Затрагиваемый код:**
- `internal/worker/reconcile.go` — убрать `StateDeleted` из
`desyncStates`; `targetPresent`/`isLaidOut` уже считают целью только
`linked/copied/exists`, новый статус `superseded` отсекается
автоматически.
- `internal/worker/review.go` (`linkPlan`) — после фиксации своих ссылок
пометить чужие `file_link` на тех же `dst_path` как `superseded`;
покрывает и авто-раскладку, и ручной `Apply` (обе идут через
`linkPlan`).
- `internal/store/recognition.go` — новый метод стора (supersede чужих
ссылок по списку `dst_path`).
- `internal/layout/layout.go` — константа статуса `StatusSuperseded`.
- **Без миграции схемы:** `file_link.status``TEXT`, новое значение
enum-а не меняет таблицу. ER-схема `docs/specs/database.md` не меняется.
- **Документация:** `docs/specs/workflow.md` (раздел «Сверка с
реальностью» — `deleted` терминален) и `docs/specs/jellyfin-layout.md`
(переход владения путём при повторной раскладке) — `file-layout` ещё не
перенесён в OpenSpec, источник истины по нему — `docs/specs/`.
- **Поведение пользователя:** исчезают ложные уведомления `orphaned`/
`target_missing` по уже удалённым загрузкам; повторная закачка того же
фильма в другом качестве больше не «трогает» прежнюю задачу.
@@ -0,0 +1,116 @@
## ADDED Requirements
### Requirement: Владение целевым путём — один путь, один владелец
Целевой путь раскладки (`file_link.dst_path`) SHALL принадлежать не более
чем одной загрузке одновременно. При успешной раскладке загрузки на путь,
который ранее заняла **другая** загрузка, владение SHALL переходить к новой
загрузке: ссылки прежней загрузки на тот же `dst_path` система SHALL
помечать вышедшими из обращения (статус, не относящийся к разложенной цели),
после чего они перестают считаться целью прежней загрузки при сверке.
Присутствие цели при сверке SHALL определяться по **владению**, а не по
факту существования пути: цель загрузки считается присутствующей, только
если существующие на ФС файлы по её путям — это ссылки, всё ещё
принадлежащие этой загрузке (не вышедшие из обращения). Файл, лежащий по
тому же пути, но созданный другой загрузкой, целью первой загрузки
считаться SHALL NOT.
Переход владения возможен лишь когда путь к моменту раскладки **свободен**
(прежний файл уже удалён): занятый реальным файлом путь по-прежнему даёт
коллизию и уходит в review (новая раскладка не перезаписывает чужой файл).
#### Scenario: Повторная закачка забирает освободившийся путь
- **GIVEN** загрузка A разложена по пути P, но её файл по P удалён вручную
- **WHEN** загрузка B успешно раскладывается по тому же пути P
- **THEN** ссылки A на P помечаются вышедшими из обращения
- **AND** при сверке цель A по пути P считается отсутствующей
#### Scenario: Чужой файл по пути не считается своей целью
- **GIVEN** по пути P лежит файл, созданный загрузкой B
- **WHEN** сверка проверяет присутствие цели загрузки A, чьи ссылки на P
вышли из обращения
- **THEN** цель A считается отсутствующей, несмотря на существование файла
по P
#### Scenario: Занятый путь даёт коллизию, а не переход владения
- **GIVEN** файл загрузки A по пути P всё ещё существует
- **WHEN** загрузка B пытается разложиться по тому же пути P
- **THEN** возникает коллизия и B уходит в review
- **AND** владение путём P за A не отбирается
## MODIFIED Requirements
### Requirement: Периодическая сверка состояния с реальностью
`worker` SHALL периодически (на тике поллинга) сверять задачи, для которых
ожидаются разложенные файлы, с фактом на файловой системе и в qBittorrent, и
выводить состояние задачи из двух независимых признаков: присутствия
**источника** (раздача с `download.infohash` в выдаче qBittorrent) и
присутствия **цели** (см. требование о владении целевым путём: существуют все
ссылки последнего батча со статусом раскладки, всё ещё принадлежащие этой
загрузке).
Сверке SHALL подвергаться только состояния `done`, `target_missing`,
`orphaned`. Состояние `deleted` сверка трогать SHALL NOT — оно терминально.
Активные (`downloading`/`recognizing`/`review`/`deferred`/`linking`) и
пользовательски-терминальные (`reverted`/`cancelled`/`failed`/`stuck`)
состояния сверка трогать SHALL NOT.
Состояние SHALL переписываться только при его изменении (без записи и логов,
когда выведенное состояние совпадает с текущим).
#### Scenario: Источник и цель на месте — состояние не меняется
- **WHEN** для задачи в `done` раздача присутствует в qBittorrent и все её
разложенные хардлинки существуют
- **THEN** задача остаётся в `done`
- **AND** запись состояния и лог перехода не выполняются
#### Scenario: Частичная пропажа цели считается отсутствием
- **WHEN** часть разложенных хардлинков задачи удалена, а источник на месте
- **THEN** цель считается отсутствующей и задача переходит в `target_missing`
#### Scenario: Задача в deleted сверкой не переоценивается
- **WHEN** задача находится в `deleted`
- **THEN** сверка её не рассматривает и состояние не меняет, даже если по её
бывшему пути появился файл другой загрузки
### Requirement: Состояние deleted при пропаже источника и цели
Когда отсутствуют и источник (с учётом дебаунса), и цель, система SHALL
переводить задачу в состояние `deleted`. `deleted` терминально: действий над
задачей больше нет, и сверка её больше не переоценивает (источник к
терминальной задаче не возвращается из-за идемпотентности, а цель отбирается
переходом владения путём к другой загрузке).
#### Scenario: Источник и цель удалены
- **WHEN** сверка устойчиво не находит раздачу в qBittorrent и разложенных
хардлинков задачи на ФС больше нет
- **THEN** задача переходит в `deleted`
#### Scenario: deleted не воскресает при переиспользовании пути
- **GIVEN** задача A в `deleted`
- **WHEN** другая задача раскладывается по бывшему пути A
- **THEN** задача A остаётся в `deleted` (не переходит в `orphaned`)
### Requirement: Самовосстановление состояния при возврате реальности
Система SHALL возвращать задачу в согласованное состояние, когда реальность
восстановилась (состояние выводится из текущей матрицы «источник × цель»):
при возврате источника и/или цели задача SHALL переходить из
`orphaned`/`target_missing` обратно (в т.ч. в `done`, когда присутствуют
оба). Из терминального `deleted` самовосстановления SHALL NOT быть.
#### Scenario: Источник вернулся
- **WHEN** для задачи в `orphaned` раздача снова появилась в qBittorrent, а
цель по-прежнему на месте
- **THEN** задача возвращается в `done`
@@ -0,0 +1,54 @@
## 1. Статус file_link и стор
- [x] 1.1 Добавить `StatusSuperseded LinkStatus = "superseded"` в
`internal/layout/layout.go` (вокабуляр статусов `file_link`).
- [x] 1.2 Добавить метод стора `SupersedeForeignLinks(ctx, downloadID int64, dstPaths []string) error`
в `internal/store/recognition.go`: `UPDATE file_link SET status='superseded'
WHERE dst_path IN (...) AND download_id != ? AND status IN ('linked','copied','exists')`.
Пустой `dstPaths` — no-op. Объявить метод в интерфейсе стора в `internal/worker/worker.go`.
## 2. Переход владения при раскладке
- [x] 2.1 В `linkPlan` (`internal/worker/review.go`) после успешного
`CreateFileLinks` собрать `dst_path` фактически разложенных ссылок
(статус из `isLaidOut`: `linked`/`copied`/`exists`) и вызвать
`SupersedeForeignLinks(ctx, d.ID, paths)` до перехода в `done`.
- [x] 2.2 Убедиться, что покрыты обе воронки раскладки (авто-апплай и ручной
`Apply`) — обе идут через `linkPlan`.
## 3. deleted вне сверки
- [x] 3.1 Убрать `store.StateDeleted` из `desyncStates`
(`internal/worker/reconcile.go`). `terminalStates`/`IsTerminal`
(`internal/store/download.go`) не трогаем — `deleted` там уже есть.
## 4. Аудит потребителей статуса
- [x] 4.1 Проверить места, читающие `file_link.status` (undo в
`internal/worker/review.go`/`internal/layout`, листинги UI в
`internal/httpapi`): `superseded`-ссылки не должны считаться активной
целью и не должны попадать в undo как «снимаемые». Поправить при
необходимости.
## 5. Тесты
- [x] 5.1 Тест сверки: задача в `deleted` не переоценивается, даже если по
её бывшему пути появился файл (нет перехода `deleted → orphaned`).
- [x] 5.2 Тест раскладки: повторная раскладка по освободившемуся чужому пути
помечает прежние ссылки `superseded`; `targetPresent` прежней загрузки →
`false`.
- [x] 5.3 Тест: занятый реальным файлом путь даёт коллизию → review,
владение не отбирается.
- [x] 5.4 Тест: загрузка не «суперсидит» сама себя (`download_id != self`).
## 6. Документация
- [x] 6.1 `docs/specs/workflow.md` («Сверка с реальностью») — `deleted`
терминален, без самовосстановления; согласовать с графом `deleted --> [*]`.
- [x] 6.2 `docs/specs/jellyfin-layout.md` — добавить переход владения целевым
путём при повторной раскладке на освободившийся путь.
## 7. Проверки
- [x] 7.1 `task test` и `task lint` зелёные.
- [x] 7.2 `openspec validate target-path-ownership --strict` проходит.