diff --git a/docs/specs/jellyfin-layout.md b/docs/specs/jellyfin-layout.md index 7e021db..1d06ebc 100644 --- a/docs/specs/jellyfin-layout.md +++ b/docs/specs/jellyfin-layout.md @@ -51,6 +51,19 @@ inode общий — диск не дублируется. же inode → готово; другой файл → коллизия → review). Инварианты и undo — в [architecture.md](architecture.md) → «Раскладка файлов». +## Владение целевым путём + +Целевой путь принадлежит **одной** загрузке. Когда новая раскладка +успешно ложится на путь, который раньше занимала другая загрузка (путь к +этому моменту **свободен** — иначе была бы коллизия → review, чужой файл +не перезаписываем), владение переходит к новой загрузке: прежние записи +`file_link` на этот путь помечаются статусом `superseded` и перестают +считаться целью прежней загрузки. Это нужно сверке с реальностью: иначе +повторная закачка того же фильма (например, в другом качестве) по тому же +пути ложно «воскрешала» бы удалённую задачу — см. +[workflow.md](workflow.md) → «Сверка с реальностью». `superseded`-ссылки +не считаются целью при сверке и не снимаются в `Undo`. + Желательно: целевой и исходный каталоги — на одной ФС/одном mount'е (внутри контейнера это обеспечивает единая песочница `/srv/media`), тогда работает дешёвый хардлинк. Если хардлинк невозможен (разные ФС или ФС без diff --git a/docs/specs/workflow.md b/docs/specs/workflow.md index e0ebba4..4095565 100644 --- a/docs/specs/workflow.md +++ b/docs/specs/workflow.md @@ -107,12 +107,19 @@ stateDiagram-v2 «Привязать заново» (`→ recognizing`); авто-действий нет. - **orphaned** — источник пропал, цель (последняя копия данных) на месте. Команд вперёд нет; `Undo` запрещён (снял бы единственную копию). -- **deleted** — нет ни источника, ни цели; терминально. +- **deleted** — нет ни источника, ни цели; **терминально**: сверка его + больше не переоценивает (см. ниже). -Сверка трогает только `done`/`target_missing`/`orphaned`/`deleted` — -активные и пользовательски-терминальные (`reverted`/`cancelled`/`failed`/ -`stuck`) состояния не задевает. Реальность «лечится» сама: при возврате -источника/цели задача переходит обратно (вплоть до `done`). Пропажа +Сверка трогает только `done`/`target_missing`/`orphaned` — терминальный +`deleted`, активные и пользовательски-терминальные (`reverted`/`cancelled`/ +`failed`/`stuck`) состояния не задевает. Реальность «лечится» сама: при +возврате источника/цели задача переходит обратно (вплоть до `done`) — но +**не из `deleted`**: к терминальной задаче источник не вернётся +(идемпотентность снимается только для активных), а её бывший целевой путь, если +его заняла другая загрузка, отбирается переходом владения (см. +[jellyfin-layout.md](jellyfin-layout.md) → «Владение целевым путём»). +Без этого правила переиспользование пути ложно «воскрешало» бы удалённую +задачу в `orphaned`. Пропажа **источника** дебаунсится (`[worker].source_missing_threshold` подряд идущих тиков), пропажа цели проверяется немедленно (локальная ФС надёжна). Команды, которым нужен источник (relink/распознать/применить/undo), проверяют его diff --git a/internal/layout/layout.go b/internal/layout/layout.go index 98d5810..eaa8cf8 100644 --- a/internal/layout/layout.go +++ b/internal/layout/layout.go @@ -213,10 +213,11 @@ func (l *Layouter) seriesDst(root, folder, base string, f *PlanFile) (string, Ki type LinkStatus string const ( - StatusLinked LinkStatus = "linked" // хардлинк создан - StatusCopied LinkStatus = "copied" // хардлинк невозможен — файл скопирован (фолбэк) - StatusExists LinkStatus = "exists" // уже была (тот же inode) — идемпотентно - StatusCollision LinkStatus = "collision" // цель занята другим файлом + StatusLinked LinkStatus = "linked" // хардлинк создан + StatusCopied LinkStatus = "copied" // хардлинк невозможен — файл скопирован (фолбэк) + StatusExists LinkStatus = "exists" // уже была (тот же inode) — идемпотентно + StatusCollision LinkStatus = "collision" // цель занята другим файлом + StatusSuperseded LinkStatus = "superseded" // путь перехватила другая загрузка (см. state-reconciliation, владение путём) ) // Result — итог по одной ссылке. diff --git a/internal/store/recognition.go b/internal/store/recognition.go index c6c4f94..303bf08 100644 --- a/internal/store/recognition.go +++ b/internal/store/recognition.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "strings" ) // Recognition — строка таблицы recognition (попытка распознавания). @@ -195,6 +196,34 @@ VALUES (?, ?, ?, ?, ?, ?)` 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 последнего применённого батча // загрузки (для undo) либо пустую строку, если ссылок нет. func (s *Store) LatestBatchID(ctx context.Context, downloadID int64) (string, error) { diff --git a/internal/store/recognition_test.go b/internal/store/recognition_test.go index a621115..a5642ba 100644 --- a/internal/store/recognition_test.go +++ b/internal/store/recognition_test.go @@ -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) { st := newTestStore(t) ctx := context.Background() diff --git a/internal/worker/reconcile.go b/internal/worker/reconcile.go index 7aa194d..b02a8da 100644 --- a/internal/worker/reconcile.go +++ b/internal/worker/reconcile.go @@ -14,14 +14,16 @@ import ( ) // desyncStates — состояния, которые ведёт сверка с реальностью: уже -// разложенные (done) и сами состояния рассинхрона. Активные и +// разложенные (done) и восстановимые состояния рассинхрона. deleted сюда не +// входит — оно терминально и сверкой не переоценивается (источник к +// терминальной задаче не вернётся из-за идемпотентности, а цель отбирается +// переходом владения путём; см. state-reconciliation). Активные и // пользовательски-терминальные (reverted/cancelled/failed/stuck) сверка не -// трогает (см. state-reconciliation). +// трогает. var desyncStates = []store.State{ store.StateDone, store.StateTargetMissing, store.StateOrphaned, - store.StateDeleted, } // deriveState выводит состояние задачи из двумерной матрицы «источник × цель» diff --git a/internal/worker/reconcile_test.go b/internal/worker/reconcile_test.go index df1933b..1b1a63e 100644 --- a/internal/worker/reconcile_test.go +++ b/internal/worker/reconcile_test.go @@ -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) { // Порог 3: первые два промаха не помечают, третий — помечает orphaned; // возврат источника лечит обратно и сбрасывает счётчик. diff --git a/internal/worker/review.go b/internal/worker/review.go index bb5a979..0380e57 100644 --- a/internal/worker/review.go +++ b/internal/worker/review.go @@ -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 { 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 { @@ -494,9 +510,15 @@ func (w *Worker) Undo(ctx context.Context, id int64) error { if err != nil { return fmt.Errorf("undo: %w", err) } - links := make([]layout.Link, len(rows)) - for i, r := range rows { - links[i] = layout.Link{Src: r.SrcPath, Dst: r.DstPath, Kind: layout.Kind(r.Kind)} + // Снимаем только реально разложенные нами ссылки. superseded-строки — + // путь забрала другая загрузка (см. state-reconciliation, владение + // путём); файл по нему теперь её хардлинк, трогать его нельзя. + 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) if err != nil { diff --git a/internal/worker/review_test.go b/internal/worker/review_test.go index a0d081b..aaed54b 100644 --- a/internal/worker/review_test.go +++ b/internal/worker/review_test.go @@ -353,6 +353,25 @@ func (m *memStore) CreateFileLinks(_ context.Context, links []store.FileLink) er m.links = append(m.links, links...) 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) { for i := len(m.links) - 1; i >= 0; i-- { 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) { plan := seriesResult().Plan f := newApplyFixture(t, plan) diff --git a/internal/worker/worker.go b/internal/worker/worker.go index bc81092..596441e 100644 --- a/internal/worker/worker.go +++ b/internal/worker/worker.go @@ -55,6 +55,7 @@ type Store interface { SetOverride(ctx context.Context, downloadID int64, field, value string) error ListOverrides(ctx context.Context, downloadID int64) (map[string]string, 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) ListFileLinksByBatch(ctx context.Context, batchID string) ([]store.FileLink, error) DeleteFileLinksByBatch(ctx context.Context, batchID string) error diff --git a/internal/worker/worker_test.go b/internal/worker/worker_test.go index 3996ff8..03b50dc 100644 --- a/internal/worker/worker_test.go +++ b/internal/worker/worker_test.go @@ -114,7 +114,10 @@ func (f *fakeStore) ListOverrides(_ context.Context, _ int64) (map[string]string return nil, 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) { return nil, nil } diff --git a/openspec/changes/archive/2026-06-29-target-path-ownership/.openspec.yaml b/openspec/changes/archive/2026-06-29-target-path-ownership/.openspec.yaml new file mode 100644 index 0000000..34f9314 --- /dev/null +++ b/openspec/changes/archive/2026-06-29-target-path-ownership/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-29 diff --git a/openspec/changes/archive/2026-06-29-target-path-ownership/design.md b/openspec/changes/archive/2026-06-29-target-path-ownership/design.md new file mode 100644 index 0000000..d48709f --- /dev/null +++ b/openspec/changes/archive/2026-06-29-target-path-ownership/design.md @@ -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 как «снимаемые». diff --git a/openspec/changes/archive/2026-06-29-target-path-ownership/proposal.md b/openspec/changes/archive/2026-06-29-target-path-ownership/proposal.md new file mode 100644 index 0000000..a872c52 --- /dev/null +++ b/openspec/changes/archive/2026-06-29-target-path-ownership/proposal.md @@ -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` по уже удалённым загрузкам; повторная закачка того же + фильма в другом качестве больше не «трогает» прежнюю задачу. diff --git a/openspec/changes/archive/2026-06-29-target-path-ownership/specs/state-reconciliation/spec.md b/openspec/changes/archive/2026-06-29-target-path-ownership/specs/state-reconciliation/spec.md new file mode 100644 index 0000000..4443db1 --- /dev/null +++ b/openspec/changes/archive/2026-06-29-target-path-ownership/specs/state-reconciliation/spec.md @@ -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` diff --git a/openspec/changes/archive/2026-06-29-target-path-ownership/tasks.md b/openspec/changes/archive/2026-06-29-target-path-ownership/tasks.md new file mode 100644 index 0000000..a2e4ce5 --- /dev/null +++ b/openspec/changes/archive/2026-06-29-target-path-ownership/tasks.md @@ -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` проходит. diff --git a/openspec/specs/state-reconciliation/spec.md b/openspec/specs/state-reconciliation/spec.md index 6e1e2aa..744ad84 100644 --- a/openspec/specs/state-reconciliation/spec.md +++ b/openspec/specs/state-reconciliation/spec.md @@ -18,12 +18,15 @@ qBittorrent. Capability описывает периодическую и при ожидаются разложенные файлы, с фактом на файловой системе и в qBittorrent, и выводить состояние задачи из двух независимых признаков: присутствия **источника** (раздача с `download.infohash` в выдаче qBittorrent) и -присутствия **цели** (все `file_link` со `status = linked` существуют на ФС). +присутствия **цели** (см. требование о владении целевым путём: существуют все +ссылки последнего батча со статусом раскладки, всё ещё принадлежащие этой +загрузке). Сверке SHALL подвергаться только состояния `done`, `target_missing`, -`orphaned`, `deleted`. Активные (`downloading`/`recognizing`/`review`/ -`deferred`/`linking`) и пользовательски-терминальные (`reverted`/`cancelled`/ -`failed`/`stuck`) состояния сверка трогать SHALL NOT. +`orphaned`. Состояние `deleted` сверка трогать SHALL NOT — оно терминально. +Активные (`downloading`/`recognizing`/`review`/`deferred`/`linking`) и +пользовательски-терминальные (`reverted`/`cancelled`/`failed`/`stuck`) +состояния сверка трогать SHALL NOT. Состояние SHALL переписываться только при его изменении (без записи и логов, когда выведенное состояние совпадает с текущим). @@ -40,6 +43,12 @@ qBittorrent. Capability описывает периодическую и при - **WHEN** часть разложенных хардлинков задачи удалена, а источник на месте - **THEN** цель считается отсутствующей и задача переходит в `target_missing` +#### Scenario: Задача в deleted сверкой не переоценивается + +- **WHEN** задача находится в `deleted` +- **THEN** сверка её не рассматривает и состояние не меняет, даже если по её + бывшему пути появился файл другой загрузки + ### Requirement: Принудительная проверка источника/цели перед действием Команда workflow, требующая наличия источника или цели, SHALL синхронно @@ -117,8 +126,10 @@ NOT полагаться только на фоновую сверку `worker` ### Requirement: Состояние deleted при пропаже источника и цели Когда отсутствуют и источник (с учётом дебаунса), и цель, система SHALL -переводить задачу в состояние `deleted`. В `deleted` действий над задачей -больше нет. +переводить задачу в состояние `deleted`. `deleted` терминально: действий над +задачей больше нет, и сверка её больше не переоценивает (источник к +терминальной задаче не возвращается из-за идемпотентности, а цель отбирается +переходом владения путём к другой загрузке). #### Scenario: Источник и цель удалены @@ -126,6 +137,54 @@ NOT полагаться только на фоновую сверку `worker` хардлинков задачи на ФС больше нет - **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: Дебаунс пропажи источника Система SHALL дебаунсить только **отсутствие источника**, чтобы временная @@ -154,8 +213,8 @@ SHALL сбрасывать счётчик пропусков. Система SHALL возвращать задачу в согласованное состояние, когда реальность восстановилась (состояние выводится из текущей матрицы «источник × цель»): при возврате источника и/или цели задача SHALL переходить из -`orphaned`/`target_missing`/`deleted` обратно (в т.ч. в `done`, когда -присутствуют оба). +`orphaned`/`target_missing` обратно (в т.ч. в `done`, когда присутствуют +оба). Из терминального `deleted` самовосстановления SHALL NOT быть. #### Scenario: Источник вернулся