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:
@@ -45,6 +45,7 @@ erDiagram
|
|||||||
TEXT error_msg "nullable"
|
TEXT error_msg "nullable"
|
||||||
INTEGER source_miss_count "NOT NULL DEFAULT 0; дебаунс пропажи источника (миграция 0003)"
|
INTEGER source_miss_count "NOT NULL DEFAULT 0; дебаунс пропажи источника (миграция 0003)"
|
||||||
TEXT source_added_at "nullable; время добавления в qBittorrent (added_on), базис сортировки (миграция 0005)"
|
TEXT source_added_at "nullable; время добавления в qBittorrent (added_on), базис сортировки (миграция 0005)"
|
||||||
|
TEXT retried_at "nullable; время последнего ручного retry (RFC 3339 UTC Z), сброс базиса таймаутов (миграция 0010)"
|
||||||
TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
|
TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
|
||||||
TEXT updated_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
|
TEXT updated_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-8
@@ -157,12 +157,17 @@ SQLite; `worker` периодически сверяет qBittorrent с БД и
|
|||||||
- **ещё качается:** `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` **простаивающий** дольше
|
||||||
`magnet_timeout` — **редкий страховочный предохранитель** (дефолт `24h`), а
|
`stuck_after` → `stuck`. `magnet_timeout` — **редкий страховочный
|
||||||
не рабочий механизм: долгий `metaDL` (медленные трекеры/мало пиров) — это
|
предохранитель** (дефолт `24h`), а не рабочий механизм: долгий `metaDL`
|
||||||
норма, его не убиваем агрессивно. Возраст считаем от времени добавления
|
(медленные трекеры/мало пиров) — это норма, его не убиваем агрессивно. Меры у
|
||||||
торрента в qBittorrent (`added_on`), а не от создания задачи (базис
|
двух таймаутов **разные**: `magnet_timeout` мерит **возраст** торрента от
|
||||||
переживает retry и усыновление).
|
добавления в qBittorrent (`added_on`, фолбэк `created_at`); `stuck_after`
|
||||||
|
мерит **длительность простоя** — от `last_activity` (последнее движение
|
||||||
|
данных), а не возраст, иначе долго качавшийся торрент, на миг зашедший в
|
||||||
|
`stalledDL`, ложно уходит в `stuck` со «stalled for 5h». Оба базиса
|
||||||
|
приподнимаются до `retried_at` — ручной retry сбрасывает отсчёт, чтобы возврат
|
||||||
|
в `downloading` не ронял задачу снова на ближайшем тике.
|
||||||
- **ошибка:** `error`/`missingFiles` → `failed` (`error_code` `qbit_error`) —
|
- **ошибка:** `error`/`missingFiles` → `failed` (`error_code` `qbit_error`) —
|
||||||
это настоящий провал, в отличие от таймаута.
|
это настоящий провал, в отличие от таймаута.
|
||||||
|
|
||||||
@@ -181,8 +186,10 @@ SQLite; `worker` периодически сверяет qBittorrent с БД и
|
|||||||
всё ещё в `metaDL`/`stalledDL`, задача остаётся упавшей (без зацикливания).
|
всё ещё в `metaDL`/`stalledDL`, задача остаётся упавшей (без зацикливания).
|
||||||
Настоящие провалы (`qbit_error`) сверкой не воскрешаются.
|
Настоящие провалы (`qbit_error`) сверкой не воскрешаются.
|
||||||
- Дополнительно доступен **ручной retry** из веб-UI и Telegram (не только
|
- Дополнительно доступен **ручной retry** из веб-UI и Telegram (не только
|
||||||
REST): возвращает в `downloading`, перецепляясь к живому торренту без
|
REST): возвращает в `downloading`, перецепляясь к живому **здоровому** торренту
|
||||||
повторного `Add`.
|
без повторного `Add` (к сломанному — `error`/`missingFiles` — не
|
||||||
|
перецепляемся, повторно отдаём источник) и сбрасывая базис таймаутов
|
||||||
|
(`retried_at`), чтобы задача не упала снова на ближайшем тике.
|
||||||
|
|
||||||
Пути файлов берём из API (`save_path` + относительные имена из
|
Пути файлов берём из API (`save_path` + относительные имена из
|
||||||
`/torrents/files`, уже включающие корневую папку торрента), не из
|
`/torrents/files`, уже включающие корневую папку торрента), не из
|
||||||
|
|||||||
+7
-2
@@ -60,8 +60,13 @@ type Torrent struct {
|
|||||||
AmountLeft int64 `json:"amount_left"`
|
AmountLeft int64 `json:"amount_left"`
|
||||||
TotalSize int64 `json:"total_size"` // полный размер раздачи, байт
|
TotalSize int64 `json:"total_size"` // полный размер раздачи, байт
|
||||||
AddedOn int64 `json:"added_on"`
|
AddedOn int64 `json:"added_on"`
|
||||||
InfohashV1 string `json:"infohash_v1"`
|
// LastActivity — Unix-время последнего движения данных по торренту (скачан/
|
||||||
InfohashV2 string `json:"infohash_v2"`
|
// отдан кусок). Базис измерения простоя для stuck_after: простой = now −
|
||||||
|
// last_activity (а не возраст от added_on), поэтому долго качавшийся торрент,
|
||||||
|
// на миг зашедший в stalledDL, не помечается «зависшим».
|
||||||
|
LastActivity int64 `json:"last_activity"`
|
||||||
|
InfohashV1 string `json:"infohash_v1"`
|
||||||
|
InfohashV2 string `json:"infohash_v2"`
|
||||||
|
|
||||||
// Живая телеметрия (для снимка воркера и веб-UI).
|
// Живая телеметрия (для снимка воркера и веб-UI).
|
||||||
Dlspeed int64 `json:"dlspeed"` // скорость загрузки, байт/с
|
Dlspeed int64 `json:"dlspeed"` // скорость загрузки, байт/с
|
||||||
|
|||||||
@@ -183,8 +183,13 @@ type Download struct {
|
|||||||
// сортировки списка. NULL, пока воркер не наблюдал раздачу. Хранится в
|
// сортировки списка. NULL, пока воркер не наблюдал раздачу. Хранится в
|
||||||
// формате RFC 3339 (UTC, суффикс Z), как created_at.
|
// формате RFC 3339 (UTC, суффикс Z), как created_at.
|
||||||
SourceAddedAt sql.NullString `db:"source_added_at"`
|
SourceAddedAt sql.NullString `db:"source_added_at"`
|
||||||
CreatedAt string `db:"created_at"`
|
// RetriedAt — время последнего ручного retry (RFC 3339 UTC, суффикс Z), NULL
|
||||||
UpdatedAt string `db:"updated_at"`
|
// пока задачу не повторяли. Приподнимает базис отсчёта таймаутов, чтобы
|
||||||
|
// возврат в downloading не ронял задачу снова на ближайшем тике (см.
|
||||||
|
// state-reconciliation «Ручной повтор»).
|
||||||
|
RetriedAt sql.NullString `db:"retried_at"`
|
||||||
|
CreatedAt string `db:"created_at"`
|
||||||
|
UpdatedAt string `db:"updated_at"`
|
||||||
|
|
||||||
// Infohashes — хеши загрузки (download_infohash); подгружаются вместе с
|
// Infohashes — хеши загрузки (download_infohash); подгружаются вместе с
|
||||||
// записью методами чтения store (v1 раньше v2 — порядок стабильный).
|
// записью методами чтения store (v1 раньше v2 — порядок стабильный).
|
||||||
@@ -236,6 +241,19 @@ func Now() time.Time { return time.Now().UTC() }
|
|||||||
// CreatedTime возвращает время создания загрузки как time.Time (UTC).
|
// CreatedTime возвращает время создания загрузки как time.Time (UTC).
|
||||||
func (d Download) CreatedTime() (time.Time, error) { return ParseTime(d.CreatedAt) }
|
func (d Download) CreatedTime() (time.Time, error) { return ParseTime(d.CreatedAt) }
|
||||||
|
|
||||||
|
// RetriedTime возвращает время последнего ручного retry (UTC) и ok=false, если
|
||||||
|
// задачу ещё не повторяли (retried_at NULL) или метку не разобрать.
|
||||||
|
func (d Download) RetriedTime() (time.Time, bool) {
|
||||||
|
if !d.RetriedAt.Valid {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
t, err := ParseTime(d.RetriedAt.String)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
return t, true
|
||||||
|
}
|
||||||
|
|
||||||
// NullString строит sql.NullString: пустая строка → NULL.
|
// NullString строит sql.NullString: пустая строка → NULL.
|
||||||
func NullString(s string) sql.NullString {
|
func NullString(s string) sql.NullString {
|
||||||
return sql.NullString{String: s, Valid: s != ""}
|
return sql.NullString{String: s, Valid: s != ""}
|
||||||
@@ -504,6 +522,22 @@ func (s *Store) SetSourceAddedAt(ctx context.Context, id string, t time.Time) er
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetRetriedAt проставляет время ручного retry задачи (сброс базиса отсчёта
|
||||||
|
// таймаутов, см. state-reconciliation «Ручной повтор»). В отличие от
|
||||||
|
// SetSourceAddedAt перезаписывает значение: retry можно повторять, и базис
|
||||||
|
// должен смещаться на каждый.
|
||||||
|
func (s *Store) SetRetriedAt(ctx context.Context, id string, t time.Time) error {
|
||||||
|
res, err := s.DB.ExecContext(ctx,
|
||||||
|
`UPDATE download SET retried_at = ? WHERE id = ?`, FormatTime(t), id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("set download %s retried_at: %w", id, err)
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
return fmt.Errorf("set download %s retried_at: not found", id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ListDownloads возвращает все загрузки, новые сверху (id — ULID, сортировка
|
// ListDownloads возвращает все загрузки, новые сверху (id — ULID, сортировка
|
||||||
// по нему хронологична).
|
// по нему хронологична).
|
||||||
func (s *Store) ListDownloads(ctx context.Context) ([]Download, error) {
|
func (s *Store) ListDownloads(ctx context.Context) ([]Download, error) {
|
||||||
|
|||||||
@@ -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) {
|
func TestListDownloadsPageRecTitleFallback(t *testing.T) {
|
||||||
st := newTestStore(t)
|
st := newTestStore(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- Время последнего ручного retry задачи (RFC 3339 UTC, суффикс Z). Приподнимает
|
||||||
|
-- базис отсчёта таймаутов (magnet_timeout/stuck_after): после retry отсчёт идёт
|
||||||
|
-- от max(базис_добавления или last_activity, retried_at), чтобы возврат в
|
||||||
|
-- downloading не ронял задачу снова на ближайшем тике (см. state-reconciliation
|
||||||
|
-- «Ручной повтор зависшей/упавшей загрузки»). Хранится в БД, а не в памяти,
|
||||||
|
-- чтобы сброс базиса пережил интервал поллинга и рестарт процесса.
|
||||||
|
ALTER TABLE download ADD COLUMN retried_at TEXT;
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
ALTER TABLE download DROP COLUMN retried_at;
|
||||||
@@ -168,3 +168,74 @@ func TestRetryReattachesNoReadd(t *testing.T) {
|
|||||||
t.Errorf("свежий metaDL не должен падать после retry, got %q", st.downloads["1"].State)
|
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=now−last_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 мерит ДЛИТЕЛЬНОСТЬ ПРОСТОЯ (now−last_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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -467,6 +467,13 @@ func (m *memStore) SetSourceAddedAt(_ context.Context, id string, t time.Time) e
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *memStore) SetRetriedAt(_ context.Context, id string, t time.Time) error {
|
||||||
|
if d, ok := m.downloads[id]; ok {
|
||||||
|
d.RetriedAt = store.NullString(store.FormatTime(t))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *memStore) CreateRecognition(_ context.Context, r *store.Recognition, reasons []string) (string, error) {
|
func (m *memStore) CreateRecognition(_ context.Context, r *store.Recognition, reasons []string) (string, error) {
|
||||||
for _, e := range m.recs {
|
for _, e := range m.recs {
|
||||||
if e.DownloadID == r.DownloadID {
|
if e.DownloadID == r.DownloadID {
|
||||||
|
|||||||
+92
-28
@@ -51,6 +51,10 @@ type Store interface {
|
|||||||
PromoteCatched(ctx context.Context, id, displayName string) error
|
PromoteCatched(ctx context.Context, id, displayName string) error
|
||||||
SetSourceMissCount(ctx context.Context, id string, n int) error
|
SetSourceMissCount(ctx context.Context, id string, n int) error
|
||||||
SetSourceAddedAt(ctx context.Context, id string, t time.Time) error
|
SetSourceAddedAt(ctx context.Context, id string, t time.Time) error
|
||||||
|
// SetRetriedAt проставляет время ручного retry — сброс базиса отсчёта
|
||||||
|
// таймаутов (magnet_timeout/stuck_after), чтобы возврат в downloading не
|
||||||
|
// ронял задачу снова на ближайшем тике.
|
||||||
|
SetRetriedAt(ctx context.Context, id string, t time.Time) error
|
||||||
|
|
||||||
// Идентичность/инвариант «одна активная загрузка на infohash».
|
// Идентичность/инвариант «одна активная загрузка на infohash».
|
||||||
ExistsByInfohash(ctx context.Context, hashes ...string) (bool, error)
|
ExistsByInfohash(ctx context.Context, hashes ...string) (bool, error)
|
||||||
@@ -506,21 +510,29 @@ func (w *Worker) reconcile(ctx context.Context, d store.Download, t qbt.Torrent)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkTimeouts помечает зависшие задачи. Возраст считаем от факта в
|
// checkTimeouts помечает зависшие задачи двумя разными мерами:
|
||||||
// qBittorrent (added_on), а не от created_at: базис переживает retry и
|
// - magnet_timeout — по ВОЗРАСТУ торрента (metaDL дольше magnet_timeout без
|
||||||
// усыновление раздачи (см. design download-failure-recovery). magnet_timeout —
|
// метаданных): страховочный предохранитель (дефолт 24h), базис — added_on;
|
||||||
// редкий страховочный предохранитель (дефолт 24h); настоящие провалы ловит
|
// - stuck_after — по ДЛИТЕЛЬНОСТИ ПРОСТОЯ (stalledDL без движения данных
|
||||||
|
// дольше stuck_after): базис — last_activity, а не возраст. Иначе долго
|
||||||
|
// качавшийся торрент, на миг зашедший в stalledDL, ложно уходит в stuck со
|
||||||
|
// «stalled for 5h» (см. state-reconciliation, MAJOR-2).
|
||||||
|
//
|
||||||
|
// Оба базиса приподняты до retried_at (ручной retry), чтобы возврат в
|
||||||
|
// downloading не ронял задачу снова на ближайшем тике. Настоящие провалы ловит
|
||||||
// classErrored, а ожившие задачи воскрешает reconcileRecovery.
|
// 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) {
|
||||||
age := w.torrentAge(d, t)
|
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case isMeta(t.State) && w.cfg.MagnetTimeout > 0 && age > w.cfg.MagnetTimeout:
|
case isMeta(t.State) && w.cfg.MagnetTimeout > 0:
|
||||||
w.transition(ctx, d, store.StateFailed, errCodeMagnetTimeout,
|
if age, ok := w.torrentAge(d, t); ok && age > w.cfg.MagnetTimeout {
|
||||||
fmt.Sprintf("no metadata after %s", age.Truncate(time.Second)))
|
w.transition(ctx, d, store.StateFailed, errCodeMagnetTimeout,
|
||||||
case isStalledDL(t.State) && w.cfg.StuckAfter > 0 && age > w.cfg.StuckAfter:
|
fmt.Sprintf("no metadata after %s", age.Truncate(time.Second)))
|
||||||
w.transition(ctx, d, store.StateStuck, errCodeStalled,
|
}
|
||||||
fmt.Sprintf("stalled for %s", age.Truncate(time.Second)))
|
case isStalledDL(t.State) && w.cfg.StuckAfter > 0:
|
||||||
|
if idle, ok := w.stallDuration(d, t); ok && idle > w.cfg.StuckAfter {
|
||||||
|
w.transition(ctx, d, store.StateStuck, errCodeStalled,
|
||||||
|
fmt.Sprintf("stalled for %s", idle.Truncate(time.Second)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -572,23 +584,61 @@ func (w *Worker) captureInfohashes(ctx context.Context, d store.Download, t qbt.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// torrentAge — возраст торрента: от added_on в qBittorrent (надёжный базис,
|
// torrentAge — возраст торрента для magnet_timeout: от added_on в qBittorrent
|
||||||
// переживает retry/усыновление), с фолбэком на created_at задачи, если qBit не
|
// (надёжный базис, переживает усыновление), с фолбэком на created_at задачи,
|
||||||
// отдал added_on.
|
// если qBit не отдал added_on (NIT-10). Базис приподнят до retried_at, чтобы
|
||||||
func (w *Worker) torrentAge(d store.Download, t qbt.Torrent) time.Duration {
|
// ручной retry сбрасывал отсчёт. ok=false — базис неизвестен (ни added_on, ни
|
||||||
|
// разбираемого created_at): таймаут не срабатывает, фиксируем диагностикой.
|
||||||
|
func (w *Worker) torrentAge(d store.Download, t qbt.Torrent) (time.Duration, bool) {
|
||||||
|
basis, ok := w.addedBasis(d, t)
|
||||||
|
if !ok {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return w.now().Sub(w.retriedFloor(d, basis)), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// stallDuration — длительность простоя торрента для stuck_after: от
|
||||||
|
// last_activity qBittorrent (момент последнего движения данных), с фолбэком на
|
||||||
|
// базис добавления, если qBit не отдал last_activity. Базис приподнят до
|
||||||
|
// retried_at (ручной retry даёт свежее окно). ok=false — базис неизвестен.
|
||||||
|
func (w *Worker) stallDuration(d store.Download, t qbt.Torrent) (time.Duration, bool) {
|
||||||
|
var basis time.Time
|
||||||
|
if t.LastActivity > 0 {
|
||||||
|
basis = time.Unix(t.LastActivity, 0).UTC()
|
||||||
|
} else {
|
||||||
|
var ok bool
|
||||||
|
if basis, ok = w.addedBasis(d, t); !ok {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return w.now().Sub(w.retriedFloor(d, basis)), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// addedBasis — момент добавления торрента: added_on qBittorrent, иначе
|
||||||
|
// created_at задачи (NIT-10). ok=false — ни того, ни другого разобрать не
|
||||||
|
// удалось; фиксируем диагностикой.
|
||||||
|
func (w *Worker) addedBasis(d store.Download, t qbt.Torrent) (time.Time, bool) {
|
||||||
if t.AddedOn > 0 {
|
if t.AddedOn > 0 {
|
||||||
return w.now().Sub(time.Unix(t.AddedOn, 0).UTC())
|
return time.Unix(t.AddedOn, 0).UTC(), true
|
||||||
}
|
}
|
||||||
created, err := d.CreatedTime()
|
created, err := d.CreatedTime()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Ни added_on от qBit, ни разбираемого created_at — возраст неизвестен,
|
|
||||||
// таймауты не сработают; фиксируем диагностикой.
|
|
||||||
w.log.Warn("cannot determine torrent age",
|
w.log.Warn("cannot determine torrent age",
|
||||||
"capability", capIngest, "download_id", d.ID,
|
"capability", capIngest, "download_id", d.ID,
|
||||||
"created_at", d.CreatedAt, "error", err)
|
"created_at", d.CreatedAt, "error", err)
|
||||||
return 0
|
return time.Time{}, false
|
||||||
}
|
}
|
||||||
return w.now().Sub(created)
|
return created, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// retriedFloor приподнимает базис отсчёта таймаута до времени последнего ручного
|
||||||
|
// retry: после retry задача получает свежее окно и не падает повторно на
|
||||||
|
// ближайшем тике (см. state-reconciliation «Ручной повтор», MAJOR-1).
|
||||||
|
func (w *Worker) retriedFloor(d store.Download, basis time.Time) time.Time {
|
||||||
|
if r, ok := d.RetriedTime(); ok && r.After(basis) {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
return basis
|
||||||
}
|
}
|
||||||
|
|
||||||
// transition пишет новое состояние и логирует переход.
|
// transition пишет новое состояние и логирует переход.
|
||||||
@@ -685,16 +735,22 @@ func (w *Worker) Retry(ctx context.Context, id string) error {
|
|||||||
if d.State != store.StateFailed && d.State != store.StateStuck {
|
if d.State != store.StateFailed && d.State != store.StateStuck {
|
||||||
return fmt.Errorf("retry: download %s is %s, only failed/stuck are retriable", id, d.State)
|
return fmt.Errorf("retry: download %s is %s, only failed/stuck are retriable", id, d.State)
|
||||||
}
|
}
|
||||||
// Если раздача уже жива в qBittorrent — перецепляемся к ней, повторный Add
|
// Если раздача уже жива и ЗДОРОВА в qBittorrent — перецепляемся к ней,
|
||||||
// не нужен (и вреден: вслепую дублировал бы торрент). Add — только когда
|
// повторный Add не нужен (и вреден: вслепую дублировал бы торрент). Add —
|
||||||
// источника в qBittorrent нет. Базис таймаута берётся от added_on, поэтому
|
// когда источника в qBittorrent нет ИЛИ он в состоянии ошибки: перецепка к
|
||||||
// возврат в downloading не роняет задачу снова на ближайшем тике.
|
// сломанному торренту (error/missingFiles) бессмысленна — reconcile тут же
|
||||||
alive := false
|
// вернул бы задачу в failed, поэтому пробуем повторно отдать источник
|
||||||
|
// (NIT-12). Базис таймаута сбрасывается ниже через retried_at, поэтому
|
||||||
|
// возврат в downloading не роняет задачу снова на ближайшем тике (MAJOR-1).
|
||||||
|
reAdd := true
|
||||||
if hashes := d.HashList(); len(hashes) > 0 {
|
if hashes := d.HashList(); len(hashes) > 0 {
|
||||||
_, alive, err = w.torrentByInfohash(ctx, hashes)
|
var t qbt.Torrent
|
||||||
|
var alive bool
|
||||||
|
t, alive, err = w.torrentByInfohash(ctx, hashes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("retry: %w", err)
|
return fmt.Errorf("retry: %w", err)
|
||||||
}
|
}
|
||||||
|
reAdd = !alive || classify(t.State) == classErrored
|
||||||
}
|
}
|
||||||
// Гард инварианта — ДО побочного эффекта в qBittorrent: пока задача лежала
|
// Гард инварианта — ДО побочного эффекта в qBittorrent: пока задача лежала
|
||||||
// в failed, тем же infohash могла завладеть другая активная задача — тогда
|
// в failed, тем же infohash могла завладеть другая активная задача — тогда
|
||||||
@@ -705,7 +761,7 @@ func (w *Worker) Retry(ctx context.Context, id string) error {
|
|||||||
}
|
}
|
||||||
return fmt.Errorf("retry: %w", err)
|
return fmt.Errorf("retry: %w", err)
|
||||||
}
|
}
|
||||||
if !alive {
|
if reAdd {
|
||||||
// Добавляем заново по типу источника (magnet — ссылкой, torrent —
|
// Добавляем заново по типу источника (magnet — ссылкой, torrent —
|
||||||
// сохранёнными байтами файлом). Rename при retry не выводим (namer здесь
|
// сохранёнными байтами файлом). Rename при retry не выводим (namer здесь
|
||||||
// не зовём — имя уже могло быть выведено при первом добавлении).
|
// не зовём — имя уже могло быть выведено при первом добавлении).
|
||||||
@@ -729,6 +785,14 @@ func (w *Worker) Retry(ctx context.Context, id string) error {
|
|||||||
return fmt.Errorf("retry: add to qbittorrent: %w", err)
|
return fmt.Errorf("retry: add to qbittorrent: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Сброс базиса отсчёта таймаутов (MAJOR-1): без него живой, но давно
|
||||||
|
// добавленный/простаивающий торрент снова упал бы по magnet_timeout/
|
||||||
|
// stuck_after на ближайшем тике. Best-effort: сбой лишь лишает свежего окна
|
||||||
|
// (WARN), сам retry уже состоялся.
|
||||||
|
if err := w.store.SetRetriedAt(ctx, id, w.now()); err != nil {
|
||||||
|
w.log.Warn("retry basis reset failed",
|
||||||
|
"capability", capReview, "download_id", id, "error", err)
|
||||||
|
}
|
||||||
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("download retried", "from", d.State)
|
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("download retried", "from", d.State)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -211,6 +211,15 @@ func (f *fakeStore) SetSourceAddedAt(_ context.Context, id string, t time.Time)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) SetRetriedAt(_ context.Context, id string, t time.Time) error {
|
||||||
|
d, ok := f.downloads[id]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("download %s not found", id)
|
||||||
|
}
|
||||||
|
d.RetriedAt = store.NullString(store.FormatTime(t))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// --- Ф3-методы Store (заглушки; переопределяются в review_test.go) ---
|
// --- Ф3-методы Store (заглушки; переопределяются в review_test.go) ---
|
||||||
|
|
||||||
func (f *fakeStore) CreateRecognition(_ context.Context, _ *store.Recognition, _ []string) (string, error) {
|
func (f *fakeStore) CreateRecognition(_ context.Context, _ *store.Recognition, _ []string) (string, error) {
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-07-08
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# Design: retry/stall basis
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
Два таймаута зависания в `Worker.checkTimeouts` сегодня используют один базис —
|
||||||
|
возраст торрента `now − added_on`:
|
||||||
|
|
||||||
|
- `magnet_timeout`: `metaDL` дольше порога → `failed/magnet_timeout`.
|
||||||
|
- `stuck_after`: `stalledDL` дольше порога → `stuck/stalled`.
|
||||||
|
|
||||||
|
Для `magnet_timeout` возраст семантически верен (сколько торрент вообще висит
|
||||||
|
без метаданных). Для `stuck_after` возраст НЕВЕРЕН: нас интересует **простой**
|
||||||
|
(сколько данные не двигаются), а не общий возраст (MAJOR-2). Отдельно retry
|
||||||
|
живого торрента не сбрасывает базис, и задача мгновенно снова падает (MAJOR-1).
|
||||||
|
|
||||||
|
## Дизайн-развилка: откуда брать базис простоя/таймаута
|
||||||
|
|
||||||
|
Ключевое решение change — где взять базис для двух мер. Рассмотрены варианты:
|
||||||
|
|
||||||
|
- **(a) Новые колонки БД** `retried_at` и/или `stalled_since`. Базис возраста =
|
||||||
|
`max(added_on, retried_at)`; простой — от `stalled_since` (момент входа в
|
||||||
|
`stalledDL`, который мы сами детектируем и пишем/сбрасываем на каждом тике).
|
||||||
|
- **(b) Переиспользовать `last_activity` из снимка торрента** для измерения
|
||||||
|
простоя, без колонки на stall.
|
||||||
|
- **(c) Гибрид (ВЫБРАН):** колонка `retried_at` (только для сброса базиса при
|
||||||
|
ручном retry) + `last_activity` qBittorrent (для измерения простоя). Колонки
|
||||||
|
`stalled_since` НЕТ.
|
||||||
|
|
||||||
|
### Выбор: (c) `retried_at` (БД) + `last_activity` (qBit)
|
||||||
|
|
||||||
|
**Простой мерим по `last_activity`, а не по `stalled_since`-колонке.**
|
||||||
|
qBittorrent уже отдаёт `last_activity` (Unix-время последнего движения данных)
|
||||||
|
в том же ответе `/torrents/info` — это авторитетный источник «сколько простой»
|
||||||
|
прямо из движка. `stalled_since` дублировал бы это состояние, требовал бы
|
||||||
|
детектировать переход «вход в stalledDL», писать/сбрасывать колонку на КАЖДОМ
|
||||||
|
тике (торренты мерцают `stalledDL`↔`downloading`) и рисковал бы разъездом с
|
||||||
|
собственным взглядом qBittorrent. Простой = `now − last_activity`: торрент,
|
||||||
|
двигавший данные секунду назад, простаивает ~0 несмотря на возраст 5ч →
|
||||||
|
MAJOR-2 закрыт без схемы для stall. Это часть варианта (b).
|
||||||
|
|
||||||
|
**Сброс базиса при retry храним в `retried_at` (БД), а не в памяти.** Спека
|
||||||
|
требует, чтобы retry давал свежее окно и задача не падала снова. `last_activity`
|
||||||
|
этого не выражает: у по-настоящему простаивающего торрента она «часы назад», и
|
||||||
|
возврат в `downloading` тут же дал бы `stuck` на следующем тике. Нужна
|
||||||
|
персистентная метка «пользователь нажал retry в момент T», которая приподнимает
|
||||||
|
пол ОБОИХ базисов: `basis = max(добавление|last_activity, retried_at)`. Она
|
||||||
|
должна пережить интервал поллинга и рестарт процесса (retry, затем рестарт не
|
||||||
|
должен ронять задачу), поэтому — колонка, а не in-memory map. Это часть варианта
|
||||||
|
(a), но минимальная: одна nullable TEXT-колонка. Закрывает MAJOR-1.
|
||||||
|
|
||||||
|
### Почему не чистые (a) или (b)
|
||||||
|
|
||||||
|
- **Чистый (b) без колонки** — нельзя записать `last_activity` qBittorrent, так
|
||||||
|
что retry не смог бы сдвинуть базис → MAJOR-1 не решается.
|
||||||
|
- **Чистый (a) со `stalled_since`** — лишняя колонка + пер-тиковая
|
||||||
|
бухгалтерия входа/выхода из `stalledDL`, дублирующая `last_activity`. Отвергнут
|
||||||
|
на минимальности схемы и единственном источнике истины.
|
||||||
|
|
||||||
|
### Замечание об интеграции (точка человеческого вето)
|
||||||
|
|
||||||
|
Change читает НОВОЕ поле qBittorrent `last_activity`, но НЕ добавляет нового
|
||||||
|
вызова API или интеграционной поверхности — поле уже приходит в ответе
|
||||||
|
`/torrents/info`, парсим на одно поле больше. Это единственная «интеграция»
|
||||||
|
change, и она безопасна. Более глубокая интеграция для полного решения NIT-12
|
||||||
|
(см. ниже) СОЗНАТЕЛЬНО отложена как точка человеческого вето.
|
||||||
|
|
||||||
|
## Итоговая схема базисов
|
||||||
|
|
||||||
|
```
|
||||||
|
magnetAge = now − max( added_on | created_at(fallback), retried_at ) // magnet_timeout
|
||||||
|
stallIdle = now − max( last_activity | added_on|created_at(fallback), retried_at ) // stuck_after
|
||||||
|
```
|
||||||
|
|
||||||
|
- `addedBasis(d,t)` — `added_on`, иначе `created_at` (NIT-10), иначе базис
|
||||||
|
неизвестен (WARN, таймаут не срабатывает).
|
||||||
|
- `retriedFloor(d,basis)` — приподнимает базис до `retried_at`, если он позже.
|
||||||
|
- `retried_at` не чистится: как только данные двинулись, `last_activity`
|
||||||
|
естественно обгоняет `retried_at`, и пол перестаёт влиять.
|
||||||
|
|
||||||
|
## NIT-12: retry сломанного живого торрента
|
||||||
|
|
||||||
|
Живой торрент в `error`/`missingFiles` (класс `classErrored`) — перецепка к
|
||||||
|
нему бессмысленна: reconcile на ближайшем тике вернёт задачу в `failed`. Retry
|
||||||
|
теперь считает такой торрент «неживым для целей перецепки» и идёт по ветке
|
||||||
|
повторного `Add` (повторно отдаёт источник). Это честнее слепой перецепки:
|
||||||
|
retry перецепляется только к ЗДОРОВОМУ живому торренту.
|
||||||
|
|
||||||
|
**Остаточное ограничение (отложено, точка вето):** для устойчиво сломанного
|
||||||
|
торрента повторный `Add` того же infohash qBittorrent, как правило, дедуплицирует
|
||||||
|
— ошибка не очистится, и следующий тик всё равно вернёт задачу в `failed`. Полное
|
||||||
|
устранение (принудительный recheck / delete+re-add через qBittorrent) требует
|
||||||
|
НОВОЙ интеграции с клиентом и вынесено за рамки change на человеческое решение.
|
||||||
|
|
||||||
|
## Тесты
|
||||||
|
|
||||||
|
- `TestRetryResetsTimeoutBasis` — MAJOR-1: retry живого stalledDL-торрента с
|
||||||
|
давним `added_on` и давним `last_activity`, затем СЛЕДУЮЩИЙ тик Poll →
|
||||||
|
остаётся `downloading` (без сброса базиса ушёл бы в `stuck`). Именно эту
|
||||||
|
регрессию прячет `TestRetryReattachesNoReadd`.
|
||||||
|
- `TestStallMeasuredFromLastActivity` — MAJOR-2: `stalledDL` с давним `added_on`,
|
||||||
|
но свежим `last_activity` → `downloading`; контроль — давняя `last_activity` →
|
||||||
|
`stuck`.
|
||||||
|
- `TestSetRetriedAtOverwrites` — store: `retried_at` перезаписывается (в отличие
|
||||||
|
от однократного `source_added_at`).
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
Два связанных бага в семантике таймаутов зависания и ручного retry делают
|
||||||
|
повседневные сценарии сломанными:
|
||||||
|
|
||||||
|
- **MAJOR-1 — retry живого торрента мгновенно снова падает.** `Worker.Retry`
|
||||||
|
при живой раздаче (`alive=true`) не переиздаёт `Add`, а лишь возвращает
|
||||||
|
задачу в `downloading`. Базис отсчёта таймаута (`age = now − added_on`) при
|
||||||
|
этом НЕ сбрасывается. Если торрент давно добавлен/давно простаивает,
|
||||||
|
ближайший тик снова видит `stalledDL && age > stuck_after` → задача опять
|
||||||
|
уходит в `stuck` (~секунды). Спека `state-reconciliation` «Ручной повтор»
|
||||||
|
требует сброса базиса, но код его не выполняет (комментарий «базис от
|
||||||
|
added_on» верен лишь для ветки повторного `Add`). Существующий тест
|
||||||
|
`TestRetryReattachesNoReadd` прячет баг, ставя `added_on` «минуту назад».
|
||||||
|
|
||||||
|
- **MAJOR-2 — `stuck_after` мерит ВОЗРАСТ, а не ПРОСТОЙ.** `checkTimeouts`
|
||||||
|
считает `stalledDL`-таймаут от `added_on` (возраст торрента). Торрент,
|
||||||
|
качавшийся 5 часов и на один тик зашедший в `stalledDL` (нормальный проход
|
||||||
|
между пирами), мгновенно получает `stuck` со лживым сообщением «stalled for
|
||||||
|
5h» и уведомление `EventFailed`. Результат — флап `stuck`↔`downloading` и
|
||||||
|
до-часовые ложные пинги. Спека сама противоречива: «`stalledDL` дольше
|
||||||
|
`stuck_after`» (простой) против «возраст от `added_on`».
|
||||||
|
|
||||||
|
Дополнительно закрываются два NIT из того же ревью:
|
||||||
|
|
||||||
|
- **NIT-10** — фолбэк базиса возраста `added_on → created_at` (когда qBit не
|
||||||
|
отдал `added_on`) остаётся, но теперь явно документирован и покрыт.
|
||||||
|
- **NIT-12** — retry задачи в `qbit_error` мгновенно откатывается: перецепка к
|
||||||
|
сломанному (`error`/`missingFiles`) живому торренту бессмысленна — reconcile
|
||||||
|
тут же возвращает задачу в `failed`. Retry перестаёт перецепляться к
|
||||||
|
сломанному торренту и повторно отдаёт источник.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- **Мера простоя вместо возраста для `stuck_after`.** `stalledDL`-таймаут
|
||||||
|
считается от `last_activity` qBittorrent (момент последнего движения данных),
|
||||||
|
а не от возраста торрента. Долго качавшийся торрент со свежей активностью в
|
||||||
|
`stuck` не уходит (MAJOR-2). `magnet_timeout` по-прежнему мерит **возраст**
|
||||||
|
(`metaDL` без метаданных) от `added_on` — это семантически верно.
|
||||||
|
- **Сброс базиса таймаутов при ручном retry.** Новая колонка `download.retried_at`
|
||||||
|
(RFC 3339 UTC) фиксирует момент retry и приподнимает базис ОБОИХ таймаутов
|
||||||
|
(`max(базис, retried_at)`). После retry задача получает свежее окно и не
|
||||||
|
падает снова на ближайшем тике (MAJOR-1). Хранится в БД (не в памяти), чтобы
|
||||||
|
сброс пережил интервал поллинга и рестарт процесса.
|
||||||
|
- **Retry не перецепляется к сломанному торренту.** Если живой торрент в
|
||||||
|
состоянии ошибки qBittorrent (`error`/`missingFiles`), retry повторно отдаёт
|
||||||
|
источник вместо перецепки (NIT-12).
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
Нет. Семантика таймаутов зависания относится к жизненному циклу загрузки,
|
||||||
|
который пока живёт в `docs/specs/workflow.md` (не мигрирован в OpenSpec).
|
||||||
|
Нормативная правка `stuck_after`/`magnet_timeout` вносится туда; в OpenSpec
|
||||||
|
затрагивается только `state-reconciliation` (восстановление и ручной retry).
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `state-reconciliation`: уточняется, что предотвращение `stuck` для
|
||||||
|
прогрессирующего торрента опирается на **простой от `last_activity`**, а не
|
||||||
|
на возраст; ручной retry сбрасывает базис таймаутов через `retried_at` и не
|
||||||
|
перецепляется к сломанному живому торренту.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- **Спеки:** дельта `state-reconciliation` (2 MODIFIED requirements);
|
||||||
|
правка семантики таймаутов и retry в `docs/specs/workflow.md` (источник
|
||||||
|
истины по жизненному циклу до миграции).
|
||||||
|
- **Код:** `internal/worker/worker.go` — `checkTimeouts` (две разные меры),
|
||||||
|
`torrentAge`/новые `stallDuration`/`addedBasis`/`retriedFloor`, `Retry`
|
||||||
|
(сброс базиса + перецепка только к здоровому торренту); `internal/qbt`
|
||||||
|
(поле `last_activity`); `internal/store/download.go` (`RetriedAt`,
|
||||||
|
`RetriedTime`, `SetRetriedAt`).
|
||||||
|
- **Миграции БД:** `0010_retried_at.sql` — колонка `download.retried_at`;
|
||||||
|
обновление ER-схемы `docs/specs/database.md`.
|
||||||
|
- **qBittorrent-клиент:** читается новое поле `last_activity` из того же
|
||||||
|
ответа `/torrents/info` (без нового вызова API).
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Восстановление зависшей загрузки при оживлении источника
|
||||||
|
|
||||||
|
Система SHALL возвращать в активный поток задачу, упавшую из-за нашей
|
||||||
|
нетерпеливости (`failed`/`magnet_timeout` или `stuck`/`stalled`), если её
|
||||||
|
источник в qBittorrent жив и продвинулся: переход выводится из текущего
|
||||||
|
состояния торрента так же, как при штатной сверке загрузки
|
||||||
|
(`uploading`/`stalledUP`/… → `completed`; `downloading`/`metaDL`/… →
|
||||||
|
`downloading`). Восстановление SHALL опираться на фактическое состояние
|
||||||
|
торрента в qBittorrent, а не на время с момента создания записи.
|
||||||
|
|
||||||
|
После возврата в любое нетерминальное состояние (`downloading` или
|
||||||
|
`completed`) повторный приём того же infohash SHALL снова дедуплицироваться
|
||||||
|
на эту задачу: активность задачи выводится только из её `state`, отдельный
|
||||||
|
восстанавливаемый ключ идемпотентности отсутствует. Если за время простоя в
|
||||||
|
`failed`/`stuck` тем же infohash (любым из хешей задачи) уже завладела
|
||||||
|
другая активная задача (новый приём, пока эта лежала упавшей), система
|
||||||
|
SHALL NOT воскрешать упавшую задачу и SHALL оставить её в `failed`/`stuck`,
|
||||||
|
сохраняя инвариант «не более одной активной задачи на infohash».
|
||||||
|
|
||||||
|
`magnet_timeout`/`stalled` SHALL быть редким страховочным исходом, а не
|
||||||
|
рабочим механизмом. Две страховочные меры при этом РАЗНЫЕ: `magnet_timeout`
|
||||||
|
SHALL мериться по **возрасту** торрента (время от добавления в qBittorrent,
|
||||||
|
`added_on`, с фолбэком на `created_at` задачи), а `stuck_after` — по
|
||||||
|
**длительности простоя** (время от `last_activity` qBittorrent — момента
|
||||||
|
последнего движения данных), а НЕ по возрасту. Пока торрент в
|
||||||
|
`metaDL`/`forcedMetaDL` или иным образом прогрессирует в пределах
|
||||||
|
страховочного таймаута, задача в `failed`/`stuck` из-за него оказаться
|
||||||
|
SHALL NOT; в частности, торрент со свежим `last_activity` в `stuck` система
|
||||||
|
пометить SHALL NOT, даже если его общий возраст превышает `stuck_after` (см.
|
||||||
|
требование о терпеливости к долгим метаданным и меры таймаутов в
|
||||||
|
`docs/specs/workflow.md`).
|
||||||
|
|
||||||
|
#### Scenario: Метаданные пришли после magnet_timeout
|
||||||
|
|
||||||
|
- **GIVEN** задача в `failed` с `error_code` `magnet_timeout`, а её торрент
|
||||||
|
в qBittorrent уже получил метаданные и качается (`downloading`)
|
||||||
|
- **WHEN** срабатывает фоновая сверка
|
||||||
|
- **THEN** задача возвращается в `downloading`
|
||||||
|
- **AND** повторный приём того же infohash снова дедуплицируется на неё
|
||||||
|
|
||||||
|
#### 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
|
||||||
|
|
||||||
|
#### Scenario: Долго качавшийся торрент на миг зашёл в stalledDL
|
||||||
|
|
||||||
|
- **GIVEN** торрент качался часами и двигал данные только что (свежий
|
||||||
|
`last_activity`), но на текущем тике qBittorrent показывает его `stalledDL`
|
||||||
|
- **WHEN** `worker` проверяет таймаут зависания
|
||||||
|
- **THEN** задача остаётся в `downloading` (простой меньше `stuck_after`),
|
||||||
|
несмотря на большой возраст торрента
|
||||||
|
- **AND** ложного `stuck` со «stalled for <возраст>» и уведомления о падении
|
||||||
|
не возникает
|
||||||
|
|
||||||
|
### Requirement: Ручной повтор зависшей/упавшей загрузки из транспортов
|
||||||
|
|
||||||
|
Система SHALL предоставлять пользователю команду повторной попытки (retry)
|
||||||
|
для задач в `failed`/`stuck` из веб-UI и Telegram (не только через REST API).
|
||||||
|
Retry SHALL переводить задачу обратно в `downloading`, не вызывая её
|
||||||
|
немедленного повторного падения по таймауту: базис отсчёта таймаутов SHALL
|
||||||
|
сбрасываться.
|
||||||
|
|
||||||
|
Сброс базиса система SHALL выполнять сохранением времени retry в поле задачи
|
||||||
|
(`retried_at`, RFC 3339 UTC), которое приподнимает пол ОБОИХ страховочных мер
|
||||||
|
(`magnet_timeout` по возрасту и `stuck_after` по простою): отсчёт ведётся от
|
||||||
|
`max(базис, retried_at)`. `retried_at` SHALL храниться в задаче (не в памяти
|
||||||
|
процесса), чтобы сброс базиса пережил интервал поллинга и рестарт процесса.
|
||||||
|
Благодаря этому даже живой, но давно добавленный либо давно простаивающий
|
||||||
|
торрент после retry SHALL получать свежее окно и на ближайшем тике сверки
|
||||||
|
падать снова SHALL NOT.
|
||||||
|
|
||||||
|
Если источник задачи уже жив и ЗДОРОВ в qBittorrent, retry SHALL перецепляться
|
||||||
|
к существующему торренту, а не добавлять источник повторно вслепую. Если же
|
||||||
|
живой торрент в состоянии ошибки qBittorrent (`error`/`missingFiles`), retry
|
||||||
|
перецепляться к нему SHALL NOT (перецепка к сломанному торренту тут же вернула
|
||||||
|
бы задачу в `failed` по сверке) и SHALL повторно отдать источник, как при
|
||||||
|
отсутствии раздачи. Повторный `Add` выполняется, только когда раздачи в
|
||||||
|
qBittorrent нет ЛИБО она сломана.
|
||||||
|
|
||||||
|
Повторный `Add` при retry система SHALL выполнять **по типу источника**
|
||||||
|
(`source_type`), как и добавление пойманной загрузки (см. `download-tracking`
|
||||||
|
«Добавление пойманной загрузки в qBittorrent»): magnet/url — ссылкой; torrent —
|
||||||
|
сохранёнными байтами `.torrent` файлом. Для torrent-источника retry БЕЗ живой
|
||||||
|
раздачи система SHALL добавлять раздачу байтами и SHALL NOT активировать задачу
|
||||||
|
в `downloading`, не добавив её (иначе задача повиснет как «нет в qBittorrent»).
|
||||||
|
|
||||||
|
#### Scenario: Retry упавшей magnet-загрузки из веб-UI
|
||||||
|
|
||||||
|
- **GIVEN** задача в `failed`, её торрент жив и здоров в qBittorrent
|
||||||
|
- **WHEN** пользователь нажимает retry в веб-UI
|
||||||
|
- **THEN** задача возвращается в `downloading` без повторного `Add`
|
||||||
|
- **AND** не падает снова на ближайшем тике сверки по таймауту
|
||||||
|
|
||||||
|
#### Scenario: Retry живого, но давно простаивающего торрента не падает снова
|
||||||
|
|
||||||
|
- **GIVEN** задача в `stuck`/`stalled`, её торрент жив в qBittorrent, но
|
||||||
|
добавлен давно и данные не двигались дольше `stuck_after`
|
||||||
|
- **WHEN** пользователь нажимает retry
|
||||||
|
- **THEN** задача возвращается в `downloading` без повторного `Add`
|
||||||
|
- **AND** на ближайшем тике сверки НЕ падает снова в `stuck` (базис сброшен
|
||||||
|
через `retried_at`)
|
||||||
|
|
||||||
|
#### Scenario: Retry доступен в Telegram
|
||||||
|
|
||||||
|
- **WHEN** для задачи в `failed`/`stuck` пользователь вызывает retry в
|
||||||
|
Telegram-боте
|
||||||
|
- **THEN** задача возвращается в `downloading`
|
||||||
|
|
||||||
|
#### Scenario: Retry без живого источника добавляет источник заново
|
||||||
|
|
||||||
|
- **GIVEN** задача в `failed`, раздачи в qBittorrent нет
|
||||||
|
- **WHEN** пользователь инициирует retry
|
||||||
|
- **THEN** источник добавляется в qBittorrent заново — magnet/url ссылкой,
|
||||||
|
torrent сохранёнными байтами файлом
|
||||||
|
- **AND** задача переходит в `downloading`
|
||||||
|
|
||||||
|
#### Scenario: Retry сломанного живого торрента повторно отдаёт источник
|
||||||
|
|
||||||
|
- **GIVEN** задача в `failed`, её торрент присутствует в qBittorrent, но в
|
||||||
|
состоянии ошибки (`error`/`missingFiles`)
|
||||||
|
- **WHEN** пользователь инициирует retry
|
||||||
|
- **THEN** источник отдаётся заново (перецепка к сломанному торренту не
|
||||||
|
выполняется)
|
||||||
|
- **AND** задача переходит в `downloading`
|
||||||
|
|
||||||
|
#### Scenario: Retry torrent-загрузки без живого источника
|
||||||
|
|
||||||
|
- **GIVEN** задача с `source_type = torrent` в `failed`, раздачи в qBittorrent
|
||||||
|
нет, байты `.torrent` сохранены
|
||||||
|
- **WHEN** пользователь инициирует retry
|
||||||
|
- **THEN** сохранённые байты добавляются в qBittorrent файлом
|
||||||
|
- **AND** задача переходит в `downloading` (не остаётся без раздачи)
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Tasks: retry-stall-basis
|
||||||
|
|
||||||
|
## Схема и хранилище
|
||||||
|
|
||||||
|
- [x] Миграция `0010_retried_at.sql` — колонка `download.retried_at` (nullable TEXT).
|
||||||
|
- [x] `store.Download.RetriedAt` (`db:"retried_at"`) + метод `RetriedTime()`.
|
||||||
|
- [x] `store.SetRetriedAt(ctx, id, t)` — перезаписывающая запись базиса retry.
|
||||||
|
- [x] Обновить ER-схему `docs/specs/database.md` (строка `retried_at`).
|
||||||
|
|
||||||
|
## qBittorrent-клиент
|
||||||
|
|
||||||
|
- [x] `qbt.Torrent.LastActivity` (`json:"last_activity"`).
|
||||||
|
|
||||||
|
## Логика воркера
|
||||||
|
|
||||||
|
- [x] `checkTimeouts` — две разные меры: `magnet_timeout` по возрасту,
|
||||||
|
`stuck_after` по простою (`last_activity`).
|
||||||
|
- [x] Разбить `torrentAge` на `addedBasis` (возраст, фолбэк `created_at`),
|
||||||
|
`stallDuration` (простой от `last_activity`), `retriedFloor` (пол по
|
||||||
|
`retried_at`).
|
||||||
|
- [x] `Retry` — сброс базиса через `SetRetriedAt`; перецепка только к здоровому
|
||||||
|
живому торренту, сломанный (`classErrored`) → повторный `Add` (NIT-12).
|
||||||
|
- [x] `Store` interface воркера — метод `SetRetriedAt`.
|
||||||
|
|
||||||
|
## Спеки
|
||||||
|
|
||||||
|
- [x] Дельта `state-reconciliation` — MODIFIED «Восстановление зависшей
|
||||||
|
загрузки» (простой от `last_activity`) и «Ручной повтор» (сброс базиса,
|
||||||
|
NIT-12).
|
||||||
|
- [x] `docs/specs/workflow.md` — устранить противоречие «возраст vs простой»,
|
||||||
|
описать сброс базиса и перецепку только к здоровому торренту.
|
||||||
|
|
||||||
|
## Тесты
|
||||||
|
|
||||||
|
- [x] `TestRetryResetsTimeoutBasis` — следующий тик после retry (MAJOR-1).
|
||||||
|
- [x] `TestStallMeasuredFromLastActivity` — простой vs возраст (MAJOR-2).
|
||||||
|
- [x] `TestSetRetriedAtOverwrites` — store.
|
||||||
|
- [x] Обновить фейки (`fakeStore`, `memStore`) методом `SetRetriedAt`.
|
||||||
|
|
||||||
|
## Проверки
|
||||||
|
|
||||||
|
- [x] `openspec validate --strict retry-stall-basis`.
|
||||||
|
- [x] `task test`.
|
||||||
|
- [x] `task lint`.
|
||||||
Reference in New Issue
Block a user