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:
@@ -183,8 +183,13 @@ type Download struct {
|
||||
// сортировки списка. NULL, пока воркер не наблюдал раздачу. Хранится в
|
||||
// формате RFC 3339 (UTC, суффикс Z), как created_at.
|
||||
SourceAddedAt sql.NullString `db:"source_added_at"`
|
||||
CreatedAt string `db:"created_at"`
|
||||
UpdatedAt string `db:"updated_at"`
|
||||
// RetriedAt — время последнего ручного retry (RFC 3339 UTC, суффикс Z), NULL
|
||||
// пока задачу не повторяли. Приподнимает базис отсчёта таймаутов, чтобы
|
||||
// возврат в downloading не ронял задачу снова на ближайшем тике (см.
|
||||
// state-reconciliation «Ручной повтор»).
|
||||
RetriedAt sql.NullString `db:"retried_at"`
|
||||
CreatedAt string `db:"created_at"`
|
||||
UpdatedAt string `db:"updated_at"`
|
||||
|
||||
// Infohashes — хеши загрузки (download_infohash); подгружаются вместе с
|
||||
// записью методами чтения store (v1 раньше v2 — порядок стабильный).
|
||||
@@ -236,6 +241,19 @@ func Now() time.Time { return time.Now().UTC() }
|
||||
// CreatedTime возвращает время создания загрузки как time.Time (UTC).
|
||||
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.
|
||||
func NullString(s string) sql.NullString {
|
||||
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
|
||||
}
|
||||
|
||||
// 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, сортировка
|
||||
// по нему хронологична).
|
||||
func (s *Store) ListDownloads(ctx context.Context) ([]Download, error) {
|
||||
|
||||
Reference in New Issue
Block a user