Восстановление зависших загрузок и уведомления о падении (state-reconciliation)
Долгий metaDL больше не убивается агрессивным таймаутом: дефолт magnet_timeout 30m → 24h (страховочный предохранитель), базис отсчёта — added_on из qBittorrent, а не created_at (переживает retry/усыновление). Авто-восстановление: фоновая сверка возвращает в поток задачи, упавшие по нашей нетерпеливости (magnet_timeout/stalled), когда источник ожил и продвинулся за условие падения (downloading/completed по статусу торрента); qbit_error не воскрешается. Конфликт idempotency (infohash занят другой активной задачей) — оставляем в failed. Уведомления: любой переход в failed/stuck пингует автора (включая приёмный qbit_add через ingest), с дебаунсом против спама при флаппинге stalled. Ручной retry добавлен в веб-UI и Telegram; Retry перецепляется к живому торренту вместо слепого Add. Дельта state-reconciliation влита в живые спеки; обновлён workflow.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -160,6 +160,96 @@ func reconcileReason(sourcePresent, targetPresent bool) string {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Восстановление зависших загрузок (failed/stuck → поток) ---
|
||||
|
||||
// reconcileRecovery воскрешает задачи, упавшие из-за нашей нетерпеливости
|
||||
// (magnet_timeout/stalled), когда их источник в qBittorrent ожил и продвинулся
|
||||
// за условие падения. Вызывается из Poll под w.mu. Реальные/пользовательские
|
||||
// провалы (qbit_error/reverted/cancelled/deleted) сюда не попадают.
|
||||
func (w *Worker) reconcileRecovery(ctx context.Context, byHash map[string]qbt.Torrent) {
|
||||
cands, err := w.store.ListRecoverable(ctx, errCodeMagnetTimeout, errCodeStalled)
|
||||
if err != nil {
|
||||
w.log.Warn("recovery list failed", "capability", capIngest, "error", err)
|
||||
return
|
||||
}
|
||||
for _, d := range cands {
|
||||
w.reconcileOneRecovery(ctx, d, byHash)
|
||||
}
|
||||
}
|
||||
|
||||
// reconcileOneRecovery возвращает одну зависшую задачу в поток, если её торрент
|
||||
// присутствует и продвинулся за условие падения.
|
||||
func (w *Worker) reconcileOneRecovery(ctx context.Context, d store.Download, byHash map[string]qbt.Torrent) {
|
||||
if !d.Infohash.Valid {
|
||||
return
|
||||
}
|
||||
t, ok := byHash[strings.ToLower(d.Infohash.String)]
|
||||
if !ok {
|
||||
return // источника нет — оставляем как есть (вернёт ручной retry)
|
||||
}
|
||||
if !torrentProgressed(d, t) {
|
||||
return // торрент всё ещё в metaDL/stalledDL или ошибочен — не воскрешаем
|
||||
}
|
||||
want := recoveredState(t.State)
|
||||
if want == "" {
|
||||
return // переходное состояние qBit (moving/checking) — ждём
|
||||
}
|
||||
ctx = w.scoped(ctx, capIngest, d.ID, d.Infohash.String)
|
||||
|
||||
// Конфликт идемпотентности: пока задача лежала в failed, тот же infohash мог
|
||||
// взять другая активная задача (idempotency_key снят при падении). Оба
|
||||
// целевых состояния (downloading/completed) нетерминальны → SetDownloadState
|
||||
// восстановит idempotency_key = infohash; при занятом ключе упёрлись бы в
|
||||
// unique-индекс. Поэтому проверяем владельца независимо от целевого состояния
|
||||
// и оставляем старую задачу в failed.
|
||||
other, err := w.store.FindActiveByInfohash(ctx, d.Infohash.String)
|
||||
if err != nil {
|
||||
logctx.From(ctx).Warn("recovery active lookup failed", "error", err)
|
||||
return
|
||||
}
|
||||
if other != nil && other.ID != d.ID {
|
||||
logctx.From(ctx).Info("recovery skipped, infohash taken by active download",
|
||||
"conflict_download_id", other.ID)
|
||||
return
|
||||
}
|
||||
// error_code/error_msg не пишем — задача снова здорова; причину в лог, а не в
|
||||
// поле ошибки (иначе она светилась бы в UI/REST как ошибка живой задачи).
|
||||
logctx.From(ctx).Info("recovery from failure", "to", want, "qbit_state", t.State)
|
||||
w.transition(ctx, d, want, "", "")
|
||||
}
|
||||
|
||||
// torrentProgressed сообщает, продвинулся ли торрент за условие, по которому
|
||||
// задача упала: для magnet_timeout — получил метаданные (вышел из metaDL); для
|
||||
// stalled — раздача ожила (вышла из stalledDL). Ошибочные состояния qBittorrent
|
||||
// продвижением не считаем (их ведёт обычный reconcile в qbit_error).
|
||||
func torrentProgressed(d store.Download, t qbt.Torrent) bool {
|
||||
if classify(t.State) == classErrored {
|
||||
return false
|
||||
}
|
||||
switch d.ErrorCode.String {
|
||||
case errCodeMagnetTimeout:
|
||||
return !isMeta(t.State)
|
||||
case errCodeStalled:
|
||||
return !isStalledDL(t.State)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// recoveredState выводит состояние воскрешённой задачи из состояния торрента:
|
||||
// готов к раскладке → completed; ещё качается → downloading. Переходные
|
||||
// (moving/checking) и ошибочные состояния не восстанавливаем (пусто).
|
||||
func recoveredState(state string) store.State {
|
||||
switch classify(state) {
|
||||
case classReady:
|
||||
return store.StateCompleted
|
||||
case classDownloading:
|
||||
return store.StateDownloading
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// --- Синхронный preflight перед действием (не доверяем state в БД) ---
|
||||
|
||||
// ensureSourcePresent синхронно (без дебаунса) проверяет, что раздача есть в
|
||||
|
||||
Reference in New Issue
Block a user