Штатные конфликты и промахи ввода возвращались голым fmt.Errorf, поэтому
classifyErr отправлял их в 500 «внутренняя ошибка» вместо 409/400 (и logCmd
писал ERROR вместо DEBUG). Продолжение f8fb4fa (Tier A), по итогам ревью Fable.
Классификация ошибок:
- новый sentinel worker.ErrInvalidInput → 400 для валидации ввода команд
(refine/set type/ignore/add source/set provider/choose candidate);
- обёртки %w ErrConflict в Cancel/Retry/Defer/Undo (штатный конфликт состояния);
- classifyErr: ErrInvalidInput→400, layout.ErrCollision→409 (коллизия цели
штатно уводит в review); ветка ErrCollision в tgbot (сообщение + refreshCard);
- logCmd относит ErrInvalidInput и ErrCollision в DEBUG «command rejected».
Конвенции (docs/conventions):
- logging.md: публичные команды воркера = доменная граница (лог один раз,
logCmd); таблица уровней доменных отказов (граница команды vs асинхронная
стадия); правило про *url.Error/секреты в URL; канон категории
state transition; уровень повторяющихся сбоев фоновых циклов;
- errors.md: таблица маппинга ошибка→статус; развилка «транзиентный ответ vs
персистентная диагностика» решена как (а) — error_msg/reasons на review-экране
и tg-карточке = операторская поверхность владельца (сырой текст ок, секреты
запрещены; аудит подтвердил, что секреты туда не текут).
Унификация категории лога state transition: cancel/retry/relink/recovery
переведены с семантических msg на общий state transition (from/to) — весь
жизненный цикл собирается одним jq-фильтром.
Мелочи: reason-коды linkPlan в const-блок; httpapi лог-поля id→download_id и
msg «… failed»; комментарий «почему» у parseIgnored; preview build failure в
ReviewData DEBUG→WARN.
Беклог: задача сведена к остатку (ext.* ERROR-шторм при недоступном qBittorrent
+ эскалация устойчивого сбоя тика), понижена в приоритете.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
294 lines
14 KiB
Go
294 lines
14 KiB
Go
package worker
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
|
|
"git.vakhrushev.me/av/jellybit/internal/layout"
|
|
"git.vakhrushev.me/av/jellybit/internal/logctx"
|
|
"git.vakhrushev.me/av/jellybit/internal/qbt"
|
|
"git.vakhrushev.me/av/jellybit/internal/store"
|
|
)
|
|
|
|
// desyncStates — состояния, которые ведёт сверка с реальностью: уже
|
|
// разложенные (done) и восстановимые состояния рассинхрона. deleted сюда не
|
|
// входит — оно терминально и сверкой не переоценивается (источник к
|
|
// терминальной задаче не вернётся из-за идемпотентности, а цель отбирается
|
|
// переходом владения путём; см. state-reconciliation). Активные и
|
|
// пользовательски-терминальные (reverted/cancelled/failed/stuck) сверка не
|
|
// трогает.
|
|
var desyncStates = []store.State{
|
|
store.StateDone,
|
|
store.StateTargetMissing,
|
|
store.StateOrphaned,
|
|
}
|
|
|
|
// deriveState выводит состояние задачи из двумерной матрицы «источник × цель»
|
|
// (см. state-reconciliation, D1).
|
|
func deriveState(sourcePresent, targetPresent bool) store.State {
|
|
switch {
|
|
case sourcePresent && targetPresent:
|
|
return store.StateDone
|
|
case sourcePresent && !targetPresent:
|
|
return store.StateTargetMissing
|
|
case !sourcePresent && targetPresent:
|
|
return store.StateOrphaned
|
|
default:
|
|
return store.StateDeleted
|
|
}
|
|
}
|
|
|
|
// reconcileDesync сверяет разложенные/desync-задачи с реальностью. Вызывается
|
|
// из Poll под w.mu. byHash — карта присутствующих в qBittorrent раздач.
|
|
func (w *Worker) reconcileDesync(ctx context.Context, byHash map[string]qbt.Torrent) {
|
|
cands, err := w.store.ListDownloadsByState(ctx, desyncStates...)
|
|
if err != nil {
|
|
w.log.Warn("reconcile list desync failed", "capability", capIngest, "error", err)
|
|
return
|
|
}
|
|
for _, d := range cands {
|
|
w.reconcileOneDesync(ctx, d, byHash)
|
|
}
|
|
}
|
|
|
|
// reconcileOneDesync сверяет одну задачу: вычисляет присутствие источника (с
|
|
// дебаунсом) и цели, выводит состояние и переходит при изменении.
|
|
func (w *Worker) reconcileOneDesync(ctx context.Context, d store.Download, byHash map[string]qbt.Torrent) {
|
|
if len(d.Infohashes) == 0 {
|
|
return // нечем сопоставить источник
|
|
}
|
|
ctx = w.scoped(ctx, capIngest, d.ID, d.PrimaryInfohash())
|
|
_, sourceSeen := torrentFor(d, byHash)
|
|
|
|
// Дебаунс пропажи источника: считаем удалённым только после порога подряд
|
|
// идущих промахов; любое появление сбрасывает счётчик.
|
|
sourcePresent := w.debounceSource(ctx, d, sourceSeen)
|
|
|
|
targetPresent, err := w.targetPresent(ctx, d.ID)
|
|
if err != nil {
|
|
logctx.From(ctx).Warn("reconcile target probe failed", "error", err)
|
|
return // не можем проверить цель — не дёргаем состояние
|
|
}
|
|
|
|
want := deriveState(sourcePresent, targetPresent)
|
|
if want == d.State {
|
|
return
|
|
}
|
|
w.transition(ctx, d, want, "reconcile", reconcileReason(sourcePresent, targetPresent))
|
|
}
|
|
|
|
// debounceSource обновляет счётчик промахов источника и возвращает, считать ли
|
|
// источник присутствующим с учётом порога. Запись счётчика — только при его
|
|
// изменении (без лишних UPDATE на каждом тике).
|
|
func (w *Worker) debounceSource(ctx context.Context, d store.Download, sourceSeen bool) bool {
|
|
threshold := max(w.cfg.SourceMissingThreshold, 1)
|
|
if sourceSeen {
|
|
if d.SourceMissCount != 0 {
|
|
if err := w.store.SetSourceMissCount(ctx, d.ID, 0); err != nil {
|
|
logctx.From(ctx).Warn("reconcile reset miss count failed", "error", err)
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
miss := d.SourceMissCount + 1
|
|
if err := w.store.SetSourceMissCount(ctx, d.ID, miss); err != nil {
|
|
logctx.From(ctx).Warn("reconcile bump miss count failed", "error", err)
|
|
}
|
|
// До порога источник трактуется как присутствующий — задача не дёргается.
|
|
return miss < threshold
|
|
}
|
|
|
|
// targetPresent сообщает, существуют ли разложенные хардлинки задачи. Цель
|
|
// считается присутствующей, только если существуют ВСЕ ссылки последнего
|
|
// батча; частичная пропажа — это отсутствие цели (библиотека сломана → relink).
|
|
func (w *Worker) targetPresent(ctx context.Context, id string) (bool, error) {
|
|
batch, err := w.store.LatestBatchID(ctx, id)
|
|
if err != nil {
|
|
return false, fmt.Errorf("latest batch: %w", err)
|
|
}
|
|
if batch == "" {
|
|
return false, nil // ничего не разложено — цели нет
|
|
}
|
|
rows, err := w.store.ListFileLinksByBatch(ctx, batch)
|
|
if err != nil {
|
|
return false, fmt.Errorf("list links: %w", err)
|
|
}
|
|
laidOut := 0
|
|
for _, r := range rows {
|
|
// Все статусы, означающие реальный файл на диске: только что слинкован,
|
|
// скопирован (фолбэк) или уже существовал тем же inode (идемпотентный
|
|
// повтор apply). Прочие (collision и т.п.) — не наша разложенная цель.
|
|
if !isLaidOut(r.Status) {
|
|
continue
|
|
}
|
|
laidOut++
|
|
if _, err := os.Lstat(r.DstPath); err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return false, nil
|
|
}
|
|
return false, fmt.Errorf("stat target %q: %w", r.DstPath, err)
|
|
}
|
|
}
|
|
return laidOut > 0, nil
|
|
}
|
|
|
|
// isLaidOut сообщает, означает ли статус file_link реально лежащий на ФС файл
|
|
// нашей раскладки (а не коллизию или иной не-разложенный исход).
|
|
func isLaidOut(status string) bool {
|
|
switch layout.LinkStatus(status) {
|
|
case layout.StatusLinked, layout.StatusCopied, layout.StatusExists:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// reconcileReason — человекочитаемая причина перехода для лога/UI.
|
|
func reconcileReason(sourcePresent, targetPresent bool) string {
|
|
switch {
|
|
case sourcePresent && targetPresent:
|
|
return "источник и цель на месте"
|
|
case sourcePresent && !targetPresent:
|
|
return "цель удалена, источник на месте"
|
|
case !sourcePresent && targetPresent:
|
|
return "источник удалён, цель (последняя копия) на месте"
|
|
default:
|
|
return "удалены и источник, и цель"
|
|
}
|
|
}
|
|
|
|
// --- Восстановление зависших загрузок (failed/stuck → поток) ---
|
|
|
|
// reconcileRecovery воскрешает задачи, упавшие из-за нашей нетерпеливости
|
|
// (magnet_timeout/stalled), когда их источник в qBittorrent ожил и продвинулся
|
|
// за условие падения. Вызывается из Poll под w.mu. Реальные/пользовательские
|
|
// провалы (qbit_error/reverted/cancelled/deleted) сюда не попадают.
|
|
func (w *Worker) reconcileRecovery(ctx context.Context, byHash map[string]qbt.Torrent) {
|
|
cands, err := w.store.ListRecoverable(ctx, errCodeMagnetTimeout, errCodeStalled)
|
|
if err != nil {
|
|
w.log.Warn("recovery list failed", "capability", capIngest, "error", err)
|
|
return
|
|
}
|
|
for _, d := range cands {
|
|
w.reconcileOneRecovery(ctx, d, byHash)
|
|
}
|
|
}
|
|
|
|
// reconcileOneRecovery возвращает одну зависшую задачу в поток, если её торрент
|
|
// присутствует и продвинулся за условие падения.
|
|
func (w *Worker) reconcileOneRecovery(ctx context.Context, d store.Download, byHash map[string]qbt.Torrent) {
|
|
if len(d.Infohashes) == 0 {
|
|
return
|
|
}
|
|
t, ok := torrentFor(d, byHash)
|
|
if !ok {
|
|
return // источника нет — оставляем как есть (вернёт ручной retry)
|
|
}
|
|
if !torrentProgressed(d, t) {
|
|
return // торрент всё ещё в metaDL/stalledDL или ошибочен — не воскрешаем
|
|
}
|
|
want := recoveredState(t.State)
|
|
if want == "" {
|
|
return // переходное состояние qBit (moving/checking) — ждём
|
|
}
|
|
ctx = w.scoped(ctx, capIngest, d.ID, d.PrimaryInfohash())
|
|
|
|
// Возврат в активное состояние — только через атомарный гард инварианта:
|
|
// пока задача лежала в failed, тем же infohash могла завладеть другая
|
|
// активная задача (новый приём). Тогда старую оставляем в failed.
|
|
// error_code/error_msg не пишем — задача снова здорова; причину в лог, а не в
|
|
// поле ошибки (иначе она светилась бы в UI/REST как ошибка живой задачи).
|
|
if err := w.store.ActivateIfNoOtherActive(ctx, d.ID, want, "", ""); err != nil {
|
|
if errors.Is(err, store.ErrInfohashTaken) {
|
|
logctx.From(ctx).Info("recovery skipped, infohash taken by active download", "error", err)
|
|
return
|
|
}
|
|
logctx.From(ctx).Warn("recovery activate failed", "error", err)
|
|
return
|
|
}
|
|
// Восстановление из failed/stuck — тоже переход состояния: единый msg
|
|
// `state transition` (from/to), qbit_state — отличительная деталь авто-воскрешения.
|
|
logctx.From(ctx).Info("state transition", "from", d.State, "to", want, "qbit_state", t.State)
|
|
}
|
|
|
|
// torrentProgressed сообщает, продвинулся ли торрент за условие, по которому
|
|
// задача упала: для magnet_timeout — получил метаданные (вышел из metaDL); для
|
|
// stalled — раздача ожила (вышла из stalledDL). Ошибочные состояния qBittorrent
|
|
// продвижением не считаем (их ведёт обычный reconcile в qbit_error).
|
|
func torrentProgressed(d store.Download, t qbt.Torrent) bool {
|
|
if classify(t.State) == classErrored {
|
|
return false
|
|
}
|
|
switch d.ErrorCode.String {
|
|
case errCodeMagnetTimeout:
|
|
return !isMeta(t.State)
|
|
case errCodeStalled:
|
|
return !isStalledDL(t.State)
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// recoveredState выводит состояние воскрешённой задачи из состояния торрента:
|
|
// готов к раскладке → completed; ещё качается → downloading. Переходные
|
|
// (moving/checking) и ошибочные состояния не восстанавливаем (пусто).
|
|
func recoveredState(state string) store.State {
|
|
switch classify(state) {
|
|
case classReady:
|
|
return store.StateCompleted
|
|
case classDownloading:
|
|
return store.StateDownloading
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// --- Синхронный preflight перед действием (не доверяем state в БД) ---
|
|
|
|
// ensureSourceReady синхронно (без дебаунса) проверяет, что раздача есть в
|
|
// qBittorrent прямо сейчас И докачана (класс classReady). Команды ревью, ведущие
|
|
// к распознаванию или раскладке, работают только с готовым источником — иначе
|
|
// хардлинки легли бы на неполные файлы (qBittorrent отдаёт имена до завершения).
|
|
// - источник исчез → приводим состояние к реальности (orphaned/deleted) и
|
|
// ErrConflict;
|
|
// - источник есть, но ещё качается → ErrNotReady БЕЗ reconcile: нахождение
|
|
// задачи в review/deferred/… легитимно, приводить нечего.
|
|
//
|
|
// Недоступность qBittorrent — честный отказ операции.
|
|
func (w *Worker) ensureSourceReady(ctx context.Context, d *store.Download, op string) error {
|
|
if len(d.Infohashes) == 0 {
|
|
return fmt.Errorf("%s: download %s has no infohash", op, d.ID)
|
|
}
|
|
t, ok, err := w.torrentByInfohash(ctx, d.HashList())
|
|
if err != nil {
|
|
return fmt.Errorf("%s: %w", op, err)
|
|
}
|
|
if !ok {
|
|
// Источник пропал — немедленно приводим состояние к реальности.
|
|
w.reconcileToReality(ctx, *d, false)
|
|
return fmt.Errorf("%s: источник удалён из qBittorrent: %w", op, ErrConflict)
|
|
}
|
|
if classify(t.State) != classReady {
|
|
return fmt.Errorf("%s: торрент ещё качается: %w", op, ErrNotReady)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// reconcileToReality выводит и проставляет состояние по уже известному факту об
|
|
// источнике (sourcePresent) и фактически проверенной цели. Используется
|
|
// preflight-проверками: немедленно, без дебаунса.
|
|
func (w *Worker) reconcileToReality(ctx context.Context, d store.Download, sourcePresent bool) {
|
|
targetPresent, err := w.targetPresent(ctx, d.ID)
|
|
if err != nil {
|
|
logctx.FromOr(ctx, w.log).Warn("preflight target probe failed", "error", err)
|
|
return
|
|
}
|
|
want := deriveState(sourcePresent, targetPresent)
|
|
if want == d.State {
|
|
return
|
|
}
|
|
w.transition(ctx, d, want, "reconcile", reconcileReason(sourcePresent, targetPresent))
|
|
}
|