Восстановление зависших загрузок и уведомления о падении (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>
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
## 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` и логируем конфликт.
|
||||
- **Уведомление об успешном авто-восстановлении** → не шлём, достаточно
|
||||
лога.
|
||||
Reference in New Issue
Block a user