Files
avandClaude Opus 4.8 0d263270cb Быстрый приём: сохранение в catched, добавление в qBittorrent — шаг worker'а
Приём (Ingest) стал быстрым: синхронно только парс magnet, синтез контекста из
полей ссылки, атомарный дедуп и запись загрузки в новое состояние `catched` —
ответ клиенту сразу. Медленный вывод имени (LLM) и добавление в qBittorrent
вынесены в асинхронный шаг машины состояний, который двигает worker.

- store: состояние `catched` (нетерминальное, активная группа); атомарный
  переход PromoteCatched (catched → downloading + display_name) с гардом
  state='catched' (ре-валидация после сетевых вызовов вне блокировки)
- ingest: убраны namer/qbt из пути приёма; пишем `catched`, отвечаем сразу
- worker.processCatched: вне w.mu выводит имя и qbt.Add, под w.mu — короткий
  переход; сбой add оставляет catched (ретрай тиком); предохранитель
  catch_timeout → failed(qbit_add)+notify; catched исключён из проверок пропажи
- config: worker.catch_timeout (дефолт 10m)
- веб-UI: бейдж catched, активная группа, самозавершающийся htmx-поллинг
  карточки/страницы до перехода в downloading; Telegram-текст без сырого catched
- OpenSpec: дельты ingest/download-tracking/web-ui влиты в спеки, change
  заархивирован

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 21:29:28 +03:00

11 KiB
Raw Permalink Blame History

Context

Текущий Ingest (internal/ingest/ingest.go) синхронно: парсит magnet, дедуплицирует, выводит имя через namer.DeriveName (потенциально медленный LLMextractViaLLM), создаёт download сразу в downloading и вызывает qbt.Add, и лишь затем возвращает Result. Медленный LLM и внешний вызов к qBit задерживают ответ транспорту (HTTP/веб-UI/Telegram) и расширяют окно «строка в БД есть, в qBittorrent ещё нет».

Worker (internal/worker) уже ведёт поллинг-цикл под единой блокировкой переходов: сверяет раздачи (discover усыновляет по категории/тегу с дедупом по infohash), ходит по ListDownloadsByState(StateDownloading) и реагирует на «active download not found in qbittorrent». Реконсилятор оперирует своими desyncStates (orphaned, …), в которые catched не входит.

Состояния — internal/store/download.go (State, terminalStates, statesInGroup). Активность выводится из state (нетерминальное = активное).

Goals / Non-Goals

Goals:

  • Быстрый ответ приёма: синхронно только парс + дедуп + запись catched.
  • Вынести медленный вывод имени и qbt.Add в асинхронный шаг worker'а.
  • Сохранить инвариант «≤1 активная загрузка на infohash» (в т.ч. с catched).
  • Корректно показать catched в веб-UI; не считать его пропажей раздачи.

Non-Goals:

  • Отдельное состояние added-to-qbittorrent — схлопнуто в catched → downloading (решение развилки).
  • Немедленный пинок фоновой добавки — двигаем worker-циклом (решение развилки); задержка ≤ poll_interval приемлема, ведь клиенту уже ответили.
  • Изменение схемы БД, API транспортов, распознавания/раскладки.
  • Перенос синтеза контекста из полей magnet — он дёшев и остаётся в приёме.

Decisions

Р1. Новое состояние catched, нетерминальное активное

StateCatched = "catched". Не входит в terminalStates → автоматически считается активным для CreateDownloadIfNoActive и инварианта. Добавляется в statesInGroup(GroupActive) рядом с downloading — чтобы попадать в активную группу списка и в поиск.

Р2. Приём пишет catched, без namer и без qBit

Ingest: парс → синтез контекста (как сейчас) → CreateDownloadIfNoActive с State: StateCatched, DisplayName: "" (имя выведет worker). Ни namer.DeriveName, ни qbt.Add в приёме не вызываются. Result возвращается сразу после записи. Транспорты (httpapi, tgbot) не меняются — они уже работают через Result.

Зависимость Namer из ingest.Service переезжает в worker (или worker получает её отдельно). ingest перестаёт зависеть от naming/qbt в пути приёма (qbt-зависимость в ingest может уйти совсем, если не нужна для дедупа).

Р3. Асинхронный шаг worker'а: catched → downloading, сеть — вне замка

