Skip to main content

nzb_dispatch/
news_engine.rs

1//! `NewsDispatchEngine` — `DispatchEngine` impl backed by the `nzb-news` crate.
2//!
3//! The layered news engine is a pure NNTP fetch layer: it takes per-article
4//! work items and emits [`nzb_news::FetchOutcome`]s. This adapter bolts the
5//! rest of the pipeline on top of it so it satisfies the contract the old
6//! `WorkerPool` engine used to satisfy:
7//!
8//! 1. **Fetch** — delegated to `nzb_news::spawn_downloader`.
9//! 2. **Decode** — `nzb_decode::decode_yenc` on each successful outcome.
10//! 3. **Assemble** — `FileAssembler::assemble_article` writes the decoded
11//!    bytes at the yEnc-declared offset.
12//! 4. **Progress** — translates per-article outcomes into
13//!    [`ProgressUpdate::ArticleComplete`] / [`ProgressUpdate::ArticleFailed`];
14//!    drives job-level terminal via `JobContext::resolve_one`.
15//!
16//! Per-job lifecycle (pause/resume/cancel/abort) is tracked in this adapter
17//! because the news engine is job-agnostic. We keep a `JobContext` per job
18//! (same struct the old engine used — it owns the assembler, progress
19//! channel, deobfuscation state, and terminal-emit logic).
20//!
21//! MVP limitations — marked with TODO comments:
22//! - `set_max_worker_idle` / `eviction_count` are stubs (no idle-worker
23//!   pool concept in nzb-news).
24
25use std::collections::{HashMap, VecDeque};
26use std::sync::Arc;
27use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
28use std::time::{Duration, Instant};
29
30use parking_lot::{Mutex, RwLock};
31use tokio::sync::{Notify, mpsc};
32use tracing::{debug, info};
33
34use nzb_core::models::NzbJob;
35use nzb_nntp::config::ServerConfig;
36
37use crate::article_failure::{ArticleFailure, ArticleFailureKind};
38use crate::dispatch_engine::DispatchEngine;
39use crate::download_engine::{JobContext, ProgressUpdate, build_job_submission};
40
41// ---------------------------------------------------------------------------
42// Tuning knobs
43// ---------------------------------------------------------------------------
44
45/// How many articles the nzb-news downloader will hold in-flight across all
46/// servers at once. Matches the old engine's rough ceiling.
47const DEFAULT_MAX_CONCURRENT_FETCHES: usize = 40;
48
49/// Work channel depth inside nzb-news. Articles are buffered here between
50/// `submit_job` enqueue and the per-server fan-out.
51const DEFAULT_WORK_CHANNEL_CAPACITY: usize = 4096;
52
53/// Outcome channel depth. Must be large enough that a momentary backlog in
54/// the decode path doesn't block the fetch loop.
55const DEFAULT_OUTCOME_CHANNEL_CAPACITY: usize = 4096;
56
57// ---------------------------------------------------------------------------
58// Config
59// ---------------------------------------------------------------------------
60
61/// Configuration for [`NewsDispatchEngine`]. Mirrors the knobs exposed by
62/// the old engine so the swap is drop-in from the caller's perspective.
63///
64/// `servers` is held as an `Arc<Mutex<_>>` so the caller (queue manager) can
65/// mutate it and [`DispatchEngine::reconcile_servers`] will pick up the new
66/// list without requiring a new config instance or a full engine rebuild.
67#[derive(Clone)]
68pub struct NewsEngineConfig {
69    pub servers: Arc<Mutex<Vec<ServerConfig>>>,
70    pub article_timeout: Duration,
71    pub max_concurrent_fetches: usize,
72    pub work_channel_capacity: usize,
73    pub outcome_channel_capacity: usize,
74    /// Optional backup-server probe policy. Forwarded verbatim to
75    /// `nzb_news::DownloaderConfig::probe_policy`.
76    ///
77    /// `None` disables probing (every cascade article tries every server).
78    /// `Some(_)` enables fast-fail on a backup server when the probed
79    /// hit-rate falls below the threshold for that job.
80    ///
81    /// Defaults to `Some(ServerProbePolicy::default())` which matches the
82    /// nzb-news default (probe 10 articles, require >=10% hits).
83    pub probe_policy: Option<nzb_news::ServerProbePolicy>,
84}
85
86impl NewsEngineConfig {
87    /// Construct a config from an owned server list. Wraps the list in an
88    /// `Arc<Mutex<_>>` internally; if the caller already owns a shared Arc
89    /// (e.g. queue manager's live server list), use
90    /// [`NewsEngineConfig::with_shared_servers`] instead so mutations are
91    /// visible to the engine.
92    pub fn new(servers: Vec<ServerConfig>, article_timeout: Duration) -> Self {
93        Self::with_shared_servers(Arc::new(Mutex::new(servers)), article_timeout)
94    }
95
96    /// Construct a config sharing an existing `Arc<Mutex<Vec<ServerConfig>>>`
97    /// with the caller. Mutating the Arc from outside and then calling
98    /// [`DispatchEngine::reconcile_servers`] rebuilds the downloader with
99    /// the latest server list — this is how live "add/remove server"
100    /// operations reach the fetch layer.
101    pub fn with_shared_servers(
102        servers: Arc<Mutex<Vec<ServerConfig>>>,
103        article_timeout: Duration,
104    ) -> Self {
105        Self {
106            servers,
107            article_timeout,
108            max_concurrent_fetches: DEFAULT_MAX_CONCURRENT_FETCHES,
109            work_channel_capacity: DEFAULT_WORK_CHANNEL_CAPACITY,
110            outcome_channel_capacity: DEFAULT_OUTCOME_CHANNEL_CAPACITY,
111            probe_policy: Some(nzb_news::ServerProbePolicy::default()),
112        }
113    }
114}
115
116// ---------------------------------------------------------------------------
117// Adapter state
118// ---------------------------------------------------------------------------
119
120/// Shared state held behind an `Arc` so the outcome-dispatcher task can
121/// access jobs while the engine is owned by the caller.
122struct Inner {
123    config: NewsEngineConfig,
124    /// Populated by `start()`. Holds the downloader's work-submission sender.
125    handle: RwLock<Option<nzb_news::DownloaderHandle>>,
126    /// Job map: `job_id` → per-job state. Cloned into the outcome task.
127    jobs: RwLock<HashMap<String, Arc<JobEntry>>>,
128    /// Monotonic tag issued for every `WorkItem` submitted to the downloader.
129    /// Also serves as the routing key back to the originating article when
130    /// outcomes come out the other side.
131    next_tag: AtomicU64,
132    /// `tag` → in-flight article metadata. We remove on outcome; this is the
133    /// only place that holds the file_id / segment_number for an article
134    /// mid-flight. Cleared on job cancel to free memory fast.
135    in_flight: RwLock<HashMap<u64, InFlight>>,
136}
137
138/// Per-job state owned by the adapter.
139struct JobEntry {
140    /// Reused from the old engine — owns the assembler, progress channel,
141    /// deobfuscation state, and terminal emit logic. Everything the adapter
142    /// needs on the success/failure path is already a field here.
143    context: Arc<JobContext>,
144    /// When true, the pump task holds items in `pending` instead of
145    /// forwarding them to the downloader. In-flight articles still
146    /// complete — pause gates the *next* work only.
147    paused: AtomicBool,
148    /// Set by `cancel_job`. The pump exits and drains the queue.
149    cancelled: AtomicBool,
150    /// Work items waiting to be handed to the downloader. `submit_job`
151    /// pushes all items here; the pump task drains on start and on
152    /// resume.
153    pending: Mutex<VecDeque<nzb_news::WorkItem>>,
154    /// Wake-up signal for the pump task. Notified when `submit_job` adds
155    /// items or when `resume_job` / `cancel_job` changes the gate.
156    pump_wake: Notify,
157}
158
159/// Metadata recorded when a `WorkItem` is dispatched so we can route the
160/// outcome back to the right job / file / segment.
161#[derive(Clone)]
162struct InFlight {
163    job_id: String,
164    file_id: String,
165    segment_number: u32,
166    work_item: nzb_news::WorkItem,
167}
168
169// ---------------------------------------------------------------------------
170// NewsDispatchEngine
171// ---------------------------------------------------------------------------
172
173/// `DispatchEngine` impl backed by the layered nzb-news fetch engine.
174pub struct NewsDispatchEngine {
175    inner: Arc<Inner>,
176}
177
178impl NewsDispatchEngine {
179    /// Construct the engine. Does **not** spawn the downloader —
180    /// [`DispatchEngine::start`] does that.
181    pub fn new(config: NewsEngineConfig) -> Self {
182        Self {
183            inner: Arc::new(Inner {
184                config,
185                handle: RwLock::new(None),
186                jobs: RwLock::new(HashMap::new()),
187                next_tag: AtomicU64::new(1),
188                in_flight: RwLock::new(HashMap::new()),
189            }),
190        }
191    }
192}
193
194#[async_trait::async_trait]
195impl DispatchEngine for NewsDispatchEngine {
196    fn start(&self) {
197        let mut slot = self.inner.handle.write();
198        if slot.is_some() {
199            return; // idempotent
200        }
201
202        let servers_snapshot = self.inner.config.servers.lock().clone();
203        if servers_snapshot.is_empty() {
204            // Deferred start: with zero servers, spawning the downloader
205            // would create an internal work queue with no per-server
206            // workers, and any items pushed would sit in limbo.
207            // `reconcile_servers` will start it once servers are added.
208            info!("NewsDispatchEngine start deferred — no servers configured");
209            return;
210        }
211        spawn_and_install_downloader(&self.inner, &mut slot, servers_snapshot);
212    }
213
214    fn submit_job(&self, job: &NzbJob, progress_tx: mpsc::Sender<ProgressUpdate>) {
215        // Reuse the old engine's job-submission builder: creates the
216        // FileAssembler, registers files, and filters out already-downloaded
217        // articles. We only use the returned `JobContext` — the WorkItem
218        // vec it produces is in the old engine's format; we build nzb-news
219        // work items fresh below.
220        let (ctx, legacy_items) = build_job_submission(job, progress_tx);
221
222        // Build nzb-news wrapper types. One NzbObject for the job, one
223        // NzbFile per file, and one Article per work item.
224        let news_files: Vec<Arc<nzb_news::NzbFile>> = job
225            .files
226            .iter()
227            .map(|f| {
228                Arc::new(nzb_news::NzbFile::new(
229                    &f.id,
230                    &job.id,
231                    &f.filename,
232                    f.articles.len() as u32,
233                ))
234            })
235            .collect();
236        let news_files_by_id: HashMap<String, Arc<nzb_news::NzbFile>> = news_files
237            .iter()
238            .map(|nf| (nf.id.clone(), Arc::clone(nf)))
239            .collect();
240        let total_articles = legacy_items.len() as u64;
241        let news_job = Arc::new(nzb_news::NzbObject::new(
242            &job.id,
243            &job.name,
244            total_articles,
245            job.total_bytes,
246            news_files.clone(),
247        ));
248
249        // Convert each legacy WorkItem into an nzb-news WorkItem, recording
250        // the routing metadata into in_flight and the item itself into the
251        // job's pending queue. The pump task forwards from pending to the
252        // downloader, gated by the `paused` flag.
253        let mut pending = VecDeque::with_capacity(legacy_items.len());
254        let tag_counter = &self.inner.next_tag;
255        for item in legacy_items {
256            let tag = tag_counter.fetch_add(1, Ordering::Relaxed);
257            let file = match news_files_by_id.get(&item.file_id) {
258                Some(f) => Arc::clone(f),
259                None => continue, // shouldn't happen — file_id came from the same job
260            };
261            let article = Arc::new(nzb_news::Article::new(
262                item.message_id.clone(),
263                item.file_id.clone(),
264                item.job_id.clone(),
265                0,
266                item.segment_number,
267                tag,
268            ));
269            let pending_item = nzb_news::WorkItem {
270                tag,
271                article,
272                file,
273                job: Arc::clone(&news_job),
274            };
275            self.inner.in_flight.write().insert(
276                tag,
277                InFlight {
278                    job_id: item.job_id.clone(),
279                    file_id: item.file_id.clone(),
280                    segment_number: item.segment_number,
281                    work_item: pending_item.clone(),
282                },
283            );
284            pending.push_back(pending_item);
285        }
286
287        let entry = Arc::new(JobEntry {
288            context: Arc::clone(&ctx),
289            paused: AtomicBool::new(false),
290            cancelled: AtomicBool::new(false),
291            pending: Mutex::new(pending),
292            pump_wake: Notify::new(),
293        });
294        self.inner
295            .jobs
296            .write()
297            .insert(ctx.job_id.clone(), Arc::clone(&entry));
298
299        // Spawn the pump. It acquires a sender from the engine handle on
300        // each iteration and parks if the downloader is absent — so
301        // submitting a job before `start()` (or during a 0-server startup
302        // window) is safe: items wait in `pending` until `reconcile_servers`
303        // spawns the downloader.
304        let job_id = job.id.clone();
305        tokio::spawn(pump_loop(entry, Arc::clone(&self.inner), job_id));
306    }
307
308    fn pause_job(&self, job_id: &str) {
309        if let Some(entry) = self.inner.jobs.read().get(job_id) {
310            // Local gate — stops the pump from handing new items to nzb-news.
311            entry.paused.store(true, Ordering::SeqCst);
312        }
313        // Scheduler-level gate — holds already-submitted articles in
314        // nzb-news's own pending queue. Without this, anything already
315        // accepted into `work_channel_capacity` (default 4096) would still
316        // route to servers despite the local gate.
317        if let Some(h) = self.inner.handle.read().as_ref() {
318            h.pause_job(job_id);
319        }
320        debug!(job_id, "paused");
321    }
322
323    fn resume_job(&self, job_id: &str) {
324        if let Some(entry) = self.inner.jobs.read().get(job_id) {
325            entry.paused.store(false, Ordering::SeqCst);
326            entry.pump_wake.notify_waiters();
327        }
328        if let Some(h) = self.inner.handle.read().as_ref() {
329            h.resume_job(job_id);
330        }
331        debug!(job_id, "resumed");
332    }
333
334    fn cancel_job(&self, job_id: &str) {
335        let entry = self.inner.jobs.write().remove(job_id);
336        if let Some(entry) = entry {
337            // Signal pump to drain + exit.
338            entry.cancelled.store(true, Ordering::SeqCst);
339            entry.pump_wake.notify_waiters();
340            // Drop any not-yet-dispatched items so the pump sees an empty
341            // queue and exits promptly.
342            entry.pending.lock().clear();
343            // Clear in-flight entries for this job so stale outcomes are
344            // dropped silently by the dispatcher (unknown-tag path).
345            self.inner
346                .in_flight
347                .write()
348                .retain(|_, m| m.job_id != job_id);
349            // Purge nzb-news scheduler-level state: items already accepted
350            // into the downloader's work_channel or pending list get emitted
351            // as Cancelled outcomes and removed. Without this, a cancelled
352            // job would keep routing its buffered articles to servers.
353            if let Some(h) = self.inner.handle.read().as_ref() {
354                h.purge_job(job_id);
355            }
356            debug!(job_id, "cancelled");
357        }
358    }
359
360    fn abort_job(&self, job_id: &str, reason: String) {
361        // Emit terminal via the existing JobContext machinery — same path
362        // the old engine uses. `emit_terminal` is idempotent; cancel_job
363        // later is safe.
364        let entry = self.inner.jobs.read().get(job_id).cloned();
365        if let Some(entry) = entry {
366            *entry.context.abort_reason.lock() = Some(reason);
367            entry.context.emit_terminal_public();
368        }
369        self.cancel_job(job_id);
370    }
371
372    fn has_job(&self, job_id: &str) -> bool {
373        self.inner.jobs.read().contains_key(job_id)
374    }
375
376    fn reconcile_servers(&self) {
377        // Rebuild the downloader with the current server list.
378        //
379        // First-time (0 → N): when `start` was deferred for lack of
380        // servers, pump_loops parked waiting for a handle. Spawning the
381        // downloader here and notifying pumps resumes dispatch cleanly —
382        // no items are lost because `pump_loop` leaves unsent items in
383        // `pending` until a sender is available.
384        //
385        // Reconfigure (N → M, N > 0): rebuild the downloader and shut the
386        // old one down. Any not-yet-attempted items from the old downloader
387        // come back through `FetchOutcome::Cancelled`; the outcome adapter
388        // requeues them onto the job's pending queue so the new downloader
389        // can pick them up.
390        let servers_snapshot = self.inner.config.servers.lock().clone();
391        let server_count = servers_snapshot.len();
392
393        let old_handle = if servers_snapshot.is_empty() {
394            // Remove handle; pumps will park until a server is added.
395            self.inner.handle.write().take()
396        } else {
397            let mut slot = self.inner.handle.write();
398            let old = slot.take();
399            spawn_and_install_downloader(&self.inner, &mut slot, servers_snapshot);
400            old
401        };
402
403        if let Some(old) = old_handle {
404            old.shutdown();
405        }
406
407        // Wake all pump loops so they re-read the handle and either pick
408        // up the new sender or park on `pump_wake` until one arrives.
409        let entries: Vec<Arc<JobEntry>> = self.inner.jobs.read().values().map(Arc::clone).collect();
410        for entry in entries {
411            entry.pump_wake.notify_waiters();
412        }
413
414        info!(
415            servers = server_count,
416            "NewsDispatchEngine reconciled server list"
417        );
418    }
419
420    fn set_max_worker_idle(&self, _d: Duration) {
421        // No per-worker idle concept in nzb-news; workers are persistent
422        // until the downloader shuts down.
423    }
424
425    fn eviction_count(&self) -> u64 {
426        0
427    }
428
429    fn server_stats_snapshot(&self) -> Vec<(String, crate::dispatch_engine::ServerAttemptStats)> {
430        let guard = self.inner.handle.read();
431        let Some(h) = guard.as_ref() else {
432            return Vec::new();
433        };
434        h.server_stats_snapshot()
435            .into_iter()
436            .map(|(id, s)| {
437                (
438                    id,
439                    crate::dispatch_engine::ServerAttemptStats {
440                        attempted: s.attempted,
441                        succeeded: s.succeeded,
442                        not_found: s.not_found,
443                        transient_failed: s.transient_failed,
444                    },
445                )
446            })
447            .collect()
448    }
449
450    async fn shutdown(&self) {
451        let handle = self.inner.handle.write().take();
452        if let Some(h) = handle {
453            h.shutdown();
454            h.join().await;
455        }
456    }
457}
458
459// ---------------------------------------------------------------------------
460// Outcome dispatcher
461// ---------------------------------------------------------------------------
462
463/// Main loop: consume `FetchOutcome`s from nzb-news and translate each into
464/// a `ProgressUpdate`, doing decode + assembly inline on success. Runs until
465/// the outcome channel is closed (downloader shutdown).
466async fn outcome_dispatcher(
467    inner: Arc<Inner>,
468    mut outcomes: mpsc::Receiver<nzb_news::FetchOutcome>,
469) {
470    while let Some(outcome) = outcomes.recv().await {
471        match outcome {
472            nzb_news::FetchOutcome::Success {
473                tag,
474                server_id,
475                bytes,
476                article_bytes: _,
477            } => {
478                // Spawn each success so decode+assemble runs in parallel.
479                // The old engine got this for free because every worker did
480                // its own fetch+decode+assemble — centralising here would
481                // serialise all post-fetch work to a single task.
482                let inner2 = Arc::clone(&inner);
483                tokio::spawn(async move {
484                    process_success(inner2, tag, server_id, bytes).await;
485                });
486            }
487            nzb_news::FetchOutcome::Failed { tag, last_error } => {
488                process_failure(&inner, tag, last_error);
489            }
490            nzb_news::FetchOutcome::Cancelled { tag } => {
491                requeue_cancelled_outcome(&inner, tag);
492            }
493        }
494    }
495    debug!("outcome_dispatcher exiting: channel closed");
496}
497
498fn requeue_cancelled_outcome(inner: &Arc<Inner>, tag: u64) {
499    let Some(meta) = inner.in_flight.write().remove(&tag) else {
500        return;
501    };
502
503    let entry = inner.jobs.read().get(&meta.job_id).cloned();
504    let Some(entry) = entry else {
505        return;
506    };
507
508    if entry.cancelled.load(Ordering::SeqCst) {
509        return;
510    }
511
512    inner.in_flight.write().insert(tag, meta.clone());
513    entry.pending.lock().push_back(meta.work_item);
514    entry.pump_wake.notify_waiters();
515}
516
517async fn process_success(inner: Arc<Inner>, tag: u64, server_id: String, raw: Vec<u8>) {
518    let meta = inner.in_flight.write().remove(&tag);
519    let Some(meta) = meta else {
520        return; // stale / cancelled
521    };
522
523    let entry = inner.jobs.read().get(&meta.job_id).cloned();
524    let Some(entry) = entry else {
525        return; // job cancelled after submit
526    };
527    let ctx = &entry.context;
528
529    // Decode (CPU-bound; SIMD is fast but not free).
530    let decode_start = Instant::now();
531    let decoded = match nzb_decode::decode_yenc(&raw) {
532        Ok(d) => d,
533        Err(e) => {
534            let failure = ArticleFailure::decode_error(server_id, format!("yEnc decode: {e}"));
535            emit_failed(ctx, &meta, failure);
536            return;
537        }
538    };
539    let decode_us = decode_start.elapsed().as_micros() as u64;
540
541    // Record yEnc filename for deobfuscation.
542    if let Some(ref fname) = decoded.filename
543        && !fname.is_empty()
544    {
545        ctx.yenc_names
546            .lock()
547            .insert(meta.file_id.clone(), fname.clone());
548    }
549
550    let data_begin = decoded.part_begin.unwrap_or(0);
551
552    // Assemble.
553    let assemble_start = Instant::now();
554    let file_complete = match ctx.assembler.assemble_article(
555        &meta.job_id,
556        &meta.file_id,
557        meta.segment_number,
558        data_begin,
559        &decoded.data,
560    ) {
561        Ok(b) => b,
562        Err(e) => {
563            let failure = ArticleFailure::decode_error(server_id, format!("assembly: {e}"));
564            emit_failed(ctx, &meta, failure);
565            return;
566        }
567    };
568    let assemble_us = assemble_start.elapsed().as_micros() as u64;
569
570    // Timing stats.
571    ctx.total_decode_us.fetch_add(decode_us, Ordering::Relaxed);
572    ctx.total_assemble_us
573        .fetch_add(assemble_us, Ordering::Relaxed);
574    ctx.total_articles_decoded.fetch_add(1, Ordering::Relaxed);
575
576    // Emit progress.
577    let decoded_bytes = decoded.data.len() as u64;
578    let _ = ctx.progress_tx.try_send(ProgressUpdate::ArticleComplete {
579        job_id: meta.job_id.clone(),
580        file_id: meta.file_id.clone(),
581        segment_number: meta.segment_number,
582        decoded_bytes,
583        file_complete,
584        server_id: Some(server_id),
585    });
586
587    ctx.resolve_one_public();
588}
589
590fn process_failure(inner: &Inner, tag: u64, last_error: Option<String>) {
591    let meta = inner.in_flight.write().remove(&tag);
592    let Some(meta) = meta else {
593        return;
594    };
595    let entry = inner.jobs.read().get(&meta.job_id).cloned();
596    let Some(entry) = entry else {
597        return;
598    };
599    let msg = last_error.unwrap_or_else(|| "all servers exhausted".into());
600    // nzb-news doesn't carry structured error info at the outcome layer —
601    // only the last attempt's error string. Pattern-match common causes
602    // so the hopeless-tracker and queue_manager can distinguish "server
603    // is broken/quota-exhausted" (transient, don't count toward hopeless)
604    // from "article genuinely missing everywhere" (counts toward
605    // hopeless). Without this, an auth/quota failure trickles through as
606    // NotFound and aborts the job with "articles confirmed missing" —
607    // confusing diagnostics that blame the content instead of the server.
608    let kind = classify_error_message(&msg);
609    let failure = ArticleFailure {
610        kind,
611        server_id: String::new(),
612        message: msg,
613    };
614    emit_failed(&entry.context, &meta, failure);
615}
616
617/// Map an opaque nzb-news error string to a typed [`ArticleFailureKind`].
618///
619/// The strings come from `nzb_nntp::error::NntpError` (via nzb-news) and are
620/// the only signal we have at this layer — nzb-news's `FetchOutcome` carries
621/// `Option<String>` rather than a structured kind. Order of checks matters:
622/// more specific patterns are tested first.
623fn classify_error_message(msg: &str) -> ArticleFailureKind {
624    let m = msg.to_ascii_lowercase();
625    // NNTP response codes in the message body are the strongest signal.
626    if m.contains("(482)") || m.contains("(481)") || m.contains("auth") {
627        return ArticleFailureKind::AuthFailed;
628    }
629    if m.contains("(403)") || m.contains("permission") || m.contains("forbidden") {
630        return ArticleFailureKind::PermissionDenied;
631    }
632    if m.contains("(430)") || m.contains("article not found") || m.contains("no such article") {
633        return ArticleFailureKind::NotFound;
634    }
635    if m.contains("(502)") || m.contains("service unavailable") {
636        return ArticleFailureKind::ServerDown;
637    }
638    if m.contains("timeout") || m.contains("timed out") {
639        return ArticleFailureKind::Timeout;
640    }
641    if m.contains("connection") || m.contains("eof") || m.contains("reset") || m.contains("closed")
642    {
643        return ArticleFailureKind::ConnectionClosed;
644    }
645    // Default: treat unknown cascade exhaustion as NotFound — same as the
646    // old behaviour — so genuinely-missing articles still abort hopeless
647    // NZBs promptly.
648    ArticleFailureKind::NotFound
649}
650
651#[cfg(test)]
652mod classify_tests {
653    use super::*;
654
655    #[test]
656    fn classifies_auth_failures() {
657        let msg = "Authentication failed: PASS rejected (482): Your block account is fully used";
658        assert_eq!(classify_error_message(msg), ArticleFailureKind::AuthFailed);
659    }
660
661    #[test]
662    fn classifies_not_found() {
663        let msg = "NNTP (430) No such article";
664        assert_eq!(classify_error_message(msg), ArticleFailureKind::NotFound);
665    }
666
667    #[test]
668    fn classifies_service_unavailable() {
669        let msg = "Service unavailable (502)";
670        assert_eq!(classify_error_message(msg), ArticleFailureKind::ServerDown);
671    }
672
673    #[test]
674    fn classifies_timeout() {
675        let msg = "read timed out after 60s";
676        assert_eq!(classify_error_message(msg), ArticleFailureKind::Timeout);
677    }
678
679    #[test]
680    fn unknown_defaults_to_not_found() {
681        assert_eq!(
682            classify_error_message("all servers exhausted"),
683            ArticleFailureKind::NotFound
684        );
685    }
686}
687
688fn emit_failed(ctx: &JobContext, meta: &InFlight, failure: ArticleFailure) {
689    ctx.articles_failed.fetch_add(1, Ordering::Relaxed);
690    let _ = ctx.progress_tx.try_send(ProgressUpdate::ArticleFailed {
691        job_id: meta.job_id.clone(),
692        file_id: meta.file_id.clone(),
693        segment_number: meta.segment_number,
694        failure,
695    });
696    ctx.resolve_one_public();
697}
698
699// ---------------------------------------------------------------------------
700// Per-job pump task
701// ---------------------------------------------------------------------------
702
703/// Drains a job's `pending` queue into the downloader's work channel,
704/// respecting the `paused` gate and exiting on `cancelled`.
705///
706/// The pump parks on `pump_wake` when `pending` is empty or when
707/// `paused` is true. `submit_job` / `resume_job` notify to wake it.
708async fn pump_loop(entry: Arc<JobEntry>, inner: Arc<Inner>, job_id: String) {
709    loop {
710        if entry.cancelled.load(Ordering::SeqCst) {
711            debug!(job_id, "pump exiting: cancelled");
712            return;
713        }
714        if entry.paused.load(Ordering::SeqCst) {
715            entry.pump_wake.notified().await;
716            continue;
717        }
718        let next = entry.pending.lock().pop_front();
719        let Some(item) = next else {
720            // Queue empty. submit_job enqueues every article up-front, so
721            // an empty queue means we're done. Park anyway so cancel can
722            // wake us.
723            entry.pump_wake.notified().await;
724            continue;
725        };
726
727        // Snapshot the current sender. If the downloader is absent (not
728        // started yet, or torn down during reconcile_servers with zero
729        // servers), stash the item back on the front of `pending` and
730        // park; reconcile_servers will notify us when a new handle exists.
731        let sender = inner.handle.read().as_ref().map(|h| h.sender());
732        let Some(sender) = sender else {
733            entry.pending.lock().push_front(item);
734            entry.pump_wake.notified().await;
735            continue;
736        };
737
738        // Send. On SendError (sender closed mid-reconcile), return the
739        // item to the queue and park — the new handle is on its way.
740        if let Err(e) = sender.send(item).await {
741            entry.pending.lock().push_front(e.0);
742            entry.pump_wake.notified().await;
743            continue;
744        }
745    }
746}
747
748/// Build a new `DownloaderConfig` from the engine's static knobs plus the
749/// given server list, spawn the downloader, install its handle in `slot`,
750/// and launch the outcome dispatcher task. Used by both `start()` and
751/// `reconcile_servers` to avoid duplicating the construction.
752///
753/// Precondition: `servers` is non-empty; caller decides the zero-server
754/// policy. `slot` must already be held under a write lock.
755fn spawn_and_install_downloader(
756    inner: &Arc<Inner>,
757    slot: &mut Option<nzb_news::DownloaderHandle>,
758    servers: Vec<ServerConfig>,
759) {
760    let cfg = &inner.config;
761    let server_count = servers.len();
762    let dl_config = nzb_news::DownloaderConfig {
763        servers,
764        max_concurrent_fetches: cfg.max_concurrent_fetches,
765        article_timeout: cfg.article_timeout,
766        work_channel_capacity: cfg.work_channel_capacity,
767        outcome_channel_capacity: cfg.outcome_channel_capacity,
768        probe_policy: cfg.probe_policy.clone(),
769    };
770    let (handle, outcomes) = nzb_news::spawn_downloader(dl_config);
771    let inner_for_task = Arc::clone(inner);
772    tokio::spawn(outcome_dispatcher(inner_for_task, outcomes));
773    *slot = Some(handle);
774    info!(
775        servers = server_count,
776        "NewsDispatchEngine downloader spawned"
777    );
778}
779
780#[cfg(test)]
781mod tests {
782    use super::*;
783
784    use chrono::Utc;
785    use nzb_core::models::{JobStatus, NzbFile, NzbJob, Priority};
786    use nzb_nntp::Article;
787    use tokio::sync::mpsc;
788
789    fn sample_job() -> NzbJob {
790        NzbJob {
791            id: "job-1".to_string(),
792            name: "sample".to_string(),
793            category: "tv".to_string(),
794            status: JobStatus::Downloading,
795            priority: Priority::Normal,
796            total_bytes: 128,
797            downloaded_bytes: 0,
798            file_count: 1,
799            files_completed: 0,
800            article_count: 1,
801            articles_downloaded: 0,
802            articles_failed: 0,
803            added_at: Utc::now(),
804            completed_at: None,
805            work_dir: tempfile::tempdir().unwrap().keep(),
806            output_dir: tempfile::tempdir().unwrap().keep(),
807            password: None,
808            error_message: None,
809            speed_bps: 0,
810            server_stats: Vec::new(),
811            files: vec![NzbFile {
812                id: "file-1".to_string(),
813                filename: "file.bin".to_string(),
814                bytes: 128,
815                bytes_downloaded: 0,
816                is_par2: false,
817                par2_setname: None,
818                par2_vol: None,
819                par2_blocks: None,
820                assembled: false,
821                groups: vec!["alt.binaries.test".to_string()],
822                articles: vec![Article {
823                    message_id: "<seg1@example>".to_string(),
824                    bytes: 128,
825                    segment_number: 1,
826                    downloaded: false,
827                    data_begin: None,
828                    data_size: None,
829                    crc32: None,
830                    tried_servers: Vec::new(),
831                    tries: 0,
832                }],
833            }],
834        }
835    }
836
837    fn submitted_engine() -> (NewsDispatchEngine, u64) {
838        let engine = NewsDispatchEngine::new(NewsEngineConfig::new(
839            Vec::<ServerConfig>::new(),
840            Duration::from_secs(30),
841        ));
842        let (tx, _rx) = mpsc::channel(8);
843        engine.submit_job(&sample_job(), tx);
844        let tag = *engine.inner.in_flight.read().keys().next().unwrap();
845        (engine, tag)
846    }
847
848    #[tokio::test]
849    async fn cancelled_outcome_requeues_live_job_item() {
850        let (engine, tag) = submitted_engine();
851        let entry = engine.inner.jobs.read().get("job-1").cloned().unwrap();
852
853        let original = entry.pending.lock().pop_front().unwrap();
854        assert_eq!(original.tag, tag);
855        assert!(entry.pending.lock().is_empty());
856
857        requeue_cancelled_outcome(&engine.inner, tag);
858
859        let queued = entry.pending.lock().pop_front().unwrap();
860        assert_eq!(queued.tag, tag);
861        assert!(engine.inner.in_flight.read().contains_key(&tag));
862    }
863
864    #[tokio::test]
865    async fn cancelled_outcome_drops_when_job_was_cancelled() {
866        let (engine, tag) = submitted_engine();
867        let entry = engine.inner.jobs.read().get("job-1").cloned().unwrap();
868
869        entry.pending.lock().pop_front().unwrap();
870        entry.cancelled.store(true, Ordering::SeqCst);
871
872        requeue_cancelled_outcome(&engine.inner, tag);
873
874        assert!(entry.pending.lock().is_empty());
875        assert!(!engine.inner.in_flight.read().contains_key(&tag));
876    }
877}