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::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 RECONNECT_DELAY: Duration = Duration::from_secs(5);
70const MAX_RECONNECT_ATTEMPTS: u32 = 5;
72const WORKER_RAMP_DELAY: Duration = Duration::from_millis(15);
75const CIRCUIT_BREAK_THRESHOLD: u32 = 3;
77const AUTH_FAILURE_COOLDOWN: Duration = Duration::from_secs(120);
79const TRANSIENT_FAILURE_COOLDOWN: Duration = Duration::from_secs(30);
81const SUPERVISOR_INTERVAL: Duration = Duration::from_secs(1);
83
84const DEFAULT_MAX_WORKER_IDLE: Duration = Duration::from_secs(60);
89const WORKER_IDLE_POLL: Duration = Duration::from_millis(500);
91
92pub const PROGRESS_CHANNEL_CAPACITY: usize = 10_000;
98
99fn try_send_progress(tx: &mpsc::Sender<ProgressUpdate>, job_id: &str, update: ProgressUpdate) {
107 if let Err(e) = tx.try_send(update) {
108 match e {
109 mpsc::error::TrySendError::Full(_) => {
110 warn!(
111 job_id,
112 capacity = PROGRESS_CHANNEL_CAPACITY,
113 "Progress channel full — dropping update (handler backpressure)"
114 );
115 }
116 mpsc::error::TrySendError::Closed(_) => {
117 }
120 }
121 }
122}
123
124pub struct ConnectionTracker {
145 pools: Mutex<HashMap<String, ServerSlot>>,
146}
147
148#[derive(Clone)]
149struct ServerSlot {
150 name: String,
151 limit: usize,
152 semaphore: Arc<tokio::sync::Semaphore>,
153 connected: Arc<AtomicUsize>,
154}
155
156impl ConnectionTracker {
157 pub fn new() -> Self {
158 Self {
159 pools: Mutex::new(HashMap::new()),
160 }
161 }
162
163 pub fn set_limit(&self, server_id: &str, server_name: &str, limit: usize) {
171 let mut pools = self.pools.lock();
172 match pools.get_mut(server_id) {
173 Some(slot) if slot.limit == limit && slot.name == server_name => {
174 }
176 Some(slot) if limit > slot.limit && slot.name == server_name => {
177 let added = limit - slot.limit;
178 let old = slot.limit;
179 slot.semaphore.add_permits(added);
180 slot.limit = limit;
181 info!(
182 server_id,
183 server = %server_name,
184 old_limit = old,
185 new_limit = limit,
186 added,
187 "Connection pool grew in place"
188 );
189 }
190 existing => {
191 let (prev_limit, prev_name) = match existing {
193 Some(s) => (Some(s.limit), Some(s.name.clone())),
194 None => (None, None),
195 };
196 pools.insert(
197 server_id.to_string(),
198 ServerSlot {
199 name: server_name.to_string(),
200 limit,
201 semaphore: Arc::new(tokio::sync::Semaphore::new(limit)),
202 connected: Arc::new(AtomicUsize::new(0)),
203 },
204 );
205 if let Some(prev) = prev_limit {
206 let renamed = prev_name.as_deref() != Some(server_name);
207 info!(
208 server_id,
209 server = %server_name,
210 old_limit = prev,
211 new_limit = limit,
212 renamed,
213 "Connection pool replaced (shrink or rename); old permits orphaned"
214 );
215 } else {
216 info!(
217 server_id,
218 server = %server_name,
219 limit,
220 "Connection pool created"
221 );
222 }
223 }
224 }
225 }
226
227 pub fn remove_server(&self, server_id: &str) {
231 self.pools.lock().remove(server_id);
232 }
233
234 pub async fn acquire(&self, server_id: &str) -> Option<ConnectionSlot> {
238 let server_slot = {
240 let pools = self.pools.lock();
241 pools.get(server_id).cloned()?
242 };
243 if server_slot.limit == 0 {
244 return None;
245 }
246 let permit = Arc::clone(&server_slot.semaphore)
247 .acquire_owned()
248 .await
249 .ok()?;
250 Some(ConnectionSlot {
251 server_id: server_id.to_string(),
252 server_name: server_slot.name,
253 semaphore_origin: server_slot.semaphore,
254 connected: server_slot.connected,
255 is_connected: false,
256 _permit: permit,
257 })
258 }
259
260 pub fn slot_is_current(&self, slot: &ConnectionSlot) -> bool {
265 matches!(self.slot_status(slot), SlotStatus::Current)
266 }
267
268 pub fn slot_status(&self, slot: &ConnectionSlot) -> SlotStatus {
271 let pools = self.pools.lock();
272 match pools.get(&slot.server_id) {
273 Some(server_slot) => {
274 if Arc::ptr_eq(&server_slot.semaphore, &slot.semaphore_origin) {
275 SlotStatus::Current
276 } else {
277 SlotStatus::PoolReplaced
278 }
279 }
280 None => SlotStatus::ServerRemoved,
281 }
282 }
283
284 pub fn snapshot(&self) -> Vec<(String, usize, usize)> {
288 let pools = self.pools.lock();
289 pools
290 .iter()
291 .map(|(id, slot)| {
292 let active = slot
293 .limit
294 .saturating_sub(slot.semaphore.available_permits());
295 (id.clone(), active, slot.limit)
296 })
297 .collect()
298 }
299
300 pub fn connected_snapshot(&self) -> Vec<(String, usize, usize)> {
303 let pools = self.pools.lock();
304 pools
305 .iter()
306 .map(|(id, slot)| {
307 (
308 id.clone(),
309 slot.connected.load(Ordering::Relaxed).min(slot.limit),
310 slot.limit,
311 )
312 })
313 .collect()
314 }
315
316 pub fn total(&self) -> usize {
320 let pools = self.pools.lock();
321 pools
322 .values()
323 .map(|s| s.limit.saturating_sub(s.semaphore.available_permits()))
324 .sum()
325 }
326}
327
328impl Default for ConnectionTracker {
329 fn default() -> Self {
330 Self::new()
331 }
332}
333
334#[derive(Debug, Clone, Copy, PartialEq, Eq)]
336pub enum SlotStatus {
337 Current,
339 PoolReplaced,
342 ServerRemoved,
344}
345
346pub struct ConnectionSlot {
351 server_id: String,
352 server_name: String,
353 semaphore_origin: Arc<tokio::sync::Semaphore>,
357 connected: Arc<AtomicUsize>,
358 is_connected: bool,
359 _permit: tokio::sync::OwnedSemaphorePermit,
360}
361
362impl ConnectionSlot {
363 pub fn server_id(&self) -> &str {
364 &self.server_id
365 }
366 pub fn server_name(&self) -> &str {
367 &self.server_name
368 }
369
370 fn mark_active(&mut self) {
371 if !self.is_connected {
372 self.connected.fetch_add(1, Ordering::Relaxed);
373 self.is_connected = true;
374 }
375 }
376
377 fn mark_inactive(&mut self) {
378 if self.is_connected {
379 self.connected.fetch_sub(1, Ordering::Relaxed);
380 self.is_connected = false;
381 }
382 }
383
384 fn activity(&mut self) -> ConnectionActivity<'_> {
385 self.mark_active();
386 ConnectionActivity { slot: self }
387 }
388}
389
390impl Drop for ConnectionSlot {
391 fn drop(&mut self) {
392 self.mark_inactive();
393 }
394}
395
396struct ConnectionActivity<'a> {
400 slot: &'a mut ConnectionSlot,
401}
402
403impl Drop for ConnectionActivity<'_> {
404 fn drop(&mut self) {
405 self.slot.mark_inactive();
406 }
407}
408
409#[derive(Debug)]
414pub struct ServerHealth {
415 pub consecutive_failures: u32,
416 pub disabled_until: Option<Instant>,
417 pub reason: Option<String>,
418 pub is_auth_failure: bool,
419}
420
421impl Default for ServerHealth {
422 fn default() -> Self {
423 Self::new()
424 }
425}
426
427impl ServerHealth {
428 pub fn new() -> Self {
429 Self {
430 consecutive_failures: 0,
431 disabled_until: None,
432 reason: None,
433 is_auth_failure: false,
434 }
435 }
436
437 pub fn is_available(&self) -> bool {
438 match self.disabled_until {
439 None => true,
440 Some(until) => Instant::now() >= until,
441 }
442 }
443
444 pub fn record_failure(&mut self, is_auth: bool, reason: &str) {
445 self.consecutive_failures += 1;
446 self.is_auth_failure = is_auth;
447 self.reason = Some(reason.to_string());
448
449 if is_auth || self.consecutive_failures >= CIRCUIT_BREAK_THRESHOLD {
450 let cooldown = if is_auth {
451 AUTH_FAILURE_COOLDOWN
452 } else {
453 TRANSIENT_FAILURE_COOLDOWN
454 };
455 self.disabled_until = Some(Instant::now() + cooldown);
456 }
457 }
458
459 pub fn record_success(&mut self) {
460 *self = Self::new();
461 }
462}
463
464pub type ServerHealthMap = Arc<Mutex<HashMap<String, ServerHealth>>>;
465
466#[derive(Debug, Clone)]
471pub enum ProgressUpdate {
472 WaitingForProviders { job_id: String, message: String },
475 ProvidersAvailable { job_id: String },
478 ArticleComplete {
479 job_id: String,
480 file_id: String,
481 segment_number: u32,
482 decoded_bytes: u64,
483 file_complete: bool,
484 server_id: Option<String>,
485 yenc_filename: Option<String>,
488 },
489 ArticleFailed {
493 job_id: String,
494 file_id: String,
495 segment_number: u32,
496 failure: crate::article_failure::ArticleFailure,
497 },
498 JobFinished {
499 job_id: String,
500 success: bool,
501 articles_failed: usize,
502 download_time_secs: f64,
503 },
504 JobAborted {
505 job_id: String,
506 reason: String,
507 articles_failed: usize,
508 download_time_secs: f64,
509 },
510}
511
512#[derive(Debug, Clone)]
517pub(crate) struct WorkItem {
518 pub(crate) job_id: String,
519 pub(crate) file_id: String,
520 pub(crate) filename: String,
521 pub(crate) message_id: String,
522 pub(crate) segment_number: u32,
523 pub(crate) tried_servers: Vec<String>,
525 pub(crate) provider_outcomes: HashMap<String, crate::article_failure::ArticleFailureKind>,
528 pub(crate) tries_on_current: u32,
530}
531
532pub(crate) struct JobContext {
542 pub job_id: String,
543 pub work_dir: PathBuf,
544 pub assembler: Arc<FileAssembler>,
545 pub progress_tx: mpsc::Sender<ProgressUpdate>,
546 pub yenc_names: Arc<Mutex<HashMap<String, String>>>,
547 pub nzb_filenames: HashMap<String, String>,
548 pub articles_remaining: AtomicUsize,
551 pub articles_failed: AtomicUsize,
552 pub paused: AtomicBool,
553 pub cancelled: AtomicBool,
554 pub abort_reason: Mutex<Option<String>>,
558 pub total_decode_us: Arc<AtomicU64>,
559 pub total_assemble_us: Arc<AtomicU64>,
560 pub total_articles_decoded: Arc<AtomicU64>,
561 pub engine_start: Instant,
562 active_started: Mutex<Option<Instant>>,
563 active_elapsed: Mutex<Duration>,
564 pub total_bytes: u64,
566 finished: AtomicBool,
568 waiting_for_providers_reported: AtomicBool,
571}
572
573pub(crate) type JobContextMap = Arc<Mutex<HashMap<String, Arc<JobContext>>>>;
574
575impl JobContext {
576 fn new(
577 job: &NzbJob,
578 assembler: Arc<FileAssembler>,
579 progress_tx: mpsc::Sender<ProgressUpdate>,
580 total_articles: usize,
581 ) -> Self {
582 let nzb_filenames = job
583 .files
584 .iter()
585 .map(|f| (f.id.clone(), f.filename.clone()))
586 .collect();
587 Self {
588 job_id: job.id.clone(),
589 work_dir: job.work_dir.clone(),
590 assembler,
591 progress_tx,
592 yenc_names: Arc::new(Mutex::new(HashMap::new())),
593 nzb_filenames,
594 articles_remaining: AtomicUsize::new(total_articles),
595 articles_failed: AtomicUsize::new(0),
596 paused: AtomicBool::new(false),
597 cancelled: AtomicBool::new(false),
598 abort_reason: Mutex::new(None),
599 total_decode_us: Arc::new(AtomicU64::new(0)),
600 total_assemble_us: Arc::new(AtomicU64::new(0)),
601 total_articles_decoded: Arc::new(AtomicU64::new(0)),
602 engine_start: Instant::now(),
603 active_started: Mutex::new(Some(Instant::now())),
604 active_elapsed: Mutex::new(Duration::ZERO),
605 total_bytes: job.total_bytes,
606 finished: AtomicBool::new(false),
607 waiting_for_providers_reported: AtomicBool::new(false),
608 }
609 }
610
611 fn report_waiting_for_providers(&self, message: &str) {
612 if self
613 .waiting_for_providers_reported
614 .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed)
615 .is_ok()
616 {
617 try_send_progress(
618 &self.progress_tx,
619 &self.job_id,
620 ProgressUpdate::WaitingForProviders {
621 job_id: self.job_id.clone(),
622 message: message.to_string(),
623 },
624 );
625 }
626 }
627
628 fn report_providers_available(&self) {
629 if self
630 .waiting_for_providers_reported
631 .swap(false, Ordering::Relaxed)
632 {
633 try_send_progress(
634 &self.progress_tx,
635 &self.job_id,
636 ProgressUpdate::ProvidersAvailable {
637 job_id: self.job_id.clone(),
638 },
639 );
640 }
641 }
642
643 fn pause_clock(&self) {
644 let mut started = self.active_started.lock();
645 if let Some(at) = started.take() {
646 *self.active_elapsed.lock() += at.elapsed();
647 }
648 }
649
650 fn resume_clock(&self) {
651 let mut started = self.active_started.lock();
652 if started.is_none() {
653 *started = Some(Instant::now());
654 }
655 }
656
657 fn active_elapsed(&self) -> Duration {
658 let elapsed = *self.active_elapsed.lock();
659 elapsed
660 + self
661 .active_started
662 .lock()
663 .map_or(Duration::ZERO, |at| at.elapsed())
664 }
665
666 fn resolve_one(&self) {
669 let prev = self.articles_remaining.fetch_sub(1, Ordering::Relaxed);
670 if prev != 1 {
671 return;
672 }
673 self.emit_terminal();
674 }
675
676 fn emit_terminal(&self) {
679 if self.finished.swap(true, Ordering::Relaxed) {
680 return;
681 }
682
683 self.deobfuscate_files();
686
687 let download_elapsed = self.engine_start.elapsed();
688 let active_elapsed = self.active_elapsed();
689 let decode_total_us = self.total_decode_us.load(Ordering::Relaxed);
690 let assemble_total_us = self.total_assemble_us.load(Ordering::Relaxed);
691 let articles_decoded = self.total_articles_decoded.load(Ordering::Relaxed);
692 let elapsed_us = download_elapsed.as_micros().max(1);
693 let throughput_mbps = (self.total_bytes as f64 / download_elapsed.as_secs_f64().max(0.001))
694 / (1024.0 * 1024.0);
695 info!(
696 job_id = %self.job_id,
697 elapsed_secs = download_elapsed.as_secs_f64(),
698 total_bytes = self.total_bytes,
699 throughput_mbps = format!("{throughput_mbps:.2}"),
700 "Download phase complete"
701 );
702 info!(
703 job_id = %self.job_id,
704 articles_decoded,
705 decode_secs = format!("{:.3}", decode_total_us as f64 / 1_000_000.0),
706 assemble_secs = format!("{:.3}", assemble_total_us as f64 / 1_000_000.0),
707 decode_pct = format!("{:.1}", decode_total_us as f64 / elapsed_us as f64 * 100.0),
708 assemble_pct = format!("{:.1}", assemble_total_us as f64 / elapsed_us as f64 * 100.0),
709 "Decode timing summary (cumulative across all workers)"
710 );
711
712 let abort_reason = self.abort_reason.lock().clone();
713 if let Some(reason) = abort_reason {
714 let failed = self.articles_failed.load(Ordering::Relaxed);
715 try_send_progress(
716 &self.progress_tx,
717 &self.job_id,
718 ProgressUpdate::JobAborted {
719 job_id: self.job_id.clone(),
720 reason,
721 articles_failed: failed,
722 download_time_secs: active_elapsed.as_secs_f64(),
723 },
724 );
725 return;
726 }
727
728 let failed = self.articles_failed.load(Ordering::Relaxed);
729 try_send_progress(
730 &self.progress_tx,
731 &self.job_id,
732 ProgressUpdate::JobFinished {
733 job_id: self.job_id.clone(),
734 success: failed == 0,
735 articles_failed: failed,
736 download_time_secs: active_elapsed.as_secs_f64(),
737 },
738 );
739 }
740
741 fn deobfuscate_files(&self) {
744 let renames = self.yenc_names.lock();
745 for (file_id, yenc_name) in renames.iter() {
746 let Some(nzb_name) = self.nzb_filenames.get(file_id) else {
747 continue;
748 };
749 if nzb_name == yenc_name {
750 continue;
751 }
752 let clean_yenc = std::path::Path::new(yenc_name.as_str())
753 .file_name()
754 .and_then(|n| n.to_str())
755 .unwrap_or(yenc_name);
756 if clean_yenc.is_empty() || nzb_name == clean_yenc {
757 continue;
758 }
759
760 let nzb_has_ext = has_known_extension(nzb_name);
761 let yenc_has_ext = has_known_extension(clean_yenc);
762
763 let (old_name, new_name) = if yenc_has_ext && !nzb_has_ext {
764 (nzb_name.as_str(), clean_yenc)
765 } else if nzb_has_ext && !yenc_has_ext {
766 continue;
767 } else if yenc_has_ext && nzb_has_ext {
768 (nzb_name.as_str(), clean_yenc)
769 } else {
770 continue;
771 };
772
773 let old_path = self.work_dir.join(old_name);
774 let new_path = self.work_dir.join(new_name);
775 if old_path.exists() && !new_path.exists() {
776 if let Err(e) = std::fs::rename(&old_path, &new_path) {
777 warn!(
778 job_id = %self.job_id,
779 from = %old_name,
780 to = %new_name,
781 "Failed to deobfuscate file: {e}"
782 );
783 } else {
784 info!(
785 job_id = %self.job_id,
786 from = %old_name,
787 to = %new_name,
788 "Deobfuscated file"
789 );
790 }
791 }
792 }
793 }
794}
795
796pub(crate) struct SharedWorkQueue {
809 inner: Mutex<QueueState>,
810 notify: Notify,
811}
812
813struct QueueState {
814 items: VecDeque<WorkItem>,
815 last_served: HashMap<String, String>,
818}
819
820impl SharedWorkQueue {
821 pub fn new() -> Self {
822 Self {
823 inner: Mutex::new(QueueState {
824 items: VecDeque::new(),
825 last_served: HashMap::new(),
826 }),
827 notify: Notify::new(),
828 }
829 }
830
831 pub fn submit_items(&self, mut items: Vec<WorkItem>) {
835 items.sort_by_key(|item| par2_sort_key(&item.filename));
836 let had_items = !items.is_empty();
837 {
838 let mut q = self.inner.lock();
839 q.items.reserve(items.len());
840 for item in items {
841 q.items.push_back(item);
842 }
843 }
844 if had_items {
845 self.notify.notify_waiters();
846 }
847 }
848
849 fn push_front(&self, item: WorkItem) {
853 self.inner.lock().items.push_front(item);
854 self.notify.notify_waiters();
855 }
856
857 fn push_back(&self, item: WorkItem) {
860 self.inner.lock().items.push_back(item);
861 self.notify.notify_waiters();
862 }
863
864 pub(crate) fn workable_count_for(
879 &self,
880 server_id: &str,
881 higher_priority_servers: &[String],
882 ) -> (usize, usize) {
883 let q = self.inner.lock();
884 let total = q.items.len();
885 let workable = q
886 .items
887 .iter()
888 .filter(|i| !i.tried_servers.iter().any(|s| s == server_id))
889 .filter(|i| {
890 higher_priority_servers
891 .iter()
892 .all(|hp| i.tried_servers.contains(hp))
893 })
894 .count();
895 (workable, total)
896 }
897
898 fn pop_workable(
915 &self,
916 server_id: &str,
917 higher_priority_servers: &[String],
918 ) -> Option<WorkItem> {
919 let mut state = self.inner.lock();
920 let last_served = state.last_served.get(server_id).cloned();
921 let is_eligible = |item: &WorkItem| {
922 !item.tried_servers.iter().any(|s| s == server_id)
923 && higher_priority_servers
924 .iter()
925 .all(|server| item.tried_servers.contains(server))
926 };
927
928 let selected = last_served
929 .as_deref()
930 .and_then(|last_job| {
931 state
932 .items
933 .iter()
934 .position(|item| is_eligible(item) && item.job_id != last_job)
935 })
936 .or_else(|| state.items.iter().position(is_eligible));
937
938 if let Some(index) = selected {
939 let item = state.items.remove(index)?;
940 state
941 .last_served
942 .insert(server_id.to_string(), item.job_id.clone());
943 Some(item)
944 } else {
945 None
946 }
947 }
948
949 fn drain_job(&self, job_id: &str) -> Vec<WorkItem> {
951 let mut q = self.inner.lock();
952 let mut kept = VecDeque::with_capacity(q.items.len());
953 let mut drained = Vec::new();
954 while let Some(item) = q.items.pop_front() {
955 if item.job_id == job_id {
956 drained.push(item);
957 } else {
958 kept.push_back(item);
959 }
960 }
961 q.items = kept;
962 drained
963 }
964
965 pub fn len(&self) -> usize {
966 self.inner.lock().items.len()
967 }
968}
969
970impl Default for SharedWorkQueue {
971 fn default() -> Self {
972 Self::new()
973 }
974}
975
976struct ActiveWorker {
981 shutdown: Arc<AtomicBool>,
982 last_progress: Arc<AtomicU64>,
987 handle: JoinHandle<()>,
988}
989
990pub struct WorkerPool {
992 work_queue: Arc<SharedWorkQueue>,
993 job_contexts: JobContextMap,
994 pub(crate) servers: Arc<Mutex<Vec<ServerConfig>>>,
995 server_health: ServerHealthMap,
996 bandwidth: Arc<BandwidthLimiter>,
997 conn_tracker: Arc<ConnectionTracker>,
998 stall_timeout: Option<Duration>,
999 created_at: Instant,
1002 max_worker_idle: Mutex<Duration>,
1006 starvation_log: Mutex<HashMap<String, Instant>>,
1009 evictions: AtomicU64,
1013 workers: Mutex<HashMap<String, Vec<ActiveWorker>>>,
1014 shutdown: Arc<AtomicBool>,
1015 supervisor_handle: Mutex<Option<JoinHandle<()>>>,
1016}
1017
1018impl WorkerPool {
1019 pub fn new(
1020 servers: Arc<Mutex<Vec<ServerConfig>>>,
1021 bandwidth: Arc<BandwidthLimiter>,
1022 conn_tracker: Arc<ConnectionTracker>,
1023 stall_timeout_secs: u64,
1024 ) -> Arc<Self> {
1025 let stall_timeout = if stall_timeout_secs > 0 {
1026 Some(Duration::from_secs(stall_timeout_secs))
1027 } else {
1028 None
1029 };
1030 Arc::new(Self {
1031 work_queue: Arc::new(SharedWorkQueue::new()),
1032 job_contexts: Arc::new(Mutex::new(HashMap::new())),
1033 servers,
1034 server_health: Arc::new(Mutex::new(HashMap::new())),
1035 bandwidth,
1036 conn_tracker,
1037 stall_timeout,
1038 created_at: Instant::now(),
1039 max_worker_idle: Mutex::new(DEFAULT_MAX_WORKER_IDLE),
1040 starvation_log: Mutex::new(HashMap::new()),
1041 evictions: AtomicU64::new(0),
1042 workers: Mutex::new(HashMap::new()),
1043 shutdown: Arc::new(AtomicBool::new(false)),
1044 supervisor_handle: Mutex::new(None),
1045 })
1046 }
1047
1048 pub fn set_max_worker_idle(&self, d: Duration) {
1051 *self.max_worker_idle.lock() = d;
1052 }
1053
1054 pub fn max_worker_idle(&self) -> Duration {
1056 *self.max_worker_idle.lock()
1057 }
1058
1059 fn elapsed_ms(&self) -> u64 {
1062 self.created_at.elapsed().as_millis() as u64
1063 }
1064
1065 fn created_at(&self) -> Instant {
1070 self.created_at
1071 }
1072
1073 fn higher_priority_servers(&self, my_priority: u8, my_server_id: &str) -> Vec<String> {
1081 let servers = self.servers.lock();
1082 let health = self.server_health.lock();
1083 servers
1084 .iter()
1085 .filter(|s| s.enabled && s.priority < my_priority && s.id != my_server_id)
1086 .filter(|s| health.get(&s.id).is_none_or(|h| h.is_available()))
1087 .map(|s| s.id.clone())
1088 .collect()
1089 }
1090
1091 fn provider_unavailable_message(&self) -> Option<&'static str> {
1095 let servers = self.servers.lock();
1096 let enabled: Vec<_> = servers.iter().filter(|server| server.enabled).collect();
1097 if enabled.is_empty() {
1098 return Some("Waiting for providers: no enabled NNTP servers are configured.");
1099 }
1100
1101 let health = self.server_health.lock();
1102 enabled
1103 .iter()
1104 .all(|server| health.get(&server.id).is_some_and(|entry| !entry.is_available()))
1105 .then_some(
1106 "Waiting for providers: every enabled server is temporarily unavailable. rustnzb will retry automatically.",
1107 )
1108 }
1109
1110 fn report_provider_outage(&self) {
1111 let Some(message) = self.provider_unavailable_message() else {
1112 return;
1113 };
1114 let contexts: Vec<_> = self.job_contexts.lock().values().cloned().collect();
1115 for ctx in contexts {
1116 ctx.report_waiting_for_providers(message);
1117 }
1118 }
1119
1120 fn report_providers_available(&self) {
1121 let contexts: Vec<_> = self.job_contexts.lock().values().cloned().collect();
1122 for ctx in contexts {
1123 ctx.report_providers_available();
1124 }
1125 }
1126
1127 pub fn eviction_count(&self) -> u64 {
1132 self.evictions.load(Ordering::Relaxed)
1133 }
1134
1135 pub fn start(self: &Arc<Self>) {
1138 self.reconcile_servers();
1139
1140 let this = Arc::clone(self);
1141 let handle = tokio::spawn(async move {
1142 this.supervisor_loop().await;
1143 });
1144 *self.supervisor_handle.lock() = Some(handle);
1145 }
1146
1147 pub fn reconcile_servers(self: &Arc<Self>) {
1154 if self.shutdown.load(Ordering::Relaxed) {
1155 return;
1156 }
1157
1158 let servers_snapshot: Vec<ServerConfig> = self.servers.lock().clone();
1159 let mut workers = self.workers.lock();
1160
1161 let mut retire: Vec<String> = Vec::new();
1163 for key in workers.keys() {
1164 let still_active = servers_snapshot.iter().any(|s| s.enabled && &s.id == key);
1165 if !still_active {
1166 retire.push(key.clone());
1167 }
1168 }
1169 for key in retire {
1170 if let Some(list) = workers.remove(&key) {
1171 for w in list {
1172 w.shutdown.store(true, Ordering::Relaxed);
1173 drop(w.handle);
1176 }
1177 }
1178 }
1179
1180 for server in &servers_snapshot {
1182 if !server.enabled {
1183 continue;
1184 }
1185 let target = (server.connections as usize).min(500);
1186 let entry = workers.entry(server.id.clone()).or_default();
1187
1188 while entry.len() > target {
1190 if let Some(w) = entry.pop() {
1191 w.shutdown.store(true, Ordering::Relaxed);
1192 drop(w.handle);
1193 }
1194 }
1195
1196 let current = entry.len();
1198 for conn_idx in current..target {
1199 let worker_shutdown = Arc::new(AtomicBool::new(false));
1200 let last_progress = Arc::new(AtomicU64::new(self.elapsed_ms()));
1203 let pool = Arc::clone(self);
1204 let server_clone = server.clone();
1205 let ws_clone = Arc::clone(&worker_shutdown);
1206 let lp_clone = Arc::clone(&last_progress);
1207 let handle = tokio::spawn(async move {
1208 pool_worker(pool, server_clone, conn_idx, ws_clone, lp_clone).await;
1209 });
1210 entry.push(ActiveWorker {
1211 shutdown: worker_shutdown,
1212 last_progress,
1213 handle,
1214 });
1215 }
1216 }
1217 }
1218
1219 pub(crate) fn submit_job(self: &Arc<Self>, ctx: Arc<JobContext>, items: Vec<WorkItem>) {
1222 let job_id = ctx.job_id.clone();
1223 if items.is_empty() {
1224 ctx.emit_terminal();
1226 return;
1227 }
1228 self.job_contexts.lock().insert(job_id.clone(), ctx);
1229 self.work_queue.submit_items(items);
1230 self.report_provider_outage();
1231 debug!(job_id = %job_id, queue_len = self.work_queue.len(), "Job submitted to worker pool");
1232 }
1233
1234 pub(crate) fn release_completed_job(&self, job_id: &str) {
1241 let ctx = self.job_contexts.lock().remove(job_id);
1242 if let Some(ctx) = ctx {
1243 ctx.assembler.clear_job(job_id);
1248 }
1249 }
1250
1251 pub fn pause_job(&self, job_id: &str) {
1254 if let Some(ctx) = self.job_contexts.lock().get(job_id) {
1255 ctx.pause_clock();
1256 ctx.paused.store(true, Ordering::Relaxed);
1257 }
1258 }
1259
1260 pub fn resume_job(&self, job_id: &str) {
1262 if let Some(ctx) = self.job_contexts.lock().get(job_id) {
1263 ctx.resume_clock();
1264 ctx.paused.store(false, Ordering::Relaxed);
1265 self.work_queue.notify.notify_waiters();
1267 }
1268 }
1269
1270 pub fn abort_job(&self, job_id: &str, reason: String) -> bool {
1273 let ctx = self.job_contexts.lock().get(job_id).cloned();
1274 let Some(ctx) = ctx else {
1275 return false;
1276 };
1277 {
1278 let mut abort_reason = ctx.abort_reason.lock();
1279 if abort_reason.is_some() {
1280 return false;
1281 }
1282 *abort_reason = Some(reason);
1283 }
1284 ctx.cancelled.store(true, Ordering::Relaxed);
1285 let drained = self.work_queue.drain_job(job_id);
1286 for _ in drained {
1289 ctx.resolve_one();
1290 }
1291 if ctx.articles_remaining.load(Ordering::Relaxed) == 0 {
1295 ctx.emit_terminal();
1296 }
1297 true
1298 }
1299
1300 pub fn cancel_job(&self, job_id: &str) {
1304 let ctx = self.job_contexts.lock().remove(job_id);
1305 let Some(ctx) = ctx else {
1306 return;
1307 };
1308 ctx.cancelled.store(true, Ordering::Relaxed);
1309 let _ = self.work_queue.drain_job(job_id);
1310 }
1311
1312 async fn supervisor_loop(self: Arc<Self>) {
1319 let mut ticker = tokio::time::interval(SUPERVISOR_INTERVAL);
1320 loop {
1321 ticker.tick().await;
1322 if self.shutdown.load(Ordering::Relaxed) {
1323 break;
1324 }
1325
1326 let now_ms = self.elapsed_ms();
1329 let max_idle_ms = self.max_worker_idle().as_millis() as u64;
1330
1331 let server_priorities: Vec<(String, u8)> = {
1338 let srv = self.servers.lock();
1339 srv.iter()
1340 .filter(|s| s.enabled)
1341 .map(|s| (s.id.clone(), s.priority))
1342 .collect()
1343 };
1344 let has_workable: HashMap<String, bool> = server_priorities
1345 .iter()
1346 .map(|(sid, prio)| {
1347 let hp = self.higher_priority_servers(*prio, sid);
1348 let (workable, _) = self.work_queue.workable_count_for(sid, &hp);
1349 (sid.clone(), workable > 0)
1350 })
1351 .collect();
1352
1353 {
1354 let workers = self.workers.lock();
1355 for (server_id, list) in workers.iter() {
1356 for (idx, w) in list.iter().enumerate() {
1357 if w.shutdown.load(Ordering::Relaxed) {
1358 continue;
1359 }
1360 let last = w.last_progress.load(Ordering::Relaxed);
1361 let idle = now_ms.saturating_sub(last);
1362 if idle > max_idle_ms {
1363 if !has_workable.get(server_id).copied().unwrap_or(true) {
1370 continue;
1371 }
1372 warn!(
1373 server = %server_id,
1374 worker_idx = idx,
1375 idle_ms = idle,
1376 max_idle_ms,
1377 "Idle-worker watchdog: evicting stalled worker"
1378 );
1379 w.shutdown.store(true, Ordering::Relaxed);
1380 self.evictions.fetch_add(1, Ordering::Relaxed);
1381 }
1382 }
1383 }
1384 }
1385
1386 {
1388 let mut workers = self.workers.lock();
1389 for list in workers.values_mut() {
1390 list.retain(|w| !w.handle.is_finished());
1391 }
1392 }
1393 self.reconcile_servers();
1395
1396 let now_instant = Instant::now();
1403 for (sid, prio) in &server_priorities {
1404 let hp = self.higher_priority_servers(*prio, sid);
1405 let (workable, total) = self.work_queue.workable_count_for(sid, &hp);
1406 if workable == 0 && total > 0 {
1407 let mut log = self.starvation_log.lock();
1408 let should_log = log
1409 .get(sid)
1410 .map(|t| now_instant.duration_since(*t) >= Duration::from_secs(60))
1411 .unwrap_or(true);
1412 if should_log {
1413 log.insert(sid.clone(), now_instant);
1414 let reason = if hp.is_empty() {
1415 "every item has been tried here already"
1416 } else {
1417 "every item has been tried here, or is waiting for a higher-priority server"
1418 };
1419 info!(
1420 server = %sid,
1421 total_items = total,
1422 higher_priority_servers = hp.len(),
1423 "Queue has items but none are workable for this server ({reason})"
1424 );
1425 }
1426 }
1427 }
1428 }
1429 }
1430
1431 pub async fn shutdown(self: &Arc<Self>) {
1433 self.shutdown.store(true, Ordering::Relaxed);
1434 let handles: Vec<JoinHandle<()>> = {
1435 let mut workers = self.workers.lock();
1436 let mut out = Vec::new();
1437 for (_id, list) in workers.drain() {
1438 for w in list {
1439 w.shutdown.store(true, Ordering::Relaxed);
1440 out.push(w.handle);
1441 }
1442 }
1443 out
1444 };
1445 self.work_queue.notify.notify_waiters();
1447
1448 let timeout = Duration::from_secs(10);
1449 for h in handles {
1450 let _ = tokio::time::timeout(timeout, h).await;
1451 }
1452
1453 if let Some(h) = self.supervisor_handle.lock().take() {
1454 h.abort();
1455 }
1456 }
1457
1458 pub fn conn_tracker(&self) -> &Arc<ConnectionTracker> {
1459 &self.conn_tracker
1460 }
1461
1462 pub fn has_job(&self, job_id: &str) -> bool {
1464 self.job_contexts.lock().contains_key(job_id)
1465 }
1466}
1467
1468async fn pool_worker(
1481 pool: Arc<WorkerPool>,
1482 primary_server: ServerConfig,
1483 conn_idx: usize,
1484 worker_shutdown: Arc<AtomicBool>,
1485 last_progress: Arc<AtomicU64>,
1486) {
1487 let worker_id = format!("{}#{}", primary_server.id, conn_idx);
1488
1489 if conn_idx > 0 {
1491 let stagger = WORKER_RAMP_DELAY * conn_idx as u32;
1492 tokio::time::sleep(stagger).await;
1493 }
1494
1495 let should_exit = |worker_shutdown: &Arc<AtomicBool>, pool: &Arc<WorkerPool>| {
1496 worker_shutdown.load(Ordering::Relaxed) || pool.shutdown.load(Ordering::Relaxed)
1497 };
1498
1499 let mut conn_slot = match pool.conn_tracker.acquire(&primary_server.id).await {
1502 Some(slot) => slot,
1503 None => {
1504 info!(
1505 worker = %worker_id,
1506 server = %primary_server.name,
1507 "No connection slot available (server removed or limit=0); worker exiting"
1508 );
1509 return;
1510 }
1511 };
1512
1513 'reconnect: loop {
1514 if should_exit(&worker_shutdown, &pool) {
1515 return;
1516 }
1517
1518 match pool.conn_tracker.slot_status(&conn_slot) {
1521 SlotStatus::Current => {}
1522 SlotStatus::PoolReplaced => {
1523 info!(
1524 worker = %worker_id,
1525 server = %primary_server.name,
1526 reason = "pool_replaced",
1527 "Connection slot is stale (connection limit changed); worker exiting"
1528 );
1529 return;
1530 }
1531 SlotStatus::ServerRemoved => {
1532 info!(
1533 worker = %worker_id,
1534 server = %primary_server.name,
1535 reason = "server_removed",
1536 "Connection slot is stale (server removed from tracker); worker exiting"
1537 );
1538 return;
1539 }
1540 }
1541
1542 let circuit_broken = {
1545 let health = pool.server_health.lock();
1546 health
1547 .get(&primary_server.id)
1548 .is_some_and(|h| !h.is_available())
1549 };
1550 if circuit_broken {
1551 pool.report_provider_outage();
1552 tokio::time::sleep(WORKER_IDLE_POLL).await;
1553 continue 'reconnect;
1554 }
1555
1556 info!(
1557 worker = %worker_id,
1558 server = %primary_server.name,
1559 host = %primary_server.host,
1560 port = primary_server.port,
1561 ssl = primary_server.ssl,
1562 conn_idx,
1563 "Worker starting — connecting to primary server"
1564 );
1565
1566 let mut conn = NntpConnection::new(worker_id.clone());
1567 conn.set_io_heartbeat(last_progress.clone(), pool.created_at());
1575 if let Err(e) = connect_with_retry(
1576 &mut conn,
1577 &primary_server,
1578 &worker_id,
1579 &pool.server_health,
1580 &pool.servers,
1581 )
1582 .await
1583 {
1584 warn!(
1585 worker = %worker_id,
1586 server = %primary_server.name,
1587 host = %primary_server.host,
1588 "Worker FAILED to connect after all retries: {e}"
1589 );
1590 if should_exit(&worker_shutdown, &pool) {
1591 return;
1592 }
1593 pool.report_provider_outage();
1594 tokio::time::sleep(RECONNECT_DELAY).await;
1595 continue 'reconnect;
1596 }
1597 pool.report_providers_available();
1598 let pipe_depth = primary_server.pipelining.max(1);
1599 let active_conns = pool.conn_tracker.total();
1600 info!(
1601 worker = %worker_id,
1602 server = %primary_server.name,
1603 host = %primary_server.host,
1604 pipelining = pipe_depth,
1605 total_nntp_connections = active_conns,
1606 "Worker connected and ready"
1607 );
1608
1609 let reconnect_needed = if pipe_depth <= 1 {
1615 run_worker_serial(
1616 &pool,
1617 &primary_server,
1618 &worker_id,
1619 &worker_shutdown,
1620 &mut conn,
1621 &mut conn_slot,
1622 &last_progress,
1623 )
1624 .await
1625 } else {
1626 run_worker_pipelined(
1627 &pool,
1628 &primary_server,
1629 &worker_id,
1630 pipe_depth,
1631 &worker_shutdown,
1632 &mut conn,
1633 &mut conn_slot,
1634 &last_progress,
1635 )
1636 .await
1637 };
1638
1639 let _ = conn.quit().await;
1640 match reconnect_needed {
1641 WorkerExit::Reconnect => {
1642 continue 'reconnect;
1644 }
1645 WorkerExit::Exit => {
1646 return;
1648 }
1649 }
1650 }
1651}
1652
1653enum WorkerExit {
1654 Exit,
1656 Reconnect,
1658}
1659
1660async fn next_work_item(
1667 pool: &Arc<WorkerPool>,
1668 server_id: &str,
1669 higher_priority_servers: &[String],
1670 worker_shutdown: &Arc<AtomicBool>,
1671) -> Option<(WorkItem, Arc<JobContext>)> {
1672 loop {
1673 if worker_shutdown.load(Ordering::Relaxed) || pool.shutdown.load(Ordering::Relaxed) {
1674 return None;
1675 }
1676
1677 if let Some(item) = pool
1678 .work_queue
1679 .pop_workable(server_id, higher_priority_servers)
1680 {
1681 let ctx = pool.job_contexts.lock().get(&item.job_id).cloned();
1684 let Some(ctx) = ctx else {
1685 continue;
1686 };
1687 if ctx.cancelled.load(Ordering::Relaxed) {
1688 ctx.resolve_one();
1689 continue;
1690 }
1691 if ctx.paused.load(Ordering::Relaxed) {
1693 pool.work_queue.push_back(item);
1694 tokio::time::sleep(WORKER_IDLE_POLL).await;
1695 continue;
1696 }
1697 return Some((item, ctx));
1698 }
1699
1700 let notified = pool.work_queue.notify.notified();
1703 tokio::select! {
1704 _ = notified => {}
1705 _ = tokio::time::sleep(WORKER_IDLE_POLL) => {}
1706 }
1707 }
1708}
1709
1710async fn run_worker_serial(
1711 pool: &Arc<WorkerPool>,
1712 primary_server: &ServerConfig,
1713 worker_id: &str,
1714 worker_shutdown: &Arc<AtomicBool>,
1715 conn: &mut NntpConnection,
1716 conn_slot: &mut ConnectionSlot,
1717 last_progress: &Arc<AtomicU64>,
1718) -> WorkerExit {
1719 let mut consecutive_errors: u32 = 0;
1720
1721 loop {
1722 let server_disabled = pool
1724 .servers
1725 .lock()
1726 .iter()
1727 .find(|s| s.id == primary_server.id)
1728 .is_none_or(|s| !s.enabled);
1729 if server_disabled {
1730 info!(
1731 worker = %worker_id,
1732 server = %primary_server.name,
1733 "Server disabled, worker exiting"
1734 );
1735 return WorkerExit::Exit;
1736 }
1737 {
1738 let health = pool.server_health.lock();
1739 if let Some(h) = health.get(&primary_server.id)
1740 && !h.is_available()
1741 {
1742 info!(
1743 worker = %worker_id,
1744 server = %primary_server.name,
1745 reason = h.reason.as_deref().unwrap_or("unknown"),
1746 "Server circuit-broken, worker reconnecting after cooldown"
1747 );
1748 return WorkerExit::Reconnect;
1749 }
1750 }
1751
1752 let higher_priority_servers =
1759 pool.higher_priority_servers(primary_server.priority, &primary_server.id);
1760
1761 let Some((mut item, ctx)) = next_work_item(
1762 pool,
1763 &primary_server.id,
1764 &higher_priority_servers,
1765 worker_shutdown,
1766 )
1767 .await
1768 else {
1769 return WorkerExit::Exit;
1770 };
1771
1772 let result = {
1773 let _activity = conn_slot.activity();
1774 let fetch_fut =
1775 fetch_article_with_retry(conn, &item, &ctx.assembler, primary_server, worker_id);
1776 if let Some(timeout) = pool.stall_timeout {
1777 match tokio::time::timeout(timeout, fetch_fut).await {
1778 Ok(r) => r,
1779 Err(_) => {
1780 warn!(
1781 worker = %worker_id,
1782 server = %primary_server.name,
1783 article = %item.message_id,
1784 "Connection stalled — no response within {}s, reconnecting",
1785 timeout.as_secs()
1786 );
1787 pool.work_queue.push_front(item);
1788 return WorkerExit::Reconnect;
1789 }
1790 }
1791 } else {
1792 fetch_fut.await
1793 }
1794 };
1795
1796 match result {
1797 Ok(process_result) => {
1798 consecutive_errors = 0;
1799 ctx.total_decode_us
1800 .fetch_add(process_result.decode_us, Ordering::Relaxed);
1801 ctx.total_assemble_us
1802 .fetch_add(process_result.assemble_us, Ordering::Relaxed);
1803 ctx.total_articles_decoded.fetch_add(1, Ordering::Relaxed);
1804 if let Some(ref yname) = process_result.yenc_filename {
1805 ctx.yenc_names
1806 .lock()
1807 .entry(item.file_id.clone())
1808 .or_insert_with(|| crate::util::normalize_nfc(yname));
1809 }
1810 if let Some(n) = std::num::NonZeroU32::new(process_result.decoded_bytes as u32) {
1811 let _ = pool.bandwidth.acquire_download(n).await;
1812 }
1813 try_send_progress(
1814 &ctx.progress_tx,
1815 &item.job_id,
1816 ProgressUpdate::ArticleComplete {
1817 job_id: item.job_id.clone(),
1818 file_id: item.file_id.clone(),
1819 segment_number: item.segment_number,
1820 decoded_bytes: process_result.decoded_bytes,
1821 file_complete: process_result.file_complete,
1822 server_id: Some(primary_server.id.clone()),
1823 yenc_filename: process_result.yenc_filename.clone(),
1824 },
1825 );
1826 ctx.resolve_one();
1827 last_progress.store(pool.elapsed_ms(), Ordering::Relaxed);
1829 }
1830 Err(ArticleError::ArticleNotFound) => {
1831 if handle_article_not_available(
1832 &mut item,
1833 primary_server,
1834 &pool.servers,
1835 &pool.server_health,
1836 &ctx,
1837 &pool.work_queue,
1838 worker_id,
1839 crate::article_failure::ArticleFailureKind::NotFound,
1840 "Article not found on any server",
1841 ) {
1842 last_progress.store(pool.elapsed_ms(), Ordering::Relaxed);
1843 }
1844 }
1845 Err(ArticleError::ConnectionLost(msg)) => {
1846 increment_counter("nntp.transient_reconnects");
1847 consecutive_errors += 1;
1848 warn!(
1849 worker = %worker_id,
1850 server = %primary_server.name,
1851 host = %primary_server.host,
1852 consecutive_errors,
1853 max_reconnects = MAX_RECONNECT_ATTEMPTS,
1854 article = %item.message_id,
1855 "Connection lost: {msg}"
1856 );
1857 pool.work_queue.push_front(item);
1858 if consecutive_errors > MAX_RECONNECT_ATTEMPTS {
1859 warn!(
1860 worker = %worker_id,
1861 server = %primary_server.name,
1862 host = %primary_server.host,
1863 consecutive_errors,
1864 "Too many consecutive errors — worker reconnecting"
1865 );
1866 return WorkerExit::Reconnect;
1867 }
1868 return WorkerExit::Reconnect;
1869 }
1870 Err(ArticleError::ProviderUnavailable { kind, message }) => {
1871 increment_counter("nntp.transient_reconnects");
1872 let is_auth = matches!(
1873 kind,
1874 crate::article_failure::ArticleFailureKind::AuthFailed
1875 | crate::article_failure::ArticleFailureKind::PermissionDenied
1876 );
1877 warn!(
1878 job_id = %item.job_id,
1879 file_id = %item.file_id,
1880 segment_number = item.segment_number,
1881 message_id = %item.message_id,
1882 server_id = %primary_server.id,
1883 worker_id = %worker_id,
1884 original_failure_kind = kind.as_str(),
1885 "Provider unavailable during article fetch: {message}"
1886 );
1887 pool.server_health
1888 .lock()
1889 .entry(primary_server.id.clone())
1890 .or_default()
1891 .record_failure(is_auth, &message);
1892 pool.report_provider_outage();
1893 pool.work_queue.push_front(item);
1894 return WorkerExit::Reconnect;
1895 }
1896 Err(ArticleError::DecodeError(msg)) => {
1897 if handle_article_not_available(
1898 &mut item,
1899 primary_server,
1900 &pool.servers,
1901 &pool.server_health,
1902 &ctx,
1903 &pool.work_queue,
1904 worker_id,
1905 crate::article_failure::ArticleFailureKind::DecodeError,
1906 &format!("Decode error: {msg}"),
1907 ) {
1908 last_progress.store(pool.elapsed_ms(), Ordering::Relaxed);
1909 }
1910 }
1911 Err(ArticleError::AssemblyError(msg)) => {
1912 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}");
1913 try_send_progress(
1914 &ctx.progress_tx,
1915 &item.job_id,
1916 ProgressUpdate::ArticleFailed {
1917 job_id: item.job_id.clone(),
1918 file_id: item.file_id.clone(),
1919 segment_number: item.segment_number,
1920 failure: crate::article_failure::ArticleFailure::decode_error(
1921 &primary_server.id,
1922 format!("Assembly error: {msg}"),
1923 ),
1924 },
1925 );
1926 ctx.articles_failed.fetch_add(1, Ordering::Relaxed);
1927 ctx.resolve_one();
1928 last_progress.store(pool.elapsed_ms(), Ordering::Relaxed);
1929 }
1930 }
1931 }
1932}
1933
1934#[allow(clippy::too_many_arguments)]
1935async fn run_worker_pipelined(
1936 pool: &Arc<WorkerPool>,
1937 primary_server: &ServerConfig,
1938 worker_id: &str,
1939 pipe_depth: u8,
1940 worker_shutdown: &Arc<AtomicBool>,
1941 conn: &mut NntpConnection,
1942 conn_slot: &mut ConnectionSlot,
1943 last_progress: &Arc<AtomicU64>,
1944) -> WorkerExit {
1945 let mut pipeline = Pipeline::new(pipe_depth);
1946 let mut in_flight_items: HashMap<u64, WorkItem> = HashMap::new();
1947 let mut next_tag: u64 = 0;
1948 let mut consecutive_errors: u32 = 0;
1949
1950 let mut perf_articles: u64 = 0;
1952 let mut perf_bytes: u64 = 0;
1953 let mut perf_queue_lock_us: u64 = 0;
1954 let mut perf_receive_us: u64 = 0;
1955 let mut perf_decode_us: u64 = 0;
1956 let mut perf_assemble_us: u64 = 0;
1957 let mut perf_bandwidth_us: u64 = 0;
1958 let mut perf_yield_us: u64 = 0;
1959 let mut perf_flush_us: u64 = 0;
1960 let mut perf_last_log = Instant::now();
1961 const PERF_LOG_INTERVAL: Duration = Duration::from_secs(10);
1962
1963 loop {
1964 if worker_shutdown.load(Ordering::Relaxed) || pool.shutdown.load(Ordering::Relaxed) {
1965 requeue_all(&mut in_flight_items, &pool.work_queue);
1966 return WorkerExit::Exit;
1967 }
1968
1969 let server_disabled = pool
1971 .servers
1972 .lock()
1973 .iter()
1974 .find(|s| s.id == primary_server.id)
1975 .is_none_or(|s| !s.enabled);
1976 if server_disabled {
1977 info!(
1978 worker = %worker_id,
1979 server = %primary_server.name,
1980 "Server disabled, worker exiting"
1981 );
1982 requeue_all(&mut in_flight_items, &pool.work_queue);
1983 return WorkerExit::Exit;
1984 }
1985 {
1986 let health = pool.server_health.lock();
1987 if let Some(h) = health.get(&primary_server.id)
1988 && !h.is_available()
1989 {
1990 info!(
1991 worker = %worker_id,
1992 server = %primary_server.name,
1993 reason = h.reason.as_deref().unwrap_or("unknown"),
1994 "Server circuit-broken, worker reconnecting after cooldown"
1995 );
1996 requeue_all(&mut in_flight_items, &pool.work_queue);
1997 return WorkerExit::Reconnect;
1998 }
1999 }
2000
2001 let higher_priority_servers =
2006 pool.higher_priority_servers(primary_server.priority, &primary_server.id);
2007
2008 while pipeline.pending_count() + pipeline.in_flight_count() < pipe_depth as usize {
2010 let lock_t = Instant::now();
2011 let item = pool
2012 .work_queue
2013 .pop_workable(&primary_server.id, &higher_priority_servers);
2014 perf_queue_lock_us += lock_t.elapsed().as_micros() as u64;
2015 let Some(item) = item else {
2016 break;
2017 };
2018 let ctx = pool.job_contexts.lock().get(&item.job_id).cloned();
2020 let Some(ctx) = ctx else {
2021 continue;
2022 };
2023 if ctx.cancelled.load(Ordering::Relaxed) {
2024 ctx.resolve_one();
2025 continue;
2026 }
2027 if ctx.paused.load(Ordering::Relaxed) {
2028 pool.work_queue.push_back(item);
2029 break;
2030 }
2031 let tag = next_tag;
2032 next_tag += 1;
2033 pipeline.submit(item.message_id.clone(), tag);
2034 in_flight_items.insert(tag, item);
2035 }
2036
2037 if pipeline.is_empty() && in_flight_items.is_empty() {
2039 let Some((first_item, ctx)) = next_work_item(
2040 pool,
2041 &primary_server.id,
2042 &higher_priority_servers,
2043 worker_shutdown,
2044 )
2045 .await
2046 else {
2047 return WorkerExit::Exit;
2048 };
2049 let _ = ctx; let tag = next_tag;
2051 next_tag += 1;
2052 pipeline.submit(first_item.message_id.clone(), tag);
2053 in_flight_items.insert(tag, first_item);
2054 }
2055
2056 let _activity = conn_slot.activity();
2057 let flush_t = Instant::now();
2058 if let Err(e) = pipeline.flush_sends(conn).await {
2059 warn!(
2060 worker = %worker_id,
2061 server = %primary_server.name,
2062 host = %primary_server.host,
2063 error = %e,
2064 in_flight = in_flight_items.len(),
2065 "Pipeline send error — re-queuing all in-flight items"
2066 );
2067 requeue_all(&mut in_flight_items, &pool.work_queue);
2068 consecutive_errors += 1;
2069 if consecutive_errors > MAX_RECONNECT_ATTEMPTS {
2070 warn!(
2071 worker = %worker_id,
2072 server = %primary_server.name,
2073 consecutive_errors,
2074 "Too many pipeline errors — worker reconnecting"
2075 );
2076 return WorkerExit::Reconnect;
2077 }
2078 tokio::time::sleep(RECONNECT_DELAY).await;
2079 return WorkerExit::Reconnect;
2080 }
2081 perf_flush_us += flush_t.elapsed().as_micros() as u64;
2082
2083 let recv_t = Instant::now();
2085 trace!(
2086 worker = %worker_id,
2087 in_flight = in_flight_items.len(),
2088 stall_timeout_secs = pool.stall_timeout.map(|d| d.as_secs()).unwrap_or(0),
2089 "Pipeline: awaiting response"
2090 );
2091 let result = if let Some(timeout) = pool.stall_timeout {
2092 match tokio::time::timeout(timeout, pipeline.receive_one(conn)).await {
2093 Ok(r) => r,
2094 Err(_) => {
2095 let elapsed_ms = recv_t.elapsed().as_millis();
2096 warn!(
2097 worker = %worker_id,
2098 server = %primary_server.name,
2099 elapsed_ms,
2100 in_flight = in_flight_items.len(),
2101 "Connection stalled — no response within {}s, reconnecting",
2102 timeout.as_secs()
2103 );
2104 requeue_all(&mut in_flight_items, &pool.work_queue);
2105 return WorkerExit::Reconnect;
2106 }
2107 }
2108 } else {
2109 pipeline.receive_one(conn).await
2110 };
2111 perf_receive_us += recv_t.elapsed().as_micros() as u64;
2112
2113 match result {
2114 Ok(Some(pipe_result)) => {
2115 let Some(mut item) = in_flight_items.remove(&pipe_result.request.tag) else {
2116 continue;
2117 };
2118 let ctx = pool.job_contexts.lock().get(&item.job_id).cloned();
2120 let Some(ctx) = ctx else {
2121 continue;
2122 };
2123 if ctx.cancelled.load(Ordering::Relaxed) {
2124 ctx.resolve_one();
2125 continue;
2126 }
2127
2128 match pipe_result.result {
2129 Ok(response) => {
2130 consecutive_errors = 0;
2131 let raw_data = response.data.unwrap_or_default();
2132 let yield_t = Instant::now();
2133 tokio::task::yield_now().await;
2134 perf_yield_us += yield_t.elapsed().as_micros() as u64;
2135 let decode_result = decode_and_assemble(&item, &raw_data, &ctx.assembler);
2136 conn.release_body_buffer(raw_data);
2140 match decode_result {
2141 Ok(process_result) => {
2142 perf_decode_us += process_result.decode_us;
2143 perf_assemble_us += process_result.assemble_us;
2144 perf_bytes += process_result.decoded_bytes;
2145 perf_articles += 1;
2146 ctx.total_decode_us
2147 .fetch_add(process_result.decode_us, Ordering::Relaxed);
2148 ctx.total_assemble_us
2149 .fetch_add(process_result.assemble_us, Ordering::Relaxed);
2150 ctx.total_articles_decoded.fetch_add(1, Ordering::Relaxed);
2151 if let Some(ref yname) = process_result.yenc_filename {
2152 ctx.yenc_names
2153 .lock()
2154 .entry(item.file_id.clone())
2155 .or_insert_with(|| crate::util::normalize_nfc(yname));
2156 }
2157 let bw_t = Instant::now();
2158 if let Some(n) =
2159 std::num::NonZeroU32::new(process_result.decoded_bytes as u32)
2160 {
2161 let _ = pool.bandwidth.acquire_download(n).await;
2162 }
2163 perf_bandwidth_us += bw_t.elapsed().as_micros() as u64;
2164 try_send_progress(
2165 &ctx.progress_tx,
2166 &item.job_id,
2167 ProgressUpdate::ArticleComplete {
2168 job_id: item.job_id.clone(),
2169 file_id: item.file_id.clone(),
2170 segment_number: item.segment_number,
2171 decoded_bytes: process_result.decoded_bytes,
2172 file_complete: process_result.file_complete,
2173 server_id: Some(primary_server.id.clone()),
2174 yenc_filename: process_result.yenc_filename.clone(),
2175 },
2176 );
2177 ctx.resolve_one();
2178 last_progress.store(pool.elapsed_ms(), Ordering::Relaxed);
2180
2181 if perf_last_log.elapsed() >= PERF_LOG_INTERVAL {
2182 let elapsed = perf_last_log.elapsed().as_secs_f64();
2183 let mbps = perf_bytes as f64 / elapsed / (1024.0 * 1024.0);
2184 info!(
2185 worker = %worker_id,
2186 articles = perf_articles,
2187 throughput_mbps = format!("{mbps:.1}"),
2188 recv_ms = perf_receive_us / 1000,
2189 decode_ms = perf_decode_us / 1000,
2190 assemble_ms = perf_assemble_us / 1000,
2191 queue_lock_ms = perf_queue_lock_us / 1000,
2192 flush_ms = perf_flush_us / 1000,
2193 yield_ms = perf_yield_us / 1000,
2194 bw_wait_ms = perf_bandwidth_us / 1000,
2195 "Worker perf summary"
2196 );
2197 perf_articles = 0;
2198 perf_bytes = 0;
2199 perf_queue_lock_us = 0;
2200 perf_receive_us = 0;
2201 perf_decode_us = 0;
2202 perf_assemble_us = 0;
2203 perf_bandwidth_us = 0;
2204 perf_yield_us = 0;
2205 perf_flush_us = 0;
2206 perf_last_log = Instant::now();
2207 }
2208 }
2209 Err(ArticleError::DecodeError(msg)) => {
2210 if handle_article_not_available(
2211 &mut item,
2212 primary_server,
2213 &pool.servers,
2214 &pool.server_health,
2215 &ctx,
2216 &pool.work_queue,
2217 worker_id,
2218 crate::article_failure::ArticleFailureKind::DecodeError,
2219 &format!("Decode error: {msg}"),
2220 ) {
2221 last_progress.store(pool.elapsed_ms(), Ordering::Relaxed);
2222 }
2223 }
2224 Err(ArticleError::AssemblyError(msg)) => {
2225 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}");
2226 try_send_progress(
2227 &ctx.progress_tx,
2228 &item.job_id,
2229 ProgressUpdate::ArticleFailed {
2230 job_id: item.job_id.clone(),
2231 file_id: item.file_id.clone(),
2232 segment_number: item.segment_number,
2233 failure:
2234 crate::article_failure::ArticleFailure::decode_error(
2235 &primary_server.id,
2236 format!("Assembly error: {msg}"),
2237 ),
2238 },
2239 );
2240 ctx.articles_failed.fetch_add(1, Ordering::Relaxed);
2241 ctx.resolve_one();
2242 last_progress.store(pool.elapsed_ms(), Ordering::Relaxed);
2243 }
2244 Err(_) => {}
2245 }
2246 }
2247 Err(NntpError::ArticleNotFound(_)) => {
2248 if handle_article_not_available(
2249 &mut item,
2250 primary_server,
2251 &pool.servers,
2252 &pool.server_health,
2253 &ctx,
2254 &pool.work_queue,
2255 worker_id,
2256 crate::article_failure::ArticleFailureKind::NotFound,
2257 "Article not found on any server",
2258 ) {
2259 last_progress.store(pool.elapsed_ms(), Ordering::Relaxed);
2260 }
2261 }
2262 Err(NntpError::Connection(_) | NntpError::Io(_)) => {
2263 increment_counter("nntp.transient_reconnects");
2264 warn!(
2265 job_id = %item.job_id,
2266 file_id = %item.file_id,
2267 segment_number = item.segment_number,
2268 worker = %worker_id,
2269 server = %primary_server.name,
2270 host = %primary_server.host,
2271 article = %item.message_id,
2272 in_flight = in_flight_items.len(),
2273 consecutive_errors,
2274 "Pipeline: connection lost during receive — re-queuing all"
2275 );
2276 pool.work_queue.push_front(item);
2277 requeue_all(&mut in_flight_items, &pool.work_queue);
2278 consecutive_errors += 1;
2279 if consecutive_errors > MAX_RECONNECT_ATTEMPTS {
2280 return WorkerExit::Reconnect;
2281 }
2282 tokio::time::sleep(RECONNECT_DELAY).await;
2283 return WorkerExit::Reconnect;
2284 }
2285 Err(e) => {
2286 increment_counter("nntp.transient_reconnects");
2287 let failure = crate::article_failure::ArticleFailure::from_nntp(
2288 &e,
2289 &primary_server.id,
2290 );
2291 warn!(
2292 job_id = %item.job_id,
2293 file_id = %item.file_id,
2294 segment_number = item.segment_number,
2295 message_id = %item.message_id,
2296 server_id = %primary_server.id,
2297 worker_id = %worker_id,
2298 original_failure_kind = failure.kind.as_str(),
2299 attempt = item.tries_on_current + 1,
2300 "Transient pipeline error — re-queuing in-flight work and reconnecting: {e}"
2301 );
2302 let is_auth = matches!(
2303 failure.kind,
2304 crate::article_failure::ArticleFailureKind::AuthFailed
2305 | crate::article_failure::ArticleFailureKind::PermissionDenied
2306 );
2307 pool.server_health
2308 .lock()
2309 .entry(primary_server.id.clone())
2310 .or_default()
2311 .record_failure(is_auth, &e.to_string());
2312 pool.report_provider_outage();
2313 pool.work_queue.push_front(item);
2314 requeue_all(&mut in_flight_items, &pool.work_queue);
2315 return WorkerExit::Reconnect;
2316 }
2317 }
2318 }
2319 Ok(None) => {
2320 }
2322 Err(e) => {
2323 warn!(
2324 worker = %worker_id,
2325 server = %primary_server.name,
2326 host = %primary_server.host,
2327 error = %e,
2328 in_flight = in_flight_items.len(),
2329 consecutive_errors,
2330 "Pipeline receive error"
2331 );
2332 requeue_all(&mut in_flight_items, &pool.work_queue);
2333 consecutive_errors += 1;
2334 if consecutive_errors > MAX_RECONNECT_ATTEMPTS {
2335 return WorkerExit::Reconnect;
2336 }
2337 tokio::time::sleep(RECONNECT_DELAY).await;
2338 return WorkerExit::Reconnect;
2339 }
2340 }
2341 }
2342}
2343
2344async fn connect_with_retry(
2349 conn: &mut NntpConnection,
2350 server: &ServerConfig,
2351 worker_id: &str,
2352 server_health: &ServerHealthMap,
2353 all_servers: &Arc<Mutex<Vec<ServerConfig>>>,
2354) -> Result<(), String> {
2355 for attempt in 1..=MAX_RECONNECT_ATTEMPTS {
2356 {
2357 let health = server_health.lock();
2358 if let Some(h) = health.get(&server.id)
2359 && !h.is_available()
2360 {
2361 return Err(format!(
2362 "Server circuit-broken: {}",
2363 h.reason.as_deref().unwrap_or("unknown")
2364 ));
2365 }
2366 }
2367
2368 let current_config = all_servers
2369 .lock()
2370 .iter()
2371 .find(|s| s.id == server.id)
2372 .cloned()
2373 .unwrap_or_else(|| server.clone());
2374
2375 info!(
2376 worker = %worker_id,
2377 server = %current_config.name,
2378 host = %current_config.host,
2379 port = current_config.port,
2380 attempt,
2381 max_attempts = MAX_RECONNECT_ATTEMPTS,
2382 "Connect attempt starting"
2383 );
2384 match conn.connect(¤t_config).await {
2385 Ok(()) => {
2386 info!(
2387 worker = %worker_id,
2388 server = %current_config.name,
2389 host = %current_config.host,
2390 attempt,
2391 "Connect attempt succeeded"
2392 );
2393 server_health
2394 .lock()
2395 .entry(server.id.clone())
2396 .or_default()
2397 .record_success();
2398 return Ok(());
2399 }
2400 Err(e) => {
2401 let is_auth = matches!(e, NntpError::Auth(_) | NntpError::ServiceUnavailable(_));
2402 {
2403 let mut health = server_health.lock();
2404 let entry = health.entry(server.id.clone()).or_default();
2405 entry.record_failure(is_auth, &e.to_string());
2406 if !entry.is_available() {
2407 warn!(
2408 worker = %worker_id,
2409 server = %current_config.name,
2410 host = %current_config.host,
2411 error = %e,
2412 cooldown_secs = if is_auth { AUTH_FAILURE_COOLDOWN.as_secs() } else { TRANSIENT_FAILURE_COOLDOWN.as_secs() },
2413 "Server circuit-broken — stopping all connection attempts"
2414 );
2415 return Err(format!("Server circuit-broken: {e}"));
2416 }
2417 }
2418
2419 warn!(
2420 worker = %worker_id,
2421 server = %current_config.name,
2422 host = %current_config.host,
2423 attempt,
2424 max_attempts = MAX_RECONNECT_ATTEMPTS,
2425 error = %e,
2426 is_auth,
2427 "Connect attempt FAILED: {e}"
2428 );
2429
2430 if is_auth {
2431 return Err(format!("Auth/permission failure: {e}"));
2432 }
2433
2434 if attempt < MAX_RECONNECT_ATTEMPTS {
2435 info!(
2436 worker = %worker_id,
2437 server = %current_config.name,
2438 delay_secs = RECONNECT_DELAY.as_secs(),
2439 "Waiting before retry"
2440 );
2441 tokio::time::sleep(RECONNECT_DELAY).await;
2442 *conn = NntpConnection::new(worker_id.to_string());
2443 } else {
2444 return Err(format!(
2445 "All {MAX_RECONNECT_ATTEMPTS} connect attempts failed: {e}"
2446 ));
2447 }
2448 }
2449 }
2450 }
2451 Err("Connect retry loop exited unexpectedly".into())
2452}
2453
2454#[allow(clippy::too_many_arguments)]
2466fn handle_article_not_available(
2467 item: &mut WorkItem,
2468 primary_server: &ServerConfig,
2469 all_servers: &Arc<Mutex<Vec<ServerConfig>>>,
2470 _server_health: &ServerHealthMap,
2471 ctx: &Arc<JobContext>,
2472 work_queue: &Arc<SharedWorkQueue>,
2473 worker_id: &str,
2474 kind: crate::article_failure::ArticleFailureKind,
2475 error_msg: &str,
2476) -> bool {
2477 item.provider_outcomes
2478 .insert(primary_server.id.clone(), kind);
2479 if !item.tried_servers.contains(&primary_server.id) {
2480 item.tried_servers.push(primary_server.id.clone());
2481 }
2482 item.tries_on_current = 0;
2483
2484 let all_definitive =
2485 all_enabled_providers_definitive(&all_servers.lock(), &item.provider_outcomes);
2486
2487 debug!(
2488 article = %item.message_id,
2489 server = %primary_server.id,
2490 kind = kind.as_str(),
2491 tried_count = item.tried_servers.len(),
2492 all_definitive,
2493 "Article returned error on this server"
2494 );
2495
2496 if all_definitive {
2498 let mut provider_outcomes = item
2499 .provider_outcomes
2500 .iter()
2501 .map(|(server, outcome)| format!("{server}={}", outcome.as_str()))
2502 .collect::<Vec<_>>();
2503 provider_outcomes.sort_unstable();
2504 let outcomes = provider_outcomes.join(",");
2505 let all_not_found = item
2506 .provider_outcomes
2507 .values()
2508 .all(|outcome| *outcome == crate::article_failure::ArticleFailureKind::NotFound);
2509 let final_kind = if all_not_found {
2510 crate::article_failure::ArticleFailureKind::NotFound
2511 } else {
2512 crate::article_failure::ArticleFailureKind::DecodeError
2513 };
2514 warn!(
2515 job_id = %item.job_id,
2516 file_id = %item.file_id,
2517 segment_number = item.segment_number,
2518 message_id = %item.message_id,
2519 server_id = %primary_server.id,
2520 worker_id = %worker_id,
2521 original_failure_kind = kind.as_str(),
2522 terminal_failure_kind = final_kind.as_str(),
2523 attempt = item.tried_servers.len(),
2524 provider_outcomes = %outcomes,
2525 "{error_msg}"
2526 );
2527 let final_failure = if final_kind == crate::article_failure::ArticleFailureKind::DecodeError
2528 {
2529 crate::article_failure::ArticleFailure::decode_error(
2530 &primary_server.id,
2531 format!("{error_msg}; provider outcomes: {outcomes}"),
2532 )
2533 } else {
2534 increment_counter("articles.explicit_global_absence");
2535 crate::article_failure::ArticleFailure::not_found_anywhere(&primary_server.id, outcomes)
2536 };
2537 try_send_progress(
2538 &ctx.progress_tx,
2539 &item.job_id,
2540 ProgressUpdate::ArticleFailed {
2541 job_id: item.job_id.clone(),
2542 file_id: item.file_id.clone(),
2543 segment_number: item.segment_number,
2544 failure: final_failure,
2545 },
2546 );
2547 ctx.articles_failed.fetch_add(1, Ordering::Relaxed);
2548 ctx.resolve_one();
2549 true
2550 } else {
2551 work_queue.push_front(item.clone());
2559 false
2560 }
2561}
2562
2563fn all_enabled_providers_definitive(
2564 servers: &[ServerConfig],
2565 outcomes: &HashMap<String, crate::article_failure::ArticleFailureKind>,
2566) -> bool {
2567 servers
2568 .iter()
2569 .filter(|server| server.enabled)
2570 .all(|server| outcomes.contains_key(&server.id))
2571}
2572
2573fn requeue_all(in_flight: &mut HashMap<u64, WorkItem>, work_queue: &Arc<SharedWorkQueue>) {
2575 let items: Vec<WorkItem> = in_flight.drain().map(|(_, item)| item).collect();
2576 for item in items {
2577 work_queue.push_front(item);
2578 }
2579}
2580
2581fn par2_sort_key(filename: &str) -> u8 {
2584 let lower = filename.to_lowercase();
2585 if lower.ends_with(".par2") {
2586 if lower.contains(".vol") { 1 } else { 0 }
2587 } else {
2588 2
2589 }
2590}
2591
2592pub fn has_known_extension(name: &str) -> bool {
2593 let lower = name.to_lowercase();
2594 if let Some(dot_pos) = lower.rfind('.') {
2595 let ext = &lower[dot_pos + 1..];
2596 matches!(
2597 ext,
2598 "rar"
2599 | "r00"
2600 | "r01"
2601 | "r02"
2602 | "r03"
2603 | "r04"
2604 | "r05"
2605 | "zip"
2606 | "7z"
2607 | "gz"
2608 | "bz2"
2609 | "xz"
2610 | "tar"
2611 | "mkv"
2612 | "mp4"
2613 | "avi"
2614 | "wmv"
2615 | "ts"
2616 | "m4v"
2617 | "mov"
2618 | "mpg"
2619 | "mpeg"
2620 | "mp3"
2621 | "flac"
2622 | "ogg"
2623 | "m4a"
2624 | "aac"
2625 | "wav"
2626 | "srt"
2627 | "sub"
2628 | "idx"
2629 | "ass"
2630 | "ssa"
2631 | "sup"
2632 | "nfo"
2633 | "jpg"
2634 | "jpeg"
2635 | "png"
2636 | "gif"
2637 | "bmp"
2638 | "par2"
2639 | "001"
2640 | "002"
2641 | "003"
2642 | "004"
2643 | "005"
2644 )
2645 } else {
2646 false
2647 }
2648}
2649
2650pub(crate) fn build_job_submission(
2657 job: &NzbJob,
2658 progress_tx: mpsc::Sender<ProgressUpdate>,
2659) -> (Arc<JobContext>, Vec<WorkItem>) {
2660 let assembler = Arc::new(FileAssembler::new());
2661 for file in &job.files {
2662 let output_path = job.work_dir.join(&file.filename);
2663 if let Err(e) =
2664 assembler.register_file(&job.id, &file.id, output_path, file.articles.len() as u32)
2665 {
2666 error!(file = %file.filename, "Failed to register file for assembly: {e}");
2667 }
2668 }
2669
2670 let work_items: Vec<WorkItem> = job
2671 .files
2672 .iter()
2673 .flat_map(|file| {
2674 file.articles
2675 .iter()
2676 .filter(|article| !article.downloaded)
2677 .map(move |article| WorkItem {
2678 job_id: job.id.clone(),
2679 file_id: file.id.clone(),
2680 filename: file.filename.clone(),
2681 message_id: article.message_id.clone(),
2682 segment_number: article.segment_number,
2683 tried_servers: Vec::new(),
2684 provider_outcomes: HashMap::new(),
2685 tries_on_current: 0,
2686 })
2687 })
2688 .collect();
2689
2690 let total_remaining = work_items.len();
2691 let ctx = Arc::new(JobContext::new(
2692 job,
2693 assembler,
2694 progress_tx,
2695 total_remaining,
2696 ));
2697 (ctx, work_items)
2698}
2699
2700async fn fetch_article_with_retry(
2705 conn: &mut NntpConnection,
2706 item: &WorkItem,
2707 assembler: &FileAssembler,
2708 _server: &ServerConfig,
2709 worker_id: &str,
2710) -> Result<ProcessResult, ArticleError> {
2711 let mut last_error = None;
2712
2713 for attempt in 1..=MAX_TRIES_PER_SERVER {
2714 let fetch_start = Instant::now();
2715 match conn.fetch_article(&item.message_id).await {
2716 Ok(response) => {
2717 let fetch_us = fetch_start.elapsed().as_micros();
2718 let raw_data = response.data.unwrap_or_default();
2719 debug!(
2720 worker = %worker_id,
2721 article = %item.message_id,
2722 raw_bytes = raw_data.len(),
2723 fetch_us,
2724 "NNTP fetch complete"
2725 );
2726 let result = decode_and_assemble(item, &raw_data, assembler);
2727 conn.release_body_buffer(raw_data);
2730 return result;
2731 }
2732 Err(NntpError::ArticleNotFound(_)) => {
2733 debug!(
2734 worker = %worker_id,
2735 article = %item.message_id,
2736 "Article not found (430) — will try next server"
2737 );
2738 return Err(ArticleError::ArticleNotFound);
2739 }
2740 Err(e @ (NntpError::Connection(_) | NntpError::Io(_))) => {
2741 warn!(
2742 worker = %worker_id,
2743 article = %item.message_id,
2744 attempt,
2745 error = %e,
2746 conn_state = ?conn.state,
2747 "Connection/IO error during fetch — connection lost"
2748 );
2749 return Err(ArticleError::ConnectionLost(format!(
2750 "Connection error on attempt {attempt}: {e}"
2751 )));
2752 }
2753 Err(e @ NntpError::Tls(_)) => {
2754 warn!(
2755 worker = %worker_id,
2756 article = %item.message_id,
2757 attempt,
2758 error = %e,
2759 "TLS error during fetch — connection lost"
2760 );
2761 return Err(ArticleError::ConnectionLost(format!("TLS error: {e}")));
2762 }
2763 Err(e @ NntpError::ServiceUnavailable(_)) => {
2764 warn!(
2765 worker = %worker_id,
2766 article = %item.message_id,
2767 attempt,
2768 error = %e,
2769 "Service unavailable (502) during article fetch — likely rate limited or blocked"
2770 );
2771 return Err(ArticleError::ProviderUnavailable {
2772 kind: crate::article_failure::ArticleFailureKind::ServerDown,
2773 message: e.to_string(),
2774 });
2775 }
2776 Err(e @ (NntpError::AuthRequired(_) | NntpError::Auth(_))) => {
2777 warn!(
2778 worker = %worker_id,
2779 article = %item.message_id,
2780 attempt,
2781 error = %e,
2782 "Auth required (480) during article fetch — session expired or rate limited"
2783 );
2784 return Err(ArticleError::ProviderUnavailable {
2785 kind: crate::article_failure::ArticleFailureKind::AuthFailed,
2786 message: e.to_string(),
2787 });
2788 }
2789 Err(e @ NntpError::PermissionDenied(_)) => {
2790 return Err(ArticleError::ProviderUnavailable {
2791 kind: crate::article_failure::ArticleFailureKind::PermissionDenied,
2792 message: e.to_string(),
2793 });
2794 }
2795 Err(e) => {
2796 last_error = Some(format!("{e}"));
2797 if attempt < MAX_TRIES_PER_SERVER {
2798 warn!(
2799 worker = %worker_id,
2800 article = %item.message_id,
2801 attempt,
2802 max_tries = MAX_TRIES_PER_SERVER,
2803 error = %e,
2804 "Transient fetch error, retrying in 500ms"
2805 );
2806 tokio::time::sleep(Duration::from_millis(500)).await;
2807 } else {
2808 warn!(
2809 worker = %worker_id,
2810 article = %item.message_id,
2811 attempt,
2812 error = %e,
2813 "All retries on this server exhausted"
2814 );
2815 }
2816 }
2817 }
2818 }
2819
2820 Err(ArticleError::ConnectionLost(
2821 last_error.unwrap_or_else(|| "Unknown error after retries".into()),
2822 ))
2823}
2824
2825#[derive(Debug)]
2830struct ProcessResult {
2831 decoded_bytes: u64,
2832 file_complete: bool,
2833 yenc_filename: Option<String>,
2834 decode_us: u64,
2835 assemble_us: u64,
2836}
2837
2838#[derive(Debug, thiserror::Error)]
2839enum ArticleError {
2840 #[error("Article not found on server")]
2841 ArticleNotFound,
2842 #[error("Connection lost: {0}")]
2843 ConnectionLost(String),
2844 #[error("Provider unavailable ({kind:?}): {message}")]
2845 ProviderUnavailable {
2846 kind: crate::article_failure::ArticleFailureKind,
2847 message: String,
2848 },
2849 #[error("Decode error: {0}")]
2850 DecodeError(String),
2851 #[error("Assembly error: {0}")]
2852 AssemblyError(String),
2853}
2854
2855fn decode_and_assemble(
2856 item: &WorkItem,
2857 raw_data: &[u8],
2858 assembler: &FileAssembler,
2859) -> Result<ProcessResult, ArticleError> {
2860 let decode_start = Instant::now();
2861 let decoded = decode_yenc(raw_data).map_err(|e| {
2862 ArticleError::DecodeError(format!(
2863 "yEnc decode failed for {} seg {}: {e}",
2864 item.filename, item.segment_number
2865 ))
2866 })?;
2867 let decode_us = decode_start.elapsed().as_micros();
2868
2869 let yenc_filename = decoded.filename;
2870 let data_begin = decoded.part_begin.unwrap_or(0);
2871 let decoded_len = decoded.data.len() as u64;
2872
2873 let assemble_start = Instant::now();
2874 let file_complete = assembler
2875 .assemble_article(
2876 &item.job_id,
2877 &item.file_id,
2878 item.segment_number,
2879 data_begin,
2880 &decoded.data,
2881 )
2882 .map_err(|e| {
2883 ArticleError::AssemblyError(format!(
2884 "Assembly failed for {} seg {}: {e}",
2885 item.filename, item.segment_number
2886 ))
2887 })?;
2888 let assemble_us = assemble_start.elapsed().as_micros();
2889
2890 debug!(
2891 file = %item.filename,
2892 segment = item.segment_number,
2893 raw_bytes = raw_data.len(),
2894 decoded_bytes = decoded_len,
2895 decode_us,
2896 assemble_us,
2897 "Article decode+assemble timing"
2898 );
2899
2900 Ok(ProcessResult {
2901 decoded_bytes: decoded_len,
2902 file_complete,
2903 yenc_filename,
2904 decode_us: decode_us as u64,
2905 assemble_us: assemble_us as u64,
2906 })
2907}
2908
2909#[cfg(test)]
2914mod tests {
2915 use super::*;
2916
2917 fn worker_pool_without_servers() -> Arc<WorkerPool> {
2918 WorkerPool::new(
2919 Arc::new(Mutex::new(Vec::new())),
2920 Arc::new(BandwidthLimiter::new(Default::default())),
2921 Arc::new(ConnectionTracker::new()),
2922 0,
2923 )
2924 }
2925
2926 fn test_job(job_id: &str, root: &std::path::Path) -> NzbJob {
2927 NzbJob {
2928 id: job_id.to_string(),
2929 name: job_id.to_string(),
2930 category: "Default".to_string(),
2931 status: nzb_core::models::JobStatus::Downloading,
2932 priority: nzb_core::models::Priority::Normal,
2933 total_bytes: 1,
2934 downloaded_bytes: 0,
2935 file_count: 1,
2936 files_completed: 0,
2937 article_count: 1,
2938 articles_downloaded: 0,
2939 articles_failed: 0,
2940 added_at: chrono::Utc::now(),
2941 completed_at: None,
2942 work_dir: root.to_path_buf(),
2943 output_dir: root.join("complete"),
2944 password: None,
2945 error_message: None,
2946 speed_bps: 0,
2947 server_stats: Vec::new(),
2948 files: Vec::new(),
2949 }
2950 }
2951
2952 fn insert_test_context(pool: &WorkerPool, job: &NzbJob, assembler: Arc<FileAssembler>) {
2953 let (progress_tx, _progress_rx) = mpsc::channel(1);
2954 let ctx = Arc::new(JobContext::new(job, assembler, progress_tx, 1));
2955 pool.job_contexts.lock().insert(job.id.clone(), ctx);
2956 }
2957
2958 #[test]
2959 fn submission_preserves_declared_segment_numbers() {
2960 let temp = tempfile::tempdir().unwrap();
2961 let mut job = test_job("segment-identity", temp.path());
2962 let article = |message_id: &str, segment_number: u32, bytes: u64| nzb_nntp::Article {
2963 message_id: message_id.to_string(),
2964 segment_number,
2965 bytes,
2966 downloaded: false,
2967 data_begin: None,
2968 data_size: None,
2969 crc32: None,
2970 tried_servers: Vec::new(),
2971 tries: 0,
2972 };
2973 job.files.push(nzb_core::models::NzbFile {
2974 id: "file-1".into(),
2975 filename: "out-of-order.bin".into(),
2976 bytes: 1_000,
2977 bytes_downloaded: 0,
2978 is_par2: false,
2979 par2_setname: None,
2980 par2_vol: None,
2981 par2_blocks: None,
2982 assembled: false,
2983 groups: Vec::new(),
2984 articles: vec![article("third", 3, 300), article("first", 1, 100)],
2985 });
2986 let (tx, _rx) = mpsc::channel(4);
2987
2988 let (_ctx, items) = build_job_submission(&job, tx);
2989
2990 assert_eq!(
2991 items
2992 .iter()
2993 .map(|item| (item.message_id.as_str(), item.segment_number))
2994 .collect::<Vec<_>>(),
2995 vec![("third", 3), ("first", 1)]
2996 );
2997 }
2998
2999 #[cfg(target_os = "linux")]
3000 fn open_fd_count_under(root: &std::path::Path) -> usize {
3001 std::fs::read_dir("/proc/self/fd")
3002 .expect("read /proc/self/fd")
3003 .filter_map(Result::ok)
3004 .filter_map(|entry| std::fs::read_link(entry.path()).ok())
3005 .filter(|target| target.starts_with(root))
3006 .count()
3007 }
3008
3009 #[test]
3010 fn has_known_extension_recognizes_archives() {
3011 assert!(has_known_extension("movie.rar"));
3012 assert!(has_known_extension("movie.part01.rar"));
3013 assert!(has_known_extension("file.zip"));
3014 assert!(has_known_extension("file.7z"));
3015 assert!(has_known_extension("archive.001"));
3016 }
3017
3018 #[test]
3019 fn has_known_extension_recognizes_video() {
3020 assert!(has_known_extension("episode.mkv"));
3021 assert!(has_known_extension("movie.mp4"));
3022 assert!(has_known_extension("video.avi"));
3023 assert!(has_known_extension("clip.ts"));
3024 }
3025
3026 #[test]
3027 fn has_known_extension_recognizes_par2() {
3028 assert!(has_known_extension("file.par2"));
3029 assert!(has_known_extension("file.vol00+01.par2"));
3030 assert!(has_known_extension("file.vol015-031.par2"));
3031 }
3032
3033 #[test]
3034 fn has_known_extension_recognizes_misc() {
3035 assert!(has_known_extension("info.nfo"));
3036 assert!(has_known_extension("sub.srt"));
3037 assert!(has_known_extension("cover.jpg"));
3038 assert!(has_known_extension("song.flac"));
3039 }
3040
3041 #[test]
3042 fn has_known_extension_rejects_obfuscated_hashes() {
3043 assert!(!has_known_extension("9b6a324d7560b87091685020371ba869"));
3044 assert!(!has_known_extension("1fG1GP7L2263LHXH213HTNIxZsX7l0cv44BZ"));
3045 assert!(!has_known_extension("DfKUx3bl7L6PSo6276WSaXSZ7"));
3046 assert!(!has_known_extension("Q77O1ZxL237vc241z77hFoLBxl"));
3047 }
3048
3049 #[test]
3050 fn has_known_extension_rejects_unknown_extensions() {
3051 assert!(!has_known_extension("file.xyz123"));
3052 assert!(!has_known_extension("noext"));
3053 assert!(!has_known_extension(""));
3054 }
3055
3056 #[test]
3057 fn has_known_extension_case_insensitive() {
3058 assert!(has_known_extension("file.RAR"));
3059 assert!(has_known_extension("file.MKV"));
3060 assert!(has_known_extension("file.Par2"));
3061 assert!(has_known_extension("file.MP4"));
3062 }
3063
3064 fn make_item(job_id: &str, msg_id: &str, filename: &str) -> WorkItem {
3065 WorkItem {
3066 job_id: job_id.to_string(),
3067 file_id: "f1".to_string(),
3068 filename: filename.to_string(),
3069 message_id: msg_id.to_string(),
3070 segment_number: 1,
3071 tried_servers: Vec::new(),
3072 provider_outcomes: HashMap::new(),
3073 tries_on_current: 0,
3074 }
3075 }
3076
3077 #[test]
3078 fn shared_queue_par2_first() {
3079 let q = SharedWorkQueue::new();
3080 q.submit_items(vec![
3081 make_item("j1", "a", "movie.rar"),
3082 make_item("j1", "b", "movie.par2"),
3083 make_item("j1", "c", "movie.vol00+01.par2"),
3084 make_item("j1", "d", "movie.r00"),
3085 ]);
3086 let first = q.pop_workable("srv1", &[]).unwrap();
3087 assert_eq!(first.filename, "movie.par2", "index file first");
3088 let second = q.pop_workable("srv1", &[]).unwrap();
3089 assert_eq!(second.filename, "movie.vol00+01.par2", "vol file second");
3090 }
3091
3092 #[test]
3093 fn shared_queue_skips_tried_servers() {
3094 let q = SharedWorkQueue::new();
3095 let mut item = make_item("j1", "a", "file.rar");
3096 item.tried_servers.push("srv1".to_string());
3097 q.submit_items(vec![item, make_item("j1", "b", "other.rar")]);
3098
3099 let picked = q.pop_workable("srv1", &[]).unwrap();
3101 assert_eq!(picked.message_id, "b");
3102 }
3103
3104 #[test]
3105 fn shared_queue_round_robins_eligible_jobs_per_server() {
3106 let q = SharedWorkQueue::new();
3107 q.submit_items(vec![
3108 make_item("job-a", "a-1", "first.rar"),
3109 make_item("job-a", "a-2", "second.rar"),
3110 make_item("job-b", "b-1", "third.rar"),
3111 ]);
3112
3113 assert_eq!(q.pop_workable("srv1", &[]).unwrap().job_id, "job-a");
3114 assert_eq!(q.pop_workable("srv1", &[]).unwrap().job_id, "job-b");
3115 assert_eq!(q.pop_workable("srv1", &[]).unwrap().job_id, "job-a");
3116 }
3117
3118 #[tokio::test]
3119 async fn provider_outage_is_reported_once_then_cleared_on_recovery() {
3120 let temp = tempfile::tempdir().unwrap();
3121 let server = ServerConfig::new("srv1", "one.invalid");
3122 let pool = WorkerPool::new(
3123 Arc::new(Mutex::new(vec![server.clone()])),
3124 Arc::new(BandwidthLimiter::new(Default::default())),
3125 Arc::new(ConnectionTracker::new()),
3126 0,
3127 );
3128 let job = test_job("provider-outage", temp.path());
3129 let (progress_tx, mut progress_rx) = mpsc::channel(4);
3130 let ctx = Arc::new(JobContext::new(
3131 &job,
3132 Arc::new(FileAssembler::new()),
3133 progress_tx,
3134 1,
3135 ));
3136 pool.job_contexts.lock().insert(job.id.clone(), ctx);
3137 pool.server_health
3138 .lock()
3139 .entry(server.id.clone())
3140 .or_default()
3141 .record_failure(false, "connection refused");
3142 pool.server_health
3143 .lock()
3144 .entry(server.id.clone())
3145 .or_default()
3146 .record_failure(false, "connection refused");
3147 pool.server_health
3148 .lock()
3149 .entry(server.id.clone())
3150 .or_default()
3151 .record_failure(false, "connection refused");
3152
3153 pool.report_provider_outage();
3154 pool.report_provider_outage();
3155 match progress_rx.recv().await.unwrap() {
3156 ProgressUpdate::WaitingForProviders { message, .. } => {
3157 assert!(message.contains("retry automatically"));
3158 }
3159 other => panic!("unexpected progress event: {other:?}"),
3160 }
3161 assert!(
3162 progress_rx.try_recv().is_err(),
3163 "outage event is deduplicated"
3164 );
3165
3166 pool.server_health
3167 .lock()
3168 .get_mut(&server.id)
3169 .unwrap()
3170 .record_success();
3171 pool.report_providers_available();
3172 assert!(matches!(
3173 progress_rx.recv().await,
3174 Some(ProgressUpdate::ProvidersAvailable { .. })
3175 ));
3176 }
3177
3178 #[test]
3179 fn unavailable_provider_does_not_prove_global_absence() {
3180 let servers = vec![
3181 ServerConfig::new("srv1", "one.invalid"),
3182 ServerConfig::new("srv2", "two.invalid"),
3183 ];
3184 let mut outcomes = HashMap::new();
3185 outcomes.insert(
3186 "srv1".to_string(),
3187 crate::article_failure::ArticleFailureKind::NotFound,
3188 );
3189
3190 assert!(!all_enabled_providers_definitive(&servers, &outcomes));
3191 outcomes.insert(
3192 "srv2".to_string(),
3193 crate::article_failure::ArticleFailureKind::NotFound,
3194 );
3195 assert!(all_enabled_providers_definitive(&servers, &outcomes));
3196 }
3197
3198 #[tokio::test]
3199 async fn abort_has_one_owner_and_waits_for_in_flight_resolution() {
3200 let temp = tempfile::tempdir().unwrap();
3201 let pool = worker_pool_without_servers();
3202 let job = test_job("abort-drain", temp.path());
3203 let assembler = Arc::new(FileAssembler::new());
3204 let (progress_tx, mut progress_rx) = mpsc::channel(8);
3205 let ctx = Arc::new(JobContext::new(&job, assembler, progress_tx, 3));
3206 ctx.articles_failed.store(2, Ordering::Relaxed);
3207 pool.job_contexts
3208 .lock()
3209 .insert(job.id.clone(), Arc::clone(&ctx));
3210 pool.work_queue.submit_items(vec![
3211 make_item(&job.id, "queued-1", "file.rar"),
3212 make_item(&job.id, "queued-2", "file.rar"),
3213 ]);
3214
3215 assert!(pool.abort_job(&job.id, "original reason".into()));
3216 assert!(!pool.abort_job(&job.id, "overwritten reason".into()));
3217 assert_eq!(ctx.articles_remaining.load(Ordering::Relaxed), 1);
3218 assert!(!ctx.finished.load(Ordering::Relaxed));
3219 assert!(progress_rx.try_recv().is_err());
3220
3221 ctx.resolve_one();
3224 match progress_rx.recv().await.unwrap() {
3225 ProgressUpdate::JobAborted {
3226 reason,
3227 articles_failed,
3228 download_time_secs,
3229 ..
3230 } => {
3231 assert_eq!(reason, "original reason");
3232 assert_eq!(articles_failed, 2);
3233 assert!(download_time_secs >= 0.0);
3234 }
3235 other => panic!("unexpected terminal event: {other:?}"),
3236 }
3237 }
3238
3239 #[test]
3240 fn pop_workable_respects_priority() {
3241 let q = SharedWorkQueue::new();
3245 q.submit_items(vec![make_item("j1", "a", "file.rar")]);
3246
3247 let higher = vec!["srv_primary".to_string()];
3248 assert!(q.pop_workable("srv_backup", &higher).is_none());
3249
3250 let item = q.pop_workable("srv_primary", &[]).unwrap();
3252 assert_eq!(item.message_id, "a");
3253 }
3254
3255 #[test]
3256 fn pop_workable_allows_backup_after_primary_tried() {
3257 let q = SharedWorkQueue::new();
3260 let mut item = make_item("j1", "a", "file.rar");
3261 item.tried_servers.push("srv_primary".to_string());
3262 q.submit_items(vec![item]);
3263
3264 let higher = vec!["srv_primary".to_string()];
3265 let picked = q.pop_workable("srv_backup", &higher).unwrap();
3266 assert_eq!(picked.message_id, "a");
3267 }
3268
3269 #[test]
3270 fn pop_workable_ignores_circuit_broken_higher_server() {
3271 let q = SharedWorkQueue::new();
3275 q.submit_items(vec![make_item("j1", "a", "file.rar")]);
3276
3277 let higher: Vec<String> = vec![]; let item = q.pop_workable("srv_backup", &higher).unwrap();
3279 assert_eq!(item.message_id, "a");
3280 }
3281
3282 #[test]
3283 fn workable_count_for_respects_priority() {
3284 let q = SharedWorkQueue::new();
3285 q.submit_items(vec![
3286 make_item("j1", "a", "a.rar"),
3287 make_item("j1", "b", "b.rar"),
3288 ]);
3289
3290 let higher = vec!["srv_primary".to_string()];
3292 let (workable, total) = q.workable_count_for("srv_backup", &higher);
3293 assert_eq!(workable, 0);
3294 assert_eq!(total, 2);
3295
3296 let (workable, total) = q.workable_count_for("srv_primary", &[]);
3298 assert_eq!(workable, 2);
3299 assert_eq!(total, 2);
3300 }
3301
3302 #[test]
3303 fn shared_queue_drain_job_removes_only_target() {
3304 let q = SharedWorkQueue::new();
3305 q.submit_items(vec![
3306 make_item("j1", "a", "a.rar"),
3307 make_item("j2", "b", "b.rar"),
3308 make_item("j1", "c", "c.rar"),
3309 ]);
3310 let drained = q.drain_job("j1");
3311 assert_eq!(drained.len(), 2);
3312 assert_eq!(q.len(), 1);
3313 let remaining = q.pop_workable("srv1", &[]).unwrap();
3314 assert_eq!(remaining.job_id, "j2");
3315 }
3316
3317 #[test]
3318 fn release_completed_job_drops_context_and_closes_assembler_files() {
3319 let tempdir = tempfile::tempdir().expect("tempdir");
3320 let assembler = Arc::new(FileAssembler::new());
3321 let job_id = "completed-job";
3322 assembler
3323 .register_file(job_id, "file-1", tempdir.path().join("file.rar"), 1)
3324 .expect("register file");
3325 assert_eq!(assembler.get_file_progress(job_id, "file-1"), (0, 1));
3326
3327 let job = test_job(job_id, tempdir.path());
3328 let pool = worker_pool_without_servers();
3329 insert_test_context(&pool, &job, Arc::clone(&assembler));
3330 assert!(pool.has_job(job_id));
3331
3332 pool.release_completed_job(job_id);
3333
3334 assert!(!pool.has_job(job_id));
3335 assert_eq!(assembler.get_file_progress(job_id, "file-1"), (0, 0));
3336 }
3337
3338 #[cfg(target_os = "linux")]
3339 #[test]
3340 fn repeated_completed_jobs_do_not_accumulate_file_descriptors() {
3341 const JOBS: usize = 64;
3342 const FILES_PER_JOB: usize = 8;
3343
3344 let tempdir = tempfile::tempdir().expect("tempdir");
3345 let pool = worker_pool_without_servers();
3346 assert_eq!(open_fd_count_under(tempdir.path()), 0);
3347
3348 for job_index in 0..JOBS {
3349 let job_id = format!("job-{job_index}");
3350 let job_dir = tempdir.path().join(&job_id);
3351 let assembler = Arc::new(FileAssembler::new());
3352 for file_index in 0..FILES_PER_JOB {
3353 assembler
3354 .register_file(
3355 &job_id,
3356 &format!("file-{file_index}"),
3357 job_dir.join(format!("file-{file_index}.rar")),
3358 1,
3359 )
3360 .expect("register file");
3361 }
3362 assert_eq!(open_fd_count_under(tempdir.path()), FILES_PER_JOB);
3363
3364 let job = test_job(&job_id, &job_dir);
3365 insert_test_context(&pool, &job, assembler);
3366 pool.release_completed_job(&job_id);
3367
3368 assert!(!pool.has_job(&job_id));
3369 assert_eq!(
3370 open_fd_count_under(tempdir.path()),
3371 0,
3372 "completed job {job_index} retained output file descriptors"
3373 );
3374 }
3375
3376 assert!(pool.job_contexts.lock().is_empty());
3377 }
3378
3379 #[tokio::test]
3384 async fn connection_tracker_acquire_releases_slot_on_drop() {
3385 let t = ConnectionTracker::new();
3386 t.set_limit("srv1", "Server 1", 2);
3387 let s1 = t.acquire("srv1").await.unwrap();
3388 let s2 = t.acquire("srv1").await.unwrap();
3389 assert_eq!(t.total(), 2);
3390 drop(s1);
3392 assert_eq!(t.total(), 1);
3393 drop(s2);
3394 assert_eq!(t.total(), 0);
3395 }
3396
3397 #[tokio::test]
3398 async fn connection_tracker_blocks_at_limit() {
3399 let t = Arc::new(ConnectionTracker::new());
3400 t.set_limit("srv1", "Server 1", 1);
3401 let _held = t.acquire("srv1").await.unwrap();
3402
3403 let t2 = Arc::clone(&t);
3406 let res = tokio::time::timeout(Duration::from_millis(150), async move {
3407 t2.acquire("srv1").await
3408 })
3409 .await;
3410 assert!(
3411 res.is_err(),
3412 "second acquire should block while limit is reached"
3413 );
3414 }
3415
3416 #[tokio::test]
3417 async fn connection_tracker_grow_in_place_lets_more_acquire() {
3418 let t = ConnectionTracker::new();
3419 t.set_limit("srv1", "Server 1", 2);
3420 let _a = t.acquire("srv1").await.unwrap();
3421 let _b = t.acquire("srv1").await.unwrap();
3422 t.set_limit("srv1", "Server 1", 4);
3425 let _c = t.acquire("srv1").await.unwrap();
3426 let _d = t.acquire("srv1").await.unwrap();
3427 assert_eq!(t.total(), 4);
3428 }
3429
3430 #[tokio::test]
3431 async fn connection_tracker_shrink_marks_old_slots_stale() {
3432 let t = ConnectionTracker::new();
3433 t.set_limit("srv1", "Server 1", 4);
3434 let s = t.acquire("srv1").await.unwrap();
3435 assert!(t.slot_is_current(&s));
3436
3437 t.set_limit("srv1", "Server 1", 1);
3439 assert!(
3440 !t.slot_is_current(&s),
3441 "after shrink, the previously-acquired slot must be marked stale"
3442 );
3443
3444 assert_eq!(t.total(), 0);
3448
3449 let new_slot = t.acquire("srv1").await.unwrap();
3451 assert!(t.slot_is_current(&new_slot));
3452 assert_eq!(t.total(), 1);
3453 }
3454
3455 #[tokio::test]
3456 async fn connection_tracker_remove_server_marks_slot_stale() {
3457 let t = ConnectionTracker::new();
3458 t.set_limit("srv1", "Server 1", 2);
3459 let s = t.acquire("srv1").await.unwrap();
3460 assert!(t.slot_is_current(&s));
3461
3462 t.remove_server("srv1");
3463 assert!(
3464 !t.slot_is_current(&s),
3465 "after remove_server, the slot must be marked stale"
3466 );
3467 assert_eq!(t.total(), 0);
3468 }
3469
3470 #[tokio::test]
3471 async fn connection_tracker_snapshot_reflects_active_count() {
3472 let t = ConnectionTracker::new();
3473 t.set_limit("srv1", "Server 1", 3);
3474 t.set_limit("srv2", "Server 2", 5);
3475
3476 let _a1 = t.acquire("srv1").await.unwrap();
3477 let _a2 = t.acquire("srv1").await.unwrap();
3478 let _b1 = t.acquire("srv2").await.unwrap();
3479
3480 let mut snap = t.snapshot();
3481 snap.sort_by(|a, b| a.0.cmp(&b.0));
3482 assert_eq!(snap.len(), 2);
3483 assert_eq!(snap[0], ("srv1".into(), 2, 3));
3484 assert_eq!(snap[1], ("srv2".into(), 1, 5));
3485 }
3486
3487 #[tokio::test]
3488 async fn active_snapshot_counts_only_article_activity() {
3489 let t = ConnectionTracker::new();
3490 t.set_limit("srv1", "Server 1", 3);
3491
3492 let mut connected = t.acquire("srv1").await.unwrap();
3493 let _disconnected = t.acquire("srv1").await.unwrap();
3494 assert_eq!(t.connected_snapshot(), vec![("srv1".into(), 0, 3)]);
3495
3496 connected.mark_active();
3497 assert_eq!(t.connected_snapshot(), vec![("srv1".into(), 1, 3)]);
3498
3499 connected.mark_inactive();
3500 assert_eq!(t.connected_snapshot(), vec![("srv1".into(), 0, 3)]);
3501 }
3502
3503 #[tokio::test]
3504 async fn activity_guard_clears_count_when_fetch_scope_ends() {
3505 let t = ConnectionTracker::new();
3506 t.set_limit("srv1", "Server 1", 1);
3507 let mut slot = t.acquire("srv1").await.unwrap();
3508
3509 {
3510 let _activity = slot.activity();
3511 assert_eq!(t.connected_snapshot(), vec![("srv1".into(), 1, 1)]);
3512 }
3513
3514 assert_eq!(t.connected_snapshot(), vec![("srv1".into(), 0, 1)]);
3515 assert_eq!(t.snapshot(), vec![("srv1".into(), 1, 1)]);
3516 }
3517}