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