Владение целевым путём при повторной раскладке (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
+13
View File
@@ -51,6 +51,19 @@ inode общий — диск не дублируется.
же inode → готово; другой файл → коллизия → review). Инварианты и undo — же inode → готово; другой файл → коллизия → review). Инварианты и undo —
в [architecture.md](architecture.md) → «Раскладка файлов». в [architecture.md](architecture.md) → «Раскладка файлов».
## Владение целевым путём
Целевой путь принадлежит **одной** загрузке. Когда новая раскладка
успешно ложится на путь, который раньше занимала другая загрузка (путь к
этому моменту **свободен** — иначе была бы коллизия → review, чужой файл
не перезаписываем), владение переходит к новой загрузке: прежние записи
`file_link` на этот путь помечаются статусом `superseded` и перестают
считаться целью прежней загрузки. Это нужно сверке с реальностью: иначе
повторная закачка того же фильма (например, в другом качестве) по тому же
пути ложно «воскрешала» бы удалённую задачу — см.
[workflow.md](workflow.md) → «Сверка с реальностью». `superseded`-ссылки
не считаются целью при сверке и не снимаются в `Undo`.
Желательно: целевой и исходный каталоги — на одной ФС/одном mount'е Желательно: целевой и исходный каталоги — на одной ФС/одном mount'е
(внутри контейнера это обеспечивает единая песочница `/srv/media`), тогда (внутри контейнера это обеспечивает единая песочница `/srv/media`), тогда
работает дешёвый хардлинк. Если хардлинк невозможен (разные ФС или ФС без работает дешёвый хардлинк. Если хардлинк невозможен (разные ФС или ФС без
+12 -5
View File
@@ -107,12 +107,19 @@ stateDiagram-v2
«Привязать заново» (`→ recognizing`); авто-действий нет. «Привязать заново» (`→ recognizing`); авто-действий нет.
- **orphaned** — источник пропал, цель (последняя копия данных) на месте. - **orphaned** — источник пропал, цель (последняя копия данных) на месте.
Команд вперёд нет; `Undo` запрещён (снял бы единственную копию). Команд вперёд нет; `Undo` запрещён (снял бы единственную копию).
- **deleted** — нет ни источника, ни цели; терминально. - **deleted** — нет ни источника, ни цели; **терминально**: сверка его
больше не переоценивает (см. ниже).
Сверка трогает только `done`/`target_missing`/`orphaned`/`deleted` Сверка трогает только `done`/`target_missing`/`orphaned` — терминальный
активные и пользовательски-терминальные (`reverted`/`cancelled`/`failed`/ `deleted`, активные и пользовательски-терминальные (`reverted`/`cancelled`/
`stuck`) состояния не задевает. Реальность «лечится» сама: при возврате `failed`/`stuck`) состояния не задевает. Реальность «лечится» сама: при
источника/цели задача переходит обратно (вплоть до `done`). Пропажа возврате источника/цели задача переходит обратно (вплоть до `done`) — но
**не из `deleted`**: к терминальной задаче источник не вернётся
(идемпотентность снимается только для активных), а её бывший целевой путь, если
его заняла другая загрузка, отбирается переходом владения (см.
[jellyfin-layout.md](jellyfin-layout.md) → «Владение целевым путём»).
Без этого правила переиспользование пути ложно «воскрешало» бы удалённую
задачу в `orphaned`. Пропажа
**источника** дебаунсится (`[worker].source_missing_threshold` подряд идущих **источника** дебаунсится (`[worker].source_missing_threshold` подряд идущих
тиков), пропажа цели проверяется немедленно (локальная ФС надёжна). Команды, тиков), пропажа цели проверяется немедленно (локальная ФС надёжна). Команды,
которым нужен источник (relink/распознать/применить/undo), проверяют его которым нужен источник (relink/распознать/применить/undo), проверяют его
+5 -4
View File
@@ -213,10 +213,11 @@ func (l *Layouter) seriesDst(root, folder, base string, f *PlanFile) (string, Ki
type LinkStatus string type LinkStatus string
const ( const (
StatusLinked LinkStatus = "linked" // хардлинк создан StatusLinked LinkStatus = "linked" // хардлинк создан
StatusCopied LinkStatus = "copied" // хардлинк невозможен — файл скопирован (фолбэк) StatusCopied LinkStatus = "copied" // хардлинк невозможен — файл скопирован (фолбэк)
StatusExists LinkStatus = "exists" // уже была (тот же inode) — идемпотентно StatusExists LinkStatus = "exists" // уже была (тот же inode) — идемпотентно
StatusCollision LinkStatus = "collision" // цель занята другим файлом StatusCollision LinkStatus = "collision" // цель занята другим файлом
StatusSuperseded LinkStatus = "superseded" // путь перехватила другая загрузка (см. state-reconciliation, владение путём)
) )
// Result — итог по одной ссылке. // Result — итог по одной ссылке.
+29
View File
@@ -6,6 +6,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"strings"
) )
// Recognition — строка таблицы recognition (попытка распознавания). // Recognition — строка таблицы recognition (попытка распознавания).
@@ -195,6 +196,34 @@ VALUES (?, ?, ?, ?, ?, ?)`
return nil return nil
} }
// SupersedeForeignLinks помечает статусом superseded ссылки ДРУГИХ загрузок,
// разложенные по тем же dst_path, что и переданные. Реализует инвариант
// «один целевой путь — один владелец» (см. state-reconciliation): свежая
// раскладка забирает владение освободившимся путём, прежние записи перестают
// считаться целью при сверке. Затрагивает только активные статусы раскладки
// (linked/copied/exists) и не трогает саму загрузку (download_id != ?).
func (s *Store) SupersedeForeignLinks(ctx context.Context, downloadID int64, dstPaths []string) error {
if len(dstPaths) == 0 {
return nil
}
ph := make([]string, len(dstPaths))
args := make([]any, 0, len(dstPaths)+1)
args = append(args, downloadID)
for i, p := range dstPaths {
ph[i] = "?"
args = append(args, p)
}
q := fmt.Sprintf(`
UPDATE file_link SET status = 'superseded'
WHERE download_id != ?
AND status IN ('linked', 'copied', 'exists')
AND dst_path IN (%s)`, strings.Join(ph, ", "))
if _, err := s.DB.ExecContext(ctx, q, args...); err != nil {
return fmt.Errorf("supersede foreign links: %w", err)
}
return nil
}
// LatestBatchID возвращает apply_batch_id последнего применённого батча // LatestBatchID возвращает apply_batch_id последнего применённого батча
// загрузки (для undo) либо пустую строку, если ссылок нет. // загрузки (для undo) либо пустую строку, если ссылок нет.
func (s *Store) LatestBatchID(ctx context.Context, downloadID int64) (string, error) { func (s *Store) LatestBatchID(ctx context.Context, downloadID int64) (string, error) {
+50
View File
@@ -159,6 +159,56 @@ func TestFileLinks_BatchLifecycle(t *testing.T) {
} }
} }
func TestSupersedeForeignLinks(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
owner := seedDownload(t, st)
foreign, err := st.CreateDownload(ctx,
newDownloading("bbccddeeff00112233445566778899aabbccddee"))
if err != nil {
t.Fatalf("seed foreign: %v", err)
}
shared := "/m/Movie (2024).mkv"
// foreign разложена по shared (linked) и по своему пути (exists);
// owner разложен по shared и по третьему пути.
if err := st.CreateFileLinks(ctx, []FileLink{
{DownloadID: foreign, ApplyBatchID: "f", SrcPath: "/d/f.mkv", DstPath: shared, Kind: "video", Status: "linked"},
{DownloadID: foreign, ApplyBatchID: "f", SrcPath: "/d/g.mkv", DstPath: "/m/Other (2024).mkv", Kind: "video", Status: "exists"},
{DownloadID: owner, ApplyBatchID: "o", SrcPath: "/d/o.mkv", DstPath: shared, Kind: "video", Status: "linked"},
}); err != nil {
t.Fatalf("create links: %v", err)
}
if err := st.SupersedeForeignLinks(ctx, owner, []string{shared}); err != nil {
t.Fatalf("supersede: %v", err)
}
links, _ := st.ListFileLinksByBatch(ctx, "f")
for _, l := range links {
switch l.DstPath {
case shared:
if l.Status != "superseded" {
t.Errorf("чужая ссылка на %q = %q, want superseded", shared, l.Status)
}
default: // /m/Other — другой путь, не трогаем
if l.Status != "exists" {
t.Errorf("ссылка на %q = %q, want exists (не тронута)", l.DstPath, l.Status)
}
}
}
// Свою ссылку owner не суперсидит (download_id != self).
own, _ := st.ListFileLinksByBatch(ctx, "o")
if len(own) != 1 || own[0].Status != "linked" {
t.Errorf("своя ссылка = %+v, want linked", own)
}
// Пустой список путей — no-op, без ошибки.
if err := st.SupersedeForeignLinks(ctx, owner, nil); err != nil {
t.Errorf("пустой dstPaths должен быть no-op: %v", err)
}
}
func TestCandidates_Lifecycle(t *testing.T) { func TestCandidates_Lifecycle(t *testing.T) {
st := newTestStore(t) st := newTestStore(t)
ctx := context.Background() ctx := context.Background()
+5 -3
View File
@@ -14,14 +14,16 @@ import (
) )
// desyncStates — состояния, которые ведёт сверка с реальностью: уже // desyncStates — состояния, которые ведёт сверка с реальностью: уже
// разложенные (done) и сами состояния рассинхрона. Активные и // разложенные (done) и восстановимые состояния рассинхрона. deleted сюда не
// входит — оно терминально и сверкой не переоценивается (источник к
// терминальной задаче не вернётся из-за идемпотентности, а цель отбирается
// переходом владения путём; см. state-reconciliation). Активные и
// пользовательски-терминальные (reverted/cancelled/failed/stuck) сверка не // пользовательски-терминальные (reverted/cancelled/failed/stuck) сверка не
// трогает (см. state-reconciliation). // трогает.
var desyncStates = []store.State{ var desyncStates = []store.State{
store.StateDone, store.StateDone,
store.StateTargetMissing, store.StateTargetMissing,
store.StateOrphaned, store.StateOrphaned,
store.StateDeleted,
} }
// deriveState выводит состояние задачи из двумерной матрицы «источник × цель» // deriveState выводит состояние задачи из двумерной матрицы «источник × цель»
+12
View File
@@ -99,6 +99,18 @@ func TestReconcilePartialTargetLoss(t *testing.T) {
} }
} }
func TestReconcileSkipsDeleted(t *testing.T) {
// deleted терминально: сверка его не переоценивает, даже если по бывшему
// пути снова появился файл (его забрала другая загрузка) и источника нет.
f := newReconcileFixture(t, store.StateDeleted, false, true)
if err := f.w.Poll(context.Background()); err != nil {
t.Fatalf("Poll: %v", err)
}
if got := f.st.downloads[1].State; got != store.StateDeleted {
t.Errorf("state = %q, want deleted (сверка не трогает терминальное)", got)
}
}
func TestReconcileDebounce(t *testing.T) { func TestReconcileDebounce(t *testing.T) {
// Порог 3: первые два промаха не помечают, третий — помечает orphaned; // Порог 3: первые два промаха не помечают, третий — помечает orphaned;
// возврат источника лечит обратно и сбрасывает счётчик. // возврат источника лечит обратно и сбрасывает счётчик.
+25 -3
View File
@@ -280,6 +280,22 @@ func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize
if err := w.store.CreateFileLinks(ctx, fl); err != nil { if err := w.store.CreateFileLinks(ctx, fl); err != nil {
return fmt.Errorf("persist links: %w", err) return fmt.Errorf("persist links: %w", err)
} }
// Инвариант «один целевой путь — один владелец»: забираем владение
// фактически разложенными путями у прежних загрузок (см.
// state-reconciliation). Сверка тех загрузок перестанет считать эти
// пути своей целью и не «воскресит» их. Это учётная операция, не
// безопасность данных: файлы уже разложены, поэтому её сбой не
// стрэндит задачу в linking — логируем WARN и доводим до done, а
// рассинхрон чужих задач исправит следующий тик сверки.
owned := make([]string, 0, len(fl))
for _, l := range fl {
if isLaidOut(l.Status) {
owned = append(owned, l.DstPath)
}
}
if err := w.store.SupersedeForeignLinks(ctx, d.ID, owned); err != nil {
logctx.From(ctx).Warn("supersede foreign links failed", "error", err)
}
} }
if applyErr != nil { if applyErr != nil {
@@ -494,9 +510,15 @@ func (w *Worker) Undo(ctx context.Context, id int64) error {
if err != nil { if err != nil {
return fmt.Errorf("undo: %w", err) return fmt.Errorf("undo: %w", err)
} }
links := make([]layout.Link, len(rows)) // Снимаем только реально разложенные нами ссылки. superseded-строки —
for i, r := range rows { // путь забрала другая загрузка (см. state-reconciliation, владение
links[i] = layout.Link{Src: r.SrcPath, Dst: r.DstPath, Kind: layout.Kind(r.Kind)} // путём); файл по нему теперь её хардлинк, трогать его нельзя.
links := make([]layout.Link, 0, len(rows))
for _, r := range rows {
if !isLaidOut(r.Status) {
continue
}
links = append(links, layout.Link{Src: r.SrcPath, Dst: r.DstPath, Kind: layout.Kind(r.Kind)})
} }
n, err := w.layouter.Undo(ctx, links) n, err := w.layouter.Undo(ctx, links)
if err != nil { if err != nil {
+85
View File
@@ -353,6 +353,25 @@ func (m *memStore) CreateFileLinks(_ context.Context, links []store.FileLink) er
m.links = append(m.links, links...) m.links = append(m.links, links...)
return nil return nil
} }
func (m *memStore) SupersedeForeignLinks(_ context.Context, downloadID int64, dstPaths []string) error {
if len(dstPaths) == 0 {
return nil
}
want := make(map[string]bool, len(dstPaths))
for _, p := range dstPaths {
want[p] = true
}
for i := range m.links {
l := &m.links[i]
if l.DownloadID == downloadID || !want[l.DstPath] {
continue
}
if isLaidOut(l.Status) {
l.Status = string(layout.StatusSuperseded)
}
}
return nil
}
func (m *memStore) LatestBatchID(_ context.Context, id int64) (string, error) { func (m *memStore) LatestBatchID(_ context.Context, id int64) (string, error) {
for i := len(m.links) - 1; i >= 0; i-- { for i := len(m.links) - 1; i >= 0; i-- {
if m.links[i].DownloadID == id { if m.links[i].DownloadID == id {
@@ -769,6 +788,72 @@ func TestApply_CollisionStaysReview(t *testing.T) {
} }
} }
func TestApply_SupersedesForeignOwnerOfPath(t *testing.T) {
// Прежняя загрузка (id=2) была разложена по тому же пути, её файл удалён.
// Новая раскладка (id=1) ложится на освободившийся путь и забирает владение.
plan := seriesResult().Plan
f := newApplyFixture(t, plan)
e01 := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E01.mkv")
f.st.put(completedDownload(2))
f.st.links = append(f.st.links, store.FileLink{
DownloadID: 2, ApplyBatchID: "old", SrcPath: "/old/e1.mkv", DstPath: e01,
Kind: "video", Status: "linked",
})
if err := f.w.Apply(context.Background(), 1); err != nil {
t.Fatalf("Apply: %v", err)
}
// Чужая ссылка на перехваченный путь — superseded.
var foreign *store.FileLink
for i := range f.st.links {
if f.st.links[i].DownloadID == 2 {
foreign = &f.st.links[i]
}
}
if foreign == nil || foreign.Status != string(layout.StatusSuperseded) {
t.Errorf("чужая ссылка status = %v, want superseded", foreign)
}
// Свои ссылки (id=1) не тронуты — download_id != self.
for _, l := range f.st.links {
if l.DownloadID == 1 && !isLaidOut(l.Status) {
t.Errorf("своя ссылка %q стала %q, ожидали разложенную", l.DstPath, l.Status)
}
}
// Прежняя загрузка больше не владеет путём → цель отсутствует.
present, err := f.w.targetPresent(context.Background(), 2)
if err != nil {
t.Fatalf("targetPresent: %v", err)
}
if present {
t.Error("targetPresent(2) = true, want false (путь забран)")
}
}
func TestApply_CollisionKeepsForeignOwner(t *testing.T) {
// Если файл прежней загрузки ВСЁ ЕЩЁ на месте — коллизия → review, владение
// не отбирается (supersede не срабатывает для не-разложенного пути).
plan := seriesResult().Plan
f := newApplyFixture(t, plan)
e01 := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E01.mkv")
_ = os.MkdirAll(filepath.Dir(e01), 0o755)
_ = os.WriteFile(e01, []byte("foreign"), 0o644)
f.st.put(completedDownload(2))
f.st.links = append(f.st.links, store.FileLink{
DownloadID: 2, ApplyBatchID: "old", SrcPath: "/old/e1.mkv", DstPath: e01,
Kind: "video", Status: "linked",
})
if err := f.w.Apply(context.Background(), 1); err == nil {
t.Fatal("want collision error")
}
for _, l := range f.st.links {
if l.DownloadID == 2 && l.Status != "linked" {
t.Errorf("чужая ссылка стала %q при коллизии, владение не должно отбираться", l.Status)
}
}
}
func TestUndo_RevertsLinks(t *testing.T) { func TestUndo_RevertsLinks(t *testing.T) {
plan := seriesResult().Plan plan := seriesResult().Plan
f := newApplyFixture(t, plan) f := newApplyFixture(t, plan)
+1
View File
@@ -55,6 +55,7 @@ type Store interface {
SetOverride(ctx context.Context, downloadID int64, field, value string) error SetOverride(ctx context.Context, downloadID int64, field, value string) error
ListOverrides(ctx context.Context, downloadID int64) (map[string]string, error) ListOverrides(ctx context.Context, downloadID int64) (map[string]string, error)
CreateFileLinks(ctx context.Context, links []store.FileLink) error CreateFileLinks(ctx context.Context, links []store.FileLink) error
SupersedeForeignLinks(ctx context.Context, downloadID int64, dstPaths []string) error
LatestBatchID(ctx context.Context, downloadID int64) (string, error) LatestBatchID(ctx context.Context, downloadID int64) (string, error)
ListFileLinksByBatch(ctx context.Context, batchID string) ([]store.FileLink, error) ListFileLinksByBatch(ctx context.Context, batchID string) ([]store.FileLink, error)
DeleteFileLinksByBatch(ctx context.Context, batchID string) error DeleteFileLinksByBatch(ctx context.Context, batchID string) error
+4 -1
View File
@@ -114,7 +114,10 @@ func (f *fakeStore) ListOverrides(_ context.Context, _ int64) (map[string]string
return nil, nil return nil, nil
} }
func (f *fakeStore) CreateFileLinks(_ context.Context, _ []store.FileLink) error { return nil } func (f *fakeStore) CreateFileLinks(_ context.Context, _ []store.FileLink) error { return nil }
func (f *fakeStore) LatestBatchID(_ context.Context, _ int64) (string, error) { return "", nil } func (f *fakeStore) SupersedeForeignLinks(_ context.Context, _ int64, _ []string) error {
return nil
}
func (f *fakeStore) LatestBatchID(_ context.Context, _ int64) (string, error) { return "", nil }
func (f *fakeStore) ListFileLinksByBatch(_ context.Context, _ string) ([]store.FileLink, error) { func (f *fakeStore) ListFileLinksByBatch(_ context.Context, _ string) ([]store.FileLink, error) {
return nil, nil return nil, nil
} }
@@ -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` проходит.
+67 -8
View File
@@ -18,12 +18,15 @@ qBittorrent. Capability описывает периодическую и при
ожидаются разложенные файлы, с фактом на файловой системе и в qBittorrent, и ожидаются разложенные файлы, с фактом на файловой системе и в qBittorrent, и
выводить состояние задачи из двух независимых признаков: присутствия выводить состояние задачи из двух независимых признаков: присутствия
**источника** (раздача с `download.infohash` в выдаче qBittorrent) и **источника** (раздача с `download.infohash` в выдаче qBittorrent) и
присутствия **цели** (все `file_link` со `status = linked` существуют на ФС). присутствия **цели** (см. требование о владении целевым путём: существуют все
ссылки последнего батча со статусом раскладки, всё ещё принадлежащие этой
загрузке).
Сверке SHALL подвергаться только состояния `done`, `target_missing`, Сверке SHALL подвергаться только состояния `done`, `target_missing`,
`orphaned`, `deleted`. Активные (`downloading`/`recognizing`/`review`/ `orphaned`. Состояние `deleted` сверка трогать SHALL NOT — оно терминально.
`deferred`/`linking`) и пользовательски-терминальные (`reverted`/`cancelled`/ Активные (`downloading`/`recognizing`/`review`/`deferred`/`linking`) и
`failed`/`stuck`) состояния сверка трогать SHALL NOT. пользовательски-терминальные (`reverted`/`cancelled`/`failed`/`stuck`)
состояния сверка трогать SHALL NOT.
Состояние SHALL переписываться только при его изменении (без записи и логов, Состояние SHALL переписываться только при его изменении (без записи и логов,
когда выведенное состояние совпадает с текущим). когда выведенное состояние совпадает с текущим).
@@ -40,6 +43,12 @@ qBittorrent. Capability описывает периодическую и при
- **WHEN** часть разложенных хардлинков задачи удалена, а источник на месте - **WHEN** часть разложенных хардлинков задачи удалена, а источник на месте
- **THEN** цель считается отсутствующей и задача переходит в `target_missing` - **THEN** цель считается отсутствующей и задача переходит в `target_missing`
#### Scenario: Задача в deleted сверкой не переоценивается
- **WHEN** задача находится в `deleted`
- **THEN** сверка её не рассматривает и состояние не меняет, даже если по её
бывшему пути появился файл другой загрузки
### Requirement: Принудительная проверка источника/цели перед действием ### Requirement: Принудительная проверка источника/цели перед действием
Команда workflow, требующая наличия источника или цели, SHALL синхронно Команда workflow, требующая наличия источника или цели, SHALL синхронно
@@ -117,8 +126,10 @@ NOT полагаться только на фоновую сверку `worker`
### Requirement: Состояние deleted при пропаже источника и цели ### Requirement: Состояние deleted при пропаже источника и цели
Когда отсутствуют и источник (с учётом дебаунса), и цель, система SHALL Когда отсутствуют и источник (с учётом дебаунса), и цель, система SHALL
переводить задачу в состояние `deleted`. В `deleted` действий над задачей переводить задачу в состояние `deleted`. `deleted` терминально: действий над
больше нет. задачей больше нет, и сверка её больше не переоценивает (источник к
терминальной задаче не возвращается из-за идемпотентности, а цель отбирается
переходом владения путём к другой загрузке).
#### Scenario: Источник и цель удалены #### Scenario: Источник и цель удалены
@@ -126,6 +137,54 @@ NOT полагаться только на фоновую сверку `worker`
хардлинков задачи на ФС больше нет хардлинков задачи на ФС больше нет
- **THEN** задача переходит в `deleted` - **THEN** задача переходит в `deleted`
#### Scenario: deleted не воскресает при переиспользовании пути
- **GIVEN** задача A в `deleted`
- **WHEN** другая задача раскладывается по бывшему пути A
- **THEN** задача A остаётся в `deleted` (не переходит в `orphaned`)
### 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 не отбирается
### Requirement: Дебаунс пропажи источника ### Requirement: Дебаунс пропажи источника
Система SHALL дебаунсить только **отсутствие источника**, чтобы временная Система SHALL дебаунсить только **отсутствие источника**, чтобы временная
@@ -154,8 +213,8 @@ SHALL сбрасывать счётчик пропусков.
Система SHALL возвращать задачу в согласованное состояние, когда реальность Система SHALL возвращать задачу в согласованное состояние, когда реальность
восстановилась (состояние выводится из текущей матрицы «источник × цель»): восстановилась (состояние выводится из текущей матрицы «источник × цель»):
при возврате источника и/или цели задача SHALL переходить из при возврате источника и/или цели задача SHALL переходить из
`orphaned`/`target_missing`/`deleted` обратно (в т.ч. в `done`, когда `orphaned`/`target_missing` обратно (в т.ч. в `done`, когда присутствуют
присутствуют оба). оба). Из терминального `deleted` самовосстановления SHALL NOT быть.
#### Scenario: Источник вернулся #### Scenario: Источник вернулся