Files
jellybit/openspec/changes/archive/2026-06-30-download-failure-recovery/design.md
T
avandClaude Opus 4.8 70d8758646 Восстановление зависших загрузок и уведомления о падении (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>
2026-06-30 14:51:57 +03:00

11 KiB
Raw Blame History

Context

Машина состояний загрузок живёт в internal/worker/worker.go (Poll/reconcile/checkTimeouts/transition/Retry), список состояний и терминальность — в internal/store/download.go. Граф переходов описан в docs/specs/workflow.md (ещё не мигрирован в OpenSpec — он остаётся источником истины по жизненному циклу). Сверка реальности с БД (capability state-reconciliation) реализована в reconcileDesync и уже исключает failed/stuck.

Текущее поведение, породившее инцидент:

  • checkTimeouts (worker.go:268-285) меряет возраст задачи от created_at и при metaDL дольше magnet_timeout гонит в failed/magnet_timeout. Дефолт magnet_timeout = 30m (config.go:184), но для долгих magnet это слишком агрессивно.
  • failed/stuck терминальны, выхода нет; transition уведомляет только review/done/orphaned/target_missing (worker.go:301-312) — падение молчит.
  • Worker.Retry (worker.go:348-373) возвращает в downloading, но базис таймаута (created_at) не меняется → checkTimeouts роняет задачу снова на ближайшем тике; retry экспонирован только в REST.

qbt.Torrent уже содержит AddedOn (unix, секунды) — время добавления торрента в qBittorrent.

Goals / Non-Goals

Goals:

  • Долгий metaDL не убивается агрессивно; magnet_timeout — редкий страховочный предохранитель (дефолт 24h), а не рабочий механизм.
  • Корректный базис таймаута — от факта в qBittorrent (added_on), не от created_at.
  • Любой переход в failed/stuck уведомляет автора.
  • Авто-восстановление задач, упавших по нашей нетерпеливости (magnet_timeout/stalled), когда источник в qBittorrent ожил и продвинулся.
  • Ручной retry в веб-UI и Telegram; перецепление к живому торренту вместо слепого повторного Add.

Non-Goals:

  • Не воскрешаем реальные/пользовательские провалы: qbit_error, reverted, cancelled, deleted.
  • Не трогаем сам торрент в qBittorrent при падении (источник неприкосновенен).
  • Без миграций БД и без новых внешних зависимостей.
  • Не вводим отдельную capability notifications — преждевременно.

Decisions

1. Базис таймаута — added_on, а не created_at

checkTimeouts считает age = now - torrent.AddedOn (UTC). Это чинит неверный отсчёт для усыновлённых раздач и — главное — делает retry/восстановление устойчивым: после возврата в downloading базис не сбрасывается в «сейчас», он привязан к реальному возрасту торрента. Отдельный сброс created_at при Retry больше не нужен.

Альтернатива: хранить «время входа в metaDL» отдельным полем БД — точнее, но требует миграции и записи на каждый тик. added_on достаточно (огрубление в большую сторону безопасно при 24h-предохранителе).

2. Дефолт magnet_timeout → 24h

Меняем дефолт в config.go и config.example.toml. Реальные провалы ловятся классом classErrored (error/missingFilesqbit_error) — это уже работает и не зависит от wall-clock. У qBittorrent нет статуса «magnet мёртв», поэтому большой страховочный таймаут — единственный сигнал на безнадёжный magnet.

3. Уведомление о падении

В transition добавляем ветки для StateFailed и StateStuck → новый EventFailed. Сообщение в notifier (tgbot/httpapi) читает состояние и error_code задачи и формирует текст. Один Event на оба состояния — дробить на EventStuck смысла нет (различие видно из error_code).

4. Авто-восстановление в сверке

Отдельный проход reconcileRecovery (рядом с reconcileDesync, под w.mu, из Poll): берём задачи в failed/stuck с восстановимым error_code (magnet_timeout/stalled), находим их торрент в уже построенном индексе byHash. Воскрешаем только если торрент продвинулся за условие падения:

  • magnet_timeout: восстанавливаем, когда !isMeta(state) и не classErrored (метаданные получены);
  • stalled: восстанавливаем, когда !isStalledDL(state) и не classErrored (раздача ожила).

Иначе (торрент всё ещё в metaDL/stalledDL, либо отсутствует) — оставляем как есть. Это критично против зацикливания: при 24h-предохранителе мёртвый magnet, упавший по таймауту, остаётся в metaDL; без проверки прогресса восстановление вернуло бы его в downloading, и он падал бы снова каждые 24ч.

Целевое состояние выводим из classify(state): classReadycompleted, classDownloadingdownloading. При возврате в downloading восстанавливаем idempotency_key = infohash (нужен метод стора, т.к. SetDownloadState его при терминальном переходе снимает), чтобы повторный приём снова дедуплицировался.

Альтернатива: расширить reconcileDesync матрицей «источник × цель». Не подходит: у failed/stuck нет разложенной цели, ось другая (прогресс источника), отдельный проход чище.

5. Починка Worker.Retry + кнопки в UI/Telegram

Retry: если торрент задачи уже есть в qBittorrent (живой) — не делаем повторный Add, только переводим в downloading и восстанавливаем idempotency_key; Add выполняем, только когда раздачи нет. Базис таймаута теперь added_on, поэтому немедленного повторного падения нет (корень бага устранён решением 1). В internal/httpapi (веб-UI) и internal/tgbot добавляем действие retry рядом с существующим Cancel, вызывающее тот же Worker.Retry.

6. error_code в именованные константы

Строки "magnet_timeout", "stalled", "qbit_error", "qbit_add" выносим в именованные константы (рядом с состояниями в store или в worker), чтобы проверка восстановимости (magnet_timeout/stalled) и присвоение не расходились по литералам. Требует, чтобы error_code задачи был доступен из store.Download (проверить наличие поля; при отсутствии — добавить чтение, без миграции, столбец уже есть).

Risks / Trade-offs

  • Флаппинг уведомлений (fail → восстановление → fail) → при дефолте 24h и условии «торрент продвинулся» падение и воскрешение редки; повторный fail возможен только если раздача снова реально застрянет. Доп. дебаунс не вводим — усложнение без явной нужды.
  • Базис added_on огрубляет (re-add торрента сбрасывает возраст) → при 24h-предохранителе и авто-восстановлении это не приводит к ложным провалам; ранее проблема была в 30m-агрессии, которую и убираем.
  • magnet_timeout всё ещё может ложно сработать на очень медленном, но живом magnet (>24h до метаданных) → теперь это не тупик: уведомление + при получении метаданных авто-восстановление вернёт задачу, плюс есть ручной retry.
  • idempotency_key восстановление при воскрешении: если за время в failed пользователь успел повторно принять тот же infohash и завести новую задачу, ключ уже занят. Обрабатываем как конфликт (не воскрешаем старую либо логируем и оставляем в failed) — уточнить в реализации.

Migration Plan

Изменение поведения + конфига, без миграций БД и без слома API. Деплой — обычный (новый бинарь на umbar). Дефолт magnet_timeout меняется; явное значение в существующем config.toml сохраняет поведение пользователя. Откат — предыдущий бинарь; данные совместимы.

Open Questions

Решены на ревью дизайна:

  • Занятый idempotency_key при авто-восстановлении → старую задачу не воскрешаем, оставляем в failed и логируем конфликт.
  • Уведомление об успешном авто-восстановлении → не шлём, достаточно лога.