Восстановление зависших загрузок и уведомления о падении (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:
av
2026-06-30 14:51:57 +03:00
co-authored by Claude Opus 4.8
parent dfa182a5a9
commit 70d8758646
24 changed files with 1168 additions and 40 deletions
+90
View File
@@ -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 синхронно (без дебаунса) проверяет, что раздача есть в