Retry/stall: сброс базиса таймаута + простой от last_activity (MAJOR-1, MAJOR-2)

Два связанных бага семантики таймаутов зависания и ручного retry.

MAJOR-1: Retry живого торрента не сбрасывал базис отсчёта таймаута — задача
мгновенно снова падала в stuck на ближайшем тике. Вводим колонку
download.retried_at (миграция 0010): ручной retry фиксирует момент и
приподнимает пол обоих таймаутов (max(базис, retried_at)). Хранится в БД, а
не в памяти, чтобы сброс пережил тик поллинга и рестарт.

MAJOR-2: stuck_after мерил ВОЗРАСТ торрента (от added_on), а не ПРОСТОЙ —
долго качавшийся торрент, на миг зашедший в stalledDL, ложно уходил в stuck
со «stalled for 5h». Теперь stuck_after мерит простой от qBit last_activity
(новое поле qbt.Torrent из того же ответа /torrents/info); magnet_timeout
по-прежнему мерит возраст (семантически верно). checkTimeouts разбит на
torrentAge/stallDuration/addedBasis/retriedFloor.

NIT-10: фолбэк базиса возраста added_on→created_at сохранён и покрыт.
NIT-12: retry перестаёт перецепляться к сломанному живому торренту
(error/missingFiles) — повторно отдаёт источник (перецепка к нему
бессмысленна: reconcile тут же вернул бы в failed).

Спека: дельта state-reconciliation (MODIFIED «Восстановление зависшей
загрузки» и «Ручной повтор»), правка docs/specs/workflow.md (устранено
противоречие «возраст vs простой»), ER-схема database.md.

