Владение целевым путём при повторной раскладке (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
+5 -3
View File
@@ -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 выводит состояние задачи из двумерной матрицы «источник × цель»
+12
View File
@@ -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;
// возврат источника лечит обратно и сбрасывает счётчик.
+25 -3
View File
@@ -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 {
+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)
+1
View File
@@ -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
+4 -1
View File
@@ -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
}