Восстановление зависших загрузок и уведомления о падении (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
+31 -4
View File
@@ -40,10 +40,13 @@ const (
// ТОЛЬКО сюда — иначе семантика «активности» разъедется (idempotency_key
// снимается по IsTerminal, а активность считалась бы по другому списку).
//
// Состояния рассинхрона (target_missing/orphaned/deleted) — терминальны по
// тем же причинам, что reverted/cancelled: дальше двигает либо человек
// (relink из target_missing), либо фоновая сверка (healing/прогрессия),
// напрямую через SetDownloadState; ключ идемпотентности при этом не нужен.
// Состояния рассинхрона (target_missing/orphaned/deleted), а также
// failed/stuck — терминальны для idempotency_key, но не «мертвы»: дальше
// двигает либо человек (relink из target_missing, retry из failed/stuck), либо
// фоновая сверка (healing/прогрессия desync; авто-восстановление failed/stuck
// при оживлении источника, см. state-reconciliation) — напрямую через
// SetDownloadState, который восстановит ключ для нетерминального целевого
// состояния.
var terminalStates = []State{
StateDone, StateCancelled, StateFailed, StateReverted,
StateTargetMissing, StateOrphaned, StateDeleted,
@@ -157,6 +160,30 @@ func (s *Store) ListDownloadsByState(ctx context.Context, states ...State) ([]Do
return out, nil
}
// ListRecoverable возвращает задачи в failed/stuck с одним из переданных
// error_code — кандидатов на авто-восстановление (см. state-reconciliation).
// Фильтр по коду в SQL, чтобы не вычитывать на каждом тике поллинга все
// накопленные провалы (qbit_error и пр.), которые восстановлению не подлежат.
func (s *Store) ListRecoverable(ctx context.Context, codes ...string) ([]Download, error) {
if len(codes) == 0 {
return nil, nil
}
ph := make([]string, len(codes))
args := make([]any, 0, len(codes)+2)
for i, c := range codes {
ph[i] = "?"
args = append(args, c)
}
args = append(args, string(StateFailed), string(StateStuck))
q := `SELECT * FROM download WHERE error_code IN (` + strings.Join(ph, ",") +
`) AND state IN (?, ?) ORDER BY id DESC`
var out []Download
if err := s.DB.SelectContext(ctx, &out, q, args...); err != nil {
return nil, fmt.Errorf("list recoverable: %w", err)
}
return out, nil
}
// FindActiveByInfohash возвращает незавершённую задачу для infohash либо
// (nil, nil), если её нет. Основа идемпотентного приёма.
func (s *Store) FindActiveByInfohash(ctx context.Context, infohash string) (*Download, error) {