Устойчивость раскладки и переходов linking (MAJOR-4, MINOR-7)
Закрывает две связанные дыры «claim-then-side-effect» в раскладке хардлинками. MINOR-7: transition глотал ошибку записи состояния — на путях Apply и авто-раскладки выполнение продолжалось к хардлинкам при незакоммиченном claim перехода в linking, а финальный linking→done отклонялся графом (задача застревала со stale-планом). Выделен transitionErr, возвращающий ошибку; Apply и finishRecognition прерываются ДО linkPlan при провале claim. Обёртка transition (void) сохранена для fire-and-forget переходов — соседние функции воркера не тронуты. MAJOR-4: (A) провал CreateFileLinks после создания хардлинков больше не оставляет задачу в linking голым return — уводим в review (код persist), повтор Apply идемпотентен. (B) новый шаг pollOnce sweepLinking возвращает осиротевшие после краха linking-задачи в review (код interrupted) на тике и старте; любая linking под w.mu устарела по построению. Восстановлен инвариант «у каждого нетерминального состояния есть владелец». Граф переходов не тронут (ребро linking→review уже объявлено). Тесты: провал claim не создаёт хардлинков; провал учёта уводит в review; sweep осиротевшего linking. OpenSpec-change linking-transition-robustness (дельты file-layout, state-reconciliation). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -322,12 +322,39 @@ func (w *Worker) pollOnce(ctx context.Context) {
|
||||
// Быстрый приём отложил добавление в qBittorrent: подхватываем пойманные
|
||||
// (catched) загрузки и добавляем их (сеть — вне блокировки переходов).
|
||||
w.processCatched(ctx)
|
||||
// Восстанавливаем задачи, застрявшие в linking после краха между claim и
|
||||
// финальным переходом (иначе их не листит никто — вечный лимбо).
|
||||
w.sweepLinking(ctx)
|
||||
// Ф3: распознаём завершённые загрузки (и перезапускаем по подсказке).
|
||||
if w.recognizer != nil {
|
||||
w.recognizePending(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// sweepLinking восстанавливает задачи, застрявшие в состоянии linking. Любая
|
||||
// linking-задача, видимая под w.mu, устарела по построению: активная раскладка
|
||||
// (linkPlan) держит w.mu на всё время и завершает переход из linking ДО отпускания
|
||||
// замка — значит эта задача осталась в linking после краха процесса между claim
|
||||
// (переходом в linking) и финальным переходом. Возвращаем её в review с причиной;
|
||||
// человек повторит Apply (linkPlan идемпотентен), и незаписанный учёт хардлинков
|
||||
// допишется. Так у linking появляется владелец на рестарте/тике — инвариант «у
|
||||
// каждого нетерминального состояния есть владелец» (как recognizePending для
|
||||
// recognizing). Выполняется на каждом тике и на старте (первый pollOnce до цикла).
|
||||
func (w *Worker) sweepLinking(ctx context.Context) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
stuck, err := w.store.ListDownloadsByState(ctx, store.StateLinking)
|
||||
if err != nil {
|
||||
w.log.Warn("sweep linking list failed", "capability", capFileLayout, "error", err)
|
||||
return
|
||||
}
|
||||
for _, d := range stuck {
|
||||
lctx := w.scoped(ctx, capFileLayout, d.ID, d.PrimaryInfohash())
|
||||
w.transition(lctx, d, store.StateReview, "interrupted",
|
||||
"прерванная раскладка, повтори применение")
|
||||
}
|
||||
}
|
||||
|
||||
// processCatched — асинхронный шаг добавления пойманных загрузок в qBittorrent.
|
||||
// Для каждой catched: (предохранитель) если висит дольше catch_timeout — уводим
|
||||
// в failed; иначе выводим имя и добавляем в qBit. Медленные вызовы (LLM-namer,
|
||||
@@ -641,14 +668,28 @@ func (w *Worker) retriedFloor(d store.Download, basis time.Time) time.Time {
|
||||
return basis
|
||||
}
|
||||
|
||||
// transition пишет новое состояние и логирует переход.
|
||||
// transition пишет новое состояние и логирует переход. Fire-and-forget обёртка
|
||||
// над transitionErr: применяется там, где переход терминален для шага — за ним
|
||||
// нет побочного эффекта, зависящего от факта записи claim (reconcile, таймауты,
|
||||
// команды ревью, финальные переходы linkPlan, sweep). Ошибку записи гасит (её
|
||||
// уже залогировал transitionErr).
|
||||
func (w *Worker) transition(ctx context.Context, d store.Download, state store.State, code, msg string) {
|
||||
_ = w.transitionErr(ctx, d, state, code, msg)
|
||||
}
|
||||
|
||||
// transitionErr пишет новое состояние, шлёт пинги/скан, логирует переход и
|
||||
// ВОЗВРАЩАЕТ ошибку записи. На claim-then-side-effect путях (ручное Apply,
|
||||
// авто-раскладка в finishRecognition) провал claim перехода в `linking` ОБЯЗАН
|
||||
// прервать выполнение ДО побочных эффектов (хардлинков): иначе ссылки лягут при
|
||||
// незакоммиченном claim, а финальный переход из фактического (не `linking`)
|
||||
// состояния граф отклонит — задача застрянет со stale-планом (MINOR-7).
|
||||
func (w *Worker) transitionErr(ctx context.Context, d store.Download, state store.State, code, msg string) error {
|
||||
// FromOr, а не From: если вызывающий не завёл scoped-логгер, падаем на
|
||||
// w.log (настроенный), а не на slog.Default().
|
||||
log := logctx.FromOr(ctx, w.log)
|
||||
if err := w.store.SetDownloadState(ctx, d.ID, state, code, msg); err != nil {
|
||||
log.Error("state transition failed", "from", d.State, "to", state, "error", err)
|
||||
return
|
||||
return fmt.Errorf("transition %s → %s: %w", d.State, state, err)
|
||||
}
|
||||
log.Info("state transition", "from", d.State, "to", state, "code", code)
|
||||
|
||||
@@ -681,6 +722,7 @@ func (w *Worker) transition(ctx context.Context, d store.Download, state store.S
|
||||
gctx := w.scoped(context.Background(), capFileLayout, d.ID, d.PrimaryInfohash())
|
||||
go func() { _ = w.scanner.RefreshLibraries(gctx) }()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// shouldNotifyFail дебаунсит повторные уведомления о падении одной задачи
|
||||
|
||||
Reference in New Issue
Block a user