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