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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-07-08 17:18:28 +03:00
co-authored by Claude Opus 4.8
parent 4475fbd548
commit 8261d5b55d
15 changed files with 659 additions and 40 deletions
+71
View File
@@ -168,3 +168,74 @@ func TestRetryReattachesNoReadd(t *testing.T) {
t.Errorf("свежий metaDL не должен падать после retry, got %q", st.downloads["1"].State)
}
}
// addedLongAgo — added_on «5 часов назад» относительно now теста (10:00:00 UTC):
// торрент давно в qBittorrent (возраст сам по себе большой).
var addedLongAgo = time.Date(2026, 6, 14, 5, 0, 0, 0, time.UTC).Unix()
// lastActivityLongAgo — last_activity «2 часа назад»: данные давно не двигались.
var lastActivityLongAgo = time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC).Unix()
// MAJOR-1: retry живого, но давно добавленного и простаивающего stalledDL-торрента
// сбрасывает базис таймаута (retried_at), поэтому СЛЕДУЮЩИЙ тик не роняет задачу
// снова в stuck. Без сброса базиса stallDuration=nowlast_activity (2ч) > StuckAfter
// (1ч) → задача мгновенно вернулась бы в stuck (регрессия, которую прячет
// TestRetryReattachesNoReadd, ставящий added_on/last_activity «минуту назад»).
func TestRetryResetsTimeoutBasis(t *testing.T) {
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
st := oneFailed(store.StateStuck, errCodeStalled, ih, timeOld)
// Торрент жив, но давно добавлен (added_on 5ч) и давно простаивает
// (last_activity 2ч) — по старой мере «возраст» он мгновенно снова stuck.
qb := &fakeQbt{torrents: []qbt.Torrent{{
Hash: ih, State: "stalledDL",
AddedOn: addedLongAgo, LastActivity: lastActivityLongAgo,
}}}
w := newTestWorker(st, qb)
if err := w.Retry(context.Background(), "1"); err != nil {
t.Fatalf("Retry: %v", err)
}
if len(qb.added) != 0 {
t.Errorf("живой здоровый торрент не должен добавляться повторно, got %d Add", len(qb.added))
}
if !st.downloads["1"].RetriedAt.Valid {
t.Error("retry должен проставить retried_at (сброс базиса)")
}
// Ключевая проверка MAJOR-1: следующий тик поллинга.
if err := w.Poll(context.Background()); err != nil {
t.Fatal(err)
}
if got := st.downloads["1"].State; got != store.StateDownloading {
t.Errorf("после retry задача не должна снова падать в stuck на ближайшем тике, got %q", got)
}
}
// MAJOR-2: stuck_after мерит ДЛИТЕЛЬНОСТЬ ПРОСТОЯ (nowlast_activity), а не возраст
// торрента. Долго качавшийся торрент (added_on 5ч назад) с недавним движением
// данных (last_activity 30с назад), на миг зашедший в stalledDL, НЕ уходит в stuck.
func TestStallMeasuredFromLastActivity(t *testing.T) {
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
lastActivityRecent := time.Date(2026, 6, 14, 9, 59, 30, 0, time.UTC).Unix() // 30с назад
st := oneDownloading(ih, timeOld)
qb := &fakeQbt{torrents: []qbt.Torrent{{
Hash: ih, State: "stalledDL",
AddedOn: addedLongAgo, LastActivity: lastActivityRecent,
}}}
w := newTestWorker(st, qb)
if err := w.Poll(context.Background()); err != nil {
t.Fatal(err)
}
if got := st.downloads["1"].State; got != store.StateDownloading {
t.Errorf("свежая активность (30с) — не stuck несмотря на возраст 5ч, got %q", got)
}
// Контроль: тот же торрент, но данные давно не двигались (last_activity 2ч) —
// простой превысил StuckAfter (1ч) → stuck.
qb.torrents[0].LastActivity = lastActivityLongAgo
if err := w.Poll(context.Background()); err != nil {
t.Fatal(err)
}
if got := st.downloads["1"].State; got != store.StateStuck {
t.Errorf("простой 2ч > stuck_after 1ч должен дать stuck, got %q", got)
}
}