Восстановление зависших загрузок и уведомления о падении (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
+4
View File
@@ -182,6 +182,10 @@ func runServe(args []string) error {
WebBaseURL: cfg.Telegram.WebBaseURL, WebBaseURL: cfg.Telegram.WebBaseURL,
}, logger) }, logger)
wrk.SetNotifier(bot) wrk.SetNotifier(bot)
// Приёмные падения (qbit_add) минуют worker — уведомляем напрямую.
ingestor.SetFailureNotifier(func(id int64) {
bot.Notify(context.Background(), id, worker.EventFailed)
})
go bot.Run(ctx) go bot.Run(ctx)
logger.Info("telegram bot enabled", logger.Info("telegram bot enabled",
"bot", api.Self.UserName, "allowed_users", len(cfg.Telegram.AllowedUserIDs)) "bot", api.Self.UserName, "allowed_users", len(cfg.Telegram.AllowedUserIDs))
+1 -1
View File
@@ -62,7 +62,7 @@ timeout = "10s" # таймаут запроса к Jellyfin
[worker] [worker]
poll_interval = "5s" # как часто опрашивать qBittorrent; Go-duration (s/m/h) poll_interval = "5s" # как часто опрашивать qBittorrent; Go-duration (s/m/h)
stuck_after = "1h" # сколько ждать прогресса, прежде чем счесть раздачу зависшей; Go-duration stuck_after = "1h" # сколько ждать прогресса, прежде чем счесть раздачу зависшей; Go-duration
magnet_timeout = "30m" # ждать метаданные magnet не дольше; Go-duration magnet_timeout = "24h" # страховочный предел ожидания метаданных magnet (не рабочий механизм: ожившие задачи воскрешаются сверкой); Go-duration
source_missing_threshold = 3 # подряд тиков сверки без раздачи в qBittorrent, чтобы счесть источник удалённым (дебаунс) source_missing_threshold = 3 # подряд тиков сверки без раздачи в qBittorrent, чтобы счесть источник удалённым (дебаунс)
[recognition] [recognition]
+32 -7
View File
@@ -14,7 +14,7 @@ stateDiagram-v2
downloading --> completed: файлы на месте downloading --> completed: файлы на месте
downloading --> stuck: stalledDL дольше stuck_after downloading --> stuck: stalledDL дольше stuck_after
downloading --> failed: metaDL дольше magnet_timeout / error downloading --> failed: metaDL дольше magnet_timeout (страховка) / error
completed --> recognizing completed --> recognizing
@@ -36,8 +36,10 @@ stateDiagram-v2
reverted --> recognizing: Привязать заново reverted --> recognizing: Привязать заново
cancelled --> recognizing: Привязать заново cancelled --> recognizing: Привязать заново
stuck --> downloading: Retry stuck --> downloading: Retry / сверка (раздача ожила)
failed --> downloading: Retry failed --> downloading: Retry / сверка (метаданные пришли)
failed --> completed: сверка (торрент уже готов)
stuck --> completed: сверка (торрент уже готов)
done --> target_missing: сверка — цель удалена done --> target_missing: сверка — цель удалена
done --> orphaned: сверка — источник пропал done --> orphaned: сверка — источник пропал
@@ -147,10 +149,33 @@ SQLite; `worker` периодически сверяет qBittorrent с БД и
проверку (готовность не объявляем, даже если флаги «UP»). проверку (готовность не объявляем, даже если флаги «UP»).
- **ещё качается:** `downloading`/`stalledDL`/`metaDL`/`forcedMetaDL`/ - **ещё качается:** `downloading`/`stalledDL`/`metaDL`/`forcedMetaDL`/
`queuedDL`/`checkingDL`/`forcedDL`/`pausedDL`/`stoppedDL`. `queuedDL`/`checkingDL`/`forcedDL`/`pausedDL`/`stoppedDL`.
- **застряло/ошибка по таймауту:** `metaDL`/`forcedMetaDL` дольше - **застряло по таймауту (страховка):** `metaDL`/`forcedMetaDL` дольше
`magnet_timeout``failed`; `stalledDL` дольше `stuck_after``stuck` `magnet_timeout``failed`; `stalledDL` дольше `stuck_after``stuck`.
(восстановимо ретраем). Возраст считаем от создания задачи. `magnet_timeout`**редкий страховочный предохранитель** (дефолт `24h`), а
- **ошибка:** `error`/`missingFiles``failed`. не рабочий механизм: долгий `metaDL` (медленные трекеры/мало пиров) — это
норма, его не убиваем агрессивно. Возраст считаем от времени добавления
торрента в qBittorrent (`added_on`), а не от создания задачи (базис
переживает retry и усыновление).
- **ошибка:** `error`/`missingFiles``failed` (`error_code` `qbit_error`) —
это настоящий провал, в отличие от таймаута.
### Уведомление и восстановление
- Любой переход в `failed`/`stuck` **уведомляет** автора загрузки
(`notifier`), чтобы падение не оставалось незамеченным — включая приёмное
падение `qbit_add` (не удалось добавить в qBittorrent), которое идёт мимо
поллинг-цикла. Повторные падения одной задачи в пределах окна дебаунса
уведомляют лишь раз — чтобы мерцающий `stalled`-торрент
(`stuck``downloading`) не спамил.
- `failed`/`stuck` из-за нашей нетерпеливости (`error_code` `magnet_timeout`/
`stalled`) **не тупик**: фоновая сверка возвращает задачу в поток, как
только источник в qBittorrent ожил и продвинулся за условие падения
(получил метаданные → `downloading`; уже готов → `completed`). Пока торрент
всё ещё в `metaDL`/`stalledDL`, задача остаётся упавшей (без зацикливания).
Настоящие провалы (`qbit_error`) сверкой не воскрешаются.
- Дополнительно доступен **ручной retry** из веб-UI и Telegram (не только
REST): возвращает в `downloading`, перецепляясь к живому торренту без
повторного `Add`.
Пути файлов берём из API (`save_path` + относительные имена из Пути файлов берём из API (`save_path` + относительные имена из
`/torrents/files`, уже включающие корневую папку торрента), не из `/torrents/files`, уже включающие корневую папку торрента), не из
+1 -1
View File
@@ -181,7 +181,7 @@ func Default() *Config {
Worker: Worker{ Worker: Worker{
PollInterval: Duration(5 * time.Second), PollInterval: Duration(5 * time.Second),
StuckAfter: Duration(time.Hour), StuckAfter: Duration(time.Hour),
MagnetTimeout: Duration(30 * time.Minute), MagnetTimeout: Duration(24 * time.Hour),
SourceMissingThreshold: 3, SourceMissingThreshold: 3,
}, },
Recognition: Recognition{AutoConfidenceThreshold: 0.85}, Recognition: Recognition{AutoConfidenceThreshold: 0.85},
+17 -1
View File
@@ -84,6 +84,7 @@ func NewRouter(d Deps) (http.Handler, error) {
r.Get("/", s.handleIndex) r.Get("/", s.handleIndex)
r.Post("/ui/downloads", s.handleUIAdd) r.Post("/ui/downloads", s.handleUIAdd)
r.Post("/ui/downloads/{id}/cancel", s.handleUICancel) r.Post("/ui/downloads/{id}/cancel", s.handleUICancel)
r.Post("/ui/downloads/{id}/retry", s.handleUIRetry)
// Веб-UI: ревью раскладки. // Веб-UI: ревью раскладки.
r.Get("/review/{id}", s.handleReview) r.Get("/review/{id}", s.handleReview)
@@ -133,6 +134,7 @@ type downloadView struct {
Reviewable bool // review/deferred — есть экран ревью Reviewable bool // review/deferred — есть экран ревью
Undoable bool // done — можно откатить раскладку Undoable bool // done — можно откатить раскладку
Relinkable bool // reverted/cancelled/target_missing — можно перепривязать заново Relinkable bool // reverted/cancelled/target_missing — можно перепривязать заново
Retriable bool // failed/stuck — можно повторить попытку
Note string // пояснение рассинхрона (target_missing/orphaned/deleted) Note string // пояснение рассинхрона (target_missing/orphaned/deleted)
} }
@@ -182,6 +184,19 @@ func (s *server) handleUICancel(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", http.StatusSeeOther) http.Redirect(w, r, "/", http.StatusSeeOther)
} }
func (s *server) handleUIRetry(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
redirectErr(w, r, "некорректный id")
return
}
if err := s.deps.Commander.Retry(r.Context(), id); err != nil {
redirectErr(w, r, userErr(r, err, id))
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// --- REST API --- // --- REST API ---
type downloadDTO struct { type downloadDTO struct {
@@ -320,7 +335,8 @@ func toView(d store.Download) downloadView {
Undoable: d.State == store.StateDone, Undoable: d.State == store.StateDone,
Relinkable: d.State == store.StateReverted || d.State == store.StateCancelled || Relinkable: d.State == store.StateReverted || d.State == store.StateCancelled ||
d.State == store.StateTargetMissing, d.State == store.StateTargetMissing,
Note: desyncNote(d.State), Retriable: d.State == store.StateFailed || d.State == store.StateStuck,
Note: desyncNote(d.State),
} }
} }
+17
View File
@@ -160,6 +160,23 @@ func TestAPICancel(t *testing.T) {
} }
} }
func TestUIRetry(t *testing.T) {
cmd := &fakeCommander{}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: cmd, Reader: &fakeReader{}})
resp, err := http.Post(srv.URL+"/ui/downloads/5/retry", "application/x-www-form-urlencoded", nil)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { // 303 → редирект на / → 200
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
if len(cmd.retried) != 1 || cmd.retried[0] != 5 {
t.Errorf("retry вызван неверно: %v", cmd.retried)
}
}
func TestAPICommandConflict(t *testing.T) { func TestAPICommandConflict(t *testing.T) {
// Конфликт состояния (worker.ErrConflict) → 409, не 500. // Конфликт состояния (worker.ErrConflict) → 409, не 500.
cmd := &fakeCommander{err: fmt.Errorf("cancel: download 5 in wrong state: %w", worker.ErrConflict)} cmd := &fakeCommander{err: fmt.Errorf("cancel: download 5 in wrong state: %w", worker.ErrConflict)}
+18 -1
View File
@@ -18,6 +18,10 @@ import (
// capIngest — стадия приёма для поля capability в логах. // capIngest — стадия приёма для поля capability в логах.
const capIngest = "ingest" const capIngest = "ingest"
// errCodeQbitAdd — error_code задачи, упавшей на добавлении источника в
// qBittorrent (раздачи в qBittorrent нет, восстановлению не подлежит).
const errCodeQbitAdd = "qbit_add"
// Store — нужная ingest часть хранилища. // Store — нужная ingest часть хранилища.
type Store interface { type Store interface {
FindActiveByInfohash(ctx context.Context, infohash string) (*store.Download, error) FindActiveByInfohash(ctx context.Context, infohash string) (*store.Download, error)
@@ -49,6 +53,12 @@ type Service struct {
namer Namer namer Namer
cfg Config cfg Config
log *slog.Logger log *slog.Logger
// notifyFailed — опц. пинг автору о падении приёма (добавление в qBittorrent
// не удалось). Closure, а не worker.Notifier: приёмное падение в qBit не
// попадает в поллинг-цикл worker (раздачи нет), поэтому уведомляет ingest
// сам; closure избавляет ядро приёма от зависимости на пакет worker.
notifyFailed func(downloadID int64)
} }
// New собирает сервис приёма. namer опционален (nil → отображаемое имя не // New собирает сервис приёма. namer опционален (nil → отображаемое имя не
@@ -57,6 +67,9 @@ func New(st Store, qb QBittorrent, namer Namer, cfg Config, log *slog.Logger) *S
return &Service{store: st, qbt: qb, namer: namer, cfg: cfg, log: log} return &Service{store: st, qbt: qb, namer: namer, cfg: cfg, log: log}
} }
// SetFailureNotifier подключает пинг о падении приёма (до начала работы).
func (s *Service) SetFailureNotifier(fn func(downloadID int64)) { s.notifyFailed = fn }
// Request — входной запрос приёма. // Request — входной запрос приёма.
type Request struct { type Request struct {
Source string // пока — magnet-ссылка Source string // пока — magnet-ссылка
@@ -135,8 +148,12 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
// это разные факты, не дубль. // это разные факты, не дубль.
log.Error("download accept failed", "error", addErr) log.Error("download accept failed", "error", addErr)
// Задача уже в БД — помечаем failed, чтобы worker её не подхватил. // Задача уже в БД — помечаем failed, чтобы worker её не подхватил.
if setErr := s.store.SetDownloadState(ctx, id, store.StateFailed, "qbit_add", addErr.Error()); setErr != nil { if setErr := s.store.SetDownloadState(ctx, id, store.StateFailed, errCodeQbitAdd, addErr.Error()); setErr != nil {
log.Error("mark download failed after qbit error failed", "error", setErr) log.Error("mark download failed after qbit error failed", "error", setErr)
} else if s.notifyFailed != nil {
// Это падение минует worker.transition (раздачи в qBit нет) — уведомляем
// сами, чтобы приёмные провалы тоже доходили до автора.
go s.notifyFailed(id)
} }
return Result{DownloadID: id, Infohash: info.Infohash, State: store.StateFailed}, return Result{DownloadID: id, Infohash: info.Infohash, State: store.StateFailed},
fmt.Errorf("ingest: add to qbittorrent: %w", addErr) fmt.Errorf("ingest: add to qbittorrent: %w", addErr)
+21
View File
@@ -6,6 +6,7 @@ import (
"io" "io"
"log/slog" "log/slog"
"testing" "testing"
"time"
"git.vakhrushev.me/av/jellybit/internal/qbt" "git.vakhrushev.me/av/jellybit/internal/qbt"
"git.vakhrushev.me/av/jellybit/internal/store" "git.vakhrushev.me/av/jellybit/internal/store"
@@ -177,6 +178,26 @@ func TestIngestQbitErrorMarksFailed(t *testing.T) {
} }
} }
func TestIngestQbitErrorNotifies(t *testing.T) {
fs := &fakeStore{}
fq := &fakeQbt{err: errors.New("connection refused")}
svc := newService(fs, fq)
got := make(chan int64, 1)
svc.SetFailureNotifier(func(id int64) { got <- id })
if _, err := svc.Ingest(context.Background(), Request{Source: sampleMagnet}); err == nil {
t.Fatal("ожидалась ошибка")
}
select {
case id := <-got:
if id == 0 {
t.Errorf("уведомление с нулевым id")
}
case <-time.After(2 * time.Second):
t.Fatal("уведомление о падении приёма не пришло")
}
}
func TestIngestRejectsNonMagnet(t *testing.T) { func TestIngestRejectsNonMagnet(t *testing.T) {
fs := &fakeStore{} fs := &fakeStore{}
fq := &fakeQbt{} fq := &fakeQbt{}
+31 -4
View File
@@ -40,10 +40,13 @@ const (
// ТОЛЬКО сюда — иначе семантика «активности» разъедется (idempotency_key // ТОЛЬКО сюда — иначе семантика «активности» разъедется (idempotency_key
// снимается по IsTerminal, а активность считалась бы по другому списку). // снимается по IsTerminal, а активность считалась бы по другому списку).
// //
// Состояния рассинхрона (target_missing/orphaned/deleted) — терминальны по // Состояния рассинхрона (target_missing/orphaned/deleted), а также
// тем же причинам, что reverted/cancelled: дальше двигает либо человек // failed/stuck — терминальны для idempotency_key, но не «мертвы»: дальше
// (relink из target_missing), либо фоновая сверка (healing/прогрессия), // двигает либо человек (relink из target_missing, retry из failed/stuck), либо
// напрямую через SetDownloadState; ключ идемпотентности при этом не нужен. // фоновая сверка (healing/прогрессия desync; авто-восстановление failed/stuck
// при оживлении источника, см. state-reconciliation) — напрямую через
// SetDownloadState, который восстановит ключ для нетерминального целевого
// состояния.
var terminalStates = []State{ var terminalStates = []State{
StateDone, StateCancelled, StateFailed, StateReverted, StateDone, StateCancelled, StateFailed, StateReverted,
StateTargetMissing, StateOrphaned, StateDeleted, StateTargetMissing, StateOrphaned, StateDeleted,
@@ -157,6 +160,30 @@ func (s *Store) ListDownloadsByState(ctx context.Context, states ...State) ([]Do
return out, nil 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 либо // FindActiveByInfohash возвращает незавершённую задачу для infohash либо
// (nil, nil), если её нет. Основа идемпотентного приёма. // (nil, nil), если её нет. Основа идемпотентного приёма.
func (s *Store) FindActiveByInfohash(ctx context.Context, infohash string) (*Download, error) { func (s *Store) FindActiveByInfohash(ctx context.Context, infohash string) (*Download, error) {
+6
View File
@@ -36,6 +36,7 @@ type Reviewer interface {
SetType(ctx context.Context, id int64, mediaType string) error SetType(ctx context.Context, id int64, mediaType string) error
Defer(ctx context.Context, id int64) error Defer(ctx context.Context, id int64) error
Cancel(ctx context.Context, id int64) error Cancel(ctx context.Context, id int64) error
Retry(ctx context.Context, id int64) error
} }
// Config — параметры бота. // Config — параметры бота.
@@ -191,6 +192,9 @@ func (b *Bot) handleCallback(ctx context.Context, cq *tgbotapi.CallbackQuery) {
case "reject": case "reject":
err = b.reviewer.Cancel(ctx, id) err = b.reviewer.Cancel(ctx, id)
note = "Отклонено" note = "Отклонено"
case "retry":
err = b.reviewer.Retry(ctx, id)
note = "Повторяю…"
case "type": case "type":
err = b.reviewer.SetType(ctx, id, val) err = b.reviewer.SetType(ctx, id, val)
note = "Меняю тип…" note = "Меняю тип…"
@@ -248,6 +252,8 @@ func (b *Bot) Notify(ctx context.Context, downloadID int64, event worker.NotifyE
text = b.renderDone(rd) text = b.renderDone(rd)
case worker.EventTargetMissing, worker.EventOrphaned: case worker.EventTargetMissing, worker.EventOrphaned:
text, kb = b.renderDesync(rd, event), b.webOnly(downloadID) text, kb = b.renderDesync(rd, event), b.webOnly(downloadID)
case worker.EventFailed:
text, kb = b.renderFailed(rd)
default: default:
text, kb = b.renderCard(rd) text, kb = b.renderCard(rd)
} }
+28
View File
@@ -65,6 +65,7 @@ type fakeReviewer struct {
typed map[int64]string typed map[int64]string
deferred []int64 deferred []int64
canceled []int64 canceled []int64
retried []int64
} }
func (f *fakeReviewer) ReviewData(context.Context, int64) (*worker.ReviewData, error) { func (f *fakeReviewer) ReviewData(context.Context, int64) (*worker.ReviewData, error) {
@@ -96,6 +97,10 @@ func (f *fakeReviewer) Cancel(_ context.Context, id int64) error {
f.canceled = append(f.canceled, id) f.canceled = append(f.canceled, id)
return nil return nil
} }
func (f *fakeReviewer) Retry(_ context.Context, id int64) error {
f.retried = append(f.retried, id)
return nil
}
func reviewData(state store.State) *worker.ReviewData { func reviewData(state store.State) *worker.ReviewData {
s, e := 2, 1 s, e := 2, 1
@@ -252,6 +257,29 @@ func TestBot_NotifyDone(t *testing.T) {
} }
} }
func TestBot_NotifyFailed(t *testing.T) {
b, api, _, rev := newTestBot(t, []int64{7})
rev.data = reviewData(store.StateFailed)
b.Notify(context.Background(), 5, worker.EventFailed)
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "не удалась") {
t.Errorf("sent = %+v", api.sent)
}
if !api.sent[0].hasKB { // кнопка повтора
t.Error("уведомление о падении без клавиатуры повтора")
}
}
func TestBot_CallbackRetry(t *testing.T) {
b, _, _, rev := newTestBot(t, []int64{7})
rev.data = reviewData(store.StateFailed)
b.handleCallback(context.Background(), cbFrom(7, "retry:5"))
if len(rev.retried) != 1 || rev.retried[0] != 5 {
t.Errorf("retried = %v", rev.retried)
}
}
func TestParseCallback(t *testing.T) { func TestParseCallback(t *testing.T) {
a, id, v := parseCallback("type:5:series") a, id, v := parseCallback("type:5:series")
if a != "type" || id != 5 || v != "series" { if a != "type" || id != 5 || v != "series" {
+39
View File
@@ -31,6 +31,10 @@ func (b *Bot) renderCard(rd *worker.ReviewData) (string, *tgbotapi.InlineKeyboar
if msg := rd.Download.ErrorMsg.String; msg != "" { if msg := rd.Download.ErrorMsg.String; msg != "" {
text += "\n" + msg text += "\n" + msg
} }
// failed/stuck — даём кнопку повтора; остальное только «в вебе».
if state == store.StateFailed || state == store.StateStuck {
return text, b.retryKeyboard(id)
}
return text, b.webOnly(id) return text, b.webOnly(id)
} }
} }
@@ -111,6 +115,41 @@ func (b *Bot) renderDesync(rd *worker.ReviewData, event worker.NotifyEvent) stri
} }
} }
// renderFailed — уведомление об упавшей/зависшей задаче с кнопкой повтора.
func (b *Bot) renderFailed(rd *worker.ReviewData) (string, *tgbotapi.InlineKeyboardMarkup) {
id := rd.Download.ID
var sb strings.Builder
verb := "не удалась"
if rd.Download.State == store.StateStuck {
verb = "зависла"
}
fmt.Fprintf(&sb, "❌ Задача #%d %s", id, verb)
if code := rd.Download.ErrorCode.String; code != "" {
fmt.Fprintf(&sb, " (%s)", code)
}
sb.WriteString(".")
if msg := rd.Download.ErrorMsg.String; msg != "" {
sb.WriteString("\n")
sb.WriteString(msg)
}
if src := contextOrSource(rd); src != "" {
fmt.Fprintf(&sb, "\nИсточник: %s", shorten(src, 80))
}
return sb.String(), b.retryKeyboard(id)
}
// retryKeyboard — клавиатура для failed/stuck: повтор + опц. ссылка в веб.
func (b *Bot) retryKeyboard(id int64) *tgbotapi.InlineKeyboardMarkup {
row := []tgbotapi.InlineKeyboardButton{
tgbotapi.NewInlineKeyboardButtonData("🔄 Повторить", "retry:"+itoa(id)),
}
if url := b.reviewURL(id); url != "" {
row = append(row, tgbotapi.NewInlineKeyboardButtonURL("🌐 В вебе", url))
}
kb := tgbotapi.NewInlineKeyboardMarkup(tgbotapi.NewInlineKeyboardRow(row...))
return &kb
}
func (b *Bot) webOnly(id int64) *tgbotapi.InlineKeyboardMarkup { func (b *Bot) webOnly(id int64) *tgbotapi.InlineKeyboardMarkup {
url := b.reviewURL(id) url := b.reviewURL(id)
if url == "" { if url == "" {
+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 в БД) --- // --- Синхронный preflight перед действием (не доверяем state в БД) ---
// ensureSourcePresent синхронно (без дебаунса) проверяет, что раздача есть в // ensureSourcePresent синхронно (без дебаунса) проверяет, что раздача есть в
+170
View File
@@ -0,0 +1,170 @@
package worker
import (
"context"
"testing"
"time"
"git.vakhrushev.me/av/jellybit/internal/qbt"
"git.vakhrushev.me/av/jellybit/internal/store"
)
// addedRecent — added_on торрента «минуту назад» относительно зафиксированного
// в newTestWorker now (2026-06-14 10:00:00 UTC).
var addedRecent = time.Date(2026, 6, 14, 9, 59, 0, 0, time.UTC).Unix()
func oneFailed(state store.State, code, infohash, createdAt string) *fakeStore {
return &fakeStore{downloads: map[int64]*store.Download{
1: {
ID: 1,
State: state,
SourceType: store.SourceMagnet,
SourceRef: "magnet:?xt=urn:btih:" + infohash,
Infohash: store.NullString(infohash),
ErrorCode: store.NullString(code),
CreatedAt: createdAt,
},
}}
}
func TestRecovery(t *testing.T) {
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
tests := []struct {
name string
state store.State
code string
qbitState string
want store.State
}{
{"метаданные пришли → downloading", store.StateFailed, errCodeMagnetTimeout, "downloading", store.StateDownloading},
{"торрент готов → completed", store.StateFailed, errCodeMagnetTimeout, "uploading", store.StateCompleted},
{"всё ещё metaDL → остаётся failed", store.StateFailed, errCodeMagnetTimeout, "metaDL", store.StateFailed},
{"stalled ожил → downloading", store.StateStuck, errCodeStalled, "downloading", store.StateDownloading},
{"stalled всё ещё stalledDL → остаётся stuck", store.StateStuck, errCodeStalled, "stalledDL", store.StateStuck},
{"qbit_error не восстанавливается", store.StateFailed, errCodeQbitError, "downloading", store.StateFailed},
{"ошибка торрента не восстанавливает", store.StateFailed, errCodeMagnetTimeout, "error", store.StateFailed},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
st := oneFailed(tc.state, tc.code, ih, timeOld)
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ih, State: tc.qbitState, AddedOn: addedRecent}}}
w := newTestWorker(st, qb)
if err := w.Poll(context.Background()); err != nil {
t.Fatalf("Poll: %v", err)
}
if got := st.downloads[1].State; got != tc.want {
t.Errorf("state = %q, want %q", got, tc.want)
}
})
}
}
// Источник пропал (торрента нет в qBittorrent) — задача остаётся failed,
// воскрешать нечего (вернёт ручной retry).
func TestRecoveryNoSourceStaysFailed(t *testing.T) {
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
st := oneFailed(store.StateFailed, errCodeMagnetTimeout, ih, timeOld)
w := newTestWorker(st, &fakeQbt{torrents: nil})
if err := w.Poll(context.Background()); err != nil {
t.Fatal(err)
}
if st.downloads[1].State != store.StateFailed {
t.Errorf("без источника задача должна остаться failed, got %q", st.downloads[1].State)
}
}
// Конфликт идемпотентности: тот же infohash уже взяла другая активная задача —
// упавшую не воскрешаем (иначе нарушим «одна активная задача на infohash»).
func TestRecoverySkipsOnIdempotencyConflict(t *testing.T) {
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
st := oneFailed(store.StateFailed, errCodeMagnetTimeout, ih, timeOld)
st.downloads[2] = &store.Download{
ID: 2,
State: store.StateDownloading,
SourceType: store.SourceMagnet,
Infohash: store.NullString(ih),
CreatedAt: timeRecent,
}
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ih, State: "downloading", AddedOn: addedRecent}}}
w := newTestWorker(st, qb)
if err := w.Poll(context.Background()); err != nil {
t.Fatal(err)
}
if st.downloads[1].State != store.StateFailed {
t.Errorf("при конфликте ключа задача #1 должна остаться failed, got %q", st.downloads[1].State)
}
}
// Тот же конфликт ключа, но торрент уже готов (recovery хочет completed):
// completed тоже нетерминален и восстановил бы idempotency_key — проверка
// конфликта обязана покрывать и эту ветку.
func TestRecoverySkipsConflictOnCompleted(t *testing.T) {
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
st := oneFailed(store.StateFailed, errCodeMagnetTimeout, ih, timeOld)
st.downloads[2] = &store.Download{
ID: 2,
State: store.StateDownloading,
SourceType: store.SourceMagnet,
Infohash: store.NullString(ih),
CreatedAt: timeRecent,
}
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ih, State: "uploading", AddedOn: addedRecent}}}
w := newTestWorker(st, qb)
if err := w.Poll(context.Background()); err != nil {
t.Fatal(err)
}
if st.downloads[1].State != store.StateFailed {
t.Errorf("при конфликте ключа задача #1 не должна уходить в completed, got %q", st.downloads[1].State)
}
}
// Повторное падение одной задачи в пределах окна дебаунса шлёт уведомление лишь
// раз (защита от спама при флаппинге stuck↔downloading).
func TestFailNotifyDebounce(t *testing.T) {
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
st := oneFailed(store.StateStuck, errCodeStalled, ih, timeOld)
w := newTestWorker(st, &fakeQbt{})
n := &recordingNotifier{ch: make(chan notifyEvent, 4)}
w.SetNotifier(n)
d := *st.downloads[1]
w.transition(context.Background(), d, store.StateStuck, errCodeStalled, "")
if e := waitNotify(t, n); e.ev != EventFailed {
t.Fatalf("первый пинг: ev=%v, want failed", e.ev)
}
// Второе падение при том же w.now() — в пределах дебаунса, без пинга.
w.transition(context.Background(), d, store.StateStuck, errCodeStalled, "")
select {
case e := <-n.ch:
t.Fatalf("повторный пинг в пределах дебаунса не ожидался: %+v", e)
case <-time.After(200 * time.Millisecond):
}
}
// Retry при живом торренте перецепляется к нему (без повторного Add) и не падает
// снова на ближайшем тике: базис таймаута берётся от added_on, а не от старого
// created_at.
func TestRetryReattachesNoReadd(t *testing.T) {
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
st := oneFailed(store.StateFailed, errCodeMagnetTimeout, ih, timeOld)
// Торрент жив, всё ещё тянет метаданные, но добавлен только что (added_on).
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ih, State: "metaDL", AddedOn: addedRecent}}}
w := newTestWorker(st, qb)
if err := w.Retry(context.Background(), 1); err != nil {
t.Fatalf("Retry: %v", err)
}
if st.downloads[1].State != store.StateDownloading {
t.Fatalf("после retry ожидался downloading, got %q", st.downloads[1].State)
}
if len(qb.added) != 0 {
t.Errorf("живой торрент не должен добавляться повторно, got %d Add", len(qb.added))
}
// Ближайший тик: metaDL свежий (added_on минуту назад) — не падает по таймауту.
if err := w.Poll(context.Background()); err != nil {
t.Fatal(err)
}
if st.downloads[1].State != store.StateDownloading {
t.Errorf("свежий metaDL не должен падать после retry, got %q", st.downloads[1].State)
}
}
+16
View File
@@ -250,6 +250,22 @@ func (m *memStore) ListDownloadsByState(_ context.Context, states ...store.State
return out, nil return out, nil
} }
func (m *memStore) ListRecoverable(_ context.Context, codes ...string) ([]store.Download, error) {
var out []store.Download
for _, d := range m.downloads {
if d.State != store.StateFailed && d.State != store.StateStuck {
continue
}
for _, c := range codes {
if d.ErrorCode.Valid && d.ErrorCode.String == c {
out = append(out, *d)
break
}
}
}
return out, nil
}
func (m *memStore) ExistsByInfohash(_ context.Context, infohash string) (bool, error) { func (m *memStore) ExistsByInfohash(_ context.Context, infohash string) (bool, error) {
for _, d := range m.downloads { for _, d := range m.downloads {
if d.Infohash.Valid && d.Infohash.String == infohash { if d.Infohash.Valid && d.Infohash.String == infohash {
+98 -20
View File
@@ -38,6 +38,7 @@ const (
// Store — нужная worker часть хранилища. // Store — нужная worker часть хранилища.
type Store interface { type Store interface {
ListDownloadsByState(ctx context.Context, states ...store.State) ([]store.Download, error) ListDownloadsByState(ctx context.Context, states ...store.State) ([]store.Download, error)
ListRecoverable(ctx context.Context, codes ...string) ([]store.Download, error)
GetDownload(ctx context.Context, id int64) (*store.Download, error) GetDownload(ctx context.Context, id int64) (*store.Download, error)
SetDownloadState(ctx context.Context, id int64, state store.State, errCode, errMsg string) error SetDownloadState(ctx context.Context, id int64, state store.State, errCode, errMsg string) error
SetSourceMissCount(ctx context.Context, id int64, n int) error SetSourceMissCount(ctx context.Context, id int64, n int) error
@@ -94,6 +95,17 @@ const (
EventDone NotifyEvent = "done" // раскладка завершена EventDone NotifyEvent = "done" // раскладка завершена
EventOrphaned NotifyEvent = "orphaned" // источник пропал, цель — последняя копия EventOrphaned NotifyEvent = "orphaned" // источник пропал, цель — последняя копия
EventTargetMissing NotifyEvent = "target_missing" // цель удалена, доступен relink EventTargetMissing NotifyEvent = "target_missing" // цель удалена, доступен relink
EventFailed NotifyEvent = "failed" // задача упала/зависла (failed/stuck)
)
// Коды ошибок (error_code) при переходе в failed/stuck. Восстановимые
// (magnet_timeout/stalled) — следствие нашей нетерпеливости: сверка воскрешает
// такие задачи при оживлении источника (см. reconcileRecovery). qbit_error —
// реальная ошибка qBittorrent, восстановлению не подлежит.
const (
errCodeMagnetTimeout = "magnet_timeout"
errCodeStalled = "stalled"
errCodeQbitError = "qbit_error"
) )
// Notifier — исходящие пинги (Telegram). Вызывается неблокирующе. // Notifier — исходящие пинги (Telegram). Вызывается неблокирующе.
@@ -136,8 +148,18 @@ type Worker struct {
newID func() string // генератор apply_batch_id (подменяется в тестах) newID func() string // генератор apply_batch_id (подменяется в тестах)
notifier Notifier // опц. исходящие пинги notifier Notifier // опц. исходящие пинги
scanner Scanner // опц. пересканирование Jellyfin scanner Scanner // опц. пересканирование Jellyfin
// failNotified — дебаунс повторных EventFailed по задаче (download_id →
// время последнего пинга). Мерцающий stalled-торрент колеблется
// stuck↔downloading; без дебаунса каждый цикл слал бы уведомление. Память
// процесса: при рестарте дебаунс сбрасывается — допустимо. Доступ под w.mu.
failNotified map[int64]time.Time
} }
// failNotifyDebounce — минимальный интервал между уведомлениями о падении
// одной задачи (см. failNotified).
const failNotifyDebounce = time.Hour
// SetNotifier подключает исходящие пинги (до запуска Run). // SetNotifier подключает исходящие пинги (до запуска Run).
func (w *Worker) SetNotifier(n Notifier) { w.notifier = n } func (w *Worker) SetNotifier(n Notifier) { w.notifier = n }
@@ -148,14 +170,15 @@ func (w *Worker) SetScanner(s Scanner) { w.scanner = s }
// распознавания и раскладки) — тогда completed-задачи не двигаются дальше. // распознавания и раскладки) — тогда completed-задачи не двигаются дальше.
func New(st Store, qb QBittorrent, rec Recognizer, lay Layouter, cfg Config, log *slog.Logger) *Worker { func New(st Store, qb QBittorrent, rec Recognizer, lay Layouter, cfg Config, log *slog.Logger) *Worker {
return &Worker{ return &Worker{
store: st, store: st,
qbt: qb, qbt: qb,
recognizer: rec, recognizer: rec,
layouter: lay, layouter: lay,
cfg: cfg, cfg: cfg,
log: log, log: log,
now: time.Now, now: time.Now,
newID: defaultBatchID, newID: defaultBatchID,
failNotified: map[int64]time.Time{},
} }
} }
@@ -246,6 +269,10 @@ func (w *Worker) Poll(ctx context.Context) error {
// Сверка разложенных задач с реальностью (источник в qBit + хардлинки на ФС) // Сверка разложенных задач с реальностью (источник в qBit + хардлинки на ФС)
// — отдельно от активных, по двумерной матрице (см. state-reconciliation). // — отдельно от активных, по двумерной матрице (см. state-reconciliation).
w.reconcileDesync(ctx, byHash) w.reconcileDesync(ctx, byHash)
// Восстановление задач, упавших по нашей нетерпеливости (magnet_timeout/
// stalled), если их источник в qBittorrent ожил и продвинулся.
w.reconcileRecovery(ctx, byHash)
return nil return nil
} }
@@ -257,7 +284,7 @@ func (w *Worker) reconcile(ctx context.Context, d store.Download, t qbt.Torrent)
case classReady: case classReady:
w.transition(ctx, d, store.StateCompleted, "", "") w.transition(ctx, d, store.StateCompleted, "", "")
case classErrored: case classErrored:
w.transition(ctx, d, store.StateFailed, "qbit_error", "qBittorrent state: "+t.State) w.transition(ctx, d, store.StateFailed, errCodeQbitError, "qBittorrent state: "+t.State)
case classDownloading: case classDownloading:
w.checkTimeouts(ctx, d, t) w.checkTimeouts(ctx, d, t)
case classBusy: case classBusy:
@@ -265,26 +292,43 @@ func (w *Worker) reconcile(ctx context.Context, d store.Download, t qbt.Torrent)
} }
} }
// checkTimeouts помечает зависшие задачи. Возраст считаем от created_at: // checkTimeouts помечает зависшие задачи. Возраст считаем от факта в
// для metaDL это время с момента добавления (огрублённо, но достаточно). // qBittorrent (added_on), а не от created_at: базис переживает retry и
// усыновление раздачи (см. design download-failure-recovery). magnet_timeout —
// редкий страховочный предохранитель (дефолт 24h); настоящие провалы ловит
// classErrored, а ожившие задачи воскрешает reconcileRecovery.
func (w *Worker) checkTimeouts(ctx context.Context, d store.Download, t qbt.Torrent) { func (w *Worker) checkTimeouts(ctx context.Context, d store.Download, t qbt.Torrent) {
created, err := d.CreatedTime() age := w.torrentAge(d, t)
if err != nil {
logctx.From(ctx).Warn("cannot parse created_at", "value", d.CreatedAt, "error", err)
return
}
age := w.now().Sub(created)
switch { switch {
case isMeta(t.State) && w.cfg.MagnetTimeout > 0 && age > w.cfg.MagnetTimeout: case isMeta(t.State) && w.cfg.MagnetTimeout > 0 && age > w.cfg.MagnetTimeout:
w.transition(ctx, d, store.StateFailed, "magnet_timeout", w.transition(ctx, d, store.StateFailed, errCodeMagnetTimeout,
fmt.Sprintf("no metadata after %s", age.Truncate(time.Second))) fmt.Sprintf("no metadata after %s", age.Truncate(time.Second)))
case isStalledDL(t.State) && w.cfg.StuckAfter > 0 && age > w.cfg.StuckAfter: case isStalledDL(t.State) && w.cfg.StuckAfter > 0 && age > w.cfg.StuckAfter:
w.transition(ctx, d, store.StateStuck, "stalled", w.transition(ctx, d, store.StateStuck, errCodeStalled,
fmt.Sprintf("stalled for %s", age.Truncate(time.Second))) fmt.Sprintf("stalled for %s", age.Truncate(time.Second)))
} }
} }
// torrentAge — возраст торрента: от added_on в qBittorrent (надёжный базис,
// переживает retry/усыновление), с фолбэком на created_at задачи, если qBit не
// отдал added_on.
func (w *Worker) torrentAge(d store.Download, t qbt.Torrent) time.Duration {
if t.AddedOn > 0 {
return w.now().Sub(time.Unix(t.AddedOn, 0).UTC())
}
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 w.now().Sub(created)
}
// transition пишет новое состояние и логирует переход. // transition пишет новое состояние и логирует переход.
func (w *Worker) transition(ctx context.Context, d store.Download, state store.State, code, msg string) { func (w *Worker) transition(ctx context.Context, d store.Download, state store.State, code, msg string) {
// FromOr, а не From: если вызывающий не завёл scoped-логгер, падаем на // FromOr, а не From: если вызывающий не завёл scoped-логгер, падаем на
@@ -308,6 +352,10 @@ func (w *Worker) transition(ctx context.Context, d store.Download, state store.S
go w.notifier.Notify(context.Background(), d.ID, EventOrphaned) go w.notifier.Notify(context.Background(), d.ID, EventOrphaned)
case store.StateTargetMissing: case store.StateTargetMissing:
go w.notifier.Notify(context.Background(), d.ID, EventTargetMissing) go w.notifier.Notify(context.Background(), d.ID, EventTargetMissing)
case store.StateFailed, store.StateStuck:
if w.shouldNotifyFail(d.ID) {
go w.notifier.Notify(context.Background(), d.ID, EventFailed)
}
} }
} }
@@ -323,6 +371,25 @@ func (w *Worker) transition(ctx context.Context, d store.Download, state store.S
} }
} }
// shouldNotifyFail дебаунсит повторные уведомления о падении одной задачи
// (мерцающий stalled-торрент: stuck↔downloading), чтобы не спамить. Вызывается
// под w.mu. НЕ сбрасываем запись при восстановлении — иначе дебаунс не гасил бы
// флаппинг.
func (w *Worker) shouldNotifyFail(id int64) bool {
now := w.now()
if last, ok := w.failNotified[id]; ok && now.Sub(last) < failNotifyDebounce {
return false
}
w.failNotified[id] = now
// Лёгкая чистка устаревших записей, чтобы карта не росла без предела.
for k, t := range w.failNotified {
if now.Sub(t) >= failNotifyDebounce {
delete(w.failNotified, k)
}
}
return true
}
// Cancel отклоняет задачу. Торрент в qBittorrent не трогаем — он продолжает // Cancel отклоняет задачу. Торрент в qBittorrent не трогаем — он продолжает
// раздачу (источник неприкосновенен). // раздачу (источник неприкосновенен).
func (w *Worker) Cancel(ctx context.Context, id int64) error { func (w *Worker) Cancel(ctx context.Context, id int64) error {
@@ -356,7 +423,18 @@ func (w *Worker) Retry(ctx context.Context, id int64) error {
if d.State != store.StateFailed && d.State != store.StateStuck { if d.State != store.StateFailed && d.State != store.StateStuck {
return fmt.Errorf("retry: download %d is %s, only failed/stuck are retriable", id, d.State) return fmt.Errorf("retry: download %d is %s, only failed/stuck are retriable", id, d.State)
} }
if d.SourceType == store.SourceMagnet { // Если раздача уже жива в qBittorrent — перецепляемся к ней, повторный Add
// не нужен (и вреден: вслепую дублировал бы торрент). Add — только когда
// источника в qBittorrent нет. Базис таймаута берётся от added_on, поэтому
// возврат в downloading не роняет задачу снова на ближайшем тике.
alive := false
if d.Infohash.Valid {
_, alive, err = w.torrentByInfohash(ctx, d.Infohash.String)
if err != nil {
return fmt.Errorf("retry: %w", err)
}
}
if !alive && d.SourceType == store.SourceMagnet {
if err := w.qbt.Add(ctx, qbt.AddRequest{ if err := w.qbt.Add(ctx, qbt.AddRequest{
URLs: []string{d.SourceRef}, URLs: []string{d.SourceRef},
Category: w.cfg.Category, Category: w.cfg.Category,
+16
View File
@@ -42,6 +42,22 @@ func (f *fakeStore) ListDownloadsByState(_ context.Context, states ...store.Stat
return out, nil return out, nil
} }
func (f *fakeStore) ListRecoverable(_ context.Context, codes ...string) ([]store.Download, error) {
var out []store.Download
for _, d := range f.downloads {
if d.State != store.StateFailed && d.State != store.StateStuck {
continue
}
for _, c := range codes {
if d.ErrorCode.Valid && d.ErrorCode.String == c {
out = append(out, *d)
break
}
}
}
return out, nil
}
func (f *fakeStore) GetDownload(_ context.Context, id int64) (*store.Download, error) { func (f *fakeStore) GetDownload(_ context.Context, id int64) (*store.Download, error) {
d, ok := f.downloads[id] d, ok := f.downloads[id]
if !ok { if !ok {
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-06-30
@@ -0,0 +1,158 @@
## Context
Машина состояний загрузок живёт в `internal/worker/worker.go`
(`Poll`/`reconcile`/`checkTimeouts`/`transition`/`Retry`), список состояний и
терминальность — в `internal/store/download.go`. Граф переходов описан в
`docs/specs/workflow.md` (ещё не мигрирован в OpenSpec — он остаётся
источником истины по жизненному циклу). Сверка реальности с БД (capability
`state-reconciliation`) реализована в `reconcileDesync` и уже исключает
`failed`/`stuck`.
Текущее поведение, породившее инцидент:
- `checkTimeouts` (worker.go:268-285) меряет возраст задачи от `created_at` и
при `metaDL` дольше `magnet_timeout` гонит в `failed`/`magnet_timeout`.
Дефолт `magnet_timeout` = 30m (`config.go:184`), но для долгих magnet это
слишком агрессивно.
- `failed`/`stuck` терминальны, выхода нет; `transition` уведомляет только
`review`/`done`/`orphaned`/`target_missing` (worker.go:301-312) — падение
молчит.
- `Worker.Retry` (worker.go:348-373) возвращает в `downloading`, но базис
таймаута (`created_at`) не меняется → `checkTimeouts` роняет задачу снова
на ближайшем тике; retry экспонирован только в REST.
`qbt.Torrent` уже содержит `AddedOn` (unix, секунды) — время добавления
торрента в qBittorrent.
## Goals / Non-Goals
**Goals:**
- Долгий `metaDL` не убивается агрессивно; `magnet_timeout` — редкий
страховочный предохранитель (дефолт 24h), а не рабочий механизм.
- Корректный базис таймаута — от факта в qBittorrent (`added_on`), не от
`created_at`.
- Любой переход в `failed`/`stuck` уведомляет автора.
- Авто-восстановление задач, упавших по нашей нетерпеливости
(`magnet_timeout`/`stalled`), когда источник в qBittorrent ожил и
продвинулся.
- Ручной retry в веб-UI и Telegram; перецепление к живому торренту вместо
слепого повторного `Add`.
**Non-Goals:**
- Не воскрешаем реальные/пользовательские провалы: `qbit_error`, `reverted`,
`cancelled`, `deleted`.
- Не трогаем сам торрент в qBittorrent при падении (источник
неприкосновенен).
- Без миграций БД и без новых внешних зависимостей.
- Не вводим отдельную capability `notifications` — преждевременно.
## Decisions
### 1. Базис таймаута — `added_on`, а не `created_at`
`checkTimeouts` считает `age = now - torrent.AddedOn` (UTC). Это чинит
неверный отсчёт для усыновлённых раздач и — главное — делает retry/восстановление
устойчивым: после возврата в `downloading` базис не сбрасывается в «сейчас»,
он привязан к реальному возрасту торрента. Отдельный сброс `created_at` при
Retry больше не нужен.
*Альтернатива:* хранить «время входа в metaDL» отдельным полем БД — точнее,
но требует миграции и записи на каждый тик. `added_on` достаточно (огрубление
в большую сторону безопасно при 24h-предохранителе).
### 2. Дефолт `magnet_timeout` → 24h
Меняем дефолт в `config.go` и `config.example.toml`. Реальные провалы ловятся
классом `classErrored` (`error`/`missingFiles``qbit_error`) — это уже
работает и не зависит от wall-clock. У qBittorrent нет статуса «magnet мёртв»,
поэтому большой страховочный таймаут — единственный сигнал на безнадёжный
magnet.
### 3. Уведомление о падении
В `transition` добавляем ветки для `StateFailed` и `StateStuck` → новый
`EventFailed`. Сообщение в `notifier` (tgbot/httpapi) читает состояние и
`error_code` задачи и формирует текст. Один `Event` на оба состояния —
дробить на `EventStuck` смысла нет (различие видно из `error_code`).
### 4. Авто-восстановление в сверке
Отдельный проход `reconcileRecovery` (рядом с `reconcileDesync`, под `w.mu`,
из `Poll`): берём задачи в `failed`/`stuck` с восстановимым `error_code`
(`magnet_timeout`/`stalled`), находим их торрент в уже построенном индексе
`byHash`. Воскрешаем **только если торрент продвинулся за условие падения**:
- `magnet_timeout`: восстанавливаем, когда `!isMeta(state)` и не `classErrored`
(метаданные получены);
- `stalled`: восстанавливаем, когда `!isStalledDL(state)` и не `classErrored`
(раздача ожила).
Иначе (торрент всё ещё в `metaDL`/`stalledDL`, либо отсутствует) — оставляем
как есть. Это **критично против зацикливания**: при 24h-предохранителе мёртвый
magnet, упавший по таймауту, остаётся в `metaDL`; без проверки прогресса
восстановление вернуло бы его в `downloading`, и он падал бы снова каждые 24ч.
Целевое состояние выводим из `classify(state)`: `classReady``completed`,
`classDownloading``downloading`. При возврате в `downloading`
восстанавливаем `idempotency_key = infohash` (нужен метод стора, т.к.
`SetDownloadState` его при терминальном переходе снимает), чтобы повторный
приём снова дедуплицировался.
*Альтернатива:* расширить `reconcileDesync` матрицей «источник × цель». Не
подходит: у `failed`/`stuck` нет разложенной цели, ось другая (прогресс
источника), отдельный проход чище.
### 5. Починка `Worker.Retry` + кнопки в UI/Telegram
`Retry`: если торрент задачи уже есть в qBittorrent (живой) — не делаем
повторный `Add`, только переводим в `downloading` и восстанавливаем
`idempotency_key`; `Add` выполняем, только когда раздачи нет. Базис таймаута
теперь `added_on`, поэтому немедленного повторного падения нет (корень бага
устранён решением 1). В `internal/httpapi` (веб-UI) и `internal/tgbot`
добавляем действие retry рядом с существующим Cancel, вызывающее тот же
`Worker.Retry`.
### 6. `error_code` в именованные константы
Строки `"magnet_timeout"`, `"stalled"`, `"qbit_error"`, `"qbit_add"` выносим в
именованные константы (рядом с состояниями в `store` или в `worker`), чтобы
проверка восстановимости (`magnet_timeout`/`stalled`) и присвоение не
расходились по литералам. Требует, чтобы `error_code` задачи был доступен из
`store.Download` (проверить наличие поля; при отсутствии — добавить чтение,
без миграции, столбец уже есть).
## Risks / Trade-offs
- **Флаппинг уведомлений** (fail → восстановление → fail) → при дефолте 24h и
условии «торрент продвинулся» падение и воскрешение редки; повторный fail
возможен только если раздача снова реально застрянет. Доп. дебаунс не
вводим — усложнение без явной нужды.
- **Базис `added_on` огрубляет** (re-add торрента сбрасывает возраст) → при
24h-предохранителе и авто-восстановлении это не приводит к ложным провалам;
ранее проблема была в 30m-агрессии, которую и убираем.
- **`magnet_timeout` всё ещё может ложно сработать** на очень медленном, но
живом magnet (>24h до метаданных) → теперь это не тупик: уведомление + при
получении метаданных авто-восстановление вернёт задачу, плюс есть ручной
retry.
- **`idempotency_key` восстановление** при воскрешении: если за время в
`failed` пользователь успел повторно принять тот же infohash и завести
новую задачу, ключ уже занят. Обрабатываем как конфликт (не воскрешаем
старую либо логируем и оставляем в failed) — уточнить в реализации.
## Migration Plan
Изменение поведения + конфига, без миграций БД и без слома API. Деплой —
обычный (новый бинарь на umbar). Дефолт `magnet_timeout` меняется; явное
значение в существующем `config.toml` сохраняет поведение пользователя.
Откат — предыдущий бинарь; данные совместимы.
## Open Questions
Решены на ревью дизайна:
- **Занятый `idempotency_key` при авто-восстановлении** → старую задачу не
воскрешаем, оставляем в `failed` и логируем конфликт.
- **Уведомление об успешном авто-восстановлении** → не шлём, достаточно
лога.
@@ -0,0 +1,75 @@
## Why
Загрузка magnet'ом ушла в терминальный `failed`/`magnet_timeout` по
wall-clock таймауту (возраст от `created_at`, ~1ч), хотя qBittorrent просто
долго тянул метаданные (медленные трекеры / мало пиров). Метаданные в итоге
пришли, торрент жив и качается, но задача застряла в терминальном состоянии
без выхода — восстановить её нельзя. Вдобавок падение происходит молча (нет
уведомления автору), а единственный путь возврата `Worker.Retry` баговый
(не сбрасывает базис времени → задача мгновенно снова падает) и доступен
только через REST, но не из веб-UI и Telegram.
## What Changes
- **Терпеливость к `metaDL`.** Перестаём убивать долгий magnet агрессивным
таймаутом. Дефолт `[worker].magnet_timeout` поднимается до `24h` — это
редкий страховочный предохранитель, а не рабочий механизм. Настоящие
провалы определяются по статусам ошибок qBittorrent (`error`/`missingFiles`
`qbit_error`), а не по wall-clock. *(У qBittorrent нет статуса «magnet
мёртв» — зависший magnet вечно висит в `metaDL`, поэтому единственный
сигнал на этот кейс — большой страховочный таймаут.)*
- **Базис таймаута — от факта, а не от `created_at`.** Возраст для
`magnet_timeout`/`stalled` считаем от времени добавления торрента в
qBittorrent (`added_on`), а не от создания записи. Это чинит неверный
отсчёт для усыновлённых раздач и устраняет мгновенное повторное падение
после возврата в `downloading`.
- **Уведомление о любом падении.** Переход в `failed` (любой `error_code`)
и `stuck` уведомляет автора загрузки через `notifier` (раньше уведомления
слались только для `review`/`done`/`orphaned`/`target_missing`).
- **Авто-восстановление из `failed`/`stuck`.** Фоновая сверка
(state-reconciliation) замечает, что у задачи в восстановимом
`failed`/`stuck` (наша нетерпеливость: `magnet_timeout`, `stalled`)
источник в qBittorrent жив и продвинулся, и возвращает задачу в поток
(`downloading` либо `completed` по статусу торрента). Пользовательские
и реальные провалы (`qbit_error`, `reverted`, `cancelled`) сверка не
воскрешает.
- **Ручной retry из UI и Telegram.** Кнопка повторной попытки добавляется в
веб-UI и Telegram-бот (раньше — только Cancel; retry был только в REST).
`Worker.Retry` чинится: перецепляется к уже живому торренту вместо слепого
повторного `Add`, базис таймаута сбрасывается.
## Capabilities
### New Capabilities
Нет. Уведомления о падении и семантика таймаута относятся к жизненному циклу
загрузки, который пока живёт в `docs/specs/workflow.md` (ещё не мигрирован в
OpenSpec); заводить отдельную capability `notifications` сейчас —
преждевременное дробление.
### Modified Capabilities
- `state-reconciliation`: восстановимые `failed`/`stuck` (`magnet_timeout`,
`stalled`) перестают быть «неприкосновенными» для сверки и подлежат
авто-восстановлению при живом продвинувшемся источнике; добавляется
требование о восстановлении и о доступности ручного retry. `qbit_error`,
`reverted`, `cancelled`, `deleted` остаются вне восстановления.
## Impact
- **Спеки:** дельта `state-reconciliation`; обновление графа переходов и
семантики таймаута/уведомлений в `docs/specs/workflow.md` (источник истины
по жизненному циклу до миграции).
- **Конфиг:** дефолт `[worker].magnet_timeout``24h`
(`internal/config/config.go`), `config.example.toml`.
- **Код:** `internal/worker/worker.go``transition` (уведомление о
failed/stuck), `checkTimeouts` (базис от `added_on`), `reconcile`/
`reconcileDesync` (воскрешение из failed/stuck), `Retry` (перецепление +
сброс базиса); новый `Event` падения и его обработка в `notifier`/
`tgbot`/`httpapi`; кнопка retry в `internal/httpapi` и `internal/tgbot`;
вынос строки `"magnet_timeout"` (и смежных `error_code`) в именованные
константы рядом с состояниями.
- **qBittorrent-клиент:** возможно потребуется поле `added_on` в
`qbt.Torrent` (если ещё не читается).
- **Миграции БД:** не ожидаются (восстановление опирается на состояние qBit и
существующие поля задачи).
@@ -0,0 +1,141 @@
## MODIFIED Requirements
### Requirement: Периодическая сверка состояния с реальностью
`worker` SHALL периодически (на тике поллинга) сверять задачи, для которых
ожидаются разложенные файлы, с фактом на файловой системе и в qBittorrent, и
выводить состояние задачи из двух независимых признаков: присутствия
**источника** (раздача с `download.infohash` в выдаче qBittorrent) и
присутствия **цели** (см. требование о владении целевым путём: существуют все
ссылки последнего батча со статусом раскладки, всё ещё принадлежащие этой
загрузке).
Сверке по матрице «источник × цель» SHALL подвергаться состояния `done`,
`target_missing`, `orphaned`. Состояние `deleted` сверка трогать SHALL NOT —
оно терминально. Активные (`downloading`/`recognizing`/`review`/`deferred`/
`linking`) и пользовательски-терминальные (`reverted`/`cancelled`) состояния
сверка по матрице трогать SHALL NOT.
**Восстановимые** `failed`/`stuck` (с `error_code` `magnet_timeout` или
`stalled` — задержки, вызванные нашей нетерпеливостью, а не реальной ошибкой)
сверка SHALL рассматривать отдельно — на предмет оживления источника (см.
требование о восстановлении зависшей загрузки), не по матрице «источник ×
цель». Прочие `failed` (например `qbit_error`) сверка трогать SHALL NOT.
Состояние SHALL переписываться только при его изменении (без записи и логов,
когда выведенное состояние совпадает с текущим).
#### Scenario: Источник и цель на месте — состояние не меняется
- **WHEN** для задачи в `done` раздача присутствует в qBittorrent и все её
разложенные хардлинки существуют
- **THEN** задача остаётся в `done`
- **AND** запись состояния и лог перехода не выполняются
#### Scenario: Частичная пропажа цели считается отсутствием
- **WHEN** часть разложенных хардлинков задачи удалена, а источник на месте
- **THEN** цель считается отсутствующей и задача переходит в `target_missing`
#### Scenario: Задача в deleted сверкой не переоценивается
- **WHEN** задача находится в `deleted`
- **THEN** сверка её не рассматривает и состояние не меняет, даже если по её
бывшему пути появился файл другой загрузки
#### Scenario: Провал по ошибке qBittorrent восстановлению не подлежит
- **WHEN** задача в `failed` с `error_code` `qbit_error`
- **THEN** сверка её не рассматривает и состояние не меняет
## ADDED Requirements
### Requirement: Восстановление зависшей загрузки при оживлении источника
Система SHALL возвращать в активный поток задачу, упавшую из-за нашей
нетерпеливости (`failed`/`magnet_timeout` или `stuck`/`stalled`), если её
источник в qBittorrent жив и продвинулся: переход выводится из текущего
состояния торрента так же, как при штатной сверке загрузки
(`uploading`/`stalledUP`/… → `completed`; `downloading`/`metaDL`/… →
`downloading`). Восстановление SHALL опираться на фактическое состояние
торрента в qBittorrent, а не на время с момента создания записи.
При возврате в любое нетерминальное состояние (`downloading` или
`completed`) система SHALL восстанавливать идемпотентность задачи
(`idempotency_key`), чтобы повторный приём того же infohash снова
дедуплицировался на эту задачу. Если за время простоя в `failed`/`stuck` тем
же infohash уже завладела другая активная задача (ключ снимается при падении и
мог быть перехвачен новым приёмом), система SHALL NOT воскрешать упавшую
задачу и SHALL оставить её в `failed`/`stuck`, сохраняя инвариант «не более
одной активной задачи на infohash».
`magnet_timeout`/`stalled` SHALL быть редким страховочным исходом, а не
рабочим механизмом: пока торрент в `metaDL`/`forcedMetaDL` или иным образом
прогрессирует в пределах страховочного таймаута, задача в `failed`/`stuck`
из-за него оказаться SHALL NOT (см. требование о терпеливости к долгим
метаданным в `docs/specs/workflow.md`).
#### Scenario: Метаданные пришли после magnet_timeout
- **GIVEN** задача в `failed` с `error_code` `magnet_timeout`, а её торрент
в qBittorrent уже получил метаданные и качается (`downloading`)
- **WHEN** срабатывает фоновая сверка
- **THEN** задача возвращается в `downloading`
- **AND** её `idempotency_key` восстанавливается
#### Scenario: Торрент уже завершился, пока задача была в failed
- **GIVEN** задача в `failed` с `error_code` `magnet_timeout`, а её торрент
в qBittorrent уже готов к раскладке (`uploading`/`stalledUP`)
- **WHEN** срабатывает фоновая сверка
- **THEN** задача переходит в `completed` и продолжает обычный поток
(распознавание/раскладка)
#### Scenario: Источник так и не ожил — состояние не меняется
- **GIVEN** задача в `failed` с `error_code` `magnet_timeout`, а её торрент
всё ещё висит в `metaDL` без метаданных (или отсутствует в qBittorrent)
- **WHEN** срабатывает фоновая сверка
- **THEN** задача остаётся в `failed`
#### Scenario: infohash уже занят другой активной задачей
- **GIVEN** задача #1 в `failed`/`magnet_timeout`, а тем же infohash уже
владеет другая активная задача #2 (приём повторили, пока #1 лежала упавшей)
- **WHEN** источник ожил (торрент получил метаданные или готов) и сверка
пытается воскресить #1
- **THEN** #1 остаётся в `failed` (восстановление не выполняется)
- **AND** активной по этому infohash остаётся #2
### Requirement: Ручной повтор зависшей/упавшей загрузки из транспортов
Система SHALL предоставлять пользователю команду повторной попытки (retry)
для задач в `failed`/`stuck` из веб-UI и Telegram (не только через REST API).
Retry SHALL переводить задачу обратно в `downloading`, не вызывая её
немедленного повторного падения по таймауту: базис отсчёта таймаута SHALL
сбрасываться (отсчёт ведётся от факта в qBittorrent, а не от старого
`created_at`).
Если источник задачи уже жив в qBittorrent, retry SHALL перецепляться к
существующему торренту, а не добавлять источник повторно вслепую; повторный
`Add` выполняется, только когда раздачи в qBittorrent нет.
#### Scenario: Retry упавшей magnet-загрузки из веб-UI
- **GIVEN** задача в `failed`, её торрент жив в qBittorrent
- **WHEN** пользователь нажимает retry в веб-UI
- **THEN** задача возвращается в `downloading` без повторного `Add`
- **AND** не падает снова на ближайшем тике сверки по таймауту
#### Scenario: Retry доступен в Telegram
- **WHEN** для задачи в `failed`/`stuck` пользователь вызывает retry в
Telegram-боте
- **THEN** задача возвращается в `downloading`
#### Scenario: Retry без живого источника добавляет торрент заново
- **GIVEN** задача в `failed`, раздачи в qBittorrent нет
- **WHEN** пользователь инициирует retry
- **THEN** источник (magnet) добавляется в qBittorrent заново
- **AND** задача переходит в `downloading`
@@ -0,0 +1,76 @@
## 1. Константы и базис таймаута
- [x] 1.1 Вынести `error_code`-строки (`magnet_timeout`, `stalled`,
`qbit_error` — в `worker`; `qbit_add` — в `ingest`) в именованные константы;
`error_code` уже читается из `store.Download.ErrorCode` (миграция не нужна)
- [x] 1.2 В `checkTimeouts` считать возраст от `torrent.AddedOn` (UTC) через
хелпер `torrentAge` (фолбэк на `created_at`, если `added_on` нет)
- [x] 1.3 Поднять дефолт `[worker].magnet_timeout` до `24h` в
`internal/config/config.go` и `config.example.toml` (с пометкой про страховку)
## 2. Уведомление о падении
- [x] 2.1 Добавить `EventFailed` в `NotifyEvent` (worker.go)
- [x] 2.2 В `transition` слать `EventFailed` при переходе в `StateFailed` и
`StateStuck` (неблокирующе, вне `w.mu`)
- [x] 2.3 Обработать `EventFailed` в Telegram (`renderFailed` + `retryKeyboard`);
httpapi `Notifier` не реализует — только tgbot
## 3. Авто-восстановление в сверке
- [x] 3.1 Восстановление `idempotency_key` отдельным методом стора НЕ нужно:
`SetDownloadState` сам ставит ключ в `infohash` для нетерминального состояния
- [x] 3.2 Реализовать `reconcileRecovery` (под `w.mu`, из `Poll`): задачи в
`failed`/`stuck` с `error_code` `magnet_timeout`/`stalled`, поиск в `byHash`
- [x] 3.3 Воскрешать только при прогрессе торрента (`torrentProgressed`):
`magnet_timeout``!isMeta`; `stalled``!isStalledDL`; не `classErrored`;
целевое состояние из `classify` (`recoveredState`: ready → `completed`,
downloading → `downloading`)
- [x] 3.4 Конфликт занятого `idempotency_key`: пре-проверка через
`FindActiveByInfohash` — оставляем в `failed` + лог
- [x] 3.5 Тесты (`recovery_test.go`): метаданные → `downloading`; готов →
`completed`; всё ещё `metaDL``failed`; `qbit_error`/ошибка не воскрешаются;
нет источника → `failed`; конфликт ключа → `failed`
## 4. Починка Retry и ручной retry в транспортах
- [x] 4.1 `Worker.Retry`: при живом торренте — без повторного `Add`, только
`downloading`; `Add` лишь когда раздачи нет
- [x] 4.2 Тест: retry при живом торренте не делает `Add` и не падает на
ближайшем тике (базис `added_on`)
- [x] 4.3 Кнопка retry в веб-UI (`internal/httpapi` + `index.html`) для
`failed`/`stuck`; роут `/ui/downloads/{id}/retry`; тест `TestUIRetry`
- [x] 4.4 Действие retry в Telegram-боте (колбэк `retry:` + кнопка); тесты
`TestBot_CallbackRetry`, `TestBot_NotifyFailed`
## 5. Документация спек
- [x] 5.1 Обновить `docs/specs/workflow.md`: `magnet_timeout` как страховка,
базис `added_on`, уведомление о `failed`/`stuck`, восстановление и retry
- [x] 5.2 `openspec validate --strict download-failure-recovery` — без ошибок
## 6. Проверка
- [x] 6.1 `task lint` (0 issues) и `task test` (зелёные)
- [x] 6.2 Ревью кода (чекпоинт до archive) — 8-угловой multi-agent проход
## 7. Фиксы по ревью кода
- [x] 7.1 Конфликт `idempotency_key`: проверка `FindActiveByInfohash`
распространена на ветку `completed` (а не только `downloading`) —
иначе constraint-ошибка и зависание в `failed` с логом каждый тик
(тест `TestRecoverySkipsConflictOnCompleted`)
- [x] 7.2 Recovery не пишет в `error_msg` здоровой задачи (передаём `""`,
причину — в лог), иначе заметка светилась бы как ошибка в UI/REST
- [x] 7.3 Эффективность: `reconcileRecovery` грузит кандидатов через
`store.ListRecoverable` (SQL-фильтр по `error_code`), а не вычитывает все
failed/stuck каждый тик
- [x] 7.4 Дебаунс уведомлений о падении (`shouldNotifyFail`, окно 1h) —
мерцающий stalled-торрент не спамит `EventFailed` (тест
`TestFailNotifyDebounce`)
- [x] 7.5 Уведомление о падении `qbit_add` в ingest через closure
(`SetFailureNotifier`, wiring в serve.go), тест
`TestIngestQbitErrorNotifies`
- [x] 7.6 Конвенция логирования: убран неймспейс-префикс `recovery:` из `msg`
- [x] 7.7 Мелочи: дедуп ветки `renderCard`, диагностика в `torrentAge`,
`time.Unix(...).UTC()`, актуализирован комментарий `terminalStates`
+106 -5
View File
@@ -22,11 +22,17 @@ qBittorrent. Capability описывает периодическую и при
ссылки последнего батча со статусом раскладки, всё ещё принадлежащие этой ссылки последнего батча со статусом раскладки, всё ещё принадлежащие этой
загрузке). загрузке).
Сверке SHALL подвергаться только состояния `done`, `target_missing`, Сверке по матрице «источник × цель» SHALL подвергаться состояния `done`,
`orphaned`. Состояние `deleted` сверка трогать SHALL NOT — оно терминально. `target_missing`, `orphaned`. Состояние `deleted` сверка трогать SHALL NOT —
Активные (`downloading`/`recognizing`/`review`/`deferred`/`linking`) и оно терминально. Активные (`downloading`/`recognizing`/`review`/`deferred`/
пользовательски-терминальные (`reverted`/`cancelled`/`failed`/`stuck`) `linking`) и пользовательски-терминальные (`reverted`/`cancelled`) состояния
состояния сверка трогать SHALL NOT. сверка по матрице трогать SHALL NOT.
**Восстановимые** `failed`/`stuck` (с `error_code` `magnet_timeout` или
`stalled` — задержки, вызванные нашей нетерпеливостью, а не реальной ошибкой)
сверка SHALL рассматривать отдельно — на предмет оживления источника (см.
требование о восстановлении зависшей загрузки), не по матрице «источник ×
цель». Прочие `failed` (например `qbit_error`) сверка трогать SHALL NOT.
Состояние SHALL переписываться только при его изменении (без записи и логов, Состояние SHALL переписываться только при его изменении (без записи и логов,
когда выведенное состояние совпадает с текущим). когда выведенное состояние совпадает с текущим).
@@ -49,6 +55,101 @@ qBittorrent. Capability описывает периодическую и при
- **THEN** сверка её не рассматривает и состояние не меняет, даже если по её - **THEN** сверка её не рассматривает и состояние не меняет, даже если по её
бывшему пути появился файл другой загрузки бывшему пути появился файл другой загрузки
#### Scenario: Провал по ошибке qBittorrent восстановлению не подлежит
- **WHEN** задача в `failed` с `error_code` `qbit_error`
- **THEN** сверка её не рассматривает и состояние не меняет
### Requirement: Восстановление зависшей загрузки при оживлении источника
Система SHALL возвращать в активный поток задачу, упавшую из-за нашей
нетерпеливости (`failed`/`magnet_timeout` или `stuck`/`stalled`), если её
источник в qBittorrent жив и продвинулся: переход выводится из текущего
состояния торрента так же, как при штатной сверке загрузки
(`uploading`/`stalledUP`/… → `completed`; `downloading`/`metaDL`/… →
`downloading`). Восстановление SHALL опираться на фактическое состояние
торрента в qBittorrent, а не на время с момента создания записи.
При возврате в любое нетерминальное состояние (`downloading` или
`completed`) система SHALL восстанавливать идемпотентность задачи
(`idempotency_key`), чтобы повторный приём того же infohash снова
дедуплицировался на эту задачу. Если за время простоя в `failed`/`stuck` тем
же infohash уже завладела другая активная задача (ключ снимается при падении и
мог быть перехвачен новым приёмом), система SHALL NOT воскрешать упавшую
задачу и SHALL оставить её в `failed`/`stuck`, сохраняя инвариант «не более
одной активной задачи на infohash».
`magnet_timeout`/`stalled` SHALL быть редким страховочным исходом, а не
рабочим механизмом: пока торрент в `metaDL`/`forcedMetaDL` или иным образом
прогрессирует в пределах страховочного таймаута, задача в `failed`/`stuck`
из-за него оказаться SHALL NOT (см. требование о терпеливости к долгим
метаданным в `docs/specs/workflow.md`).
#### Scenario: Метаданные пришли после magnet_timeout
- **GIVEN** задача в `failed` с `error_code` `magnet_timeout`, а её торрент
в qBittorrent уже получил метаданные и качается (`downloading`)
- **WHEN** срабатывает фоновая сверка
- **THEN** задача возвращается в `downloading`
- **AND** её `idempotency_key` восстанавливается
#### Scenario: Торрент уже завершился, пока задача была в failed
- **GIVEN** задача в `failed` с `error_code` `magnet_timeout`, а её торрент
в qBittorrent уже готов к раскладке (`uploading`/`stalledUP`)
- **WHEN** срабатывает фоновая сверка
- **THEN** задача переходит в `completed` и продолжает обычный поток
(распознавание/раскладка)
#### Scenario: Источник так и не ожил — состояние не меняется
- **GIVEN** задача в `failed` с `error_code` `magnet_timeout`, а её торрент
всё ещё висит в `metaDL` без метаданных (или отсутствует в qBittorrent)
- **WHEN** срабатывает фоновая сверка
- **THEN** задача остаётся в `failed`
#### Scenario: infohash уже занят другой активной задачей
- **GIVEN** задача #1 в `failed`/`magnet_timeout`, а тем же infohash уже
владеет другая активная задача #2 (приём повторили, пока #1 лежала упавшей)
- **WHEN** источник ожил (торрент получил метаданные или готов) и сверка
пытается воскресить #1
- **THEN** #1 остаётся в `failed` (восстановление не выполняется)
- **AND** активной по этому infohash остаётся #2
### Requirement: Ручной повтор зависшей/упавшей загрузки из транспортов
Система SHALL предоставлять пользователю команду повторной попытки (retry)
для задач в `failed`/`stuck` из веб-UI и Telegram (не только через REST API).
Retry SHALL переводить задачу обратно в `downloading`, не вызывая её
немедленного повторного падения по таймауту: базис отсчёта таймаута SHALL
сбрасываться (отсчёт ведётся от факта в qBittorrent, а не от старого
`created_at`).
Если источник задачи уже жив в qBittorrent, retry SHALL перецепляться к
существующему торренту, а не добавлять источник повторно вслепую; повторный
`Add` выполняется, только когда раздачи в qBittorrent нет.
#### Scenario: Retry упавшей magnet-загрузки из веб-UI
- **GIVEN** задача в `failed`, её торрент жив в qBittorrent
- **WHEN** пользователь нажимает retry в веб-UI
- **THEN** задача возвращается в `downloading` без повторного `Add`
- **AND** не падает снова на ближайшем тике сверки по таймауту
#### Scenario: Retry доступен в Telegram
- **WHEN** для задачи в `failed`/`stuck` пользователь вызывает retry в
Telegram-боте
- **THEN** задача возвращается в `downloading`
#### Scenario: Retry без живого источника добавляет торрент заново
- **GIVEN** задача в `failed`, раздачи в qBittorrent нет
- **WHEN** пользователь инициирует retry
- **THEN** источник (magnet) добавляется в qBittorrent заново
- **AND** задача переходит в `downloading`
### Requirement: Принудительная проверка источника/цели перед действием ### Requirement: Принудительная проверка источника/цели перед действием
Команда workflow, требующая наличия источника или цели, SHALL синхронно Команда workflow, требующая наличия источника или цели, SHALL синхронно
+5
View File
@@ -78,6 +78,11 @@
<button type="submit">Привязать заново</button> <button type="submit">Привязать заново</button>
</form> </form>
{{end}} {{end}}
{{if .Retriable}}
<form method="post" action="/ui/downloads/{{.ID}}/retry">
<button type="submit">Повторить</button>
</form>
{{end}}
{{if not .Terminal}} {{if not .Terminal}}
<form method="post" action="/ui/downloads/{{.ID}}/cancel"> <form method="post" action="/ui/downloads/{{.ID}}/cancel">
<button type="submit">Отклонить</button> <button type="submit">Отклонить</button>