1use 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
41const DEFAULT_MAX_CONCURRENT_FETCHES: usize = 40;
48
49const DEFAULT_WORK_CHANNEL_CAPACITY: usize = 4096;
52
53const DEFAULT_OUTCOME_CHANNEL_CAPACITY: usize = 4096;
56
57#[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 pub probe_policy: Option<nzb_news::ServerProbePolicy>,
84}
85
86impl NewsEngineConfig {
87 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 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
116struct Inner {
123 config: NewsEngineConfig,
124 handle: RwLock<Option<nzb_news::DownloaderHandle>>,
126 jobs: RwLock<HashMap<String, Arc<JobEntry>>>,
128 next_tag: AtomicU64,
132 in_flight: RwLock<HashMap<u64, InFlight>>,
136}
137
138struct JobEntry {
140 context: Arc<JobContext>,
144 paused: AtomicBool,
148 cancelled: AtomicBool,
150 pending: Mutex<VecDeque<nzb_news::WorkItem>>,
154 pump_wake: Notify,
157}
158
159#[derive(Clone)]
162struct InFlight {
163 job_id: String,
164 file_id: String,
165 segment_number: u32,
166 work_item: nzb_news::WorkItem,
167}
168
169pub struct NewsDispatchEngine {
175 inner: Arc<Inner>,
176}
177
178impl NewsDispatchEngine {
179 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; }
201
202 let servers_snapshot = self.inner.config.servers.lock().clone();
203 if servers_snapshot.is_empty() {
204 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 let (ctx, legacy_items) = build_job_submission(job, progress_tx);
221
222 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 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, };
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 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 entry.paused.store(true, Ordering::SeqCst);
312 }
313 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 entry.cancelled.store(true, Ordering::SeqCst);
339 entry.pump_wake.notify_waiters();
340 entry.pending.lock().clear();
343 self.inner
346 .in_flight
347 .write()
348 .retain(|_, m| m.job_id != job_id);
349 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 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 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 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 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 }
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
459async 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 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; };
522
523 let entry = inner.jobs.read().get(&meta.job_id).cloned();
524 let Some(entry) = entry else {
525 return; };
527 let ctx = &entry.context;
528
529 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 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 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 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 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 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
617fn classify_error_message(msg: &str) -> ArticleFailureKind {
624 let m = msg.to_ascii_lowercase();
625 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 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
699async 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 entry.pump_wake.notified().await;
724 continue;
725 };
726
727 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 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
748fn 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}