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:
@@ -225,6 +225,34 @@ func TestSetSourceAddedAtOnce(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetRetriedAtOverwrites(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
id := mkDownload(t, st, 1, StateStuck, "x")
|
||||
first := time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC)
|
||||
second := time.Date(2026, 6, 2, 10, 0, 0, 0, time.UTC)
|
||||
|
||||
if err := st.SetRetriedAt(ctx, id, first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// В отличие от source_added_at — повторный retry перезаписывает базис.
|
||||
if err := st.SetRetriedAt(ctx, id, second); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d, err := st.GetDownload(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !d.RetriedAt.Valid || d.RetriedAt.String != FormatTime(second) {
|
||||
t.Fatalf("retried_at = %q, want %q (последний retry)", d.RetriedAt.String, FormatTime(second))
|
||||
}
|
||||
got, ok := d.RetriedTime()
|
||||
if !ok || !got.Equal(second) {
|
||||
t.Fatalf("RetriedTime() = %v, %v; want %v, true", got, ok, second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDownloadsPageRecTitleFallback(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
Reference in New Issue
Block a user