Приём (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>
151 lines
11 KiB
Markdown
151 lines
11 KiB
Markdown
## 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`, т.к. корень — невозможность добавить).
|