Критично: w.mu в worker'е сериализует ВЕСЬ поллинг-цикл И команды транспортов (Cancel, Retry, review). Медленный namer.DeriveName (до max_retries сетевых попыток) и qbt.Add под этим замком заморозили бы все действия пользователя на секунды каждый тик — это ровно та блокировка, которую change устраняет. Поэтому:

  1. Под w.mu (быстро): снять список ListDownloadsByState(StateCatched).
  2. Вне w.mu (для каждой загрузки): вывести имя namer.DeriveName(ctx, d.Context, dnHint), вызвать qbt.Add(urls=d.SourceRef, category, savepath, rename=name). Имя выводится непосредственно перед add (rename действует только при добавлении).
  3. Снова под w.mu (быстро): ре-валидировать state == catched (мог быть отменён/добавлен параллельно) и записать переход catched → downloading + display_name. Ре-валидацию обеспечивает гард setState (target нетерминальный → state NOT IN terminalStates): если пользователь успел catched → cancelled, переход корректно отклонится.

То есть под сериализацией переходов — только запись перехода в БД, а не сетевые вызовы. Спека («Добавление пойманной загрузки») формулирует это так же: под блокировкой сериализуется переход, не add/namer.

dnHint (dn из magnet) worker получает разбором d.SourceRef (magnet.Parse) — дёшево, без сети; для add используется сам d.SourceRef (URL), хеши уже есть в d.Infohashes.

Альтернатива: хранить hint отдельным полем. Отвергнуто — SourceRef уже есть, повторный парс тривиален, схему не трогаем.

Р4. Сбой add — ретрай тиком, предохранитель catch_timeout

Парс magnet уже прошёл синхронно в приёме, поэтому в catched ссылка валидна — сбои add почти всегда транзиентны (qBit недоступен). Поэтому неуспешный add оставляет загрузку в catched (повтор на следующем тике), а не уводит в failed по первому сбою. Страховка от устойчивой недоступности — предохранитель catch_timeout (новый конфиг, дефолт консервативный, напр. по образцу magnet_timeout): catched старше него → failed (qbit_add) + уведомление автора. Это переиспользует существующий паттерн таймаутов-предохранителей (magnet_timeout/stuck_after).

Р5. catched исключён из проверок пропажи раздачи

Поллинг активных (worker.go: ListDownloadsByState(StateDownloading)) уже не включает catched — но фиксируем это требованием и тестом. Реконсилятор (desyncStates) catched не содержит. discover: когда worker добавит раздачу catched-загрузки, следующий тик увидит её по категории, но exists-чек по infohash найдёт активную загрузку и не заведёт дубль (инвариант держится).

Р6. Веб-UI: бейдж/фаза catched

Добавить подпись бейджа и фазу жизненного цикла для catched (перед downloading), включить в активную группу. Заголовок при пустом display_name уже деградирует по фолбеку. Секции раздачи/живого прогресса для catched нет (нет qBit-записи) — шаблон должен это переносить без ошибок (обычно уже так, т.к. телеметрия ищется по infohash и не находится).

Risks / Trade-offs

  • [Задержка появления в qBit до ~poll_interval (5с)] → Приемлемо: клиенту уже ответили; пользователь видит catched в UI. При желании позже — немедленный пинок, но вне объёма.
  • [Гонка discover ↔ шаг добавления (worker добавил, тот же/следующий тик усыновляет)] → Дедуп по infohash в discover (exists-чек) уже защищает; оба пути под общей блокировкой переходов.
  • [Пустой display_name в catched виден в UI] → Фолбек заголовка уже есть (распознанное/усечённый источник); визуально корректно.
  • [Namer/LLM-ошибка на шаге добавления] → Как и раньше best-effort: пустое имя → add без rename; шаг добавления не срывается из-за namer.
  • [Учёт catched во всех местах, где перечислены активные состояния] → Единая точка statesInGroup + аудит по StateDownloading-упоминаниям в worker/store; покрыть тестами дедупа и группировки.

Migration Plan

Аддитивно: новое состояние и новый конфиг catch_timeout (с дефолтом — старый конфиг валиден). Существующие загрузки в downloading/терминальных не затронуты. Новый путь приёма применяется к новым загрузкам.

Откат — ревертом кода. Загрузки, застрявшие в catched на момент отката, старая логика не знает и retry их не поднимет (Retry разрешён только из failed/stuck), а как активные они ещё и блокируют повторный приём того же infohash. Окно мало (catched живёт секунды до тика worker'а), но при откате такие строки нужно снять вручную: UPDATE download SET state='failed', error_code='qbit_add' WHERE state='catched' — после чего они доступны штатному retry. Зафиксировать в задаче/рантбуке отката.

Open Questions

  • Значение дефолта catch_timeout (5–15 мин?) — уточнить при apply, на спеку не влияет.
  • Нужен ли отдельный error_code для catch_timeout или переиспользуем qbit_add (взято qbit_add, т.к. корень — невозможность добавить).