Обработка рассинхрона состояния с реальностью (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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user