Владение целевым путём при повторной раскладке (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
+85
View File
@@ -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)