Владение целевым путём при повторной раскладке (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,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` по уже удалённым загрузкам; повторная закачка того же
|
||||
фильма в другом качестве больше не «трогает» прежнюю задачу.
|
||||
+116
@@ -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` проходит.
|
||||
Reference in New Issue
Block a user