Быстрый приём: сохранение в 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>
This commit is contained in:
av
2026-07-07 21:29:28 +03:00
co-authored by Claude Opus 4.8
parent f0ce6b4bc8
commit 0d263270cb
30 changed files with 1198 additions and 348 deletions
@@ -0,0 +1,150 @@
## 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 устраняет. Поэтому:
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`, т.к. корень — невозможность добавить).