Владение целевым путём при повторной раскладке (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:
@@ -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 выводит состояние задачи из двумерной матрицы «источник × цель»
|
||||
|
||||
@@ -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;
|
||||
// возврат источника лечит обратно и сбрасывает счётчик.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user