Восстановление зависших загрузок и уведомления о падении (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,
}, logger)
wrk.SetNotifier(bot)
// Приёмные падения (qbit_add) минуют worker — уведомляем напрямую.
ingestor.SetFailureNotifier(func(id int64) {
bot.Notify(context.Background(), id, worker.EventFailed)
})
go bot.Run(ctx)
logger.Info("telegram bot enabled",
"bot", api.Self.UserName, "allowed_users", len(cfg.Telegram.AllowedUserIDs))
+1 -1
View File
@@ -62,7 +62,7 @@ timeout = "10s" # таймаут запроса к Jellyfin
[worker]
poll_interval = "5s" # как часто опрашивать qBittorrent; Go-duration (s/m/h)
stuck_after = "1h" # сколько ждать прогресса, прежде чем счесть раздачу зависшей; Go-duration
magnet_timeout = "30m" # ждать метаданные magnet не дольше; Go-duration
magnet_timeout = "24h" # страховочный предел ожидания метаданных magnet (не рабочий механизм: ожившие задачи воскрешаются сверкой); Go-duration
source_missing_threshold = 3 # подряд тиков сверки без раздачи в qBittorrent, чтобы счесть источник удалённым (дебаунс)
[recognition]
+32 -7
View File
@@ -14,7 +14,7 @@ stateDiagram-v2
downloading --> completed: файлы на месте
downloading --> stuck: stalledDL дольше stuck_after
downloading --> failed: metaDL дольше magnet_timeout / error
downloading --> failed: metaDL дольше magnet_timeout (страховка) / error
completed --> recognizing
@@ -36,8 +36,10 @@ stateDiagram-v2
reverted --> recognizing: Привязать заново
cancelled --> recognizing: Привязать заново
stuck --> downloading: Retry
failed --> downloading: Retry
stuck --> downloading: Retry / сверка (раздача ожила)
failed --> downloading: Retry / сверка (метаданные пришли)
failed --> completed: сверка (торрент уже готов)
stuck --> completed: сверка (торрент уже готов)
done --> target_missing: сверка — цель удалена
done --> orphaned: сверка — источник пропал
@@ -147,10 +149,33 @@ SQLite; `worker` периодически сверяет qBittorrent с БД и
проверку (готовность не объявляем, даже если флаги «UP»).
- **ещё качается:** `downloading`/`stalledDL`/`metaDL`/`forcedMetaDL`/
`queuedDL`/`checkingDL`/`forcedDL`/`pausedDL`/`stoppedDL`.
- **застряло/ошибка по таймауту:** `metaDL`/`forcedMetaDL` дольше
`magnet_timeout``failed`; `stalledDL` дольше `stuck_after``stuck`
(восстановимо ретраем). Возраст считаем от создания задачи.
- **ошибка:** `error`/`missingFiles``failed`.
- **застряло по таймауту (страховка):** `metaDL`/`forcedMetaDL` дольше
`magnet_timeout``failed`; `stalledDL` дольше `stuck_after``stuck`.
`magnet_timeout`**редкий страховочный предохранитель** (дефолт `24h`), а
не рабочий механизм: долгий `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` + относительные имена из
`/torrents/files`, уже включающие корневую папку торрента), не из
+1 -1
View File
@@ -181,7 +181,7 @@ func Default() *Config {
Worker: Worker{
PollInterval: Duration(5 * time.Second),
StuckAfter: Duration(time.Hour),
MagnetTimeout: Duration(30 * time.Minute),
MagnetTimeout: Duration(24 * time.Hour),
SourceMissingThreshold: 3,
},
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.Post("/ui/downloads", s.handleUIAdd)
r.Post("/ui/downloads/{id}/cancel", s.handleUICancel)
r.Post("/ui/downloads/{id}/retry", s.handleUIRetry)
// Веб-UI: ревью раскладки.
r.Get("/review/{id}", s.handleReview)
@@ -133,6 +134,7 @@ type downloadView struct {
Reviewable bool // review/deferred — есть экран ревью
Undoable bool // done — можно откатить раскладку
Relinkable bool // reverted/cancelled/target_missing — можно перепривязать заново
Retriable bool // failed/stuck — можно повторить попытку
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)
}
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 ---
type downloadDTO struct {
@@ -320,7 +335,8 @@ func toView(d store.Download) downloadView {
Undoable: d.State == store.StateDone,
Relinkable: d.State == store.StateReverted || d.State == store.StateCancelled ||
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) {
// Конфликт состояния (worker.ErrConflict) → 409, не 500.
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 в логах.
const capIngest = "ingest"
// errCodeQbitAdd — error_code задачи, упавшей на добавлении источника в
// qBittorrent (раздачи в qBittorrent нет, восстановлению не подлежит).
const errCodeQbitAdd = "qbit_add"
// Store — нужная ingest часть хранилища.
type Store interface {
FindActiveByInfohash(ctx context.Context, infohash string) (*store.Download, error)
@@ -49,6 +53,12 @@ type Service struct {
namer Namer
cfg Config
log *slog.Logger
// notifyFailed — опц. пинг автору о падении приёма (добавление в qBittorrent
// не удалось). Closure, а не worker.Notifier: приёмное падение в qBit не
// попадает в поллинг-цикл worker (раздачи нет), поэтому уведомляет ingest
// сам; closure избавляет ядро приёма от зависимости на пакет worker.
notifyFailed func(downloadID int64)
}
// 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}
}
// SetFailureNotifier подключает пинг о падении приёма (до начала работы).
func (s *Service) SetFailureNotifier(fn func(downloadID int64)) { s.notifyFailed = fn }
// Request — входной запрос приёма.
type Request struct {
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)
// Задача уже в БД — помечаем 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)
} else if s.notifyFailed != nil {
// Это падение минует worker.transition (раздачи в qBit нет) — уведомляем
// сами, чтобы приёмные провалы тоже доходили до автора.
go s.notifyFailed(id)
}
return Result{DownloadID: id, Infohash: info.Infohash, State: store.StateFailed},
fmt.Errorf("ingest: add to qbittorrent: %w", addErr)
+21
View File
@@ -6,6 +6,7 @@ import (
"io"
"log/slog"
"testing"
"time"
"git.vakhrushev.me/av/jellybit/internal/qbt"
"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) {
fs := &fakeStore{}
fq := &fakeQbt{}
+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) {
+6
View File
@@ -36,6 +36,7 @@ type Reviewer interface {
SetType(ctx context.Context, id int64, mediaType string) error
Defer(ctx context.Context, id int64) error
Cancel(ctx context.Context, id int64) error
Retry(ctx context.Context, id int64) error
}
// Config — параметры бота.
@@ -191,6 +192,9 @@ func (b *Bot) handleCallback(ctx context.Context, cq *tgbotapi.CallbackQuery) {
case "reject":
err = b.reviewer.Cancel(ctx, id)
note = "Отклонено"
case "retry":
err = b.reviewer.Retry(ctx, id)
note = "Повторяю…"
case "type":
err = b.reviewer.SetType(ctx, id, val)
note = "Меняю тип…"
@@ -248,6 +252,8 @@ func (b *Bot) Notify(ctx context.Context, downloadID int64, event worker.NotifyE
text = b.renderDone(rd)
case worker.EventTargetMissing, worker.EventOrphaned:
text, kb = b.renderDesync(rd, event), b.webOnly(downloadID)
case worker.EventFailed:
text, kb = b.renderFailed(rd)
default:
text, kb = b.renderCard(rd)
}
+28
View File
@@ -65,6 +65,7 @@ type fakeReviewer struct {
typed map[int64]string
deferred []int64
canceled []int64
retried []int64
}
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)
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 {
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) {
a, id, v := parseCallback("type:5: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 != "" {
text += "\n" + msg
}
// failed/stuck — даём кнопку повтора; остальное только «в вебе».
if state == store.StateFailed || state == store.StateStuck {
return text, b.retryKeyboard(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 {
url := b.reviewURL(id)
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 в БД) ---
// 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
}
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) {
for _, d := range m.downloads {
if d.Infohash.Valid && d.Infohash.String == infohash {
+98 -20
View File
@@ -38,6 +38,7 @@ const (
// Store — нужная worker часть хранилища.
type Store interface {
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)
SetDownloadState(ctx context.Context, id int64, state store.State, errCode, errMsg string) error
SetSourceMissCount(ctx context.Context, id int64, n int) error
@@ -94,6 +95,17 @@ const (
EventDone NotifyEvent = "done" // раскладка завершена
EventOrphaned NotifyEvent = "orphaned" // источник пропал, цель — последняя копия
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). Вызывается неблокирующе.
@@ -136,8 +148,18 @@ type Worker struct {
newID func() string // генератор apply_batch_id (подменяется в тестах)
notifier Notifier // опц. исходящие пинги
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).
func (w *Worker) SetNotifier(n Notifier) { w.notifier = n }
@@ -148,14 +170,15 @@ func (w *Worker) SetScanner(s Scanner) { w.scanner = s }
// распознавания и раскладки) — тогда completed-задачи не двигаются дальше.
func New(st Store, qb QBittorrent, rec Recognizer, lay Layouter, cfg Config, log *slog.Logger) *Worker {
return &Worker{
store: st,
qbt: qb,
recognizer: rec,
layouter: lay,
cfg: cfg,
log: log,
now: time.Now,
newID: defaultBatchID,
store: st,
qbt: qb,
recognizer: rec,
layouter: lay,
cfg: cfg,
log: log,
now: time.Now,
newID: defaultBatchID,
failNotified: map[int64]time.Time{},
}
}
@@ -246,6 +269,10 @@ func (w *Worker) Poll(ctx context.Context) error {
// Сверка разложенных задач с реальностью (источник в qBit + хардлинки на ФС)
// — отдельно от активных, по двумерной матрице (см. state-reconciliation).
w.reconcileDesync(ctx, byHash)
// Восстановление задач, упавших по нашей нетерпеливости (magnet_timeout/
// stalled), если их источник в qBittorrent ожил и продвинулся.
w.reconcileRecovery(ctx, byHash)
return nil
}
@@ -257,7 +284,7 @@ func (w *Worker) reconcile(ctx context.Context, d store.Download, t qbt.Torrent)
case classReady:
w.transition(ctx, d, store.StateCompleted, "", "")
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:
w.checkTimeouts(ctx, d, t)
case classBusy:
@@ -265,26 +292,43 @@ func (w *Worker) reconcile(ctx context.Context, d store.Download, t qbt.Torrent)
}
}
// checkTimeouts помечает зависшие задачи. Возраст считаем от created_at:
// для metaDL это время с момента добавления (огрублённо, но достаточно).
// checkTimeouts помечает зависшие задачи. Возраст считаем от факта в
// 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) {
created, err := d.CreatedTime()
if err != nil {
logctx.From(ctx).Warn("cannot parse created_at", "value", d.CreatedAt, "error", err)
return
}
age := w.now().Sub(created)
age := w.torrentAge(d, t)
switch {
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)))
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)))
}
}
// 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 пишет новое состояние и логирует переход.
func (w *Worker) transition(ctx context.Context, d store.Download, state store.State, code, msg string) {
// 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)
case store.StateTargetMissing:
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 не трогаем — он продолжает
// раздачу (источник неприкосновенен).
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 {
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{
URLs: []string{d.SourceRef},
Category: w.cfg.Category,
+16
View File
@@ -42,6 +42,22 @@ func (f *fakeStore) ListDownloadsByState(_ context.Context, states ...store.Stat
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) {
d, ok := f.downloads[id]
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`,
`orphaned`. Состояние `deleted` сверка трогать SHALL NOT — оно терминально.
Активные (`downloading`/`recognizing`/`review`/`deferred`/`linking`) и
пользовательски-терминальные (`reverted`/`cancelled`/`failed`/`stuck`)
состояния сверка трогать SHALL NOT.
Сверке по матрице «источник × цель» 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 переписываться только при его изменении (без записи и логов,
когда выведенное состояние совпадает с текущим).
@@ -49,6 +55,101 @@ qBittorrent. Capability описывает периодическую и при
- **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: Принудительная проверка источника/цели перед действием
Команда workflow, требующая наличия источника или цели, SHALL синхронно
+5
View File
@@ -78,6 +78,11 @@
<button type="submit">Привязать заново</button>
</form>
{{end}}
{{if .Retriable}}
<form method="post" action="/ui/downloads/{{.ID}}/retry">
<button type="submit">Повторить</button>
</form>
{{end}}
{{if not .Terminal}}
<form method="post" action="/ui/downloads/{{.ID}}/cancel">
<button type="submit">Отклонить</button>