Быстрый приём: сохранение в 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:
@@ -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`, т.к. корень — невозможность добавить).
|
||||
Reference in New Issue
Block a user