Приём (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>
11 KiB
Context
Текущий Ingest (internal/ingest/ingest.go) синхронно: парсит magnet,
дедуплицирует, выводит имя через namer.DeriveName (потенциально медленный
LLM — extractViaLLM), создаёт 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 устраняет. Поэтому:
- Под
w.mu(быстро): снять списокListDownloadsByState(StateCatched). - Вне
w.mu(для каждой загрузки): вывести имяnamer.DeriveName(ctx, d.Context, dnHint), вызватьqbt.Add(urls=d.SourceRef, category, savepath, rename=name). Имя выводится непосредственно передadd(renameдействует только при добавлении). - Снова под
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, т.к. корень — невозможность добавить).