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

159 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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`/`missingFiles``qbit_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)`: `classReady``completed`,
`classDownloading``downloading`. При возврате в `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` и логируем конфликт.
- **Уведомление об успешном авто-восстановлении** → не шлём, достаточно
лога.