Обработка рассинхрона состояния с реальностью (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:
av
2026-06-29 11:07:09 +03:00
co-authored by Claude Opus 4.8
parent f9d7fd9216
commit cc7e51b3a4
29 changed files with 1556 additions and 80 deletions
+66 -6
View File
@@ -228,6 +228,11 @@ type Result struct {
// ErrCollision — цель существует и это другой файл (нужен review).
var ErrCollision = errors.New("layout: target collision")
// ErrLastCopy — отказ снять ссылку, которая осталась последней копией данных
// (источник недоступен или nlink<=1). Undo снимает лишний хардлинк, а не
// единственный файл (см. state-reconciliation, инвариант безопасного Undo).
var ErrLastCopy = errors.New("layout: refusing to remove last remaining copy")
// Apply создаёт хардлинки по ссылкам. Идемпотентно: повтор после сбоя
// доводит начатое. При коллизии (цель занята чужим файлом) возвращает
// ErrCollision, не перезаписывая. Если хардлинк невозможен (разные ФС или ФС
@@ -367,16 +372,41 @@ func sameFile(src, dst string) (bool, error) {
// Undo удаляет ссылки и подчищает опустевшие каталоги. Снимает только пути
// строго под библиотеками (источник недосягаем). Отсутствующая цель — не
// ошибка (идемпотентно). Возвращает число удалённых ссылок.
//
// Защита от потери данных: сперва предпроверка всего батча — если хоть одна
// существующая цель оказывается последней копией (источник пропал или
// nlink<=1), весь Undo отклоняется с ErrLastCopy и НИ ОДНА ссылка не
// снимается (иначе частичный откат стёр бы часть данных). Так откат снимает
// лишний хардлинк, а не единственный файл.
func (l *Layouter) Undo(ctx context.Context, links []Link) (int, error) {
log := logctx.FromOr(ctx, l.log)
// Предпроверка: путь под библиотекой + не последняя копия.
for _, ln := range links {
if _, err := undoRoot(l, ln.Dst); err != nil {
return 0, err
}
fi, err := os.Lstat(ln.Dst)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
continue // цели уже нет — снимать нечего (идемпотентно)
}
return 0, fmt.Errorf("layout: undo stat %q: %w", ln.Dst, err)
}
last, err := isLastCopy(fi, ln.Src)
if err != nil {
return 0, fmt.Errorf("layout: undo check %q: %w", ln.Dst, err)
}
if last {
return 0, fmt.Errorf("%w: %q (источник %q недоступен)", ErrLastCopy, ln.Dst, ln.Src)
}
}
removed := 0
for _, ln := range links {
root := l.movies
if !underRoot(l.movies, ln.Dst) {
root = l.series
}
if !underRoot(root, ln.Dst) {
return removed, fmt.Errorf("layout: undo outside library: %q", ln.Dst)
root, err := undoRoot(l, ln.Dst)
if err != nil {
return removed, err
}
if err := os.Remove(ln.Dst); err != nil {
if errors.Is(err, fs.ErrNotExist) {
@@ -392,6 +422,36 @@ func (l *Layouter) Undo(ctx context.Context, links []Link) (int, error) {
return removed, nil
}
// undoRoot возвращает корень библиотеки, под которым лежит dst, либо ошибку,
// если путь не под movies/series (откат трогает только библиотеку).
func undoRoot(l *Layouter, dst string) (string, error) {
root := l.movies
if !underRoot(l.movies, dst) {
root = l.series
}
if !underRoot(root, dst) {
return "", fmt.Errorf("layout: undo outside library: %q", dst)
}
return root, nil
}
// isLastCopy сообщает, является ли цель последней копией данных: исходный файл
// уже не существует, либо у цели не осталось других жёстких ссылок (nlink<=1).
// В обоих случаях unlink цели уничтожил бы единственную копию.
func isLastCopy(fi os.FileInfo, src string) (bool, error) {
if _, err := os.Lstat(src); err != nil {
if errors.Is(err, fs.ErrNotExist) {
return true, nil // источника нет — цель единственная копия
}
return false, fmt.Errorf("stat source %q: %w", src, err)
}
st, ok := fi.Sys().(*syscall.Stat_t)
if !ok {
return false, fmt.Errorf("unexpected stat type for %q", fi.Name())
}
return st.Nlink <= 1, nil
}
// pruneEmptyDirs удаляет опустевшие каталоги вверх до (не включая) root.
// Ошибки игнорируются: непустой каталог os.Remove не удалит — это и нужно.
func pruneEmptyDirs(dir, root string) {
+54
View File
@@ -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,