Восстановление зависших загрузок и уведомления о падении (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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user