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