Владение целевым путём при повторной раскладке (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:
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Recognition — строка таблицы recognition (попытка распознавания).
|
||||
@@ -195,6 +196,34 @@ VALUES (?, ?, ?, ?, ?, ?)`
|
||||
return nil
|
||||
}
|
||||
|
||||
// SupersedeForeignLinks помечает статусом superseded ссылки ДРУГИХ загрузок,
|
||||
// разложенные по тем же dst_path, что и переданные. Реализует инвариант
|
||||
// «один целевой путь — один владелец» (см. state-reconciliation): свежая
|
||||
// раскладка забирает владение освободившимся путём, прежние записи перестают
|
||||
// считаться целью при сверке. Затрагивает только активные статусы раскладки
|
||||
// (linked/copied/exists) и не трогает саму загрузку (download_id != ?).
|
||||
func (s *Store) SupersedeForeignLinks(ctx context.Context, downloadID int64, dstPaths []string) error {
|
||||
if len(dstPaths) == 0 {
|
||||
return nil
|
||||
}
|
||||
ph := make([]string, len(dstPaths))
|
||||
args := make([]any, 0, len(dstPaths)+1)
|
||||
args = append(args, downloadID)
|
||||
for i, p := range dstPaths {
|
||||
ph[i] = "?"
|
||||
args = append(args, p)
|
||||
}
|
||||
q := fmt.Sprintf(`
|
||||
UPDATE file_link SET status = 'superseded'
|
||||
WHERE download_id != ?
|
||||
AND status IN ('linked', 'copied', 'exists')
|
||||
AND dst_path IN (%s)`, strings.Join(ph, ", "))
|
||||
if _, err := s.DB.ExecContext(ctx, q, args...); err != nil {
|
||||
return fmt.Errorf("supersede foreign links: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LatestBatchID возвращает apply_batch_id последнего применённого батча
|
||||
// загрузки (для undo) либо пустую строку, если ссылок нет.
|
||||
func (s *Store) LatestBatchID(ctx context.Context, downloadID int64) (string, error) {
|
||||
|
||||
@@ -159,6 +159,56 @@ func TestFileLinks_BatchLifecycle(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupersedeForeignLinks(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
owner := seedDownload(t, st)
|
||||
foreign, err := st.CreateDownload(ctx,
|
||||
newDownloading("bbccddeeff00112233445566778899aabbccddee"))
|
||||
if err != nil {
|
||||
t.Fatalf("seed foreign: %v", err)
|
||||
}
|
||||
|
||||
shared := "/m/Movie (2024).mkv"
|
||||
// foreign разложена по shared (linked) и по своему пути (exists);
|
||||
// owner разложен по shared и по третьему пути.
|
||||
if err := st.CreateFileLinks(ctx, []FileLink{
|
||||
{DownloadID: foreign, ApplyBatchID: "f", SrcPath: "/d/f.mkv", DstPath: shared, Kind: "video", Status: "linked"},
|
||||
{DownloadID: foreign, ApplyBatchID: "f", SrcPath: "/d/g.mkv", DstPath: "/m/Other (2024).mkv", Kind: "video", Status: "exists"},
|
||||
{DownloadID: owner, ApplyBatchID: "o", SrcPath: "/d/o.mkv", DstPath: shared, Kind: "video", Status: "linked"},
|
||||
}); err != nil {
|
||||
t.Fatalf("create links: %v", err)
|
||||
}
|
||||
|
||||
if err := st.SupersedeForeignLinks(ctx, owner, []string{shared}); err != nil {
|
||||
t.Fatalf("supersede: %v", err)
|
||||
}
|
||||
|
||||
links, _ := st.ListFileLinksByBatch(ctx, "f")
|
||||
for _, l := range links {
|
||||
switch l.DstPath {
|
||||
case shared:
|
||||
if l.Status != "superseded" {
|
||||
t.Errorf("чужая ссылка на %q = %q, want superseded", shared, l.Status)
|
||||
}
|
||||
default: // /m/Other — другой путь, не трогаем
|
||||
if l.Status != "exists" {
|
||||
t.Errorf("ссылка на %q = %q, want exists (не тронута)", l.DstPath, l.Status)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Свою ссылку owner не суперсидит (download_id != self).
|
||||
own, _ := st.ListFileLinksByBatch(ctx, "o")
|
||||
if len(own) != 1 || own[0].Status != "linked" {
|
||||
t.Errorf("своя ссылка = %+v, want linked", own)
|
||||
}
|
||||
|
||||
// Пустой список путей — no-op, без ошибки.
|
||||
if err := st.SupersedeForeignLinks(ctx, owner, nil); err != nil {
|
||||
t.Errorf("пустой dstPaths должен быть no-op: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCandidates_Lifecycle(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
Reference in New Issue
Block a user