Тесты: TestRetryResetsTimeoutBasis (следующий тик после retry — прячется в
TestRetryReattachesNoReadd), TestStallMeasuredFromLastActivity,
TestSetRetriedAtOverwrites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-07-08 17:18:28 +03:00
co-authored by Claude Opus 4.8
parent 4475fbd548
commit 8261d5b55d
15 changed files with 659 additions and 40 deletions
+92 -28
View File
@@ -51,6 +51,10 @@ type Store interface {
PromoteCatched(ctx context.Context, id, displayName string) error
SetSourceMissCount(ctx context.Context, id string, n int) error
SetSourceAddedAt(ctx context.Context, id string, t time.Time) error
// SetRetriedAt проставляет время ручного retry — сброс базиса отсчёта
// таймаутов (magnet_timeout/stuck_after), чтобы возврат в downloading не
// ронял задачу снова на ближайшем тике.
SetRetriedAt(ctx context.Context, id string, t time.Time) error
// Идентичность/инвариант «одна активная загрузка на infohash».
ExistsByInfohash(ctx context.Context, hashes ...string) (bool, error)
@@ -506,21 +510,29 @@ func (w *Worker) reconcile(ctx context.Context, d store.Download, t qbt.Torrent)
}
}
// checkTimeouts помечает зависшие задачи. Возраст считаем от факта в
// qBittorrent (added_on), а не от created_at: базис переживает retry и
// усыновление раздачи (см. design download-failure-recovery). magnet_timeout —
// редкий страховочный предохранитель (дефолт 24h); настоящие провалы ловит
// checkTimeouts помечает зависшие задачи двумя разными мерами:
// - magnet_timeout — по ВОЗРАСТУ торрента (metaDL дольше magnet_timeout без
// метаданных): страховочный предохранитель (дефолт 24h), базис — added_on;
// - stuck_after — по ДЛИТЕЛЬНОСТИ ПРОСТОЯ (stalledDL без движения данных
// дольше stuck_after): базис — last_activity, а не возраст. Иначе долго
// качавшийся торрент, на миг зашедший в stalledDL, ложно уходит в stuck со
// «stalled for 5h» (см. state-reconciliation, MAJOR-2).
//
// Оба базиса приподняты до retried_at (ручной retry), чтобы возврат в
// downloading не ронял задачу снова на ближайшем тике. Настоящие провалы ловит
// classErrored, а ожившие задачи воскрешает reconcileRecovery.
func (w *Worker) checkTimeouts(ctx context.Context, d store.Download, t qbt.Torrent) {
age := w.torrentAge(d, t)
switch {
case isMeta(t.State) && w.cfg.MagnetTimeout > 0 && age > w.cfg.MagnetTimeout:
w.transition(ctx, d, store.StateFailed, errCodeMagnetTimeout,
fmt.Sprintf("no metadata after %s", age.Truncate(time.Second)))
case isStalledDL(t.State) && w.cfg.StuckAfter > 0 && age > w.cfg.StuckAfter:
w.transition(ctx, d, store.StateStuck, errCodeStalled,
fmt.Sprintf("stalled for %s", age.Truncate(time.Second)))
case isMeta(t.State) && w.cfg.MagnetTimeout > 0:
if age, ok := w.torrentAge(d, t); ok && age > w.cfg.MagnetTimeout {
w.transition(ctx, d, store.StateFailed, errCodeMagnetTimeout,
fmt.Sprintf("no metadata after %s", age.Truncate(time.Second)))
}
case isStalledDL(t.State) && w.cfg.StuckAfter > 0:
if idle, ok := w.stallDuration(d, t); ok && idle > w.cfg.StuckAfter {
w.transition(ctx, d, store.StateStuck, errCodeStalled,
fmt.Sprintf("stalled for %s", idle.Truncate(time.Second)))
}
}
}
@@ -572,23 +584,61 @@ func (w *Worker) captureInfohashes(ctx context.Context, d store.Download, t qbt.
}
}
// torrentAge — возраст торрента: от added_on в qBittorrent (надёжный базис,
// переживает retry/усыновление), с фолбэком на created_at задачи, если qBit не
// отдал added_on.
func (w *Worker) torrentAge(d store.Download, t qbt.Torrent) time.Duration {
// torrentAge — возраст торрента для magnet_timeout: от added_on в qBittorrent
// (надёжный базис, переживает усыновление), с фолбэком на created_at задачи,
// если qBit не отдал added_on (NIT-10). Базис приподнят до retried_at, чтобы
// ручной retry сбрасывал отсчёт. ok=false — базис неизвестен (ни added_on, ни
// разбираемого created_at): таймаут не срабатывает, фиксируем диагностикой.
func (w *Worker) torrentAge(d store.Download, t qbt.Torrent) (time.Duration, bool) {
basis, ok := w.addedBasis(d, t)
if !ok {
return 0, false
}
return w.now().Sub(w.retriedFloor(d, basis)), true
}
// stallDuration — длительность простоя торрента для stuck_after: от
// last_activity qBittorrent (момент последнего движения данных), с фолбэком на
// базис добавления, если qBit не отдал last_activity. Базис приподнят до
// retried_at (ручной retry даёт свежее окно). ok=false — базис неизвестен.
func (w *Worker) stallDuration(d store.Download, t qbt.Torrent) (time.Duration, bool) {
var basis time.Time
if t.LastActivity > 0 {
basis = time.Unix(t.LastActivity, 0).UTC()
} else {
var ok bool
if basis, ok = w.addedBasis(d, t); !ok {
return 0, false
}
}
return w.now().Sub(w.retriedFloor(d, basis)), true
}
// addedBasis — момент добавления торрента: added_on qBittorrent, иначе
// created_at задачи (NIT-10). ok=false — ни того, ни другого разобрать не
// удалось; фиксируем диагностикой.
func (w *Worker) addedBasis(d store.Download, t qbt.Torrent) (time.Time, bool) {
if t.AddedOn > 0 {
return w.now().Sub(time.Unix(t.AddedOn, 0).UTC())
return time.Unix(t.AddedOn, 0).UTC(), true
}
created, err := d.CreatedTime()
if err != nil {
// Ни added_on от qBit, ни разбираемого created_at — возраст неизвестен,
// таймауты не сработают; фиксируем диагностикой.
w.log.Warn("cannot determine torrent age",
"capability", capIngest, "download_id", d.ID,
"created_at", d.CreatedAt, "error", err)
return 0
return time.Time{}, false
}
return w.now().Sub(created)
return created, true
}
// retriedFloor приподнимает базис отсчёта таймаута до времени последнего ручного
// retry: после retry задача получает свежее окно и не падает повторно на
// ближайшем тике (см. state-reconciliation «Ручной повтор», MAJOR-1).
func (w *Worker) retriedFloor(d store.Download, basis time.Time) time.Time {
if r, ok := d.RetriedTime(); ok && r.After(basis) {
return r
}
return basis
}
// transition пишет новое состояние и логирует переход.
@@ -685,16 +735,22 @@ func (w *Worker) Retry(ctx context.Context, id string) error {
if d.State != store.StateFailed && d.State != store.StateStuck {
return fmt.Errorf("retry: download %s is %s, only failed/stuck are retriable", id, d.State)
}
// Если раздача уже жива в qBittorrent — перецепляемся к ней, повторный Add
// не нужен (и вреден: вслепую дублировал бы торрент). Add — только когда
// источника в qBittorrent нет. Базис таймаута берётся от added_on, поэтому
// возврат в downloading не роняет задачу снова на ближайшем тике.
alive := false
// Если раздача уже жива и ЗДОРОВА в qBittorrent — перецепляемся к ней,
// повторный Add не нужен (и вреден: вслепую дублировал бы торрент). Add —
// когда источника в qBittorrent нет ИЛИ он в состоянии ошибки: перецепка к
// сломанному торренту (error/missingFiles) бессмысленна — reconcile тут же
// вернул бы задачу в failed, поэтому пробуем повторно отдать источник
// (NIT-12). Базис таймаута сбрасывается ниже через retried_at, поэтому
// возврат в downloading не роняет задачу снова на ближайшем тике (MAJOR-1).
reAdd := true
if hashes := d.HashList(); len(hashes) > 0 {
_, alive, err = w.torrentByInfohash(ctx, hashes)
var t qbt.Torrent
var alive bool
t, alive, err = w.torrentByInfohash(ctx, hashes)
if err != nil {
return fmt.Errorf("retry: %w", err)
}
reAdd = !alive || classify(t.State) == classErrored
}
// Гард инварианта — ДО побочного эффекта в qBittorrent: пока задача лежала
// в failed, тем же infohash могла завладеть другая активная задача — тогда
@@ -705,7 +761,7 @@ func (w *Worker) Retry(ctx context.Context, id string) error {
}
return fmt.Errorf("retry: %w", err)
}
if !alive {
if reAdd {
// Добавляем заново по типу источника (magnet — ссылкой, torrent —
// сохранёнными байтами файлом). Rename при retry не выводим (namer здесь
// не зовём — имя уже могло быть выведено при первом добавлении).
@@ -729,6 +785,14 @@ func (w *Worker) Retry(ctx context.Context, id string) error {
return fmt.Errorf("retry: add to qbittorrent: %w", err)
}
}
// Сброс базиса отсчёта таймаутов (MAJOR-1): без него живой, но давно
// добавленный/простаивающий торрент снова упал бы по magnet_timeout/
// stuck_after на ближайшем тике. Best-effort: сбой лишь лишает свежего окна
// (WARN), сам retry уже состоялся.
if err := w.store.SetRetriedAt(ctx, id, w.now()); err != nil {
w.log.Warn("retry basis reset failed",
"capability", capReview, "download_id", id, "error", err)
}
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("download retried", "from", d.State)
return nil
}