Владение целевым путём при повторной раскладке (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
+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) {
st := newTestStore(t)
ctx := context.Background()