Files
jellybit/internal/worker/reconcile.go
T
avandClaude Opus 4.8 6b7c090ce4 Владение целевым путём при повторной раскладке (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>
2026-06-29 18:10:05 +03:00

199 lines
8.7 KiB
Go

package worker
import (
"context"
"errors"
"fmt"
"os"
"strings"
"git.vakhrushev.me/av/jellybit/internal/layout"
"git.vakhrushev.me/av/jellybit/internal/logctx"
"git.vakhrushev.me/av/jellybit/internal/qbt"
"git.vakhrushev.me/av/jellybit/internal/store"
)
// desyncStates — состояния, которые ведёт сверка с реальностью: уже
// разложенные (done) и восстановимые состояния рассинхрона. deleted сюда не
// входит — оно терминально и сверкой не переоценивается (источник к
// терминальной задаче не вернётся из-за идемпотентности, а цель отбирается
// переходом владения путём; см. state-reconciliation). Активные и
// пользовательски-терминальные (reverted/cancelled/failed/stuck) сверка не
// трогает.
var desyncStates = []store.State{
store.StateDone,
store.StateTargetMissing,
store.StateOrphaned,
}
// deriveState выводит состояние задачи из двумерной матрицы «источник × цель»
// (см. state-reconciliation, D1).
func deriveState(sourcePresent, targetPresent bool) store.State {
switch {
case sourcePresent && targetPresent:
return store.StateDone
case sourcePresent && !targetPresent:
return store.StateTargetMissing
case !sourcePresent && targetPresent:
return store.StateOrphaned
default:
return store.StateDeleted
}
}
// reconcileDesync сверяет разложенные/desync-задачи с реальностью. Вызывается
// из Poll под w.mu. byHash — карта присутствующих в qBittorrent раздач.
func (w *Worker) reconcileDesync(ctx context.Context, byHash map[string]qbt.Torrent) {
cands, err := w.store.ListDownloadsByState(ctx, desyncStates...)
if err != nil {
w.log.Warn("reconcile list desync failed", "capability", capIngest, "error", err)
return
}
for _, d := range cands {
w.reconcileOneDesync(ctx, d, byHash)
}
}
// reconcileOneDesync сверяет одну задачу: вычисляет присутствие источника (с
// дебаунсом) и цели, выводит состояние и переходит при изменении.
func (w *Worker) reconcileOneDesync(ctx context.Context, d store.Download, byHash map[string]qbt.Torrent) {
if !d.Infohash.Valid {
return // нечем сопоставить источник
}
ctx = w.scoped(ctx, capIngest, d.ID, d.Infohash.String)
_, sourceSeen := byHash[strings.ToLower(d.Infohash.String)]
// Дебаунс пропажи источника: считаем удалённым только после порога подряд
// идущих промахов; любое появление сбрасывает счётчик.
sourcePresent := w.debounceSource(ctx, d, sourceSeen)
targetPresent, err := w.targetPresent(ctx, d.ID)
if err != nil {
logctx.From(ctx).Warn("reconcile target probe failed", "error", err)
return // не можем проверить цель — не дёргаем состояние
}
want := deriveState(sourcePresent, targetPresent)
if want == d.State {
return
}
w.transition(ctx, d, want, "reconcile", reconcileReason(sourcePresent, targetPresent))
}
// debounceSource обновляет счётчик промахов источника и возвращает, считать ли
// источник присутствующим с учётом порога. Запись счётчика — только при его
// изменении (без лишних UPDATE на каждом тике).
func (w *Worker) debounceSource(ctx context.Context, d store.Download, sourceSeen bool) bool {
threshold := max(w.cfg.SourceMissingThreshold, 1)
if sourceSeen {
if d.SourceMissCount != 0 {
if err := w.store.SetSourceMissCount(ctx, d.ID, 0); err != nil {
logctx.From(ctx).Warn("reconcile reset miss count failed", "error", err)
}
}
return true
}
miss := d.SourceMissCount + 1
if err := w.store.SetSourceMissCount(ctx, d.ID, miss); err != nil {
logctx.From(ctx).Warn("reconcile bump miss count failed", "error", err)
}
// До порога источник трактуется как присутствующий — задача не дёргается.
return miss < threshold
}
// targetPresent сообщает, существуют ли разложенные хардлинки задачи. Цель
// считается присутствующей, только если существуют ВСЕ ссылки последнего
// батча; частичная пропажа — это отсутствие цели (библиотека сломана → relink).
func (w *Worker) targetPresent(ctx context.Context, id int64) (bool, error) {
batch, err := w.store.LatestBatchID(ctx, id)
if err != nil {
return false, fmt.Errorf("latest batch: %w", err)
}
if batch == "" {
return false, nil // ничего не разложено — цели нет
}
rows, err := w.store.ListFileLinksByBatch(ctx, batch)
if err != nil {
return false, fmt.Errorf("list links: %w", err)
}
laidOut := 0
for _, r := range rows {
// Все статусы, означающие реальный файл на диске: только что слинкован,
// скопирован (фолбэк) или уже существовал тем же inode (идемпотентный
// повтор apply). Прочие (collision и т.п.) — не наша разложенная цель.
if !isLaidOut(r.Status) {
continue
}
laidOut++
if _, err := os.Lstat(r.DstPath); err != nil {
if errors.Is(err, os.ErrNotExist) {
return false, nil
}
return false, fmt.Errorf("stat target %q: %w", r.DstPath, err)
}
}
return laidOut > 0, nil
}
// isLaidOut сообщает, означает ли статус file_link реально лежащий на ФС файл
// нашей раскладки (а не коллизию или иной не-разложенный исход).
func isLaidOut(status string) bool {
switch layout.LinkStatus(status) {
case layout.StatusLinked, layout.StatusCopied, layout.StatusExists:
return true
default:
return false
}
}
// reconcileReason — человекочитаемая причина перехода для лога/UI.
func reconcileReason(sourcePresent, targetPresent bool) string {
switch {
case sourcePresent && targetPresent:
return "источник и цель на месте"
case sourcePresent && !targetPresent:
return "цель удалена, источник на месте"
case !sourcePresent && targetPresent:
return "источник удалён, цель (последняя копия) на месте"
default:
return "удалены и источник, и цель"
}
}
// --- Синхронный preflight перед действием (не доверяем state в БД) ---
// ensureSourcePresent синхронно (без дебаунса) проверяет, что раздача есть в
// qBittorrent прямо сейчас. При отсутствии приводит состояние к реальности и
// возвращает ErrConflict. Недоступность qBittorrent — честный отказ операции.
func (w *Worker) ensureSourcePresent(ctx context.Context, d *store.Download, op string) error {
if !d.Infohash.Valid {
return fmt.Errorf("%s: download %d has no infohash", op, d.ID)
}
_, ok, err := w.torrentByInfohash(ctx, d.Infohash.String)
if err != nil {
return fmt.Errorf("%s: %w", op, err)
}
if ok {
return nil
}
// Источник пропал — немедленно приводим состояние к реальности.
w.reconcileToReality(ctx, *d, false)
return fmt.Errorf("%s: источник удалён из qBittorrent: %w", op, ErrConflict)
}
// reconcileToReality выводит и проставляет состояние по уже известному факту об
// источнике (sourcePresent) и фактически проверенной цели. Используется
// preflight-проверками: немедленно, без дебаунса.
func (w *Worker) reconcileToReality(ctx context.Context, d store.Download, sourcePresent bool) {
targetPresent, err := w.targetPresent(ctx, d.ID)
if err != nil {
logctx.FromOr(ctx, w.log).Warn("preflight target probe failed", "error", err)
return
}
want := deriveState(sourcePresent, targetPresent)
if want == d.State {
return
}
w.transition(ctx, d, want, "reconcile", reconcileReason(sourcePresent, targetPresent))
}