Обработка рассинхрона состояния с реальностью (state-reconciliation)
Распознаём ручное удаление источника (раздача в qBittorrent) и/или цели (разложенные хардлинки) и отражаем его в состоянии задачи, без автодействий. - Новая capability state-reconciliation (OpenSpec): фоновая сверка по матрице «источник × цель» → состояния target_missing/orphaned/deleted, переходы и самовосстановление (healing). - worker: reconcileDesync в Poll (только разложенные/desync-задачи), дебаунс пропажи источника (порог [worker].source_missing_threshold) и синхронный preflight перед действиями (relink/recognize/apply/undo) — не доверяем state в БД. - layout.Undo: отказ снять последнюю копию (nlink<=1 или нет источника), отказ всего батча без частичного отката (ErrLastCopy). - store: единый список terminalStates для IsTerminal и FindActiveByInfohash (иначе семантика «активности» разъезжается), столбец source_miss_count, миграция 0003. - httpapi/web и Telegram: показ новых состояний и уведомления о рассинхроне. - Доки: workflow.md, jellyfin-layout.md, database.md (+0003), config. Change заархивирован в openspec/changes/archive, дельта влита в openspec/specs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -232,6 +232,60 @@ func TestUndo_RemovesLinksAndPrunesDirs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUndo_RefusesLastCopyWhenSourceGone(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
links, _ := f.l.BuildLinks(Plan{Type: Movie, Title: "Film", Year: 2020,
|
||||
Files: []PlanFile{{Src: f.srcFile(t, "m/film.mkv", "data"), Role: RoleMain}}})
|
||||
if _, err := f.l.Apply(context.Background(), links); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Источник удалён (как при удалении раздачи из qBittorrent) → цель стала
|
||||
// последней копией (nlink упал до 1).
|
||||
if err := os.Remove(links[0].Src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
n, err := f.l.Undo(context.Background(), links)
|
||||
if !errors.Is(err, ErrLastCopy) {
|
||||
t.Fatalf("err = %v, want ErrLastCopy", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("removed = %d, want 0 (ничего не сняли)", n)
|
||||
}
|
||||
// Цель НЕ удалена — данные целы.
|
||||
if _, err := os.Stat(links[0].Dst); err != nil {
|
||||
t.Errorf("target must remain (last copy): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUndo_RefusesWholeBatchIfAnyLastCopy(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
links, _ := f.l.BuildLinks(Plan{Type: Series, Title: "Show", Year: 2021,
|
||||
Files: []PlanFile{
|
||||
{Src: f.srcFile(t, "s/e1.mkv", "1"), Role: RoleEpisode, Season: intp(1), Episode: intp(1)},
|
||||
{Src: f.srcFile(t, "s/e2.mkv", "2"), Role: RoleEpisode, Season: intp(1), Episode: intp(2)},
|
||||
}})
|
||||
if _, err := f.l.Apply(context.Background(), links); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Источник второй серии пропал — весь батч должен быть отклонён целиком.
|
||||
if err := os.Remove(links[1].Src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
n, err := f.l.Undo(context.Background(), links)
|
||||
if !errors.Is(err, ErrLastCopy) {
|
||||
t.Fatalf("err = %v, want ErrLastCopy", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("removed = %d, want 0 (батч не трогаем)", n)
|
||||
}
|
||||
// Первая ссылка (источник жив) НЕ снята — частичного отката нет.
|
||||
if _, err := os.Stat(links[0].Dst); err != nil {
|
||||
t.Errorf("first target must remain (no partial undo): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUndo_Idempotent(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
links, _ := f.l.BuildLinks(Plan{Type: Movie, Title: "Film", Year: 2020,
|
||||
|
||||
Reference in New Issue
Block a user