Восстановление зависших загрузок и уведомления о падении (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:
@@ -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
@@ -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
@@ -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`, уже включающие корневую папку торрента), не из
|
||||||
|
|||||||
@@ -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},
|
||||||
|
|||||||
@@ -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,6 +335,7 @@ 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,
|
||||||
|
Retriable: d.State == store.StateFailed || d.State == store.StateStuck,
|
||||||
Note: desyncNote(d.State),
|
Note: desyncNote(d.State),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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)
|
||||||
|
|||||||
@@ -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{}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" {
|
||||||
|
|||||||
@@ -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 == "" {
|
||||||
|
|||||||
@@ -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 синхронно (без дебаунса) проверяет, что раздача есть в
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
+90
-12
@@ -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 }
|
||||||
|
|
||||||
@@ -156,6 +178,7 @@ func New(st Store, qb QBittorrent, rec Recognizer, lay Layouter, cfg Config, log
|
|||||||
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,
|
||||||
|
|||||||
@@ -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 и
|
||||||
|
существующие поля задачи).
|
||||||
+141
@@ -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`
|
||||||
@@ -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 синхронно
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user