Владение целевым путём при повторной раскладке (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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user