1use std::collections::{HashMap, VecDeque};
34use std::path::PathBuf;
35use std::sync::Arc;
36use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
37use std::time::{Duration, Instant};
38
39use parking_lot::Mutex;
40use tokio::sync::{Notify, mpsc};
41use tokio::task::JoinHandle;
42use tracing::{debug, error, info, trace, warn};
43
44use nzb_core::config::ServerConfig;
45use nzb_core::models::NzbJob;
46use nzb_decode::FileAssembler;
47use nzb_decode::yenc::decode_yenc;
48use nzb_nntp::Pipeline;
49use nzb_nntp::connection::{ConnectionState, NntpConnection};
50use nzb_nntp::error::NntpError;
51
52use crate::bandwidth::BandwidthLimiter;
53
54fn increment_counter(name: &'static str) {
55 opentelemetry::global::meter_provider()
56 .meter("nzb-dispatch")
57 .u64_counter(name)
58 .build()
59 .add(1, &[]);
60}
61
62const MAX_TRIES_PER_SERVER: u32 = 3;
68const MAX_OUTAGE_TRIES_PER_SERVER: u32 = 10;
73const RECONNECT_DELAY: Duration = Duration::from_secs(5);
75const MAX_RECONNECT_ATTEMPTS: u32 = 5;
77const WORKER_RAMP_DELAY: Duration = Duration::from_millis(15);
80const CIRCUIT_BREAK_THRESHOLD: u32 = 3;
82const AUTH_FAILURE_COOLDOWN: Duration = Duration::from_secs(120);
84const TRANSIENT_FAILURE_COOLDOWN: Duration = Duration::from_secs(30);
86const SUPERVISOR_INTERVAL: Duration = Duration::from_secs(1);
88
89const DEFAULT_MAX_WORKER_IDLE: Duration = Duration::from_secs(60);
94const WORKER_IDLE_POLL: Duration = Duration::from_millis(500);
96
97pub const PROGRESS_CHANNEL_CAPACITY: usize = 10_000;
103
104fn try_send_progress(tx: &mpsc::Sender<ProgressUpdate>, job_id: &str, update: ProgressUpdate) {
112 if let Err(e) = tx.try_send(update) {
113 match e {
114 mpsc::error::TrySendError::Full(_) => {
115 warn!(
116 job_id,
117 capacity = PROGRESS_CHANNEL_CAPACITY,
118 "Progress channel full — dropping update (handler backpressure)"
119 );
120 }
121 mpsc::error::TrySendError::Closed(_) => {
122 }
125 }
126 }
127}
128
129pub struct ConnectionTracker {
150 pools: Mutex<HashMap<String, ServerSlot>>,
151}
152
153#[derive(Clone)]
154struct ServerSlot {
155 name: String,
156 limit: usize,
157 semaphore: Arc<tokio::sync::Semaphore>,
158 connected: Arc<AtomicUsize>,
159}
160
161impl ConnectionTracker {
162 pub fn new() -> Self {
163 Self {
164 pools: Mutex::new(HashMap::new()),
165 }
166 }
167
168 pub fn set_limit(&self, server_id: &str, server_name: &str, limit: usize) {
176 let mut pools = self.pools.lock();
177 match pools.get_mut(server_id) {
178 Some(slot) if slot.limit == limit && slot.name == server_name => {
179 }
181 Some(slot) if limit > slot.limit && slot.name == server_name => {
182 let added = limit - slot.limit;
183 let old = slot.limit;
184 slot.semaphore.add_permits(added);
185 slot.limit = limit;
186 info!(
187 server_id,
188 server = %server_name,
189 old_limit = old,
190 new_limit = limit,
191 added,
192 "Connection pool grew in place"
193 );
194 }
195 existing => {
196 let (prev_limit, prev_name) = match existing {
198 Some(s) => (Some(s.limit), Some(s.name.clone())),
199 None => (None, None),
200 };
201 pools.insert(
202 server_id.to_string(),
203 ServerSlot {
204 name: server_name.to_string(),
205 limit,
206 semaphore: Arc::new(tokio::sync::Semaphore::new(limit)),
207 connected: Arc::new(AtomicUsize::new(0)),
208 },
209 );
210 if let Some(prev) = prev_limit {
211 let renamed = prev_name.as_deref() != Some(server_name);
212 info!(
213 server_id,
214 server = %server_name,
215 old_limit = prev,
216 new_limit = limit,
217 renamed,
218 "Connection pool replaced (shrink or rename); old permits orphaned"
219 );
220 } else {
221 info!(
222 server_id,
223 server = %server_name,
224 limit,
225 "Connection pool created"
226 );
227 }
228 }
229 }
230 }
231
232 pub fn remove_server(&self, server_id: &str) {
236 self.pools.lock().remove(server_id);
237 }
238
239 pub async fn acquire(&self, server_id: &str) -> Option<ConnectionSlot> {
243 let server_slot = {
245 let pools = self.pools.lock();
246 pools.get(server_id).cloned()?
247 };
248 if server_slot.limit == 0 {
249 return None;
250 }
251 let permit = Arc::clone(&server_slot.semaphore)
252 .acquire_owned()
253 .await
254 .ok()?;
255 Some(ConnectionSlot {
256 server_id: server_id.to_string(),
257 server_name: server_slot.name,
258 semaphore_origin: server_slot.semaphore,
259 connected: server_slot.connected,
260 is_connected: false,
261 _permit: permit,
262 })
263 }
264
265 pub fn slot_is_current(&self, slot: &ConnectionSlot) -> bool {
270 matches!(self.slot_status(slot), SlotStatus::Current)
271 }
272
273 pub fn slot_status(&self, slot: &ConnectionSlot) -> SlotStatus {
276 let pools = self.pools.lock();
277 match pools.get(&slot.server_id) {
278 Some(server_slot) => {
279 if Arc::ptr_eq(&server_slot.semaphore, &slot.semaphore_origin) {
280 SlotStatus::Current
281 } else {
282 SlotStatus::PoolReplaced
283 }
284 }
285 None => SlotStatus::ServerRemoved,
286 }
287 }
288
289 pub fn snapshot(&self) -> Vec<(String, usize, usize)> {
293 let pools = self.pools.lock();
294 pools
295 .iter()
296 .map(|(id, slot)| {
297 let active = slot
298 .limit
299 .saturating_sub(slot.semaphore.available_permits());
300 (id.clone(), active, slot.limit)
301 })
302 .collect()
303 }
304
305 pub fn connected_snapshot(&self) -> Vec<(String, usize, usize)> {
308 let pools = self.pools.lock();
309 pools
310 .iter()
311 .map(|(id, slot)| {
312 (
313 id.clone(),
314 slot.connected.load(Ordering::Relaxed).min(slot.limit),
315 slot.limit,
316 )
317 })
318 .collect()
319 }
320
321 pub fn total(&self) -> usize {
325 let pools = self.pools.lock();
326 pools
327 .values()
328 .map(|s| s.limit.saturating_sub(s.semaphore.available_permits()))
329 .sum()
330 }
331}
332
333impl Default for ConnectionTracker {
334 fn default() -> Self {
335 Self::new()
336 }
337}
338
339#[derive(Debug, Clone, Copy, PartialEq, Eq)]
341pub enum SlotStatus {
342 Current,
344 PoolReplaced,
347 ServerRemoved,
349}
350
351pub struct ConnectionSlot {
356 server_id: String,
357 server_name: String,
358 semaphore_origin: Arc<tokio::sync::Semaphore>,
362 connected: Arc<AtomicUsize>,
363 is_connected: bool,
364 _permit: tokio::sync::OwnedSemaphorePermit,
365}
366
367impl ConnectionSlot {
368 pub fn server_id(&self) -> &str {
369 &self.server_id
370 }
371 pub fn server_name(&self) -> &str {
372 &self.server_name
373 }
374
375 fn mark_active(&mut self) {
376 if !self.is_connected {
377 self.connected.fetch_add(1, Ordering::Relaxed);
378 self.is_connected = true;
379 }
380 }
381
382 fn mark_inactive(&mut self) {
383 if self.is_connected {
384 self.connected.fetch_sub(1, Ordering::Relaxed);
385 self.is_connected = false;
386 }
387 }
388
389 fn activity(&mut self) -> ConnectionActivity<'_> {
390 self.mark_active();
391 ConnectionActivity { slot: self }
392 }
393}
394
395impl Drop for ConnectionSlot {
396 fn drop(&mut self) {
397 self.mark_inactive();
398 }
399}
400
401struct ConnectionActivity<'a> {
405 slot: &'a mut ConnectionSlot,
406}
407
408impl Drop for ConnectionActivity<'_> {
409 fn drop(&mut self) {
410 self.slot.mark_inactive();
411 }
412}
413
414#[derive(Debug)]
419pub struct ServerHealth {
420 pub consecutive_failures: u32,
421 pub disabled_until: Option<Instant>,
422 pub reason: Option<String>,
423 pub is_auth_failure: bool,
424}
425
426impl Default for ServerHealth {
427 fn default() -> Self {
428 Self::new()
429 }
430}
431
432impl ServerHealth {
433 pub fn new() -> Self {
434 Self {
435 consecutive_failures: 0,
436 disabled_until: None,
437 reason: None,
438 is_auth_failure: false,
439 }
440 }
441
442 pub fn is_available(&self) -> bool {
443 match self.disabled_until {
444 None => true,
445 Some(until) => Instant::now() >= until,
446 }
447 }
448
449 pub fn record_failure(&mut self, is_auth: bool, reason: &str) {
450 self.consecutive_failures += 1;
451 self.is_auth_failure = is_auth;
452 self.reason = Some(reason.to_string());
453
454 if is_auth || self.consecutive_failures >= CIRCUIT_BREAK_THRESHOLD {
455 let cooldown = if is_auth {
456 AUTH_FAILURE_COOLDOWN
457 } else {
458 TRANSIENT_FAILURE_COOLDOWN
459 };
460 self.disabled_until = Some(Instant::now() + cooldown);
461 }
462 }
463
464 pub fn record_success(&mut self) {
465 *self = Self::new();
466 }
467}
468
469pub type ServerHealthMap = Arc<Mutex<HashMap<String, ServerHealth>>>;
470
471#[derive(Debug, Clone)]
476pub enum ProgressUpdate {
477 WaitingForProviders { job_id: String, message: String },
480 ProvidersAvailable { job_id: String },
483 ArticleComplete {
484 job_id: String,
485 file_id: String,
486 segment_number: u32,
487 decoded_bytes: u64,
488 file_complete: bool,
489 server_id: Option<String>,
490 yenc_filename: Option<String>,
493 },
494 ArticleFailed {
498 job_id: String,
499 file_id: String,
500 segment_number: u32,
501 failure: crate::article_failure::ArticleFailure,
502 },
503 JobFinished {
504 job_id: String,
505 success: bool,
506 articles_failed: usize,
507 download_time_secs: f64,
508 },
509 JobAborted {
510 job_id: String,
511 reason: String,
512 articles_failed: usize,
513 download_time_secs: f64,
514 },
515}
516
517#[derive(Debug, Clone)]
522pub(crate) struct WorkItem {
523 pub(crate) job_id: String,
524 pub(crate) file_id: String,
525 pub(crate) filename: String,
526 pub(crate) message_id: String,
527 pub(crate) segment_number: u32,
528 pub(crate) tried_servers: Vec<String>,
530 pub(crate) provider_outcomes: HashMap<String, crate::article_failure::ArticleFailureKind>,
533 pub(crate) tries_on_current: u32,
535 pub(crate) outage_tries: u32,
538}
539
540pub(crate) struct JobContext {
550 pub job_id: String,
551 pub work_dir: PathBuf,
552 pub assembler: Arc<FileAssembler>,
553 pub progress_tx: mpsc::Sender<ProgressUpdate>,
554 pub yenc_names: Arc<Mutex<HashMap<String, String>>>,
555 pub nzb_filenames: HashMap<String, String>,
556 pub articles_remaining: AtomicUsize,
559 pub articles_failed: AtomicUsize,
560 pub paused: AtomicBool,
561 pub cancelled: AtomicBool,
562 pub abort_reason: Mutex<Option<String>>,
566 pub total_decode_us: Arc<AtomicU64>,
567 pub total_assemble_us: Arc<AtomicU64>,
568 pub total_articles_decoded: Arc<AtomicU64>,
569 pub engine_start: Instant,
570 active_started: Mutex<Option<Instant>>,
571 active_elapsed: Mutex<Duration>,
572 pub total_bytes: u64,
574 pinned_file_sizes: Mutex<HashMap<String, u64>>,
579 finished: AtomicBool,
581 waiting_for_providers_reported: AtomicBool,
584}
585
586pub(crate) type JobContextMap = Arc<Mutex<HashMap<String, Arc<JobContext>>>>;
587
588impl JobContext {
589 fn new(
590 job: &NzbJob,
591 assembler: Arc<FileAssembler>,
592 progress_tx: mpsc::Sender<ProgressUpdate>,
593 total_articles: usize,
594 ) -> Self {
595 let nzb_filenames = job
596 .files
597 .iter()
598 .map(|f| (f.id.clone(), f.filename.clone()))
599 .collect();
600 Self {
601 job_id: job.id.clone(),
602 work_dir: job.work_dir.clone(),
603 assembler,
604 progress_tx,
605 yenc_names: Arc::new(Mutex::new(HashMap::new())),
606 nzb_filenames,
607 articles_remaining: AtomicUsize::new(total_articles),
608 articles_failed: AtomicUsize::new(0),
609 paused: AtomicBool::new(false),
610 cancelled: AtomicBool::new(false),
611 abort_reason: Mutex::new(None),
612 total_decode_us: Arc::new(AtomicU64::new(0)),
613 total_assemble_us: Arc::new(AtomicU64::new(0)),
614 total_articles_decoded: Arc::new(AtomicU64::new(0)),
615 engine_start: Instant::now(),
616 active_started: Mutex::new(Some(Instant::now())),
617 active_elapsed: Mutex::new(Duration::ZERO),
618 total_bytes: job.total_bytes,
619 pinned_file_sizes: Mutex::new(HashMap::new()),
620 finished: AtomicBool::new(false),
621 waiting_for_providers_reported: AtomicBool::new(false),
622 }
623 }
624
625 fn report_waiting_for_providers(&self, message: &str) {
626 if self
627 .waiting_for_providers_reported
628 .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed)
629 .is_ok()
630 {
631 try_send_progress(
632 &self.progress_tx,
633 &self.job_id,
634 ProgressUpdate::WaitingForProviders {
635 job_id: self.job_id.clone(),
636 message: message.to_string(),
637 },
638 );
639 }
640 }
641
642 fn report_providers_available(&self) {
643 if self
644 .waiting_for_providers_reported
645 .swap(false, Ordering::Relaxed)
646 {
647 try_send_progress(
648 &self.progress_tx,
649 &self.job_id,
650 ProgressUpdate::ProvidersAvailable {
651 job_id: self.job_id.clone(),
652 },
653 );
654 }
655 }
656
657 fn pause_clock(&self) {
658 let mut started = self.active_started.lock();
659 if let Some(at) = started.take() {
660 *self.active_elapsed.lock() += at.elapsed();
661 }
662 }
663
664 fn resume_clock(&self) {
665 let mut started = self.active_started.lock();
666 if started.is_none() {
667 *started = Some(Instant::now());
668 }
669 }
670
671 fn active_elapsed(&self) -> Duration {
672 let elapsed = *self.active_elapsed.lock();
673 elapsed
674 + self
675 .active_started
676 .lock()
677 .map_or(Duration::ZERO, |at| at.elapsed())
678 }
679
680 fn resolve_one(&self) {
683 let prev = self.articles_remaining.fetch_sub(1, Ordering::Relaxed);
684 if prev != 1 {
685 return;
686 }
687 self.emit_terminal();
688 }
689
690 fn emit_terminal(&self) {
693 if self.finished.swap(true, Ordering::Relaxed) {
694 return;
695 }
696
697 self.deobfuscate_files();
700
701 let download_elapsed = self.engine_start.elapsed();
702 let active_elapsed = self.active_elapsed();
703 let decode_total_us = self.total_decode_us.load(Ordering::Relaxed);
704 let assemble_total_us = self.total_assemble_us.load(Ordering::Relaxed);
705 let articles_decoded = self.total_articles_decoded.load(Ordering::Relaxed);
706 let elapsed_us = download_elapsed.as_micros().max(1);
707 let throughput_mbps = (self.total_bytes as f64 / download_elapsed.as_secs_f64().max(0.001))
708 / (1024.0 * 1024.0);
709 info!(
710 job_id = %self.job_id,
711 elapsed_secs = download_elapsed.as_secs_f64(),
712 total_bytes = self.total_bytes,
713 throughput_mbps = format!("{throughput_mbps:.2}"),
714 "Download phase complete"
715 );
716 info!(
717 job_id = %self.job_id,
718 articles_decoded,
719 decode_secs = format!("{:.3}", decode_total_us as f64 / 1_000_000.0),
720 assemble_secs = format!("{:.3}", assemble_total_us as f64 / 1_000_000.0),
721 decode_pct = format!("{:.1}", decode_total_us as f64 / elapsed_us as f64 * 100.0),
722 assemble_pct = format!("{:.1}", assemble_total_us as f64 / elapsed_us as f64 * 100.0),
723 "Decode timing summary (cumulative across all workers)"
724 );
725
726 let abort_reason = self.abort_reason.lock().clone();
727 if let Some(reason) = abort_reason {
728 let failed = self.articles_failed.load(Ordering::Relaxed);
729 try_send_progress(
730 &self.progress_tx,
731 &self.job_id,
732 ProgressUpdate::JobAborted {
733 job_id: self.job_id.clone(),
734 reason,
735 articles_failed: failed,
736 download_time_secs: active_elapsed.as_secs_f64(),
737 },
738 );
739 return;
740 }
741
742 let failed = self.articles_failed.load(Ordering::Relaxed);
743 try_send_progress(
744 &self.progress_tx,
745 &self.job_id,
746 ProgressUpdate::JobFinished {
747 job_id: self.job_id.clone(),
748 success: failed == 0,
749 articles_failed: failed,
750 download_time_secs: active_elapsed.as_secs_f64(),
751 },
752 );
753 }
754
755 fn deobfuscate_files(&self) {
758 let renames = self.yenc_names.lock();
759 for (file_id, yenc_name) in renames.iter() {
760 let Some(nzb_name) = self.nzb_filenames.get(file_id) else {
761 continue;
762 };
763 if nzb_name == yenc_name {
764 continue;
765 }
766 let clean_yenc = std::path::Path::new(yenc_name.as_str())
767 .file_name()
768 .and_then(|n| n.to_str())
769 .unwrap_or(yenc_name);
770 if clean_yenc.is_empty() || nzb_name == clean_yenc {
771 continue;
772 }
773
774 let nzb_has_ext = has_known_extension(nzb_name);
775 let yenc_has_ext = has_known_extension(clean_yenc);
776
777 let (old_name, new_name) = if yenc_has_ext && !nzb_has_ext {
778 (nzb_name.as_str(), clean_yenc)
779 } else if nzb_has_ext && !yenc_has_ext {
780 continue;
781 } else if yenc_has_ext && nzb_has_ext {
782 (nzb_name.as_str(), clean_yenc)
783 } else {
784 continue;
785 };
786
787 let old_path = self.work_dir.join(old_name);
788 let new_path = self.work_dir.join(new_name);
789 if old_path.exists() && !new_path.exists() {
790 if let Err(e) = std::fs::rename(&old_path, &new_path) {
791 warn!(
792 job_id = %self.job_id,
793 from = %old_name,
794 to = %new_name,
795 "Failed to deobfuscate file: {e}"
796 );
797 } else {
798 info!(
799 job_id = %self.job_id,
800 from = %old_name,
801 to = %new_name,
802 "Deobfuscated file"
803 );
804 }
805 }
806 }
807 }
808}
809
810pub(crate) struct SharedWorkQueue {
823 inner: Mutex<QueueState>,
824 notify: Notify,
825}
826
827struct QueueState {
828 items: VecDeque<WorkItem>,
829 last_served: HashMap<String, String>,
832}
833
834impl SharedWorkQueue {
835 pub fn new() -> Self {
836 Self {
837 inner: Mutex::new(QueueState {
838 items: VecDeque::new(),
839 last_served: HashMap::new(),
840 }),
841 notify: Notify::new(),
842 }
843 }
844
845 pub fn submit_items(&self, mut items: Vec<WorkItem>) {
849 items.sort_by_key(|item| par2_sort_key(&item.filename));
850 let had_items = !items.is_empty();
851 {
852 let mut q = self.inner.lock();
853 q.items.reserve(items.len());
854 for item in items {
855 q.items.push_back(item);
856 }
857 }
858 if had_items {
859 self.notify.notify_waiters();
860 }
861 }
862
863 fn push_front(&self, item: WorkItem) {
867 self.inner.lock().items.push_front(item);
868 self.notify.notify_waiters();
869 }
870
871 fn push_back(&self, item: WorkItem) {
874 self.inner.lock().items.push_back(item);
875 self.notify.notify_waiters();
876 }
877
878 pub(crate) fn workable_count_for(
893 &self,
894 server_id: &str,
895 higher_priority_servers: &[String],
896 ) -> (usize, usize) {
897 let q = self.inner.lock();
898 let total = q.items.len();
899 let workable = q
900 .items
901 .iter()
902 .filter(|i| !i.tried_servers.iter().any(|s| s == server_id))
903 .filter(|i| {
904 higher_priority_servers
905 .iter()
906 .all(|hp| i.tried_servers.contains(hp))
907 })
908 .count();
909 (workable, total)
910 }
911
912 fn pop_workable(
929 &self,
930 server_id: &str,
931 higher_priority_servers: &[String],
932 ) -> Option<WorkItem> {
933 let mut state = self.inner.lock();
934 let last_served = state.last_served.get(server_id).cloned();
935 let is_eligible = |item: &WorkItem| {
936 !item.tried_servers.iter().any(|s| s == server_id)
937 && higher_priority_servers
938 .iter()
939 .all(|server| item.tried_servers.contains(server))
940 };
941
942 let selected = last_served
943 .as_deref()
944 .and_then(|last_job| {
945 state
946 .items
947 .iter()
948 .position(|item| is_eligible(item) && item.job_id != last_job)
949 })
950 .or_else(|| state.items.iter().position(is_eligible));
951
952 if let Some(index) = selected {
953 let item = state.items.remove(index)?;
954 state
955 .last_served
956 .insert(server_id.to_string(), item.job_id.clone());
957 Some(item)
958 } else {
959 None
960 }
961 }
962
963 fn drain_job(&self, job_id: &str) -> Vec<WorkItem> {
965 let mut q = self.inner.lock();
966 let mut kept = VecDeque::with_capacity(q.items.len());
967 let mut drained = Vec::new();
968 while let Some(item) = q.items.pop_front() {
969 if item.job_id == job_id {
970 drained.push(item);
971 } else {
972 kept.push_back(item);
973 }
974 }
975 q.items = kept;
976 drained
977 }
978
979 fn take_where(&self, mut predicate: impl FnMut(&WorkItem) -> bool) -> Vec<WorkItem> {
983 let mut q = self.inner.lock();
984 let mut taken = Vec::new();
985 let mut index = 0;
986 while index < q.items.len() {
987 if predicate(&q.items[index]) {
988 taken.push(q.items.remove(index).expect("index < len"));
989 } else {
990 index += 1;
991 }
992 }
993 taken
994 }
995
996 pub fn len(&self) -> usize {
997 self.inner.lock().items.len()
998 }
999}
1000
1001impl Default for SharedWorkQueue {
1002 fn default() -> Self {
1003 Self::new()
1004 }
1005}
1006
1007struct ActiveWorker {
1012 shutdown: Arc<AtomicBool>,
1013 last_progress: Arc<AtomicU64>,
1018 handle: JoinHandle<()>,
1019}
1020
1021pub struct WorkerPool {
1023 work_queue: Arc<SharedWorkQueue>,
1024 job_contexts: JobContextMap,
1025 pub(crate) servers: Arc<Mutex<Vec<ServerConfig>>>,
1026 server_health: ServerHealthMap,
1027 bandwidth: Arc<BandwidthLimiter>,
1028 conn_tracker: Arc<ConnectionTracker>,
1029 stall_timeout: Option<Duration>,
1030 created_at: Instant,
1033 max_worker_idle: Mutex<Duration>,
1037 starvation_log: Mutex<HashMap<String, Instant>>,
1040 evictions: AtomicU64,
1044 workers: Mutex<HashMap<String, Vec<ActiveWorker>>>,
1045 shutdown: Arc<AtomicBool>,
1046 supervisor_handle: Mutex<Option<JoinHandle<()>>>,
1047}
1048
1049impl WorkerPool {
1050 pub fn new(
1051 servers: Arc<Mutex<Vec<ServerConfig>>>,
1052 bandwidth: Arc<BandwidthLimiter>,
1053 conn_tracker: Arc<ConnectionTracker>,
1054 stall_timeout_secs: u64,
1055 ) -> Arc<Self> {
1056 let stall_timeout = if stall_timeout_secs > 0 {
1057 Some(Duration::from_secs(stall_timeout_secs))
1058 } else {
1059 None
1060 };
1061 Arc::new(Self {
1062 work_queue: Arc::new(SharedWorkQueue::new()),
1063 job_contexts: Arc::new(Mutex::new(HashMap::new())),
1064 servers,
1065 server_health: Arc::new(Mutex::new(HashMap::new())),
1066 bandwidth,
1067 conn_tracker,
1068 stall_timeout,
1069 created_at: Instant::now(),
1070 max_worker_idle: Mutex::new(DEFAULT_MAX_WORKER_IDLE),
1071 starvation_log: Mutex::new(HashMap::new()),
1072 evictions: AtomicU64::new(0),
1073 workers: Mutex::new(HashMap::new()),
1074 shutdown: Arc::new(AtomicBool::new(false)),
1075 supervisor_handle: Mutex::new(None),
1076 })
1077 }
1078
1079 pub fn set_max_worker_idle(&self, d: Duration) {
1082 *self.max_worker_idle.lock() = d;
1083 }
1084
1085 pub fn max_worker_idle(&self) -> Duration {
1087 *self.max_worker_idle.lock()
1088 }
1089
1090 fn elapsed_ms(&self) -> u64 {
1093 self.created_at.elapsed().as_millis() as u64
1094 }
1095
1096 fn created_at(&self) -> Instant {
1101 self.created_at
1102 }
1103
1104 fn higher_priority_servers(&self, my_priority: u8, my_server_id: &str) -> Vec<String> {
1112 let servers = self.servers.lock();
1113 let health = self.server_health.lock();
1114 servers
1115 .iter()
1116 .filter(|s| s.enabled && s.priority < my_priority && s.id != my_server_id)
1117 .filter(|s| health.get(&s.id).is_none_or(|h| h.is_available()))
1118 .map(|s| s.id.clone())
1119 .collect()
1120 }
1121
1122 fn provider_unavailable_message(&self) -> Option<&'static str> {
1126 let servers = self.servers.lock();
1127 let enabled: Vec<_> = servers.iter().filter(|server| server.enabled).collect();
1128 if enabled.is_empty() {
1129 return Some("Waiting for providers: no enabled NNTP servers are configured.");
1130 }
1131
1132 let health = self.server_health.lock();
1133 enabled
1134 .iter()
1135 .all(|server| health.get(&server.id).is_some_and(|entry| !entry.is_available()))
1136 .then_some(
1137 "Waiting for providers: every enabled server is temporarily unavailable. rustnzb will retry automatically.",
1138 )
1139 }
1140
1141 fn report_provider_outage(&self) {
1142 let Some(message) = self.provider_unavailable_message() else {
1143 return;
1144 };
1145 let contexts: Vec<_> = self.job_contexts.lock().values().cloned().collect();
1146 for ctx in contexts {
1147 ctx.report_waiting_for_providers(message);
1148 }
1149 }
1150
1151 fn report_providers_available(&self) {
1152 let contexts: Vec<_> = self.job_contexts.lock().values().cloned().collect();
1153 for ctx in contexts {
1154 ctx.report_providers_available();
1155 }
1156 }
1157
1158 pub fn eviction_count(&self) -> u64 {
1163 self.evictions.load(Ordering::Relaxed)
1164 }
1165
1166 pub fn start(self: &Arc<Self>) {
1169 self.reconcile_servers();
1170
1171 let this = Arc::clone(self);
1172 let handle = tokio::spawn(async move {
1173 this.supervisor_loop().await;
1174 });
1175 *self.supervisor_handle.lock() = Some(handle);
1176 }
1177
1178 pub fn reconcile_servers(self: &Arc<Self>) {
1185 if self.shutdown.load(Ordering::Relaxed) {
1186 return;
1187 }
1188
1189 let servers_snapshot: Vec<ServerConfig> = self.servers.lock().clone();
1190 let mut workers = self.workers.lock();
1191
1192 let mut retire: Vec<String> = Vec::new();
1194 for key in workers.keys() {
1195 let still_active = servers_snapshot.iter().any(|s| s.enabled && &s.id == key);
1196 if !still_active {
1197 retire.push(key.clone());
1198 }
1199 }
1200 for key in retire {
1201 if let Some(list) = workers.remove(&key) {
1202 for w in list {
1203 w.shutdown.store(true, Ordering::Relaxed);
1204 drop(w.handle);
1207 }
1208 }
1209 }
1210
1211 for server in &servers_snapshot {
1213 if !server.enabled {
1214 continue;
1215 }
1216 let target = (server.connections as usize).min(500);
1217 let entry = workers.entry(server.id.clone()).or_default();
1218
1219 while entry.len() > target {
1221 if let Some(w) = entry.pop() {
1222 w.shutdown.store(true, Ordering::Relaxed);
1223 drop(w.handle);
1224 }
1225 }
1226
1227 let current = entry.len();
1229 for conn_idx in current..target {
1230 let worker_shutdown = Arc::new(AtomicBool::new(false));
1231 let last_progress = Arc::new(AtomicU64::new(self.elapsed_ms()));
1234 let pool = Arc::clone(self);
1235 let server_clone = server.clone();
1236 let ws_clone = Arc::clone(&worker_shutdown);
1237 let lp_clone = Arc::clone(&last_progress);
1238 let handle = tokio::spawn(async move {
1239 pool_worker(pool, server_clone, conn_idx, ws_clone, lp_clone).await;
1240 });
1241 entry.push(ActiveWorker {
1242 shutdown: worker_shutdown,
1243 last_progress,
1244 handle,
1245 });
1246 }
1247 }
1248 }
1249
1250 pub(crate) fn submit_job(self: &Arc<Self>, ctx: Arc<JobContext>, items: Vec<WorkItem>) {
1253 let job_id = ctx.job_id.clone();
1254 if items.is_empty() {
1255 ctx.emit_terminal();
1257 return;
1258 }
1259 self.job_contexts.lock().insert(job_id.clone(), ctx);
1260 self.work_queue.submit_items(items);
1261 self.report_provider_outage();
1262 debug!(job_id = %job_id, queue_len = self.work_queue.len(), "Job submitted to worker pool");
1263 }
1264
1265 pub(crate) fn release_completed_job(&self, job_id: &str) {
1272 let ctx = self.job_contexts.lock().remove(job_id);
1273 if let Some(ctx) = ctx {
1274 ctx.assembler.clear_job(job_id);
1279 }
1280 }
1281
1282 pub fn pause_job(&self, job_id: &str) {
1285 if let Some(ctx) = self.job_contexts.lock().get(job_id) {
1286 ctx.pause_clock();
1287 ctx.paused.store(true, Ordering::Relaxed);
1288 }
1289 }
1290
1291 pub fn resume_job(&self, job_id: &str) {
1293 if let Some(ctx) = self.job_contexts.lock().get(job_id) {
1294 ctx.resume_clock();
1295 ctx.paused.store(false, Ordering::Relaxed);
1296 self.work_queue.notify.notify_waiters();
1298 }
1299 }
1300
1301 pub fn abort_job(&self, job_id: &str, reason: String) -> bool {
1304 let ctx = self.job_contexts.lock().get(job_id).cloned();
1305 let Some(ctx) = ctx else {
1306 return false;
1307 };
1308 {
1309 let mut abort_reason = ctx.abort_reason.lock();
1310 if abort_reason.is_some() {
1311 return false;
1312 }
1313 *abort_reason = Some(reason);
1314 }
1315 ctx.cancelled.store(true, Ordering::Relaxed);
1316 let drained = self.work_queue.drain_job(job_id);
1317 for _ in drained {
1320 ctx.resolve_one();
1321 }
1322 if ctx.articles_remaining.load(Ordering::Relaxed) == 0 {
1326 ctx.emit_terminal();
1327 }
1328 true
1329 }
1330
1331 pub fn cancel_job(&self, job_id: &str) {
1335 let ctx = self.job_contexts.lock().remove(job_id);
1336 let Some(ctx) = ctx else {
1337 return;
1338 };
1339 ctx.cancelled.store(true, Ordering::Relaxed);
1340 let _ = self.work_queue.drain_job(job_id);
1341 }
1342
1343 async fn supervisor_loop(self: Arc<Self>) {
1350 let mut ticker = tokio::time::interval(SUPERVISOR_INTERVAL);
1351 loop {
1352 ticker.tick().await;
1353 if self.shutdown.load(Ordering::Relaxed) {
1354 break;
1355 }
1356
1357 let now_ms = self.elapsed_ms();
1360 let max_idle_ms = self.max_worker_idle().as_millis() as u64;
1361
1362 let server_priorities: Vec<(String, u8)> = {
1369 let srv = self.servers.lock();
1370 srv.iter()
1371 .filter(|s| s.enabled)
1372 .map(|s| (s.id.clone(), s.priority))
1373 .collect()
1374 };
1375 let has_workable: HashMap<String, bool> = server_priorities
1376 .iter()
1377 .map(|(sid, prio)| {
1378 let hp = self.higher_priority_servers(*prio, sid);
1379 let (workable, _) = self.work_queue.workable_count_for(sid, &hp);
1380 (sid.clone(), workable > 0)
1381 })
1382 .collect();
1383
1384 {
1385 let workers = self.workers.lock();
1386 for (server_id, list) in workers.iter() {
1387 for (idx, w) in list.iter().enumerate() {
1388 if w.shutdown.load(Ordering::Relaxed) {
1389 continue;
1390 }
1391 let last = w.last_progress.load(Ordering::Relaxed);
1392 let idle = now_ms.saturating_sub(last);
1393 if idle > max_idle_ms {
1394 if !has_workable.get(server_id).copied().unwrap_or(true) {
1401 continue;
1402 }
1403 warn!(
1404 server = %server_id,
1405 worker_idx = idx,
1406 idle_ms = idle,
1407 max_idle_ms,
1408 "Idle-worker watchdog: evicting stalled worker"
1409 );
1410 w.shutdown.store(true, Ordering::Relaxed);
1411 self.evictions.fetch_add(1, Ordering::Relaxed);
1412 }
1413 }
1414 }
1415 }
1416
1417 {
1419 let mut workers = self.workers.lock();
1420 for list in workers.values_mut() {
1421 list.retain(|w| !w.handle.is_finished());
1422 }
1423 }
1424 self.reconcile_servers();
1426
1427 self.resolve_items_not_waiting_on_required_providers();
1429
1430 let now_instant = Instant::now();
1437 for (sid, prio) in &server_priorities {
1438 let hp = self.higher_priority_servers(*prio, sid);
1439 let (workable, total) = self.work_queue.workable_count_for(sid, &hp);
1440 if workable == 0 && total > 0 {
1441 let mut log = self.starvation_log.lock();
1442 let should_log = log
1443 .get(sid)
1444 .map(|t| now_instant.duration_since(*t) >= Duration::from_secs(60))
1445 .unwrap_or(true);
1446 if should_log {
1447 log.insert(sid.clone(), now_instant);
1448 let reason = if hp.is_empty() {
1449 "every item has been tried here already"
1450 } else {
1451 "every item has been tried here, or is waiting for a higher-priority server"
1452 };
1453 info!(
1454 server = %sid,
1455 total_items = total,
1456 higher_priority_servers = hp.len(),
1457 "Queue has items but none are workable for this server ({reason})"
1458 );
1459 }
1460 }
1461 }
1462 }
1463 }
1464
1465 fn resolve_items_not_waiting_on_required_providers(&self) {
1474 {
1478 let servers = self.servers.lock();
1479 let health = self.server_health.lock();
1480 let optional_down = servers.iter().any(|server| {
1481 server.enabled
1482 && server.optional
1483 && health.get(&server.id).is_some_and(|h| !h.is_available())
1484 });
1485 if !optional_down {
1486 return;
1487 }
1488 }
1489 let taken = {
1490 let servers = self.servers.lock();
1491 let health = self.server_health.lock();
1492 self.work_queue.take_where(|item| {
1493 !item.provider_outcomes.is_empty()
1494 && all_enabled_providers_definitive(&servers, &health, &item.provider_outcomes)
1495 })
1496 };
1497 for item in taken {
1498 let ctx = self.job_contexts.lock().get(&item.job_id).cloned();
1499 let Some(ctx) = ctx else {
1500 continue;
1501 };
1502 if ctx.cancelled.load(Ordering::Relaxed) {
1503 ctx.resolve_one();
1504 continue;
1505 }
1506 let last_server = item.tried_servers.last().cloned().unwrap_or_default();
1507 let last_kind = item
1508 .provider_outcomes
1509 .get(&last_server)
1510 .copied()
1511 .unwrap_or(crate::article_failure::ArticleFailureKind::NotFound);
1512 fail_article_everywhere(
1513 &item,
1514 &last_server,
1515 &ctx,
1516 "supervisor",
1517 last_kind,
1518 "Article unavailable on every required server (optional servers unavailable)",
1519 );
1520 }
1521 }
1522
1523 pub async fn shutdown(self: &Arc<Self>) {
1525 self.shutdown.store(true, Ordering::Relaxed);
1526 let handles: Vec<JoinHandle<()>> = {
1527 let mut workers = self.workers.lock();
1528 let mut out = Vec::new();
1529 for (_id, list) in workers.drain() {
1530 for w in list {
1531 w.shutdown.store(true, Ordering::Relaxed);
1532 out.push(w.handle);
1533 }
1534 }
1535 out
1536 };
1537 self.work_queue.notify.notify_waiters();
1539
1540 await_worker_shutdown(handles, Duration::from_secs(10)).await;
1541
1542 if let Some(h) = self.supervisor_handle.lock().take() {
1543 h.abort();
1544 }
1545 }
1546
1547 pub fn conn_tracker(&self) -> &Arc<ConnectionTracker> {
1548 &self.conn_tracker
1549 }
1550
1551 pub fn has_job(&self, job_id: &str) -> bool {
1553 self.job_contexts.lock().contains_key(job_id)
1554 }
1555
1556 pub fn job_active_secs(&self, job_id: &str) -> Option<f64> {
1558 self.job_contexts
1559 .lock()
1560 .get(job_id)
1561 .map(|ctx| ctx.active_elapsed().as_secs_f64())
1562 }
1563}
1564
1565async fn await_worker_shutdown(mut handles: Vec<JoinHandle<()>>, timeout: Duration) {
1566 let deadline = tokio::time::Instant::now() + timeout;
1567 for handle in &mut handles {
1568 if tokio::time::timeout_at(deadline, &mut *handle)
1569 .await
1570 .is_err()
1571 {
1572 break;
1573 }
1574 }
1575 for handle in handles {
1576 if !handle.is_finished() {
1577 handle.abort();
1578 }
1579 }
1580}
1581
1582async fn pool_worker(
1595 pool: Arc<WorkerPool>,
1596 primary_server: ServerConfig,
1597 conn_idx: usize,
1598 worker_shutdown: Arc<AtomicBool>,
1599 last_progress: Arc<AtomicU64>,
1600) {
1601 let worker_id = format!("{}#{}", primary_server.id, conn_idx);
1602
1603 if conn_idx > 0 {
1605 let stagger = WORKER_RAMP_DELAY * conn_idx as u32;
1606 tokio::time::sleep(stagger).await;
1607 }
1608
1609 let should_exit = |worker_shutdown: &Arc<AtomicBool>, pool: &Arc<WorkerPool>| {
1610 worker_shutdown.load(Ordering::Relaxed) || pool.shutdown.load(Ordering::Relaxed)
1611 };
1612
1613 let mut conn_slot = match pool.conn_tracker.acquire(&primary_server.id).await {
1616 Some(slot) => slot,
1617 None => {
1618 info!(
1619 worker = %worker_id,
1620 server = %primary_server.name,
1621 "No connection slot available (server removed or limit=0); worker exiting"
1622 );
1623 return;
1624 }
1625 };
1626
1627 'reconnect: loop {
1628 if should_exit(&worker_shutdown, &pool) {
1629 return;
1630 }
1631
1632 match pool.conn_tracker.slot_status(&conn_slot) {
1635 SlotStatus::Current => {}
1636 SlotStatus::PoolReplaced => {
1637 info!(
1638 worker = %worker_id,
1639 server = %primary_server.name,
1640 reason = "pool_replaced",
1641 "Connection slot is stale (connection limit changed); worker exiting"
1642 );
1643 return;
1644 }
1645 SlotStatus::ServerRemoved => {
1646 info!(
1647 worker = %worker_id,
1648 server = %primary_server.name,
1649 reason = "server_removed",
1650 "Connection slot is stale (server removed from tracker); worker exiting"
1651 );
1652 return;
1653 }
1654 }
1655
1656 let circuit_broken = {
1659 let health = pool.server_health.lock();
1660 health
1661 .get(&primary_server.id)
1662 .is_some_and(|h| !h.is_available())
1663 };
1664 if circuit_broken {
1665 pool.report_provider_outage();
1666 tokio::time::sleep(WORKER_IDLE_POLL).await;
1667 continue 'reconnect;
1668 }
1669
1670 info!(
1671 worker = %worker_id,
1672 server = %primary_server.name,
1673 host = %primary_server.host,
1674 port = primary_server.port,
1675 ssl = primary_server.ssl,
1676 conn_idx,
1677 "Worker starting — connecting to primary server"
1678 );
1679
1680 let mut conn = NntpConnection::new(worker_id.clone());
1681 conn.set_io_heartbeat(last_progress.clone(), pool.created_at());
1689 if let Err(e) = connect_with_retry(
1690 &mut conn,
1691 &primary_server,
1692 &worker_id,
1693 &pool.server_health,
1694 &pool.servers,
1695 )
1696 .await
1697 {
1698 warn!(
1699 worker = %worker_id,
1700 server = %primary_server.name,
1701 host = %primary_server.host,
1702 "Worker FAILED to connect after all retries: {e}"
1703 );
1704 if should_exit(&worker_shutdown, &pool) {
1705 return;
1706 }
1707 pool.report_provider_outage();
1708 tokio::time::sleep(RECONNECT_DELAY).await;
1709 continue 'reconnect;
1710 }
1711 pool.report_providers_available();
1712 let pipe_depth = primary_server.pipelining.max(1);
1713 let active_conns = pool.conn_tracker.total();
1714 info!(
1715 worker = %worker_id,
1716 server = %primary_server.name,
1717 host = %primary_server.host,
1718 pipelining = pipe_depth,
1719 total_nntp_connections = active_conns,
1720 "Worker connected and ready"
1721 );
1722
1723 let reconnect_needed = if pipe_depth <= 1 {
1729 run_worker_serial(
1730 &pool,
1731 &primary_server,
1732 &worker_id,
1733 &worker_shutdown,
1734 &mut conn,
1735 &mut conn_slot,
1736 &last_progress,
1737 )
1738 .await
1739 } else {
1740 run_worker_pipelined(
1741 &pool,
1742 &primary_server,
1743 &worker_id,
1744 pipe_depth,
1745 &worker_shutdown,
1746 &mut conn,
1747 &mut conn_slot,
1748 &last_progress,
1749 )
1750 .await
1751 };
1752
1753 let _ = conn.quit().await;
1754 match reconnect_needed {
1755 WorkerExit::Reconnect => {
1756 continue 'reconnect;
1758 }
1759 WorkerExit::Exit => {
1760 return;
1762 }
1763 }
1764 }
1765}
1766
1767enum WorkerExit {
1768 Exit,
1770 Reconnect,
1772}
1773
1774async fn next_work_item(
1781 pool: &Arc<WorkerPool>,
1782 server_id: &str,
1783 higher_priority_servers: &[String],
1784 worker_shutdown: &Arc<AtomicBool>,
1785) -> Option<(WorkItem, Arc<JobContext>)> {
1786 loop {
1787 if worker_shutdown.load(Ordering::Relaxed) || pool.shutdown.load(Ordering::Relaxed) {
1788 return None;
1789 }
1790
1791 if let Some(item) = pool
1792 .work_queue
1793 .pop_workable(server_id, higher_priority_servers)
1794 {
1795 let ctx = pool.job_contexts.lock().get(&item.job_id).cloned();
1798 let Some(ctx) = ctx else {
1799 continue;
1800 };
1801 if ctx.cancelled.load(Ordering::Relaxed) {
1802 ctx.resolve_one();
1803 continue;
1804 }
1805 if ctx.paused.load(Ordering::Relaxed) {
1807 pool.work_queue.push_back(item);
1808 tokio::time::sleep(WORKER_IDLE_POLL).await;
1809 continue;
1810 }
1811 return Some((item, ctx));
1812 }
1813
1814 let notified = pool.work_queue.notify.notified();
1817 tokio::select! {
1818 _ = notified => {}
1819 _ = tokio::time::sleep(WORKER_IDLE_POLL) => {}
1820 }
1821 }
1822}
1823
1824async fn run_worker_serial(
1825 pool: &Arc<WorkerPool>,
1826 primary_server: &ServerConfig,
1827 worker_id: &str,
1828 worker_shutdown: &Arc<AtomicBool>,
1829 conn: &mut NntpConnection,
1830 conn_slot: &mut ConnectionSlot,
1831 last_progress: &Arc<AtomicU64>,
1832) -> WorkerExit {
1833 let mut consecutive_errors: u32 = 0;
1834
1835 loop {
1836 let server_disabled = pool
1838 .servers
1839 .lock()
1840 .iter()
1841 .find(|s| s.id == primary_server.id)
1842 .is_none_or(|s| !s.enabled);
1843 if server_disabled {
1844 info!(
1845 worker = %worker_id,
1846 server = %primary_server.name,
1847 "Server disabled, worker exiting"
1848 );
1849 return WorkerExit::Exit;
1850 }
1851 {
1852 let health = pool.server_health.lock();
1853 if let Some(h) = health.get(&primary_server.id)
1854 && !h.is_available()
1855 {
1856 info!(
1857 worker = %worker_id,
1858 server = %primary_server.name,
1859 reason = h.reason.as_deref().unwrap_or("unknown"),
1860 "Server circuit-broken, worker reconnecting after cooldown"
1861 );
1862 return WorkerExit::Reconnect;
1863 }
1864 }
1865
1866 let higher_priority_servers =
1873 pool.higher_priority_servers(primary_server.priority, &primary_server.id);
1874
1875 let Some((mut item, ctx)) = next_work_item(
1876 pool,
1877 &primary_server.id,
1878 &higher_priority_servers,
1879 worker_shutdown,
1880 )
1881 .await
1882 else {
1883 return WorkerExit::Exit;
1884 };
1885
1886 let result = {
1887 let _activity = conn_slot.activity();
1888 let fetch_fut = fetch_article_with_retry(
1889 conn,
1890 &item,
1891 &ctx.assembler,
1892 &ctx.cancelled,
1893 primary_server,
1894 worker_id,
1895 );
1896 if let Some(timeout) = pool.stall_timeout {
1897 match tokio::time::timeout(timeout, fetch_fut).await {
1898 Ok(r) => r,
1899 Err(_) => {
1900 warn!(
1901 worker = %worker_id,
1902 server = %primary_server.name,
1903 article = %item.message_id,
1904 "Connection stalled — no response within {}s, reconnecting",
1905 timeout.as_secs()
1906 );
1907 if retry_or_fail_over(
1908 item,
1909 primary_server,
1910 pool,
1911 &ctx,
1912 worker_id,
1913 crate::article_failure::ArticleFailureKind::Timeout,
1914 "connection stalled",
1915 ) {
1916 last_progress.store(pool.elapsed_ms(), Ordering::Relaxed);
1917 }
1918 return WorkerExit::Reconnect;
1919 }
1920 }
1921 } else {
1922 fetch_fut.await
1923 }
1924 };
1925
1926 match result {
1927 Ok(process_result) => {
1928 consecutive_errors = 0;
1929 ctx.total_decode_us
1930 .fetch_add(process_result.decode_us, Ordering::Relaxed);
1931 ctx.total_assemble_us
1932 .fetch_add(process_result.assemble_us, Ordering::Relaxed);
1933 ctx.total_articles_decoded.fetch_add(1, Ordering::Relaxed);
1934 if let Some(ref yname) = process_result.yenc_filename {
1935 ctx.yenc_names
1936 .lock()
1937 .entry(item.file_id.clone())
1938 .or_insert_with(|| crate::util::normalize_nfc(yname));
1939 }
1940 if let Some(n) = std::num::NonZeroU32::new(process_result.decoded_bytes as u32) {
1941 let _ = pool.bandwidth.acquire_download(n).await;
1942 }
1943 try_send_progress(
1944 &ctx.progress_tx,
1945 &item.job_id,
1946 ProgressUpdate::ArticleComplete {
1947 job_id: item.job_id.clone(),
1948 file_id: item.file_id.clone(),
1949 segment_number: item.segment_number,
1950 decoded_bytes: process_result.decoded_bytes,
1951 file_complete: process_result.file_complete,
1952 server_id: Some(primary_server.id.clone()),
1953 yenc_filename: process_result.yenc_filename.clone(),
1954 },
1955 );
1956 ctx.resolve_one();
1957 last_progress.store(pool.elapsed_ms(), Ordering::Relaxed);
1959 }
1960 Err(ArticleError::ArticleNotFound) => {
1961 if handle_article_not_available(
1962 &mut item,
1963 primary_server,
1964 &pool.servers,
1965 &pool.server_health,
1966 &ctx,
1967 &pool.work_queue,
1968 worker_id,
1969 crate::article_failure::ArticleFailureKind::NotFound,
1970 "Article not found on any server",
1971 ) {
1972 last_progress.store(pool.elapsed_ms(), Ordering::Relaxed);
1973 }
1974 }
1975 Err(ArticleError::ConnectionLost(msg)) => {
1976 increment_counter("nntp.transient_reconnects");
1977 consecutive_errors += 1;
1978 warn!(
1979 worker = %worker_id,
1980 server = %primary_server.name,
1981 host = %primary_server.host,
1982 consecutive_errors,
1983 max_reconnects = MAX_RECONNECT_ATTEMPTS,
1984 article = %item.message_id,
1985 "Connection lost: {msg}"
1986 );
1987 pool.work_queue.push_front(item);
1988 if consecutive_errors > MAX_RECONNECT_ATTEMPTS {
1989 warn!(
1990 worker = %worker_id,
1991 server = %primary_server.name,
1992 host = %primary_server.host,
1993 consecutive_errors,
1994 "Too many consecutive errors — worker reconnecting"
1995 );
1996 return WorkerExit::Reconnect;
1997 }
1998 return WorkerExit::Reconnect;
1999 }
2000 Err(ArticleError::ProviderUnavailable { kind, message }) => {
2001 increment_counter("nntp.transient_reconnects");
2002 let is_auth = matches!(
2003 kind,
2004 crate::article_failure::ArticleFailureKind::AuthFailed
2005 | crate::article_failure::ArticleFailureKind::PermissionDenied
2006 );
2007 warn!(
2008 job_id = %item.job_id,
2009 file_id = %item.file_id,
2010 segment_number = item.segment_number,
2011 message_id = %item.message_id,
2012 server_id = %primary_server.id,
2013 worker_id = %worker_id,
2014 original_failure_kind = kind.as_str(),
2015 "Provider unavailable during article fetch: {message}"
2016 );
2017 pool.server_health
2018 .lock()
2019 .entry(primary_server.id.clone())
2020 .or_default()
2021 .record_failure(is_auth, &message);
2022 pool.report_provider_outage();
2023 pool.work_queue.push_front(item);
2024 return WorkerExit::Reconnect;
2025 }
2026 Err(ArticleError::ServerError { kind, message }) => {
2027 if retry_or_fail_over(item, primary_server, pool, &ctx, worker_id, kind, &message) {
2028 last_progress.store(pool.elapsed_ms(), Ordering::Relaxed);
2029 }
2030 if conn.state != ConnectionState::Ready {
2031 return WorkerExit::Reconnect;
2032 }
2033 }
2034 Err(ArticleError::DecodeError(msg)) => {
2035 if handle_article_not_available(
2036 &mut item,
2037 primary_server,
2038 &pool.servers,
2039 &pool.server_health,
2040 &ctx,
2041 &pool.work_queue,
2042 worker_id,
2043 crate::article_failure::ArticleFailureKind::DecodeError,
2044 &format!("Decode error: {msg}"),
2045 ) {
2046 last_progress.store(pool.elapsed_ms(), Ordering::Relaxed);
2047 }
2048 }
2049 Err(ArticleError::Cancelled) => {
2050 ctx.resolve_one();
2053 }
2054 Err(ArticleError::AssemblyError(msg)) => {
2055 error!(job_id = %item.job_id, file_id = %item.file_id, segment_number = item.segment_number, message_id = %item.message_id, server_id = %primary_server.id, worker_id = %worker_id, original_failure_kind = "decode_error", terminal_failure_kind = "decode_error", "Assembly error: {msg}");
2056 try_send_progress(
2057 &ctx.progress_tx,
2058 &item.job_id,
2059 ProgressUpdate::ArticleFailed {
2060 job_id: item.job_id.clone(),
2061 file_id: item.file_id.clone(),
2062 segment_number: item.segment_number,
2063 failure: crate::article_failure::ArticleFailure::decode_error(
2064 &primary_server.id,
2065 format!("Assembly error: {msg}"),
2066 ),
2067 },
2068 );
2069 ctx.articles_failed.fetch_add(1, Ordering::Relaxed);
2070 ctx.resolve_one();
2071 last_progress.store(pool.elapsed_ms(), Ordering::Relaxed);
2072 }
2073 }
2074 }
2075}
2076
2077#[allow(clippy::too_many_arguments)]
2078async fn run_worker_pipelined(
2079 pool: &Arc<WorkerPool>,
2080 primary_server: &ServerConfig,
2081 worker_id: &str,
2082 pipe_depth: u8,
2083 worker_shutdown: &Arc<AtomicBool>,
2084 conn: &mut NntpConnection,
2085 conn_slot: &mut ConnectionSlot,
2086 last_progress: &Arc<AtomicU64>,
2087) -> WorkerExit {
2088 let mut pipeline = Pipeline::new(pipe_depth);
2089 let mut in_flight_items: HashMap<u64, WorkItem> = HashMap::new();
2090 let mut next_tag: u64 = 0;
2091 let mut consecutive_errors: u32 = 0;
2092
2093 let mut perf_articles: u64 = 0;
2095 let mut perf_bytes: u64 = 0;
2096 let mut perf_queue_lock_us: u64 = 0;
2097 let mut perf_receive_us: u64 = 0;
2098 let mut perf_decode_us: u64 = 0;
2099 let mut perf_assemble_us: u64 = 0;
2100 let mut perf_bandwidth_us: u64 = 0;
2101 let mut perf_yield_us: u64 = 0;
2102 let mut perf_flush_us: u64 = 0;
2103 let mut perf_last_log = Instant::now();
2104 const PERF_LOG_INTERVAL: Duration = Duration::from_secs(10);
2105
2106 loop {
2107 if worker_shutdown.load(Ordering::Relaxed) || pool.shutdown.load(Ordering::Relaxed) {
2108 requeue_all(&mut in_flight_items, &pool.work_queue);
2109 return WorkerExit::Exit;
2110 }
2111
2112 let server_disabled = pool
2114 .servers
2115 .lock()
2116 .iter()
2117 .find(|s| s.id == primary_server.id)
2118 .is_none_or(|s| !s.enabled);
2119 if server_disabled {
2120 info!(
2121 worker = %worker_id,
2122 server = %primary_server.name,
2123 "Server disabled, worker exiting"
2124 );
2125 requeue_all(&mut in_flight_items, &pool.work_queue);
2126 return WorkerExit::Exit;
2127 }
2128 {
2129 let health = pool.server_health.lock();
2130 if let Some(h) = health.get(&primary_server.id)
2131 && !h.is_available()
2132 {
2133 info!(
2134 worker = %worker_id,
2135 server = %primary_server.name,
2136 reason = h.reason.as_deref().unwrap_or("unknown"),
2137 "Server circuit-broken, worker reconnecting after cooldown"
2138 );
2139 requeue_all(&mut in_flight_items, &pool.work_queue);
2140 return WorkerExit::Reconnect;
2141 }
2142 }
2143
2144 let higher_priority_servers =
2149 pool.higher_priority_servers(primary_server.priority, &primary_server.id);
2150
2151 while pipeline.pending_count() + pipeline.in_flight_count() < pipe_depth as usize {
2153 let lock_t = Instant::now();
2154 let item = pool
2155 .work_queue
2156 .pop_workable(&primary_server.id, &higher_priority_servers);
2157 perf_queue_lock_us += lock_t.elapsed().as_micros() as u64;
2158 let Some(item) = item else {
2159 break;
2160 };
2161 let ctx = pool.job_contexts.lock().get(&item.job_id).cloned();
2163 let Some(ctx) = ctx else {
2164 continue;
2165 };
2166 if ctx.cancelled.load(Ordering::Relaxed) {
2167 ctx.resolve_one();
2168 continue;
2169 }
2170 if ctx.paused.load(Ordering::Relaxed) {
2171 pool.work_queue.push_back(item);
2172 break;
2173 }
2174 let tag = next_tag;
2175 next_tag += 1;
2176 pipeline.submit(item.message_id.clone(), tag);
2177 in_flight_items.insert(tag, item);
2178 }
2179
2180 if pipeline.is_empty() && in_flight_items.is_empty() {
2182 let Some((first_item, ctx)) = next_work_item(
2183 pool,
2184 &primary_server.id,
2185 &higher_priority_servers,
2186 worker_shutdown,
2187 )
2188 .await
2189 else {
2190 return WorkerExit::Exit;
2191 };
2192 let _ = ctx; let tag = next_tag;
2194 next_tag += 1;
2195 pipeline.submit(first_item.message_id.clone(), tag);
2196 in_flight_items.insert(tag, first_item);
2197 }
2198
2199 let _activity = conn_slot.activity();
2200 let flush_t = Instant::now();
2201 if let Err(e) = pipeline.flush_sends(conn).await {
2202 warn!(
2203 worker = %worker_id,
2204 server = %primary_server.name,
2205 host = %primary_server.host,
2206 error = %e,
2207 in_flight = in_flight_items.len(),
2208 "Pipeline send error — re-queuing all in-flight items"
2209 );
2210 requeue_all(&mut in_flight_items, &pool.work_queue);
2211 consecutive_errors += 1;
2212 if consecutive_errors > MAX_RECONNECT_ATTEMPTS {
2213 warn!(
2214 worker = %worker_id,
2215 server = %primary_server.name,
2216 consecutive_errors,
2217 "Too many pipeline errors — worker reconnecting"
2218 );
2219 return WorkerExit::Reconnect;
2220 }
2221 tokio::time::sleep(RECONNECT_DELAY).await;
2222 return WorkerExit::Reconnect;
2223 }
2224 perf_flush_us += flush_t.elapsed().as_micros() as u64;
2225
2226 let recv_t = Instant::now();
2228 trace!(
2229 worker = %worker_id,
2230 in_flight = in_flight_items.len(),
2231 stall_timeout_secs = pool.stall_timeout.map(|d| d.as_secs()).unwrap_or(0),
2232 "Pipeline: awaiting response"
2233 );
2234 let result = if let Some(timeout) = pool.stall_timeout {
2235 match tokio::time::timeout(timeout, pipeline.receive_one(conn)).await {
2236 Ok(r) => r,
2237 Err(_) => {
2238 let elapsed_ms = recv_t.elapsed().as_millis();
2239 warn!(
2240 worker = %worker_id,
2241 server = %primary_server.name,
2242 elapsed_ms,
2243 in_flight = in_flight_items.len(),
2244 "Connection stalled — no response within {}s, reconnecting",
2245 timeout.as_secs()
2246 );
2247 requeue_all(&mut in_flight_items, &pool.work_queue);
2248 return WorkerExit::Reconnect;
2249 }
2250 }
2251 } else {
2252 pipeline.receive_one(conn).await
2253 };
2254 perf_receive_us += recv_t.elapsed().as_micros() as u64;
2255
2256 match result {
2257 Ok(Some(pipe_result)) => {
2258 let Some(mut item) = in_flight_items.remove(&pipe_result.request.tag) else {
2259 continue;
2260 };
2261 let ctx = pool.job_contexts.lock().get(&item.job_id).cloned();
2263 let Some(ctx) = ctx else {
2264 continue;
2265 };
2266 if ctx.cancelled.load(Ordering::Relaxed) {
2267 ctx.resolve_one();
2268 continue;
2269 }
2270
2271 match pipe_result.result {
2272 Ok(response) => {
2273 consecutive_errors = 0;
2274 let raw_data = response.data.unwrap_or_default();
2275 let yield_t = Instant::now();
2276 tokio::task::yield_now().await;
2277 perf_yield_us += yield_t.elapsed().as_micros() as u64;
2278 let decode_result = decode_and_assemble(
2279 &item,
2280 &raw_data,
2281 &ctx.assembler,
2282 Some(&ctx.pinned_file_sizes),
2283 );
2284 conn.release_body_buffer(raw_data);
2288 match decode_result {
2289 Ok(process_result) => {
2290 perf_decode_us += process_result.decode_us;
2291 perf_assemble_us += process_result.assemble_us;
2292 perf_bytes += process_result.decoded_bytes;
2293 perf_articles += 1;
2294 ctx.total_decode_us
2295 .fetch_add(process_result.decode_us, Ordering::Relaxed);
2296 ctx.total_assemble_us
2297 .fetch_add(process_result.assemble_us, Ordering::Relaxed);
2298 ctx.total_articles_decoded.fetch_add(1, Ordering::Relaxed);
2299 if let Some(ref yname) = process_result.yenc_filename {
2300 ctx.yenc_names
2301 .lock()
2302 .entry(item.file_id.clone())
2303 .or_insert_with(|| crate::util::normalize_nfc(yname));
2304 }
2305 let bw_t = Instant::now();
2306 if let Some(n) =
2307 std::num::NonZeroU32::new(process_result.decoded_bytes as u32)
2308 {
2309 let _ = pool.bandwidth.acquire_download(n).await;
2310 }
2311 perf_bandwidth_us += bw_t.elapsed().as_micros() as u64;
2312 try_send_progress(
2313 &ctx.progress_tx,
2314 &item.job_id,
2315 ProgressUpdate::ArticleComplete {
2316 job_id: item.job_id.clone(),
2317 file_id: item.file_id.clone(),
2318 segment_number: item.segment_number,
2319 decoded_bytes: process_result.decoded_bytes,
2320 file_complete: process_result.file_complete,
2321 server_id: Some(primary_server.id.clone()),
2322 yenc_filename: process_result.yenc_filename.clone(),
2323 },
2324 );
2325 ctx.resolve_one();
2326 last_progress.store(pool.elapsed_ms(), Ordering::Relaxed);
2328
2329 if perf_last_log.elapsed() >= PERF_LOG_INTERVAL {
2330 let elapsed = perf_last_log.elapsed().as_secs_f64();
2331 let mbps = perf_bytes as f64 / elapsed / (1024.0 * 1024.0);
2332 info!(
2333 worker = %worker_id,
2334 articles = perf_articles,
2335 throughput_mbps = format!("{mbps:.1}"),
2336 recv_ms = perf_receive_us / 1000,
2337 decode_ms = perf_decode_us / 1000,
2338 assemble_ms = perf_assemble_us / 1000,
2339 queue_lock_ms = perf_queue_lock_us / 1000,
2340 flush_ms = perf_flush_us / 1000,
2341 yield_ms = perf_yield_us / 1000,
2342 bw_wait_ms = perf_bandwidth_us / 1000,
2343 "Worker perf summary"
2344 );
2345 perf_articles = 0;
2346 perf_bytes = 0;
2347 perf_queue_lock_us = 0;
2348 perf_receive_us = 0;
2349 perf_decode_us = 0;
2350 perf_assemble_us = 0;
2351 perf_bandwidth_us = 0;
2352 perf_yield_us = 0;
2353 perf_flush_us = 0;
2354 perf_last_log = Instant::now();
2355 }
2356 }
2357 Err(ArticleError::DecodeError(msg)) => {
2358 if handle_article_not_available(
2359 &mut item,
2360 primary_server,
2361 &pool.servers,
2362 &pool.server_health,
2363 &ctx,
2364 &pool.work_queue,
2365 worker_id,
2366 crate::article_failure::ArticleFailureKind::DecodeError,
2367 &format!("Decode error: {msg}"),
2368 ) {
2369 last_progress.store(pool.elapsed_ms(), Ordering::Relaxed);
2370 }
2371 }
2372 Err(ArticleError::AssemblyError(msg)) => {
2373 error!(job_id = %item.job_id, file_id = %item.file_id, segment_number = item.segment_number, message_id = %item.message_id, server_id = %primary_server.id, worker_id = %worker_id, original_failure_kind = "decode_error", terminal_failure_kind = "decode_error", "Assembly error: {msg}");
2374 try_send_progress(
2375 &ctx.progress_tx,
2376 &item.job_id,
2377 ProgressUpdate::ArticleFailed {
2378 job_id: item.job_id.clone(),
2379 file_id: item.file_id.clone(),
2380 segment_number: item.segment_number,
2381 failure:
2382 crate::article_failure::ArticleFailure::decode_error(
2383 &primary_server.id,
2384 format!("Assembly error: {msg}"),
2385 ),
2386 },
2387 );
2388 ctx.articles_failed.fetch_add(1, Ordering::Relaxed);
2389 ctx.resolve_one();
2390 last_progress.store(pool.elapsed_ms(), Ordering::Relaxed);
2391 }
2392 Err(_) => {}
2393 }
2394 }
2395 Err(
2396 NntpError::ArticleNotFound(_)
2397 | NntpError::NoSuchGroup(_)
2398 | NntpError::NoArticleSelected(_),
2399 ) => {
2400 if handle_article_not_available(
2403 &mut item,
2404 primary_server,
2405 &pool.servers,
2406 &pool.server_health,
2407 &ctx,
2408 &pool.work_queue,
2409 worker_id,
2410 crate::article_failure::ArticleFailureKind::NotFound,
2411 "Article not found on any server",
2412 ) {
2413 last_progress.store(pool.elapsed_ms(), Ordering::Relaxed);
2414 }
2415 }
2416 Err(NntpError::Connection(_) | NntpError::Io(_)) => {
2417 increment_counter("nntp.transient_reconnects");
2418 warn!(
2419 job_id = %item.job_id,
2420 file_id = %item.file_id,
2421 segment_number = item.segment_number,
2422 worker = %worker_id,
2423 server = %primary_server.name,
2424 host = %primary_server.host,
2425 article = %item.message_id,
2426 in_flight = in_flight_items.len(),
2427 consecutive_errors,
2428 "Pipeline: connection lost during receive — re-queuing all"
2429 );
2430 pool.work_queue.push_front(item);
2431 requeue_all(&mut in_flight_items, &pool.work_queue);
2432 consecutive_errors += 1;
2433 if consecutive_errors > MAX_RECONNECT_ATTEMPTS {
2434 return WorkerExit::Reconnect;
2435 }
2436 tokio::time::sleep(RECONNECT_DELAY).await;
2437 return WorkerExit::Reconnect;
2438 }
2439 Err(e) => {
2440 increment_counter("nntp.transient_reconnects");
2441 let failure = crate::article_failure::ArticleFailure::from_nntp(
2442 &e,
2443 &primary_server.id,
2444 );
2445 warn!(
2446 job_id = %item.job_id,
2447 file_id = %item.file_id,
2448 segment_number = item.segment_number,
2449 message_id = %item.message_id,
2450 server_id = %primary_server.id,
2451 worker_id = %worker_id,
2452 original_failure_kind = failure.kind.as_str(),
2453 attempt = item.tries_on_current + 1,
2454 "Transient pipeline error — re-queuing in-flight work and reconnecting: {e}"
2455 );
2456 if counts_against_circuit_breaker(&e) {
2463 let is_auth = matches!(
2464 failure.kind,
2465 crate::article_failure::ArticleFailureKind::AuthFailed
2466 | crate::article_failure::ArticleFailureKind::PermissionDenied
2467 );
2468 pool.server_health
2469 .lock()
2470 .entry(primary_server.id.clone())
2471 .or_default()
2472 .record_failure(is_auth, &e.to_string());
2473 pool.report_provider_outage();
2474 }
2475 let server_wide = matches!(
2484 failure.kind,
2485 crate::article_failure::ArticleFailureKind::ServerDown
2486 | crate::article_failure::ArticleFailureKind::AuthFailed
2487 | crate::article_failure::ArticleFailureKind::PermissionDenied
2488 );
2489 let article_outage = matches!(
2490 failure.kind,
2491 crate::article_failure::ArticleFailureKind::Timeout
2492 | crate::article_failure::ArticleFailureKind::ConnectionClosed
2493 | crate::article_failure::ArticleFailureKind::Other
2494 );
2495 if server_wide {
2496 pool.work_queue.push_front(item);
2497 } else if article_outage {
2498 item.outage_tries += 1;
2499 if item.outage_tries < MAX_OUTAGE_TRIES_PER_SERVER {
2500 pool.work_queue.push_front(item);
2501 } else if retry_or_fail_over(
2502 item,
2503 primary_server,
2504 pool,
2505 &ctx,
2506 worker_id,
2507 failure.kind,
2508 &e.to_string(),
2509 ) {
2510 last_progress.store(pool.elapsed_ms(), Ordering::Relaxed);
2511 }
2512 } else if retry_or_fail_over(
2513 item,
2514 primary_server,
2515 pool,
2516 &ctx,
2517 worker_id,
2518 failure.kind,
2519 &e.to_string(),
2520 ) {
2521 last_progress.store(pool.elapsed_ms(), Ordering::Relaxed);
2522 }
2523 requeue_all(&mut in_flight_items, &pool.work_queue);
2524 return WorkerExit::Reconnect;
2525 }
2526 }
2527 }
2528 Ok(None) => {
2529 }
2531 Err(e) => {
2532 warn!(
2533 worker = %worker_id,
2534 server = %primary_server.name,
2535 host = %primary_server.host,
2536 error = %e,
2537 in_flight = in_flight_items.len(),
2538 consecutive_errors,
2539 "Pipeline receive error"
2540 );
2541 requeue_all(&mut in_flight_items, &pool.work_queue);
2542 consecutive_errors += 1;
2543 if consecutive_errors > MAX_RECONNECT_ATTEMPTS {
2544 return WorkerExit::Reconnect;
2545 }
2546 tokio::time::sleep(RECONNECT_DELAY).await;
2547 return WorkerExit::Reconnect;
2548 }
2549 }
2550 }
2551}
2552
2553async fn connect_with_retry(
2558 conn: &mut NntpConnection,
2559 server: &ServerConfig,
2560 worker_id: &str,
2561 server_health: &ServerHealthMap,
2562 all_servers: &Arc<Mutex<Vec<ServerConfig>>>,
2563) -> Result<(), String> {
2564 for attempt in 1..=MAX_RECONNECT_ATTEMPTS {
2565 {
2566 let health = server_health.lock();
2567 if let Some(h) = health.get(&server.id)
2568 && !h.is_available()
2569 {
2570 return Err(format!(
2571 "Server circuit-broken: {}",
2572 h.reason.as_deref().unwrap_or("unknown")
2573 ));
2574 }
2575 }
2576
2577 let current_config = all_servers
2578 .lock()
2579 .iter()
2580 .find(|s| s.id == server.id)
2581 .cloned()
2582 .unwrap_or_else(|| server.clone());
2583
2584 info!(
2585 worker = %worker_id,
2586 server = %current_config.name,
2587 host = %current_config.host,
2588 port = current_config.port,
2589 attempt,
2590 max_attempts = MAX_RECONNECT_ATTEMPTS,
2591 "Connect attempt starting"
2592 );
2593 match conn.connect(¤t_config).await {
2594 Ok(()) => {
2595 info!(
2596 worker = %worker_id,
2597 server = %current_config.name,
2598 host = %current_config.host,
2599 attempt,
2600 "Connect attempt succeeded"
2601 );
2602 server_health
2603 .lock()
2604 .entry(server.id.clone())
2605 .or_default()
2606 .record_success();
2607 return Ok(());
2608 }
2609 Err(e) => {
2610 let is_auth = matches!(e, NntpError::Auth(_) | NntpError::ServiceUnavailable(_));
2611 {
2612 let mut health = server_health.lock();
2613 let entry = health.entry(server.id.clone()).or_default();
2614 entry.record_failure(is_auth, &e.to_string());
2615 if !entry.is_available() {
2616 warn!(
2617 worker = %worker_id,
2618 server = %current_config.name,
2619 host = %current_config.host,
2620 error = %e,
2621 cooldown_secs = if is_auth { AUTH_FAILURE_COOLDOWN.as_secs() } else { TRANSIENT_FAILURE_COOLDOWN.as_secs() },
2622 "Server circuit-broken — stopping all connection attempts"
2623 );
2624 return Err(format!("Server circuit-broken: {e}"));
2625 }
2626 }
2627
2628 warn!(
2629 worker = %worker_id,
2630 server = %current_config.name,
2631 host = %current_config.host,
2632 attempt,
2633 max_attempts = MAX_RECONNECT_ATTEMPTS,
2634 error = %e,
2635 is_auth,
2636 "Connect attempt FAILED: {e}"
2637 );
2638
2639 if is_auth {
2640 return Err(format!("Auth/permission failure: {e}"));
2641 }
2642
2643 if attempt < MAX_RECONNECT_ATTEMPTS {
2644 info!(
2645 worker = %worker_id,
2646 server = %current_config.name,
2647 delay_secs = RECONNECT_DELAY.as_secs(),
2648 "Waiting before retry"
2649 );
2650 tokio::time::sleep(RECONNECT_DELAY).await;
2651 *conn = NntpConnection::new(worker_id.to_string());
2652 } else {
2653 return Err(format!(
2654 "All {MAX_RECONNECT_ATTEMPTS} connect attempts failed: {e}"
2655 ));
2656 }
2657 }
2658 }
2659 }
2660 Err("Connect retry loop exited unexpectedly".into())
2661}
2662
2663#[allow(clippy::too_many_arguments)]
2675fn handle_article_not_available(
2676 item: &mut WorkItem,
2677 primary_server: &ServerConfig,
2678 all_servers: &Arc<Mutex<Vec<ServerConfig>>>,
2679 server_health: &ServerHealthMap,
2680 ctx: &Arc<JobContext>,
2681 work_queue: &Arc<SharedWorkQueue>,
2682 worker_id: &str,
2683 kind: crate::article_failure::ArticleFailureKind,
2684 error_msg: &str,
2685) -> bool {
2686 item.provider_outcomes
2687 .insert(primary_server.id.clone(), kind);
2688 if !item.tried_servers.contains(&primary_server.id) {
2689 item.tried_servers.push(primary_server.id.clone());
2690 }
2691 item.tries_on_current = 0;
2692 item.outage_tries = 0;
2693
2694 let all_definitive = all_enabled_providers_definitive(
2695 &all_servers.lock(),
2696 &server_health.lock(),
2697 &item.provider_outcomes,
2698 );
2699
2700 debug!(
2701 article = %item.message_id,
2702 server = %primary_server.id,
2703 kind = kind.as_str(),
2704 tried_count = item.tried_servers.len(),
2705 all_definitive,
2706 "Article returned error on this server"
2707 );
2708
2709 if all_definitive {
2711 fail_article_everywhere(item, &primary_server.id, ctx, worker_id, kind, error_msg);
2712 true
2713 } else {
2714 work_queue.push_front(item.clone());
2722 false
2723 }
2724}
2725
2726fn retry_or_fail_over(
2734 mut item: WorkItem,
2735 primary_server: &ServerConfig,
2736 pool: &Arc<WorkerPool>,
2737 ctx: &Arc<JobContext>,
2738 worker_id: &str,
2739 kind: crate::article_failure::ArticleFailureKind,
2740 message: &str,
2741) -> bool {
2742 item.tries_on_current += 1;
2743 if item.tries_on_current < MAX_TRIES_PER_SERVER {
2744 pool.work_queue.push_front(item);
2745 return false;
2746 }
2747 handle_article_not_available(
2748 &mut item,
2749 primary_server,
2750 &pool.servers,
2751 &pool.server_health,
2752 ctx,
2753 &pool.work_queue,
2754 worker_id,
2755 kind,
2756 &format!(
2757 "Article failed on every server; last error after {MAX_TRIES_PER_SERVER} attempts: {message}"
2758 ),
2759 )
2760}
2761
2762fn fail_article_everywhere(
2765 item: &WorkItem,
2766 server_id: &str,
2767 ctx: &Arc<JobContext>,
2768 worker_id: &str,
2769 kind: crate::article_failure::ArticleFailureKind,
2770 error_msg: &str,
2771) {
2772 let mut provider_outcomes = item
2773 .provider_outcomes
2774 .iter()
2775 .map(|(server, outcome)| format!("{server}={}", outcome.as_str()))
2776 .collect::<Vec<_>>();
2777 provider_outcomes.sort_unstable();
2778 let outcomes = provider_outcomes.join(",");
2779 let all_not_found = item
2780 .provider_outcomes
2781 .values()
2782 .all(|outcome| *outcome == crate::article_failure::ArticleFailureKind::NotFound);
2783 let final_kind = if all_not_found {
2784 crate::article_failure::ArticleFailureKind::NotFound
2785 } else {
2786 crate::article_failure::ArticleFailureKind::DecodeError
2787 };
2788 warn!(
2789 job_id = %item.job_id,
2790 file_id = %item.file_id,
2791 segment_number = item.segment_number,
2792 message_id = %item.message_id,
2793 server_id = %server_id,
2794 worker_id = %worker_id,
2795 original_failure_kind = kind.as_str(),
2796 terminal_failure_kind = final_kind.as_str(),
2797 attempt = item.tried_servers.len(),
2798 provider_outcomes = %outcomes,
2799 "{error_msg}"
2800 );
2801 let final_failure = if final_kind == crate::article_failure::ArticleFailureKind::DecodeError {
2802 crate::article_failure::ArticleFailure::decode_error(
2803 server_id,
2804 format!("{error_msg}; provider outcomes: {outcomes}"),
2805 )
2806 } else {
2807 increment_counter("articles.explicit_global_absence");
2808 crate::article_failure::ArticleFailure::not_found_anywhere(server_id, outcomes)
2809 };
2810 try_send_progress(
2811 &ctx.progress_tx,
2812 &item.job_id,
2813 ProgressUpdate::ArticleFailed {
2814 job_id: item.job_id.clone(),
2815 file_id: item.file_id.clone(),
2816 segment_number: item.segment_number,
2817 failure: final_failure,
2818 },
2819 );
2820 ctx.articles_failed.fetch_add(1, Ordering::Relaxed);
2821 ctx.resolve_one();
2822}
2823
2824fn all_enabled_providers_definitive(
2833 servers: &[ServerConfig],
2834 health: &HashMap<String, ServerHealth>,
2835 outcomes: &HashMap<String, crate::article_failure::ArticleFailureKind>,
2836) -> bool {
2837 servers
2838 .iter()
2839 .filter(|server| server.enabled)
2840 .filter(|server| {
2841 !(server.optional && health.get(&server.id).is_some_and(|h| !h.is_available()))
2842 })
2843 .all(|server| outcomes.contains_key(&server.id))
2844}
2845
2846fn counts_against_circuit_breaker(e: &NntpError) -> bool {
2852 matches!(
2853 e,
2854 NntpError::Connection(_)
2855 | NntpError::Io(_)
2856 | NntpError::Tls(_)
2857 | NntpError::Auth(_)
2858 | NntpError::AuthRequired(_)
2859 | NntpError::PermissionDenied(_)
2860 | NntpError::ServiceUnavailable(_)
2861 | NntpError::Timeout(_)
2862 )
2863}
2864
2865fn requeue_all(in_flight: &mut HashMap<u64, WorkItem>, work_queue: &Arc<SharedWorkQueue>) {
2867 let items: Vec<WorkItem> = in_flight.drain().map(|(_, item)| item).collect();
2868 for item in items {
2869 work_queue.push_front(item);
2870 }
2871}
2872
2873fn par2_sort_key(filename: &str) -> u8 {
2876 let lower = filename.to_lowercase();
2877 if lower.ends_with(".par2") {
2878 if lower.contains(".vol") { 1 } else { 0 }
2879 } else {
2880 2
2881 }
2882}
2883
2884pub fn has_known_extension(name: &str) -> bool {
2885 let lower = name.to_lowercase();
2886 if let Some(dot_pos) = lower.rfind('.') {
2887 let ext = &lower[dot_pos + 1..];
2888 matches!(
2889 ext,
2890 "rar"
2891 | "r00"
2892 | "r01"
2893 | "r02"
2894 | "r03"
2895 | "r04"
2896 | "r05"
2897 | "zip"
2898 | "7z"
2899 | "gz"
2900 | "bz2"
2901 | "xz"
2902 | "tar"
2903 | "mkv"
2904 | "mp4"
2905 | "avi"
2906 | "wmv"
2907 | "ts"
2908 | "m4v"
2909 | "mov"
2910 | "mpg"
2911 | "mpeg"
2912 | "mp3"
2913 | "flac"
2914 | "ogg"
2915 | "m4a"
2916 | "aac"
2917 | "wav"
2918 | "srt"
2919 | "sub"
2920 | "idx"
2921 | "ass"
2922 | "ssa"
2923 | "sup"
2924 | "nfo"
2925 | "jpg"
2926 | "jpeg"
2927 | "png"
2928 | "gif"
2929 | "bmp"
2930 | "par2"
2931 | "001"
2932 | "002"
2933 | "003"
2934 | "004"
2935 | "005"
2936 )
2937 } else {
2938 false
2939 }
2940}
2941
2942pub(crate) fn build_job_submission(
2949 job: &NzbJob,
2950 progress_tx: mpsc::Sender<ProgressUpdate>,
2951 enabled_server_ids: &[String],
2952) -> (Arc<JobContext>, Vec<WorkItem>) {
2953 let assembler = Arc::new(FileAssembler::new());
2954 for file in &job.files {
2955 let output_path = job.work_dir.join(&file.filename);
2956 let completed_segments: Vec<u32> = file
2957 .articles
2958 .iter()
2959 .filter(|article| article.downloaded)
2960 .map(|article| article.segment_number)
2961 .collect();
2962 let segment_numbers: Vec<u32> = file
2965 .articles
2966 .iter()
2967 .map(|article| article.segment_number)
2968 .collect();
2969 let declared_bytes = file
2975 .articles
2976 .iter()
2977 .all(|article| article.bytes > 0)
2978 .then_some(file.bytes);
2979 if let Err(e) = assembler.register_file_with_segment_numbers(
2980 &job.id,
2981 &file.id,
2982 output_path,
2983 &segment_numbers,
2984 &completed_segments,
2985 declared_bytes,
2986 ) {
2987 error!(file = %file.filename, "Failed to register file for assembly: {e}");
2988 }
2989 }
2990
2991 let is_previously_failed = |article: &nzb_nntp::Article| {
2997 !article.downloaded
2998 && !enabled_server_ids.is_empty()
2999 && enabled_server_ids
3000 .iter()
3001 .all(|server| article.tried_servers.iter().any(|tried| tried == server))
3002 };
3003 let previously_failed = job
3004 .files
3005 .iter()
3006 .flat_map(|file| file.articles.iter())
3007 .filter(|article| is_previously_failed(article))
3008 .count();
3009
3010 let work_items: Vec<WorkItem> = job
3011 .files
3012 .iter()
3013 .flat_map(|file| {
3014 file.articles
3015 .iter()
3016 .filter(|article| !article.downloaded && !is_previously_failed(article))
3017 .map(move |article| WorkItem {
3018 job_id: job.id.clone(),
3019 file_id: file.id.clone(),
3020 filename: file.filename.clone(),
3021 message_id: article.message_id.clone(),
3022 segment_number: article.segment_number,
3023 tried_servers: article.tried_servers.clone(),
3024 provider_outcomes: HashMap::new(),
3025 tries_on_current: 0,
3026 outage_tries: 0,
3027 })
3028 })
3029 .collect();
3030
3031 let total_remaining = work_items.len();
3032 let ctx = Arc::new(JobContext::new(
3033 job,
3034 assembler,
3035 progress_tx,
3036 total_remaining,
3037 ));
3038 ctx.articles_failed
3039 .store(previously_failed, Ordering::Relaxed);
3040 (ctx, work_items)
3041}
3042
3043async fn fetch_article_with_retry(
3048 conn: &mut NntpConnection,
3049 item: &WorkItem,
3050 assembler: &FileAssembler,
3051 cancelled: &AtomicBool,
3052 _server: &ServerConfig,
3053 worker_id: &str,
3054) -> Result<ProcessResult, ArticleError> {
3055 let attempt = item.tries_on_current + 1;
3058 let fetch_start = Instant::now();
3059 match conn.fetch_article(&item.message_id).await {
3060 Ok(response) => {
3061 let fetch_us = fetch_start.elapsed().as_micros();
3062 let raw_data = response.data.unwrap_or_default();
3063 debug!(
3064 worker = %worker_id,
3065 article = %item.message_id,
3066 raw_bytes = raw_data.len(),
3067 fetch_us,
3068 "NNTP fetch complete"
3069 );
3070 if cancelled.load(Ordering::Relaxed) {
3074 conn.release_body_buffer(raw_data);
3075 return Err(ArticleError::Cancelled);
3076 }
3077 let result = decode_and_assemble(item, &raw_data, assembler, None);
3078 conn.release_body_buffer(raw_data);
3081 result
3082 }
3083 Err(NntpError::ArticleNotFound(_)) => {
3084 debug!(
3085 worker = %worker_id,
3086 article = %item.message_id,
3087 "Article not found (430) — will try next server"
3088 );
3089 Err(ArticleError::ArticleNotFound)
3090 }
3091 Err(e @ (NntpError::NoSuchGroup(_) | NntpError::NoArticleSelected(_))) => {
3092 debug!(
3095 worker = %worker_id,
3096 article = %item.message_id,
3097 error = %e,
3098 "Article unavailable on this server — will try next server"
3099 );
3100 Err(ArticleError::ArticleNotFound)
3101 }
3102 Err(e @ (NntpError::Connection(_) | NntpError::Io(_))) => {
3103 warn!(
3104 worker = %worker_id,
3105 article = %item.message_id,
3106 attempt,
3107 error = %e,
3108 conn_state = ?conn.state,
3109 "Connection/IO error during fetch — connection lost"
3110 );
3111 Err(ArticleError::ConnectionLost(format!(
3112 "Connection error on attempt {attempt}: {e}"
3113 )))
3114 }
3115 Err(e @ NntpError::Tls(_)) => {
3116 warn!(
3117 worker = %worker_id,
3118 article = %item.message_id,
3119 attempt,
3120 error = %e,
3121 "TLS error during fetch — connection lost"
3122 );
3123 Err(ArticleError::ConnectionLost(format!("TLS error: {e}")))
3124 }
3125 Err(e @ NntpError::ServiceUnavailable(_)) => {
3126 warn!(
3127 worker = %worker_id,
3128 article = %item.message_id,
3129 attempt,
3130 error = %e,
3131 "Service unavailable (502) during article fetch — likely rate limited or blocked"
3132 );
3133 Err(ArticleError::ProviderUnavailable {
3134 kind: crate::article_failure::ArticleFailureKind::ServerDown,
3135 message: e.to_string(),
3136 })
3137 }
3138 Err(e @ (NntpError::AuthRequired(_) | NntpError::Auth(_))) => {
3139 warn!(
3140 worker = %worker_id,
3141 article = %item.message_id,
3142 attempt,
3143 error = %e,
3144 "Auth required (480) during article fetch — session expired or rate limited"
3145 );
3146 Err(ArticleError::ProviderUnavailable {
3147 kind: crate::article_failure::ArticleFailureKind::AuthFailed,
3148 message: e.to_string(),
3149 })
3150 }
3151 Err(e @ NntpError::PermissionDenied(_)) => Err(ArticleError::ProviderUnavailable {
3152 kind: crate::article_failure::ArticleFailureKind::PermissionDenied,
3153 message: e.to_string(),
3154 }),
3155 Err(e) => {
3156 warn!(
3160 worker = %worker_id,
3161 article = %item.message_id,
3162 attempt,
3163 max_tries = MAX_TRIES_PER_SERVER,
3164 error = %e,
3165 "Article fetch error on this server"
3166 );
3167 Err(ArticleError::ServerError {
3168 kind: crate::article_failure::ArticleFailure::from_nntp(&e, "").kind,
3169 message: e.to_string(),
3170 })
3171 }
3172 }
3173}
3174
3175#[derive(Debug)]
3180struct ProcessResult {
3181 decoded_bytes: u64,
3182 file_complete: bool,
3183 yenc_filename: Option<String>,
3184 decode_us: u64,
3185 assemble_us: u64,
3186}
3187
3188#[derive(Debug, thiserror::Error)]
3189enum ArticleError {
3190 #[error("Article not found on server")]
3191 ArticleNotFound,
3192 #[error("Connection lost: {0}")]
3193 ConnectionLost(String),
3194 #[error("Server error ({kind:?}): {message}")]
3198 ServerError {
3199 kind: crate::article_failure::ArticleFailureKind,
3200 message: String,
3201 },
3202 #[error("Provider unavailable ({kind:?}): {message}")]
3203 ProviderUnavailable {
3204 kind: crate::article_failure::ArticleFailureKind,
3205 message: String,
3206 },
3207 #[error("Decode error: {0}")]
3208 DecodeError(String),
3209 #[error("Assembly error: {0}")]
3210 AssemblyError(String),
3211 #[error("Job cancelled")]
3214 Cancelled,
3215}
3216
3217fn validated_write_offset(
3230 segment_number: u32,
3231 decoded: &nzb_decode::YencDecodeResult,
3232 data_len: u64,
3233 declared_bytes: Option<u64>,
3234 pinned_size: Option<u64>,
3235) -> Result<u64, String> {
3236 let file_size = decoded.file_size;
3237 let part_begin = decoded.part_begin;
3238 let part_end = decoded.part_end;
3239 if file_size != pinned_size {
3240 return Err(format!(
3241 "article size {file_size:?} does not match the size {pinned_size:?} established for this file"
3242 ));
3243 }
3244 let bound = match declared_bytes.or(pinned_size) {
3245 Some(size) => Some(size),
3246 None if segment_number <= 1 => None,
3247 None => {
3248 return Err(format!(
3249 "no declared file size for multi-segment file (segment {segment_number})"
3250 ));
3251 }
3252 };
3253 let begin = match part_begin {
3254 Some(begin) => begin,
3255 None if segment_number <= 1 => 0,
3257 None => {
3258 return Err(format!(
3259 "no =ypart header on segment {segment_number} of a multi-part file"
3260 ));
3261 }
3262 };
3263 if let Some(size) = bound {
3264 if size == 0 {
3265 return Err("declared file size is 0".to_string());
3266 }
3267 if begin >= size {
3268 return Err(format!(
3269 "=ypart begin {begin} is past the declared file size {size}"
3270 ));
3271 }
3272 match begin.checked_add(data_len) {
3273 Some(end) if end <= size => {
3274 if let Some(part_end) = part_end
3275 && part_end != end
3276 {
3277 return Err(format!(
3278 "=ypart end {part_end} does not match the decoded bytes ending at {end}"
3279 ));
3280 }
3281 }
3282 _ => {
3283 return Err(format!(
3284 "part range begin={begin} len={data_len} exceeds declared file size {size}"
3285 ));
3286 }
3287 }
3288 }
3289 Ok(begin)
3290}
3291
3292fn decode_and_assemble(
3293 item: &WorkItem,
3294 raw_data: &[u8],
3295 assembler: &FileAssembler,
3296 pinned_file_sizes: Option<&Mutex<HashMap<String, u64>>>,
3297) -> Result<ProcessResult, ArticleError> {
3298 let decode_start = Instant::now();
3299 let decoded = decode_yenc(raw_data).map_err(|e| {
3300 ArticleError::DecodeError(format!(
3301 "yEnc decode failed for {} seg {}: {e}",
3302 item.filename, item.segment_number
3303 ))
3304 })?;
3305 let decode_us = decode_start.elapsed().as_micros();
3306
3307 let declared = assembler.declared_bytes(&item.job_id, &item.file_id);
3312 let pinned_size = match (pinned_file_sizes, decoded.file_size) {
3313 (Some(sizes), Some(size)) => {
3314 let mut sizes = sizes.lock();
3315 match sizes.entry(item.file_id.clone()) {
3316 std::collections::hash_map::Entry::Occupied(existing) => Some(*existing.get()),
3317 std::collections::hash_map::Entry::Vacant(slot) => {
3318 slot.insert(size);
3319 Some(size)
3320 }
3321 }
3322 }
3323 (Some(sizes), None) => sizes.lock().get(&item.file_id).copied(),
3324 (None, size) => size,
3325 };
3326 if declared.is_some() && decoded.file_size != pinned_size {
3327 warn!(
3328 file_id = %item.file_id,
3329 segment_number = item.segment_number,
3330 article_size = ?decoded.file_size,
3331 pinned_size = ?pinned_size,
3332 "yEnc size= disagrees with the size pinned for this file; the NZB-declared size is the bound"
3333 );
3334 }
3335
3336 let decoded_len = decoded.data.len() as u64;
3337 let data_begin = validated_write_offset(
3338 item.segment_number,
3339 &decoded,
3340 decoded_len,
3341 declared,
3342 if declared.is_some() {
3343 decoded.file_size
3344 } else {
3345 pinned_size
3346 },
3347 )
3348 .map_err(|reason| {
3349 ArticleError::DecodeError(format!(
3350 "Corrupt yEnc article for {} seg {}: {reason}",
3351 item.filename, item.segment_number
3352 ))
3353 })?;
3354 let yenc_filename = decoded.filename;
3355
3356 let assemble_start = Instant::now();
3357 let file_complete = assembler
3358 .assemble_article(
3359 &item.job_id,
3360 &item.file_id,
3361 item.segment_number,
3362 data_begin,
3363 &decoded.data,
3364 )
3365 .map_err(|e| {
3366 ArticleError::AssemblyError(format!(
3367 "Assembly failed for {} seg {}: {e}",
3368 item.filename, item.segment_number
3369 ))
3370 })?;
3371 let assemble_us = assemble_start.elapsed().as_micros();
3372
3373 debug!(
3374 file = %item.filename,
3375 segment = item.segment_number,
3376 raw_bytes = raw_data.len(),
3377 decoded_bytes = decoded_len,
3378 decode_us,
3379 assemble_us,
3380 "Article decode+assemble timing"
3381 );
3382
3383 Ok(ProcessResult {
3384 decoded_bytes: decoded_len,
3385 file_complete,
3386 yenc_filename,
3387 decode_us: decode_us as u64,
3388 assemble_us: assemble_us as u64,
3389 })
3390}
3391
3392#[cfg(test)]
3397mod tests {
3398 use super::*;
3399
3400 fn worker_pool_without_servers() -> Arc<WorkerPool> {
3401 WorkerPool::new(
3402 Arc::new(Mutex::new(Vec::new())),
3403 Arc::new(BandwidthLimiter::new(Default::default())),
3404 Arc::new(ConnectionTracker::new()),
3405 0,
3406 )
3407 }
3408
3409 #[tokio::test(start_paused = true)]
3410 async fn worker_shutdown_uses_one_shared_deadline() {
3411 let handles = (0..3)
3412 .map(|_| tokio::spawn(std::future::pending()))
3413 .collect();
3414 let shutdown = tokio::spawn(await_worker_shutdown(handles, Duration::from_secs(10)));
3415 tokio::task::yield_now().await;
3416 tokio::time::advance(Duration::from_secs(9)).await;
3417 tokio::task::yield_now().await;
3418 assert!(!shutdown.is_finished());
3419 tokio::time::advance(Duration::from_secs(2)).await;
3420 shutdown.await.unwrap();
3421 }
3422
3423 fn test_job(job_id: &str, root: &std::path::Path) -> NzbJob {
3424 NzbJob {
3425 id: job_id.to_string(),
3426 name: job_id.to_string(),
3427 category: "Default".to_string(),
3428 status: nzb_core::models::JobStatus::Downloading,
3429 priority: nzb_core::models::Priority::Normal,
3430 total_bytes: 1,
3431 downloaded_bytes: 0,
3432 file_count: 1,
3433 files_completed: 0,
3434 article_count: 1,
3435 articles_downloaded: 0,
3436 articles_failed: 0,
3437 added_at: chrono::Utc::now(),
3438 completed_at: None,
3439 work_dir: root.to_path_buf(),
3440 output_dir: root.join("complete"),
3441 password: None,
3442 error_message: None,
3443 speed_bps: 0,
3444 pp_override: None,
3445 delete_archives: None,
3446 server_stats: Vec::new(),
3447 files: Vec::new(),
3448 }
3449 }
3450
3451 fn insert_test_context(pool: &WorkerPool, job: &NzbJob, assembler: Arc<FileAssembler>) {
3452 let (progress_tx, _progress_rx) = mpsc::channel(1);
3453 let ctx = Arc::new(JobContext::new(job, assembler, progress_tx, 1));
3454 pool.job_contexts.lock().insert(job.id.clone(), ctx);
3455 }
3456
3457 #[test]
3458 fn submission_preserves_declared_segment_numbers() {
3459 let temp = tempfile::tempdir().unwrap();
3460 let mut job = test_job("segment-identity", temp.path());
3461 let article = |message_id: &str, segment_number: u32, bytes: u64| nzb_nntp::Article {
3462 message_id: message_id.to_string(),
3463 segment_number,
3464 bytes,
3465 downloaded: false,
3466 data_begin: None,
3467 data_size: None,
3468 crc32: None,
3469 tried_servers: Vec::new(),
3470 tries: 0,
3471 };
3472 job.files.push(nzb_core::models::NzbFile {
3473 id: "file-1".into(),
3474 filename: "out-of-order.bin".into(),
3475 bytes: 1_000,
3476 bytes_downloaded: 0,
3477 is_par2: false,
3478 par2_setname: None,
3479 par2_vol: None,
3480 par2_blocks: None,
3481 assembled: false,
3482 groups: Vec::new(),
3483 articles: vec![article("third", 3, 300), article("first", 1, 100)],
3484 });
3485 let (tx, _rx) = mpsc::channel(4);
3486
3487 let (_ctx, items) = build_job_submission(&job, tx, &["srv1".into()]);
3488
3489 assert_eq!(
3490 items
3491 .iter()
3492 .map(|item| (item.message_id.as_str(), item.segment_number))
3493 .collect::<Vec<_>>(),
3494 vec![("third", 3), ("first", 1)]
3495 );
3496 }
3497
3498 #[test]
3499 fn file_without_segment_bytes_still_assembles() {
3500 let temp = tempfile::tempdir().unwrap();
3501 let mut job = test_job("no-bytes", temp.path());
3502 let article = |message_id: &str, segment_number: u32| nzb_nntp::Article {
3503 message_id: message_id.to_string(),
3504 segment_number,
3505 bytes: 0,
3506 downloaded: false,
3507 data_begin: None,
3508 data_size: None,
3509 crc32: None,
3510 tried_servers: Vec::new(),
3511 tries: 0,
3512 };
3513 job.files.push(nzb_core::models::NzbFile {
3514 id: "file-1".into(),
3515 filename: "out.bin".into(),
3516 bytes: 0,
3517 bytes_downloaded: 0,
3518 is_par2: false,
3519 par2_setname: None,
3520 par2_vol: None,
3521 par2_blocks: None,
3522 assembled: false,
3523 groups: Vec::new(),
3524 articles: vec![article("seg1", 1), article("seg2", 2)],
3525 });
3526 let (tx, _rx) = mpsc::channel(4);
3527 let (ctx, _items) = build_job_submission(&job, tx, &["srv1".into()]);
3528 assert_eq!(ctx.assembler.declared_bytes(&job.id, "file-1"), None);
3530
3531 let (seg1, _) = yenc_simd::encode_article(b"AAAA", "out.bin", 1, 2, 0, 8);
3532 let (seg2, _) = yenc_simd::encode_article(b"BBBB", "out.bin", 2, 2, 4, 8);
3533 let item = |segment_number| {
3534 let mut item = make_item(&job.id, "msg@test", "out.bin");
3535 item.file_id = "file-1".into();
3536 item.segment_number = segment_number;
3537 item
3538 };
3539 decode_and_assemble(&item(1), &seg1, &ctx.assembler, None).unwrap();
3540 let second = decode_and_assemble(&item(2), &seg2, &ctx.assembler, None).unwrap();
3541
3542 assert!(second.file_complete);
3543 assert_eq!(
3544 std::fs::read(temp.path().join("out.bin")).unwrap(),
3545 b"AAAABBBB"
3546 );
3547 }
3548
3549 #[test]
3550 fn submission_with_segment_gap_completes_when_all_declared_segments_written() {
3551 let temp = tempfile::tempdir().unwrap();
3552 let mut job = test_job("segment-gap", temp.path());
3553 let article = |message_id: &str, segment_number: u32| nzb_nntp::Article {
3554 message_id: message_id.to_string(),
3555 segment_number,
3556 bytes: 4,
3557 downloaded: false,
3558 data_begin: None,
3559 data_size: None,
3560 crc32: None,
3561 tried_servers: Vec::new(),
3562 tries: 0,
3563 };
3564 job.files.push(nzb_core::models::NzbFile {
3565 id: "file-1".into(),
3566 filename: "gap.bin".into(),
3567 bytes: 16,
3568 bytes_downloaded: 0,
3569 is_par2: false,
3570 par2_setname: None,
3571 par2_vol: None,
3572 par2_blocks: None,
3573 assembled: false,
3574 groups: Vec::new(),
3575 articles: vec![article("s1", 1), article("s2", 2), article("s4", 4)],
3577 });
3578 let (tx, _rx) = mpsc::channel(4);
3579 let (ctx, _items) = build_job_submission(&job, tx, &["srv1".into()]);
3580
3581 let asm = &ctx.assembler;
3582 assert!(
3583 !asm.assemble_article(&job.id, "file-1", 1, 0, b"AAAA")
3584 .unwrap()
3585 );
3586 assert!(
3587 !asm.assemble_article(&job.id, "file-1", 2, 4, b"BBBB")
3588 .unwrap()
3589 );
3590 assert!(
3592 asm.assemble_article(&job.id, "file-1", 4, 12, b"DDDD")
3593 .unwrap()
3594 );
3595 assert!(asm.is_file_complete(&job.id, "file-1"));
3596 assert_eq!(asm.get_file_progress(&job.id, "file-1"), (3, 3));
3597 }
3598
3599 #[test]
3600 fn submission_does_not_requeue_previously_failed_articles() {
3601 let temp = tempfile::tempdir().unwrap();
3602 let mut job = test_job("previously-failed", temp.path());
3603 let article = |message_id: &str, segment_number: u32, tried: &[&str]| nzb_nntp::Article {
3604 message_id: message_id.to_string(),
3605 segment_number,
3606 bytes: 100,
3607 downloaded: false,
3608 data_begin: None,
3609 data_size: None,
3610 crc32: None,
3611 tried_servers: tried.iter().map(|s| s.to_string()).collect(),
3612 tries: tried.len() as u32,
3613 };
3614 job.files.push(nzb_core::models::NzbFile {
3615 id: "file-1".into(),
3616 filename: "partial.bin".into(),
3617 bytes: 200,
3618 bytes_downloaded: 0,
3619 is_par2: false,
3620 par2_setname: None,
3621 par2_vol: None,
3622 par2_blocks: None,
3623 assembled: false,
3624 groups: Vec::new(),
3625 articles: vec![article("pending", 1, &[]), article("failed", 2, &["srv1"])],
3626 });
3627 let (tx, _rx) = mpsc::channel(4);
3628
3629 let (ctx, items) = build_job_submission(&job, tx, &["srv1".into()]);
3630
3631 assert_eq!(
3632 items
3633 .iter()
3634 .map(|item| item.message_id.as_str())
3635 .collect::<Vec<_>>(),
3636 vec!["pending"]
3637 );
3638 assert_eq!(ctx.articles_remaining.load(Ordering::Relaxed), 1);
3639 assert_eq!(ctx.articles_failed.load(Ordering::Relaxed), 1);
3640
3641 let (tx, _rx) = mpsc::channel(4);
3644 let (ctx, items) = build_job_submission(&job, tx, &["srv1".into(), "srv2".into()]);
3645 assert_eq!(items.len(), 2);
3646 assert_eq!(ctx.articles_failed.load(Ordering::Relaxed), 0);
3647 }
3648
3649 fn decode_test_item(job_id: &str, segment_number: u32) -> WorkItem {
3650 let mut item = make_item(job_id, "msg@test", "out.bin");
3651 item.segment_number = segment_number;
3652 item
3653 }
3654
3655 #[test]
3656 fn decode_rejects_ypart_offset_beyond_declared_file_size() {
3657 let temp = tempfile::tempdir().unwrap();
3658 let path = temp.path().join("out.bin");
3659 let assembler = FileAssembler::new();
3660 assembler
3661 .register_file("j1", "f1", path.clone(), 2)
3662 .unwrap();
3663
3664 let (raw, _) = yenc_simd::encode_article(b"BBBB", "out.bin", 2, 2, 1_000_000_000_000, 8);
3666 let result = decode_and_assemble(&decode_test_item("j1", 2), &raw, &assembler, None);
3667
3668 assert!(
3669 matches!(result, Err(ArticleError::DecodeError(_))),
3670 "expected corrupt-article error, got {result:?}"
3671 );
3672 assert!(std::fs::metadata(&path).unwrap().len() <= 8);
3673 assert_eq!(assembler.get_file_progress("j1", "f1"), (0, 2));
3674 }
3675
3676 #[test]
3677 fn decode_rejects_forged_size_past_nzb_declared_bytes() {
3678 let temp = tempfile::tempdir().unwrap();
3679 let path = temp.path().join("out.bin");
3680 let assembler = FileAssembler::new();
3681 assembler
3684 .register_file_with_segment_numbers("j1", "f1", path.clone(), &[1, 2], &[], Some(8))
3685 .unwrap();
3686
3687 let (raw, _) = yenc_simd::encode_article(
3688 b"BBBB",
3689 "out.bin",
3690 2,
3691 2,
3692 1_000_000_000_000,
3693 2_000_000_000_000,
3694 );
3695 let result = decode_and_assemble(&decode_test_item("j1", 2), &raw, &assembler, None);
3696
3697 assert!(
3698 matches!(result, Err(ArticleError::DecodeError(_))),
3699 "forged size= must not win over the NZB size, got {result:?}"
3700 );
3701 assert!(std::fs::metadata(&path).unwrap().len() <= 8);
3702 assert_eq!(assembler.get_file_progress("j1", "f1"), (0, 2));
3703 }
3704
3705 #[test]
3706 fn decode_rejects_missing_size_on_multi_segment_file() {
3707 let temp = tempfile::tempdir().unwrap();
3708 let path = temp.path().join("out.bin");
3709 let assembler = FileAssembler::new();
3710 assembler
3712 .register_file("j1", "f1", path.clone(), 2)
3713 .unwrap();
3714
3715 let (mut raw, _) = yenc_simd::encode_article(b"BBBB", "out.bin", 2, 2, 4, 8);
3716 let header_end = raw.windows(2).position(|w| w == b"\r\n").unwrap();
3717 raw.splice(
3718 0..header_end,
3719 b"=ybegin part=2 line=128 name=out.bin".iter().copied(),
3720 );
3721 let result = decode_and_assemble(&decode_test_item("j1", 2), &raw, &assembler, None);
3722
3723 assert!(
3724 matches!(result, Err(ArticleError::DecodeError(_))),
3725 "a multi-segment file with no bound at all must be rejected, got {result:?}"
3726 );
3727 assert_eq!(std::fs::metadata(&path).unwrap().len(), 0);
3728 assert_eq!(assembler.get_file_progress("j1", "f1"), (0, 2));
3729 }
3730
3731 #[test]
3732 fn decode_rejects_ypart_range_overrunning_declared_file_size() {
3733 let temp = tempfile::tempdir().unwrap();
3734 let assembler = FileAssembler::new();
3735 assembler
3736 .register_file("j1", "f1", temp.path().join("out.bin"), 2)
3737 .unwrap();
3738
3739 let (raw, _) = yenc_simd::encode_article(b"BBBB", "out.bin", 2, 2, 6, 8);
3741 let result = decode_and_assemble(&decode_test_item("j1", 2), &raw, &assembler, None);
3742
3743 assert!(matches!(result, Err(ArticleError::DecodeError(_))));
3744 assert_eq!(assembler.get_file_progress("j1", "f1"), (0, 2));
3745 }
3746
3747 #[test]
3748 fn decode_rejects_missing_ypart_on_non_first_segment() {
3749 let temp = tempfile::tempdir().unwrap();
3750 let path = temp.path().join("out.bin");
3751 let assembler = FileAssembler::new();
3752 assembler
3753 .register_file("j1", "f1", path.clone(), 2)
3754 .unwrap();
3755
3756 let (seg1, _) = yenc_simd::encode_article(b"AAAA", "out.bin", 1, 2, 0, 8);
3757 decode_and_assemble(&decode_test_item("j1", 1), &seg1, &assembler, None).unwrap();
3758
3759 let (seg2, _) = yenc_simd::encode_article(b"ZZZZ", "out.bin", 1, 1, 0, 4);
3762 let result = decode_and_assemble(&decode_test_item("j1", 2), &seg2, &assembler, None);
3763
3764 assert!(matches!(result, Err(ArticleError::DecodeError(_))));
3765 assert_eq!(std::fs::read(&path).unwrap(), b"AAAA");
3766 assert_eq!(assembler.get_file_progress("j1", "f1"), (1, 2));
3767 }
3768
3769 #[test]
3770 fn decode_accepts_valid_parts_and_single_part_articles() {
3771 let temp = tempfile::tempdir().unwrap();
3772 let multi = temp.path().join("multi.bin");
3773 let single = temp.path().join("single.bin");
3774 let assembler = FileAssembler::new();
3775 assembler
3776 .register_file("j1", "f1", multi.clone(), 2)
3777 .unwrap();
3778 assembler
3779 .register_file("j1", "f2", single.clone(), 1)
3780 .unwrap();
3781
3782 let (seg2, _) = yenc_simd::encode_article(b"BBBB", "multi.bin", 2, 2, 4, 8);
3783 let (seg1, _) = yenc_simd::encode_article(b"AAAA", "multi.bin", 1, 2, 0, 8);
3784 assert!(
3785 !decode_and_assemble(&decode_test_item("j1", 2), &seg2, &assembler, None)
3786 .unwrap()
3787 .file_complete
3788 );
3789 assert!(
3790 decode_and_assemble(&decode_test_item("j1", 1), &seg1, &assembler, None)
3791 .unwrap()
3792 .file_complete
3793 );
3794 assert_eq!(std::fs::read(&multi).unwrap(), b"AAAABBBB");
3795
3796 let (whole, _) = yenc_simd::encode_article(b"ONE", "single.bin", 1, 1, 0, 3);
3797 let mut item = decode_test_item("j1", 1);
3798 item.file_id = "f2".into();
3799 assert!(
3800 decode_and_assemble(&item, &whole, &assembler, None)
3801 .unwrap()
3802 .file_complete
3803 );
3804 assert_eq!(std::fs::read(&single).unwrap(), b"ONE");
3805 }
3806
3807 #[tokio::test]
3808 async fn serial_fetch_does_not_write_article_after_job_cancelled() {
3809 use nzb_nntp::testutil::{MockConfig, MockNntpServer, test_config};
3810
3811 let (encoded, _) = yenc_simd::encode_article(b"payload", "out.bin", 1, 1, 0, 7);
3812 let mut articles = HashMap::new();
3813 articles.insert("msg@test".to_string(), encoded);
3814 let server = MockNntpServer::start(MockConfig {
3815 articles,
3816 ..MockConfig::default()
3817 })
3818 .await;
3819 let config = test_config(server.port());
3820 let mut conn = NntpConnection::new(config.id.clone());
3821 conn.connect(&config).await.unwrap();
3822
3823 let temp = tempfile::tempdir().unwrap();
3824 let path = temp.path().join("out.bin");
3825 let assembler = FileAssembler::new();
3826 assembler
3827 .register_file("j1", "f1", path.clone(), 1)
3828 .unwrap();
3829 let item = make_item("j1", "msg@test", "out.bin");
3830
3831 let cancelled = AtomicBool::new(true);
3833 let result =
3834 fetch_article_with_retry(&mut conn, &item, &assembler, &cancelled, &config, "w0").await;
3835
3836 assert!(
3837 matches!(result, Err(ArticleError::Cancelled)),
3838 "expected Cancelled, got {result:?}"
3839 );
3840 assert_eq!(assembler.get_file_progress("j1", "f1"), (0, 1));
3841 assert_eq!(std::fs::metadata(&path).unwrap().len(), 0);
3842
3843 let not_cancelled = AtomicBool::new(false);
3845 let result =
3846 fetch_article_with_retry(&mut conn, &item, &assembler, ¬_cancelled, &config, "w0")
3847 .await
3848 .unwrap();
3849 assert!(result.file_complete);
3850 assert_eq!(std::fs::read(&path).unwrap(), b"payload");
3851 }
3852
3853 #[cfg(target_os = "linux")]
3854 fn open_fd_count_under(root: &std::path::Path) -> usize {
3855 std::fs::read_dir("/proc/self/fd")
3856 .expect("read /proc/self/fd")
3857 .filter_map(Result::ok)
3858 .filter_map(|entry| std::fs::read_link(entry.path()).ok())
3859 .filter(|target| target.starts_with(root))
3860 .count()
3861 }
3862
3863 #[test]
3864 fn has_known_extension_recognizes_archives() {
3865 assert!(has_known_extension("movie.rar"));
3866 assert!(has_known_extension("movie.part01.rar"));
3867 assert!(has_known_extension("file.zip"));
3868 assert!(has_known_extension("file.7z"));
3869 assert!(has_known_extension("archive.001"));
3870 }
3871
3872 #[test]
3873 fn has_known_extension_recognizes_video() {
3874 assert!(has_known_extension("episode.mkv"));
3875 assert!(has_known_extension("movie.mp4"));
3876 assert!(has_known_extension("video.avi"));
3877 assert!(has_known_extension("clip.ts"));
3878 }
3879
3880 #[test]
3881 fn has_known_extension_recognizes_par2() {
3882 assert!(has_known_extension("file.par2"));
3883 assert!(has_known_extension("file.vol00+01.par2"));
3884 assert!(has_known_extension("file.vol015-031.par2"));
3885 }
3886
3887 #[test]
3888 fn has_known_extension_recognizes_misc() {
3889 assert!(has_known_extension("info.nfo"));
3890 assert!(has_known_extension("sub.srt"));
3891 assert!(has_known_extension("cover.jpg"));
3892 assert!(has_known_extension("song.flac"));
3893 }
3894
3895 #[test]
3896 fn has_known_extension_rejects_obfuscated_hashes() {
3897 assert!(!has_known_extension("9b6a324d7560b87091685020371ba869"));
3898 assert!(!has_known_extension("1fG1GP7L2263LHXH213HTNIxZsX7l0cv44BZ"));
3899 assert!(!has_known_extension("DfKUx3bl7L6PSo6276WSaXSZ7"));
3900 assert!(!has_known_extension("Q77O1ZxL237vc241z77hFoLBxl"));
3901 }
3902
3903 #[test]
3904 fn has_known_extension_rejects_unknown_extensions() {
3905 assert!(!has_known_extension("file.xyz123"));
3906 assert!(!has_known_extension("noext"));
3907 assert!(!has_known_extension(""));
3908 }
3909
3910 #[test]
3911 fn has_known_extension_case_insensitive() {
3912 assert!(has_known_extension("file.RAR"));
3913 assert!(has_known_extension("file.MKV"));
3914 assert!(has_known_extension("file.Par2"));
3915 assert!(has_known_extension("file.MP4"));
3916 }
3917
3918 fn make_item(job_id: &str, msg_id: &str, filename: &str) -> WorkItem {
3919 WorkItem {
3920 job_id: job_id.to_string(),
3921 file_id: "f1".to_string(),
3922 filename: filename.to_string(),
3923 message_id: msg_id.to_string(),
3924 segment_number: 1,
3925 tried_servers: Vec::new(),
3926 provider_outcomes: HashMap::new(),
3927 tries_on_current: 0,
3928 outage_tries: 0,
3929 }
3930 }
3931
3932 #[test]
3933 fn shared_queue_par2_first() {
3934 let q = SharedWorkQueue::new();
3935 q.submit_items(vec![
3936 make_item("j1", "a", "movie.rar"),
3937 make_item("j1", "b", "movie.par2"),
3938 make_item("j1", "c", "movie.vol00+01.par2"),
3939 make_item("j1", "d", "movie.r00"),
3940 ]);
3941 let first = q.pop_workable("srv1", &[]).unwrap();
3942 assert_eq!(first.filename, "movie.par2", "index file first");
3943 let second = q.pop_workable("srv1", &[]).unwrap();
3944 assert_eq!(second.filename, "movie.vol00+01.par2", "vol file second");
3945 }
3946
3947 #[test]
3948 fn shared_queue_skips_tried_servers() {
3949 let q = SharedWorkQueue::new();
3950 let mut item = make_item("j1", "a", "file.rar");
3951 item.tried_servers.push("srv1".to_string());
3952 q.submit_items(vec![item, make_item("j1", "b", "other.rar")]);
3953
3954 let picked = q.pop_workable("srv1", &[]).unwrap();
3956 assert_eq!(picked.message_id, "b");
3957 }
3958
3959 #[test]
3960 fn shared_queue_round_robins_eligible_jobs_per_server() {
3961 let q = SharedWorkQueue::new();
3962 q.submit_items(vec![
3963 make_item("job-a", "a-1", "first.rar"),
3964 make_item("job-a", "a-2", "second.rar"),
3965 make_item("job-b", "b-1", "third.rar"),
3966 ]);
3967
3968 assert_eq!(q.pop_workable("srv1", &[]).unwrap().job_id, "job-a");
3969 assert_eq!(q.pop_workable("srv1", &[]).unwrap().job_id, "job-b");
3970 assert_eq!(q.pop_workable("srv1", &[]).unwrap().job_id, "job-a");
3971 }
3972
3973 #[tokio::test]
3974 async fn provider_outage_is_reported_once_then_cleared_on_recovery() {
3975 let temp = tempfile::tempdir().unwrap();
3976 let server = ServerConfig::new("srv1", "one.invalid");
3977 let pool = WorkerPool::new(
3978 Arc::new(Mutex::new(vec![server.clone()])),
3979 Arc::new(BandwidthLimiter::new(Default::default())),
3980 Arc::new(ConnectionTracker::new()),
3981 0,
3982 );
3983 let job = test_job("provider-outage", temp.path());
3984 let (progress_tx, mut progress_rx) = mpsc::channel(4);
3985 let ctx = Arc::new(JobContext::new(
3986 &job,
3987 Arc::new(FileAssembler::new()),
3988 progress_tx,
3989 1,
3990 ));
3991 pool.job_contexts.lock().insert(job.id.clone(), ctx);
3992 pool.server_health
3993 .lock()
3994 .entry(server.id.clone())
3995 .or_default()
3996 .record_failure(false, "connection refused");
3997 pool.server_health
3998 .lock()
3999 .entry(server.id.clone())
4000 .or_default()
4001 .record_failure(false, "connection refused");
4002 pool.server_health
4003 .lock()
4004 .entry(server.id.clone())
4005 .or_default()
4006 .record_failure(false, "connection refused");
4007
4008 pool.report_provider_outage();
4009 pool.report_provider_outage();
4010 match progress_rx.recv().await.unwrap() {
4011 ProgressUpdate::WaitingForProviders { message, .. } => {
4012 assert!(message.contains("retry automatically"));
4013 }
4014 other => panic!("unexpected progress event: {other:?}"),
4015 }
4016 assert!(
4017 progress_rx.try_recv().is_err(),
4018 "outage event is deduplicated"
4019 );
4020
4021 pool.server_health
4022 .lock()
4023 .get_mut(&server.id)
4024 .unwrap()
4025 .record_success();
4026 pool.report_providers_available();
4027 assert!(matches!(
4028 progress_rx.recv().await,
4029 Some(ProgressUpdate::ProvidersAvailable { .. })
4030 ));
4031 }
4032
4033 #[test]
4034 fn unavailable_provider_does_not_prove_global_absence() {
4035 let servers = vec![
4036 ServerConfig::new("srv1", "one.invalid"),
4037 ServerConfig::new("srv2", "two.invalid"),
4038 ];
4039 let mut outcomes = HashMap::new();
4040 outcomes.insert(
4041 "srv1".to_string(),
4042 crate::article_failure::ArticleFailureKind::NotFound,
4043 );
4044
4045 let health = HashMap::new();
4046 assert!(!all_enabled_providers_definitive(
4047 &servers, &health, &outcomes
4048 ));
4049 outcomes.insert(
4050 "srv2".to_string(),
4051 crate::article_failure::ArticleFailureKind::NotFound,
4052 );
4053 assert!(all_enabled_providers_definitive(
4054 &servers, &health, &outcomes
4055 ));
4056 }
4057
4058 #[test]
4059 fn unavailable_optional_provider_is_not_required() {
4060 let mut optional = ServerConfig::new("fill", "fill.invalid");
4061 optional.optional = true;
4062 let servers = vec![ServerConfig::new("main", "main.invalid"), optional];
4063 let mut outcomes = HashMap::new();
4064 outcomes.insert(
4065 "main".to_string(),
4066 crate::article_failure::ArticleFailureKind::NotFound,
4067 );
4068 let mut health = HashMap::new();
4069
4070 assert!(!all_enabled_providers_definitive(
4072 &servers, &health, &outcomes
4073 ));
4074
4075 let mut down = ServerHealth::new();
4076 down.record_failure(true, "502 on connect");
4077 health.insert("fill".to_string(), down);
4078 assert!(all_enabled_providers_definitive(
4079 &servers, &health, &outcomes
4080 ));
4081
4082 let mut main_down = ServerHealth::new();
4084 main_down.record_failure(true, "502 on connect");
4085 health.insert("main".to_string(), main_down);
4086 outcomes.clear();
4087 assert!(!all_enabled_providers_definitive(
4088 &servers, &health, &outcomes
4089 ));
4090 }
4091
4092 #[tokio::test]
4093 async fn supervisor_sweep_fails_items_left_waiting_on_down_optional_server() {
4094 let temp = tempfile::tempdir().unwrap();
4095 let pool = worker_pool_without_servers();
4096 let mut optional = ServerConfig::new("fill", "fill.invalid");
4097 optional.optional = true;
4098 *pool.servers.lock() = vec![ServerConfig::new("main", "main.invalid"), optional];
4099
4100 let job = test_job("sweep", temp.path());
4101 let (progress_tx, mut progress_rx) = mpsc::channel(8);
4102 let ctx = Arc::new(JobContext::new(
4103 &job,
4104 Arc::new(FileAssembler::new()),
4105 progress_tx,
4106 1,
4107 ));
4108 pool.job_contexts.lock().insert(job.id.clone(), ctx.clone());
4109 let mut item = make_item("sweep", "missing@test", "payload.bin");
4110 item.tried_servers.push("main".into());
4111 item.provider_outcomes.insert(
4112 "main".into(),
4113 crate::article_failure::ArticleFailureKind::NotFound,
4114 );
4115 pool.work_queue.push_front(item);
4116
4117 pool.resolve_items_not_waiting_on_required_providers();
4119 assert_eq!(pool.work_queue.len(), 1);
4120
4121 let mut down = ServerHealth::new();
4123 down.record_failure(true, "502 on connect");
4124 pool.server_health.lock().insert("fill".into(), down);
4125 pool.resolve_items_not_waiting_on_required_providers();
4126
4127 assert_eq!(pool.work_queue.len(), 0);
4128 assert_eq!(ctx.articles_failed.load(Ordering::Relaxed), 1);
4129 assert_eq!(ctx.articles_remaining.load(Ordering::Relaxed), 0);
4130 let mut saw_failure = false;
4131 while let Ok(update) = progress_rx.try_recv() {
4132 saw_failure |= matches!(update, ProgressUpdate::ArticleFailed { .. });
4133 }
4134 assert!(saw_failure);
4135 }
4136
4137 #[tokio::test]
4138 async fn abort_has_one_owner_and_waits_for_in_flight_resolution() {
4139 let temp = tempfile::tempdir().unwrap();
4140 let pool = worker_pool_without_servers();
4141 let job = test_job("abort-drain", temp.path());
4142 let assembler = Arc::new(FileAssembler::new());
4143 let (progress_tx, mut progress_rx) = mpsc::channel(8);
4144 let ctx = Arc::new(JobContext::new(&job, assembler, progress_tx, 3));
4145 ctx.articles_failed.store(2, Ordering::Relaxed);
4146 pool.job_contexts
4147 .lock()
4148 .insert(job.id.clone(), Arc::clone(&ctx));
4149 pool.work_queue.submit_items(vec![
4150 make_item(&job.id, "queued-1", "file.rar"),
4151 make_item(&job.id, "queued-2", "file.rar"),
4152 ]);
4153
4154 assert!(pool.abort_job(&job.id, "original reason".into()));
4155 assert!(!pool.abort_job(&job.id, "overwritten reason".into()));
4156 assert_eq!(ctx.articles_remaining.load(Ordering::Relaxed), 1);
4157 assert!(!ctx.finished.load(Ordering::Relaxed));
4158 assert!(progress_rx.try_recv().is_err());
4159
4160 ctx.resolve_one();
4163 match progress_rx.recv().await.unwrap() {
4164 ProgressUpdate::JobAborted {
4165 reason,
4166 articles_failed,
4167 download_time_secs,
4168 ..
4169 } => {
4170 assert_eq!(reason, "original reason");
4171 assert_eq!(articles_failed, 2);
4172 assert!(download_time_secs >= 0.0);
4173 }
4174 other => panic!("unexpected terminal event: {other:?}"),
4175 }
4176 }
4177
4178 #[test]
4179 fn pop_workable_respects_priority() {
4180 let q = SharedWorkQueue::new();
4184 q.submit_items(vec![make_item("j1", "a", "file.rar")]);
4185
4186 let higher = vec!["srv_primary".to_string()];
4187 assert!(q.pop_workable("srv_backup", &higher).is_none());
4188
4189 let item = q.pop_workable("srv_primary", &[]).unwrap();
4191 assert_eq!(item.message_id, "a");
4192 }
4193
4194 #[test]
4195 fn pop_workable_allows_backup_after_primary_tried() {
4196 let q = SharedWorkQueue::new();
4199 let mut item = make_item("j1", "a", "file.rar");
4200 item.tried_servers.push("srv_primary".to_string());
4201 q.submit_items(vec![item]);
4202
4203 let higher = vec!["srv_primary".to_string()];
4204 let picked = q.pop_workable("srv_backup", &higher).unwrap();
4205 assert_eq!(picked.message_id, "a");
4206 }
4207
4208 #[test]
4209 fn pop_workable_ignores_circuit_broken_higher_server() {
4210 let q = SharedWorkQueue::new();
4214 q.submit_items(vec![make_item("j1", "a", "file.rar")]);
4215
4216 let higher: Vec<String> = vec![]; let item = q.pop_workable("srv_backup", &higher).unwrap();
4218 assert_eq!(item.message_id, "a");
4219 }
4220
4221 #[test]
4222 fn workable_count_for_respects_priority() {
4223 let q = SharedWorkQueue::new();
4224 q.submit_items(vec![
4225 make_item("j1", "a", "a.rar"),
4226 make_item("j1", "b", "b.rar"),
4227 ]);
4228
4229 let higher = vec!["srv_primary".to_string()];
4231 let (workable, total) = q.workable_count_for("srv_backup", &higher);
4232 assert_eq!(workable, 0);
4233 assert_eq!(total, 2);
4234
4235 let (workable, total) = q.workable_count_for("srv_primary", &[]);
4237 assert_eq!(workable, 2);
4238 assert_eq!(total, 2);
4239 }
4240
4241 #[test]
4242 fn shared_queue_drain_job_removes_only_target() {
4243 let q = SharedWorkQueue::new();
4244 q.submit_items(vec![
4245 make_item("j1", "a", "a.rar"),
4246 make_item("j2", "b", "b.rar"),
4247 make_item("j1", "c", "c.rar"),
4248 ]);
4249 let drained = q.drain_job("j1");
4250 assert_eq!(drained.len(), 2);
4251 assert_eq!(q.len(), 1);
4252 let remaining = q.pop_workable("srv1", &[]).unwrap();
4253 assert_eq!(remaining.job_id, "j2");
4254 }
4255
4256 #[test]
4257 fn release_completed_job_drops_context_and_closes_assembler_files() {
4258 let tempdir = tempfile::tempdir().expect("tempdir");
4259 let assembler = Arc::new(FileAssembler::new());
4260 let job_id = "completed-job";
4261 assembler
4262 .register_file(job_id, "file-1", tempdir.path().join("file.rar"), 1)
4263 .expect("register file");
4264 assert_eq!(assembler.get_file_progress(job_id, "file-1"), (0, 1));
4265
4266 let job = test_job(job_id, tempdir.path());
4267 let pool = worker_pool_without_servers();
4268 insert_test_context(&pool, &job, Arc::clone(&assembler));
4269 assert!(pool.has_job(job_id));
4270
4271 pool.release_completed_job(job_id);
4272
4273 assert!(!pool.has_job(job_id));
4274 assert_eq!(assembler.get_file_progress(job_id, "file-1"), (0, 0));
4275 }
4276
4277 #[test]
4278 fn job_active_secs_reports_registered_jobs_only() {
4279 let tempdir = tempfile::tempdir().expect("tempdir");
4280 let job = test_job("active-secs", tempdir.path());
4281 let pool = worker_pool_without_servers();
4282 assert_eq!(pool.job_active_secs(&job.id), None);
4283
4284 insert_test_context(&pool, &job, Arc::new(FileAssembler::new()));
4285 pool.pause_job(&job.id);
4286 let paused = pool.job_active_secs(&job.id).expect("registered job");
4287 assert!(paused >= 0.0);
4288 assert_eq!(pool.job_active_secs(&job.id), Some(paused));
4290
4291 pool.release_completed_job(&job.id);
4292 assert_eq!(pool.job_active_secs(&job.id), None);
4293 }
4294
4295 #[cfg(target_os = "linux")]
4296 #[test]
4297 fn repeated_completed_jobs_do_not_accumulate_file_descriptors() {
4298 const JOBS: usize = 64;
4299 const FILES_PER_JOB: usize = 8;
4300
4301 let tempdir = tempfile::tempdir().expect("tempdir");
4302 let pool = worker_pool_without_servers();
4303 assert_eq!(open_fd_count_under(tempdir.path()), 0);
4304
4305 for job_index in 0..JOBS {
4306 let job_id = format!("job-{job_index}");
4307 let job_dir = tempdir.path().join(&job_id);
4308 let assembler = Arc::new(FileAssembler::new());
4309 for file_index in 0..FILES_PER_JOB {
4310 assembler
4311 .register_file(
4312 &job_id,
4313 &format!("file-{file_index}"),
4314 job_dir.join(format!("file-{file_index}.rar")),
4315 1,
4316 )
4317 .expect("register file");
4318 }
4319 assert_eq!(open_fd_count_under(tempdir.path()), FILES_PER_JOB);
4320
4321 let job = test_job(&job_id, &job_dir);
4322 insert_test_context(&pool, &job, assembler);
4323 pool.release_completed_job(&job_id);
4324
4325 assert!(!pool.has_job(&job_id));
4326 assert_eq!(
4327 open_fd_count_under(tempdir.path()),
4328 0,
4329 "completed job {job_index} retained output file descriptors"
4330 );
4331 }
4332
4333 assert!(pool.job_contexts.lock().is_empty());
4334 }
4335
4336 #[tokio::test]
4341 async fn connection_tracker_acquire_releases_slot_on_drop() {
4342 let t = ConnectionTracker::new();
4343 t.set_limit("srv1", "Server 1", 2);
4344 let s1 = t.acquire("srv1").await.unwrap();
4345 let s2 = t.acquire("srv1").await.unwrap();
4346 assert_eq!(t.total(), 2);
4347 drop(s1);
4349 assert_eq!(t.total(), 1);
4350 drop(s2);
4351 assert_eq!(t.total(), 0);
4352 }
4353
4354 #[tokio::test]
4355 async fn connection_tracker_blocks_at_limit() {
4356 let t = Arc::new(ConnectionTracker::new());
4357 t.set_limit("srv1", "Server 1", 1);
4358 let _held = t.acquire("srv1").await.unwrap();
4359
4360 let t2 = Arc::clone(&t);
4363 let res = tokio::time::timeout(Duration::from_millis(150), async move {
4364 t2.acquire("srv1").await
4365 })
4366 .await;
4367 assert!(
4368 res.is_err(),
4369 "second acquire should block while limit is reached"
4370 );
4371 }
4372
4373 #[tokio::test]
4374 async fn connection_tracker_grow_in_place_lets_more_acquire() {
4375 let t = ConnectionTracker::new();
4376 t.set_limit("srv1", "Server 1", 2);
4377 let _a = t.acquire("srv1").await.unwrap();
4378 let _b = t.acquire("srv1").await.unwrap();
4379 t.set_limit("srv1", "Server 1", 4);
4382 let _c = t.acquire("srv1").await.unwrap();
4383 let _d = t.acquire("srv1").await.unwrap();
4384 assert_eq!(t.total(), 4);
4385 }
4386
4387 #[tokio::test]
4388 async fn connection_tracker_shrink_marks_old_slots_stale() {
4389 let t = ConnectionTracker::new();
4390 t.set_limit("srv1", "Server 1", 4);
4391 let s = t.acquire("srv1").await.unwrap();
4392 assert!(t.slot_is_current(&s));
4393
4394 t.set_limit("srv1", "Server 1", 1);
4396 assert!(
4397 !t.slot_is_current(&s),
4398 "after shrink, the previously-acquired slot must be marked stale"
4399 );
4400
4401 assert_eq!(t.total(), 0);
4405
4406 let new_slot = t.acquire("srv1").await.unwrap();
4408 assert!(t.slot_is_current(&new_slot));
4409 assert_eq!(t.total(), 1);
4410 }
4411
4412 #[tokio::test]
4413 async fn connection_tracker_remove_server_marks_slot_stale() {
4414 let t = ConnectionTracker::new();
4415 t.set_limit("srv1", "Server 1", 2);
4416 let s = t.acquire("srv1").await.unwrap();
4417 assert!(t.slot_is_current(&s));
4418
4419 t.remove_server("srv1");
4420 assert!(
4421 !t.slot_is_current(&s),
4422 "after remove_server, the slot must be marked stale"
4423 );
4424 assert_eq!(t.total(), 0);
4425 }
4426
4427 #[tokio::test]
4428 async fn connection_tracker_snapshot_reflects_active_count() {
4429 let t = ConnectionTracker::new();
4430 t.set_limit("srv1", "Server 1", 3);
4431 t.set_limit("srv2", "Server 2", 5);
4432
4433 let _a1 = t.acquire("srv1").await.unwrap();
4434 let _a2 = t.acquire("srv1").await.unwrap();
4435 let _b1 = t.acquire("srv2").await.unwrap();
4436
4437 let mut snap = t.snapshot();
4438 snap.sort_by(|a, b| a.0.cmp(&b.0));
4439 assert_eq!(snap.len(), 2);
4440 assert_eq!(snap[0], ("srv1".into(), 2, 3));
4441 assert_eq!(snap[1], ("srv2".into(), 1, 5));
4442 }
4443
4444 #[tokio::test]
4445 async fn active_snapshot_counts_only_article_activity() {
4446 let t = ConnectionTracker::new();
4447 t.set_limit("srv1", "Server 1", 3);
4448
4449 let mut connected = t.acquire("srv1").await.unwrap();
4450 let _disconnected = t.acquire("srv1").await.unwrap();
4451 assert_eq!(t.connected_snapshot(), vec![("srv1".into(), 0, 3)]);
4452
4453 connected.mark_active();
4454 assert_eq!(t.connected_snapshot(), vec![("srv1".into(), 1, 3)]);
4455
4456 connected.mark_inactive();
4457 assert_eq!(t.connected_snapshot(), vec![("srv1".into(), 0, 3)]);
4458 }
4459
4460 #[tokio::test]
4461 async fn activity_guard_clears_count_when_fetch_scope_ends() {
4462 let t = ConnectionTracker::new();
4463 t.set_limit("srv1", "Server 1", 1);
4464 let mut slot = t.acquire("srv1").await.unwrap();
4465
4466 {
4467 let _activity = slot.activity();
4468 assert_eq!(t.connected_snapshot(), vec![("srv1".into(), 1, 1)]);
4469 }
4470
4471 assert_eq!(t.connected_snapshot(), vec![("srv1".into(), 0, 1)]);
4472 assert_eq!(t.snapshot(), vec![("srv1".into(), 1, 1)]);
4473 }
4474
4475 #[test]
4476 fn only_provider_level_errors_count_against_circuit_breaker() {
4477 let s = || "x".to_string();
4478 for e in [
4479 NntpError::Connection(s()),
4480 NntpError::Tls(s()),
4481 NntpError::Auth(s()),
4482 NntpError::AuthRequired(s()),
4483 NntpError::PermissionDenied(s()),
4484 NntpError::ServiceUnavailable(s()),
4485 NntpError::Timeout(s()),
4486 ] {
4487 assert!(counts_against_circuit_breaker(&e), "{e}");
4488 }
4489 for e in [
4490 NntpError::ArticleNotFound(s()),
4491 NntpError::NoSuchGroup(s()),
4492 NntpError::NoArticleSelected(s()),
4493 NntpError::Protocol(s()),
4494 ] {
4495 assert!(!counts_against_circuit_breaker(&e), "{e}");
4496 }
4497 }
4498}