Долгий 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>
159 lines
11 KiB
Markdown
159 lines
11 KiB
Markdown
## 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` и логируем конфликт.
|
||
- **Уведомление об успешном авто-восстановлении** → не шлём, достаточно
|
||
лога.
|