Skip to main content

nzb_dispatch/
download_engine.rs

1//! Download engine — shared NNTP worker pool that services all active jobs.
2//!
3//! Architecture:
4//! - A single long-lived [`WorkerPool`] is owned by [`crate::queue_manager::QueueManager`].
5//! - For each enabled server, exactly `server.connections` workers are
6//!   spawned and live as long as the server stays enabled. When the server
7//!   list or per-server connection limit changes, the pool reconciles.
8//! - Jobs register a [`JobContext`] and push their work items into a
9//!   [`SharedWorkQueue`]. Workers pop items tagged with `job_id` and look up
10//!   per-job state (assembler, progress sink, pause/cancel flags) via the
11//!   shared [`JobContextMap`].
12//! - Pause / cancel / completion are per-job flags; workers themselves are
13//!   never torn down on job transitions. Pausing a job causes workers holding
14//!   one of its items to return that item to the queue and pull something
15//!   else. Cancelling a job drains its items and drops in-flight results.
16//! - Transient provider outages leave work queued until circuit-breaker
17//!   cooldown expires; only definitive provider outcomes resolve an article.
18//!
19//! Retry logic (per article):
20//! 1. Try the article on the current server up to [`MAX_TRIES_PER_SERVER`]
21//!    times, reconnecting on transient errors.
22//! 2. On `ArticleNotFound` (430) — requeue with the current server added to
23//!    `tried_servers`; another worker on a different server picks it up.
24//! 3. On connection loss — requeue and reconnect.
25//! 4. On decode error — treated like "not available on this server", try
26//!    another.
27//! 5. An article is missing only when every enabled provider explicitly
28//!    returned `430`. Circuit-broken and transiently failing providers do
29//!    not contribute evidence of absence.
30//! 6. A job only fails if failed articles exceed the threshold and no par2
31//!    recovery is possible.
32
33use 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
62// ---------------------------------------------------------------------------
63// Constants
64// ---------------------------------------------------------------------------
65
66/// Max times to retry an article on the SAME server before trying the next.
67const MAX_TRIES_PER_SERVER: u32 = 3;
68/// Delay between reconnection attempts.
69const RECONNECT_DELAY: Duration = Duration::from_secs(5);
70/// Max reconnect attempts before giving up on a server for this session.
71const MAX_RECONNECT_ATTEMPTS: u32 = 5;
72/// Stagger delay between worker initial connections to avoid thundering herd.
73/// Each worker waits conn_idx * WORKER_RAMP_DELAY before its first connect.
74const WORKER_RAMP_DELAY: Duration = Duration::from_millis(15);
75/// Consecutive connect failures before circuit-breaking a server.
76const CIRCUIT_BREAK_THRESHOLD: u32 = 3;
77/// Cooldown after auth/permission failure (bad credentials, 502, account blocked).
78const AUTH_FAILURE_COOLDOWN: Duration = Duration::from_secs(120);
79/// Cooldown after transient connection failures exceed threshold.
80const TRANSIENT_FAILURE_COOLDOWN: Duration = Duration::from_secs(30);
81/// Supervisor tick interval for detecting stuck jobs.
82const SUPERVISOR_INTERVAL: Duration = Duration::from_secs(1);
83
84/// Default maximum idle time before a worker is evicted by the supervisor.
85/// Tunable per pool via [`WorkerPool::set_max_worker_idle`]. Production code
86/// uses the default; the test harness shrinks this to make Phase 5 tests
87/// converge in seconds rather than minutes.
88const DEFAULT_MAX_WORKER_IDLE: Duration = Duration::from_secs(60);
89/// Worker idle poll interval when the shared queue is empty.
90const WORKER_IDLE_POLL: Duration = Duration::from_millis(500);
91
92/// Phase 7: capacity of the per-job progress channel. The handler reads
93/// this at ~articles per second; under DB-lock contention or
94/// post-processing pauses it can fall behind. With this cap, the worst
95/// case is bounded buffering plus a `WARN` from
96/// [`try_send_progress`] when the channel is full.
97pub const PROGRESS_CHANNEL_CAPACITY: usize = 10_000;
98
99/// Send a progress update to the per-job channel. On `Full` (handler
100/// backpressure), log a warning and drop the update — the alternative is
101/// awaiting and stalling the worker, which would cascade into the entire
102/// download pipeline. On `Closed` (handler shut down), drop silently.
103///
104/// Workers should call this through their `JobContext` rather than
105/// touching `progress_tx` directly.
106fn 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                // Handler has shut down. Workers can't recover this state;
118                // dropping is correct.
119            }
120        }
121    }
122}
123
124// ---------------------------------------------------------------------------
125// Global connection tracking — semaphore-backed permit pools
126// ---------------------------------------------------------------------------
127
128/// Tracks the per-server NNTP connection budget. Each server has a
129/// `tokio::sync::Semaphore` whose initial permit count is the configured
130/// `connections` limit. Workers acquire a permit *before* connecting and
131/// hold it for their entire lifetime; the permit's `Drop` releases the slot
132/// synchronously back to the pool. This makes over-allocation a type-level
133/// impossibility — once `limit` permits are out, the next acquire awaits.
134///
135/// **Limit changes at runtime** are handled differently for grow vs shrink:
136///
137/// - **Grow** (e.g. 5 → 10): the existing semaphore is given the additional
138///   permits via `add_permits`; existing slot holders are unaffected.
139/// - **Shrink** (e.g. 10 → 5): the entire `ServerSlot` is replaced with a
140///   fresh one. Old permit holders continue to reference the orphaned
141///   semaphore via their `Arc`; their drops release back to it (a no-op
142///   since nothing else points at it). Workers detect the replacement via
143///   [`ConnectionTracker::slot_is_current`] and exit on the next iteration.
144pub 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    /// Set or update the per-server connection limit.
164    ///
165    /// - First call for a `server_id`: creates a fresh semaphore with `limit` permits.
166    /// - Subsequent call with the same `limit` and `server_name`: no-op.
167    /// - Grow (`limit > current`): adds permits in place.
168    /// - Shrink or rename: replaces the slot. Old permit holders detach
169    ///   naturally via [`slot_is_current`].
170    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                // No change.
175            }
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                // Capture diagnostics before re-borrowing `pools` for insert.
192                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    /// Forget a server entirely (e.g. on `update_servers` removing it).
228    /// Existing permit holders are unaffected; they will detect that their
229    /// slot is no longer current and exit.
230    pub fn remove_server(&self, server_id: &str) {
231        self.pools.lock().remove(server_id);
232    }
233
234    /// Acquire a connection slot for `server_id`. Awaits if the pool is at
235    /// the limit. Returns `None` if the server isn't registered or its
236    /// limit is zero.
237    pub async fn acquire(&self, server_id: &str) -> Option<ConnectionSlot> {
238        // Snapshot the ServerSlot under lock, release the lock before await.
239        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    /// Returns true if `slot` was acquired from the *current* semaphore for
261    /// its server. False if the limit was changed (semaphore replaced) or
262    /// the server was removed — the worker should exit at its next safe
263    /// checkpoint.
264    pub fn slot_is_current(&self, slot: &ConnectionSlot) -> bool {
265        matches!(self.slot_status(slot), SlotStatus::Current)
266    }
267
268    /// Like [`slot_is_current`] but distinguishes the reason a slot is no
269    /// longer current — useful for diagnostics on the worker exit path.
270    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    /// `(server_id, active, limit)` triples for the live pool. `active` is
285    /// derived from the semaphore's available permits and is always
286    /// `<= limit` by construction.
287    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    /// `(server_id, active, limit)` triples for sockets currently transferring
301    /// an article. Authenticated sockets waiting for work are idle/free.
302    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    /// Total currently-held permits across all servers in the live pool.
317    /// Permits held against orphaned (replaced) semaphores are NOT counted —
318    /// they'll go away as the holding workers exit.
319    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/// Why a `ConnectionSlot` may no longer match its server's live pool.
335#[derive(Debug, Clone, Copy, PartialEq, Eq)]
336pub enum SlotStatus {
337    /// Slot is still attached to the current semaphore for its server.
338    Current,
339    /// The semaphore was replaced (set_limit shrunk or rebuilt the pool).
340    /// Old permit holders should exit at their next safe checkpoint.
341    PoolReplaced,
342    /// The server has been removed entirely from the tracker.
343    ServerRemoved,
344}
345
346/// RAII handle for one acquired NNTP connection slot. The underlying
347/// semaphore permit is released synchronously on drop. Workers should hold
348/// a `ConnectionSlot` for their entire lifetime — across reconnects, even
349/// across temporary connect failures — and only drop it when exiting.
350pub struct ConnectionSlot {
351    server_id: String,
352    server_name: String,
353    /// The Arc identity of the semaphore the permit was issued by.
354    /// Used by `ConnectionTracker::slot_is_current` to detect a stale slot
355    /// after a `set_limit` shrink (which replaces the semaphore).
356    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
396/// Marks a connection as actively transferring an article for the lifetime
397/// of the guard. An authenticated but idle socket does not consume a "used"
398/// connection in API/UI accounting.
399struct 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// ---------------------------------------------------------------------------
410// Server health tracking (circuit breaker)
411// ---------------------------------------------------------------------------
412
413#[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// ---------------------------------------------------------------------------
467// Progress update messages
468// ---------------------------------------------------------------------------
469
470#[derive(Debug, Clone)]
471pub enum ProgressUpdate {
472    /// Every enabled NNTP provider is temporarily unavailable. The job stays
473    /// active and will retry automatically when a provider recovers.
474    WaitingForProviders { job_id: String, message: String },
475    /// At least one NNTP provider recovered after a
476    /// [`ProgressUpdate::WaitingForProviders`] notification.
477    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        /// Filename declared by the yEnc header. This lets the queue layer
486        /// recognize obfuscated PAR2 files before terminal completion.
487        yenc_filename: Option<String>,
488    },
489    /// An article could not be retrieved. `failure` carries the typed
490    /// classification of *why* (NotFound, ServerDown, AuthFailed, …).
491    /// See `crate::article_failure` for the taxonomy.
492    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// ---------------------------------------------------------------------------
513// Work item
514// ---------------------------------------------------------------------------
515
516#[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    /// Servers already tried for this article (by server ID).
524    pub(crate) tried_servers: Vec<String>,
525    /// Definitive per-provider outcomes. Transient failures are not recorded
526    /// because they provide no evidence about article availability.
527    pub(crate) provider_outcomes: HashMap<String, crate::article_failure::ArticleFailureKind>,
528    /// Number of attempts on the current server.
529    pub(crate) tries_on_current: u32,
530}
531
532// ---------------------------------------------------------------------------
533// Per-job context
534// ---------------------------------------------------------------------------
535
536/// Per-job state that workers reference via `item.job_id`.
537///
538/// Everything a worker needs to process an article for a specific job lives
539/// here. The queue manager owns one `Arc<JobContext>` per active job; the
540/// pool holds a clone in its [`JobContextMap`] so workers can look it up.
541pub(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    /// Articles that still need a definitive result (success or all-server
549    /// failure). When this reaches zero, `JobFinished` is emitted.
550    pub articles_remaining: AtomicUsize,
551    pub articles_failed: AtomicUsize,
552    pub paused: AtomicBool,
553    pub cancelled: AtomicBool,
554    /// Optional abort reason — if set when articles_remaining hits zero
555    /// (or when cancellation fires), `JobAborted` is emitted instead of
556    /// `JobFinished`.
557    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    /// Total bytes across all files (for perf summary throughput).
565    pub total_bytes: u64,
566    /// Ensures JobFinished/JobAborted is only emitted once.
567    finished: AtomicBool,
568    /// Avoid repeatedly reporting the same all-provider outage while the
569    /// workers wait for circuit-breaker cooldown.
570    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    /// Decrement articles_remaining. If it reaches zero, run deobfuscation
667    /// and emit the job-finished/aborted terminal update. Idempotent.
668    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    /// Emit the terminal (JobFinished / JobAborted) message. Safe to call
677    /// multiple times; only the first call does anything.
678    fn emit_terminal(&self) {
679        if self.finished.swap(true, Ordering::Relaxed) {
680            return;
681        }
682
683        // Run deobfuscation before signalling completion so post-processing
684        // sees the final filenames.
685        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    /// Choose the best filename between NZB subject and yEnc header per file
742    /// and rename on disk if needed. Called exactly once at job completion.
743    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
796// ---------------------------------------------------------------------------
797// Shared work queue
798// ---------------------------------------------------------------------------
799
800/// Multi-job queue with per-server round-robin fairness and PAR2-first
801/// priority within each submission.
802///
803/// Items submitted via [`SharedWorkQueue::submit_items`] are inserted so that
804/// PAR2 index and volume files land ahead of data files (matching the prior
805/// per-job ordering), while data files land at the tail. Cross-job ordering
806/// is FIFO by submission time until a server has served a job; it then prefers
807/// another eligible job before returning to that job's backlog.
808pub(crate) struct SharedWorkQueue {
809    inner: Mutex<QueueState>,
810    notify: Notify,
811}
812
813struct QueueState {
814    items: VecDeque<WorkItem>,
815    /// Last job selected by each server. This is intentionally per server:
816    /// separate NNTP providers can make independent fair scheduling choices.
817    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    /// Insert a batch of work items with PAR2 items ahead of data items.
832    /// Cross-batch order is preserved: PAR2 items from this batch go after
833    /// any existing items, then data items.
834    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    /// Push a single item back onto the front (used when a worker is
850    /// returning an item because its job is paused or its server was just
851    /// tried for this item).
852    fn push_front(&self, item: WorkItem) {
853        self.inner.lock().items.push_front(item);
854        self.notify.notify_waiters();
855    }
856
857    /// Push a single item to the back (used after handle_article_not_available
858    /// when another server can still try it).
859    fn push_back(&self, item: WorkItem) {
860        self.inner.lock().items.push_back(item);
861        self.notify.notify_waiters();
862    }
863
864    /// `(workable, total)` for `server_id`. `workable` is the number of
865    /// items in the queue that are eligible for a worker on `server_id`;
866    /// `total` is the queue length. Used by the supervisor's starvation
867    /// diagnostic — if `workable == 0` while `total > 0`, the server has
868    /// items it can't service yet (either already tried, or waiting on a
869    /// higher-priority server).
870    ///
871    /// An item is "workable" for this server when:
872    /// - `server_id` is NOT in `item.tried_servers`, AND
873    /// - no healthy higher-priority server still needs to try it (i.e. every
874    ///   server in `higher_priority_servers` is already in `item.tried_servers`).
875    ///
876    /// Pass an empty slice when the caller has no strictly-higher-priority
877    /// peers (priority 0, single-server setups, or all peers circuit-broken).
878    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    /// Pop the next item that can be processed by a worker on `server_id`.
899    ///
900    /// Prefers an eligible item from a job other than the one this server last
901    /// served, then falls back to any eligible item. It also enforces server
902    /// priority: items where any healthy higher-priority server has not yet
903    /// tried the article are skipped for the backup server.
904    ///
905    /// `higher_priority_servers` is a caller-prepared list of server IDs with
906    /// strictly higher priority (lower priority number) than the caller, filtered
907    /// to only enabled + healthy servers. See `run_worker_pipelined` and
908    /// `run_worker_serial` for the canonical computation. Empty slice disables
909    /// the priority gate (priority-0 servers, single-server setups, or all
910    /// higher-priority peers circuit-broken → backup can take over).
911    ///
912    /// Returns `None` if the queue is empty or if every item is either already
913    /// tried here or pending a higher-priority server.
914    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    /// Remove all items belonging to `job_id`. Used on cancel_job / remove_job.
950    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
976// ---------------------------------------------------------------------------
977// Worker pool
978// ---------------------------------------------------------------------------
979
980struct ActiveWorker {
981    shutdown: Arc<AtomicBool>,
982    /// Worker heartbeat: monotonic millis since pool creation
983    /// (`WorkerPool::created_at`). Updated by the worker on every
984    /// "definitive progress" event (successful decode or definitive
985    /// failure). The supervisor reads this to detect zombie workers.
986    last_progress: Arc<AtomicU64>,
987    handle: JoinHandle<()>,
988}
989
990/// Long-lived worker pool that services all active download jobs.
991pub 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    /// Reference epoch for worker heartbeats. All `last_progress` values
1000    /// store millis elapsed since this instant.
1001    created_at: Instant,
1002    /// Idle threshold above which the supervisor evicts a worker.
1003    /// Mutable so the harness (and runtime config reload) can adjust it
1004    /// without recreating the pool.
1005    max_worker_idle: Mutex<Duration>,
1006    /// Per-server "last starvation log" timestamp; rate-limits the
1007    /// "no workable items" diagnostic to once per minute per server.
1008    starvation_log: Mutex<HashMap<String, Instant>>,
1009    /// Lifetime count of worker evictions performed by the heartbeat
1010    /// watchdog. Useful for tests and observability — a non-zero value
1011    /// means at least one worker stalled long enough to be reclaimed.
1012    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    /// Override the worker idle eviction threshold. Tests use this to make
1049    /// the supervisor's heartbeat check converge in seconds.
1050    pub fn set_max_worker_idle(&self, d: Duration) {
1051        *self.max_worker_idle.lock() = d;
1052    }
1053
1054    /// Read current worker idle eviction threshold.
1055    pub fn max_worker_idle(&self) -> Duration {
1056        *self.max_worker_idle.lock()
1057    }
1058
1059    /// Millis elapsed since the pool was constructed. Used as the
1060    /// monotonic clock for `ActiveWorker::last_progress`.
1061    fn elapsed_ms(&self) -> u64 {
1062        self.created_at.elapsed().as_millis() as u64
1063    }
1064
1065    /// Reference `Instant` used as the epoch for `last_progress` / heartbeat
1066    /// timestamps. Exposed so the NNTP layer can share the same clock when
1067    /// ticking its socket-liveness heartbeat — values then compare directly
1068    /// against `self.elapsed_ms()` in the supervisor's idle-worker check.
1069    fn created_at(&self) -> Instant {
1070        self.created_at
1071    }
1072
1073    /// Collect server IDs with strictly higher priority (lower priority number)
1074    /// than `my_priority`, restricted to enabled + healthy (non-circuit-broken)
1075    /// servers. `my_server_id` is excluded (a server never blocks itself).
1076    ///
1077    /// Used by worker loops and the supervisor to drive the priority gate in
1078    /// [`SharedWorkQueue::pop_workable`] and [`SharedWorkQueue::workable_count_for`].
1079    /// See `sabnzbd/nzb/article.py::get_article` for the reference behaviour.
1080    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    /// Return a queue-facing message when no enabled provider can currently
1092    /// accept work. A circuit-broken provider is deliberately retried later,
1093    /// so this is a waiting condition rather than an article failure.
1094    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    /// Lifetime count of worker evictions performed by the heartbeat
1128    /// watchdog. Increases by 1 each time the supervisor reclaims a stalled
1129    /// worker. Test harnesses use this as a positive signal that the Phase
1130    /// 5 idle watchdog actually fired.
1131    pub fn eviction_count(&self) -> u64 {
1132        self.evictions.load(Ordering::Relaxed)
1133    }
1134
1135    /// Spawn workers for all currently enabled servers and start the
1136    /// supervisor task. Call once at queue-manager startup.
1137    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    /// Create or tear down workers to match the current server list.
1148    ///
1149    /// For each enabled server, ensures exactly `server.connections` workers
1150    /// exist. Extra workers (from a shrunk limit or disabled server) have
1151    /// their per-worker shutdown flag flipped so they exit gracefully after
1152    /// the current article.
1153    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        // First pass: retire workers for servers that are gone or disabled.
1162        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                    // Don't await — workers check shutdown on next loop
1174                    // iteration and exit within ~WORKER_IDLE_POLL.
1175                    drop(w.handle);
1176                }
1177            }
1178        }
1179
1180        // Second pass: spawn or shrink to match target count per enabled server.
1181        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            // Shrink: signal extras to exit.
1189            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            // Grow: spawn new workers with stagger.
1197            let current = entry.len();
1198            for conn_idx in current..target {
1199                let worker_shutdown = Arc::new(AtomicBool::new(false));
1200                // Initialize heartbeat to *now* so the worker has a full
1201                // grace period before its first eviction check.
1202                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    /// Register a new job context and submit its unfinished articles to the
1220    /// shared queue. Called by QueueManager::launch_download.
1221    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            // Nothing to do — emit completion immediately.
1225            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    /// Unregister a normally completed job and close its assembler files.
1235    ///
1236    /// This must only be called after `JobFinished` is received, which means
1237    /// every article has reached a definitive result and no worker can write
1238    /// another segment for this job. Abort and cancellation paths unregister
1239    /// their contexts separately because they may still have in-flight work.
1240    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            // Workers can briefly retain an Arc<JobContext> after resolving
1244            // the final article. Clear the assembler explicitly so those
1245            // transient references do not keep every output file open during
1246            // post-processing.
1247            ctx.assembler.clear_job(job_id);
1248        }
1249    }
1250
1251    /// Pause a job: workers stop pulling its items, and any item currently
1252    /// being held while paused is returned to the queue.
1253    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    /// Resume a paused job.
1261    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            // Wake any workers that were idle waiting for work.
1266            self.work_queue.notify.notify_waiters();
1267        }
1268    }
1269
1270    /// Abort a job with a reason. Drains queued items, sets the abort flag,
1271    /// and emits JobAborted via the job's progress channel.
1272    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        // Decrement the remaining counter for drained items so the terminal
1287        // callback fires if nothing is in-flight for this job.
1288        for _ in drained {
1289            ctx.resolve_one();
1290        }
1291        // In-flight items still own the remaining count. They will resolve
1292        // (without writing) as their responses arrive or after a connection
1293        // failure requeues them. Only the final resolution emits JobAborted.
1294        if ctx.articles_remaining.load(Ordering::Relaxed) == 0 {
1295            ctx.emit_terminal();
1296        }
1297        true
1298    }
1299
1300    /// Cancel a job silently (no JobFinished / JobAborted emission).
1301    /// Used by `remove_job` when the user deletes a job from the queue —
1302    /// the progress receiver is about to be dropped anyway.
1303    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    /// Per-tick checks that maintain pool health: idle-worker eviction,
1313    /// dead-worker reaping, worker reconciliation, and starvation diagnostics.
1314    ///
1315    /// Circuit-broken providers retain their queued articles. Workers retry
1316    /// after the provider cooldown instead of converting a transient outage
1317    /// into a persistent per-job pause.
1318    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            // ---------- 1. Heartbeat eviction ----------
1327            // Compute idle threshold + current epoch outside the workers lock.
1328            let now_ms = self.elapsed_ms();
1329            let max_idle_ms = self.max_worker_idle().as_millis() as u64;
1330
1331            // Pre-compute per-server "has workable items now" so idle backup
1332            // workers aren't evicted when there's legitimately nothing for
1333            // them to do (waiting on higher-priority servers to fail first).
1334            // Computed once per tick, outside the workers lock, to avoid
1335            // lock-ordering hazards. Matches SABnzbd's idle-thread model
1336            // (sabnzbd/downloader.py — idle threads stay connected).
1337            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                            // Bug 2 fix: don't evict a worker whose server
1364                            // has no workable items. It's idle because it's
1365                            // waiting for its primary/higher-priority peers
1366                            // to fail articles — not because it's zombied.
1367                            // If the server isn't in the map at all (e.g.
1368                            // disabled mid-tick), default to true (evict).
1369                            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            // ---------- 2. Reap finished workers and respawn ----------
1387            {
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            // Reconcile fills any gaps left by reaped workers.
1394            self.reconcile_servers();
1395
1396            // ---------- 3. Starvation diagnostic ----------
1397            // For each enabled server: if the queue has items but none are
1398            // workable for this server, log once per minute. "Not workable"
1399            // can mean either (a) every item has already been tried here, or
1400            // (b) every item is still waiting on a higher-priority server
1401            // (backup server legitimately idle — not a bug).
1402            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    /// Shut down all workers gracefully. In-flight articles finish first.
1432    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        // Notify workers so any parked on notify.notified() wake up.
1446        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    /// Whether this job still has an active context in the pool.
1463    pub fn has_job(&self, job_id: &str) -> bool {
1464        self.job_contexts.lock().contains_key(job_id)
1465    }
1466}
1467
1468// ---------------------------------------------------------------------------
1469// Worker body
1470// ---------------------------------------------------------------------------
1471
1472/// Single pool worker. Owns an NNTP connection to `primary_server` and pulls
1473/// items from the shared queue until `worker_shutdown` is flipped (server
1474/// reconciled away, limit shrunk) or the pool shuts down.
1475///
1476/// The worker acquires its connection slot (a semaphore permit from the
1477/// per-server pool) **before** the first connect attempt and holds it for
1478/// the entire lifetime of the worker, across every reconnect. This bounds
1479/// concurrent connections to `server.connections` by construction.
1480async 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    // Stagger worker startup to avoid thundering herd of connections.
1490    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    // Acquire the slot up-front. If the server isn't registered or its limit
1500    // is zero, the worker has nothing to do — exit.
1501    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        // If the limit was shrunk under us, the semaphore will have been
1519        // replaced. Drop our (now-orphaned) slot and exit cleanly.
1520        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        // Check circuit breaker before connecting. Compute an owned bool
1543        // so we don't hold the MutexGuard across an await point.
1544        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        // Attach socket-liveness heartbeat BEFORE connect so every byte
1568        // received — from the welcome banner onward — counts as progress.
1569        // This is the fix for false-eviction of slow-but-working workers:
1570        // previously `last_progress` only advanced on full article decode,
1571        // so a worker receiving a 50-second article looked idle for the
1572        // entire fetch. Now any line read from the socket keeps it alive.
1573        // Matches SABnzbd's `nw.timeout` model (newswrapper.py:315).
1574        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        // NOTE: do not tick heartbeat here. Reconnects are *not* progress
1610        // — counting them masks the zombie cycle (worker reconnects forever
1611        // without ever decoding an article). The heartbeat is initialised
1612        // to spawn time in `reconcile_servers`, which gives every worker a
1613        // full grace period to first-connect and process its first article.
1614        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                // Loop back to the top and reconnect — slot is preserved.
1643                continue 'reconnect;
1644            }
1645            WorkerExit::Exit => {
1646                // Slot drops here when conn_slot goes out of scope.
1647                return;
1648            }
1649        }
1650    }
1651}
1652
1653enum WorkerExit {
1654    /// Exit the worker function entirely (server retired or pool shutdown).
1655    Exit,
1656    /// Reconnect and keep pulling work (transient connection loss).
1657    Reconnect,
1658}
1659
1660/// Wait for work, with early exit on shutdown / server retirement.
1661/// Returns `Some(item, ctx)` when a processable item is available, or `None`
1662/// if the worker should exit.
1663///
1664/// `higher_priority_servers` gates which items this server is allowed to take
1665/// — see `pop_workable` for the priority model.
1666async 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            // Look up the job context. If the job is gone or cancelled, drop
1682            // the item and keep going.
1683            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            // Respect per-job pause: return the item and wait.
1692            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        // Queue empty (or nothing workable for this server) — wait with a
1701        // timeout so we still notice shutdown and new work alike.
1702        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        // Server runtime checks.
1723        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        // Snapshot of healthy servers with strictly higher priority (lower
1753        // priority number). Used as the priority gate in pop_workable:
1754        // items waiting for a higher-priority server won't be dispatched
1755        // to this worker. Recomputed each loop iteration so runtime
1756        // priority / health changes are picked up. See SABnzbd
1757        // `Article.get_article` for the reference behaviour.
1758        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                // Heartbeat: definitive forward progress.
1828                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    // Perf metrics
1951    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        // Server runtime checks.
1970        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        // Snapshot of healthy servers with strictly higher priority. Gates
2002        // which items the pipeline-fill and wait-for-work paths are allowed
2003        // to take (see `pop_workable`). Recomputed each loop iteration so
2004        // runtime priority / health changes are picked up.
2005        let higher_priority_servers =
2006            pool.higher_priority_servers(primary_server.priority, &primary_server.id);
2007
2008        // Fill the pipeline.
2009        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            // Look up ctx to respect pause/cancel.
2019            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 nothing is queued and nothing in flight, wait for work.
2038        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; // ctx is validated in next_work_item
2050            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        // Read one response.
2084        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                // Look up the ctx for this item's job (may have been cancelled).
2119                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                        // Return the buffer to the connection's pool so the
2137                        // next article's fetch reuses it instead of
2138                        // allocating fresh.
2139                        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                                // Heartbeat: definitive forward progress.
2179                                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                // No in-flight requests — loop will fill more.
2321            }
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
2344// ---------------------------------------------------------------------------
2345// Connection with retry
2346// ---------------------------------------------------------------------------
2347
2348async 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(&current_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// ---------------------------------------------------------------------------
2455// Helpers: re-queue, not-available routing, par2 sort key
2456// ---------------------------------------------------------------------------
2457
2458/// Handle an article that's not available on this server (not found, decode
2459/// error, etc.): mark the server as tried and either requeue or mark failed.
2460///
2461/// `kind` lets the failure be classified — if every server has been tried
2462/// and the failure was per-server (NotFound, ServerDown, …), we promote it
2463/// to a definitive `NotFound` for the hopeless tracker. DecodeError stays
2464/// classified as DecodeError because it's typically not server-specific.
2465#[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    // (debug log immediately below was added for observability)
2497    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        // push_FRONT (not push_back): put the failed item at the front of the
2552        // queue so the next eligible server picks it up IMMEDIATELY instead
2553        // of queueing behind thousands of fresh items. Same-server workers
2554        // rotate the item back via pop_workable's existing skip-and-push_back
2555        // logic. This transforms a retention-dead NZB from "11+ minutes per
2556        // article's full-server cascade" into "fractions of a second per
2557        // cascade" — the dominant failure mode for hung downloads.
2558        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
2573/// Re-queue all in-flight items back to the work queue (on connection loss).
2574fn 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
2581/// Sort key for work-queue prioritisation of PAR2 files. Index files (0)
2582/// first, then volume files (1), then data files (2).
2583fn 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
2650// ---------------------------------------------------------------------------
2651// Public helper used by queue_manager: build work items + context for a job
2652// ---------------------------------------------------------------------------
2653
2654/// Build the WorkItems for a job's unfinished articles and an initialised
2655/// JobContext. Called by QueueManager before [`WorkerPool::submit_job`].
2656pub(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
2700// ---------------------------------------------------------------------------
2701// Article fetch with per-server retry
2702// ---------------------------------------------------------------------------
2703
2704async 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                // Return the buffer to the connection's pool so the next
2728                // article's fetch reuses it instead of allocating fresh.
2729                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// ---------------------------------------------------------------------------
2826// Article processing
2827// ---------------------------------------------------------------------------
2828
2829#[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// ---------------------------------------------------------------------------
2910// Tests
2911// ---------------------------------------------------------------------------
2912
2913#[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        // srv1 should skip the first item and return the second.
3100        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        // Simulate the sole in-flight worker settling after its write handle
3222        // is no longer usable. Only now may the terminal event be published.
3223        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        // Fresh item (tried_servers empty). A backup-priority caller whose
3242        // higher_priority_servers list is non-empty must NOT get the item —
3243        // the primary server still needs a chance first.
3244        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        // Primary server (empty higher list) should still get it.
3251        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        // Once the primary has been added to tried_servers (because it
3258        // failed), the backup is allowed to take the item.
3259        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        // When the caller's `higher_priority_servers` list is empty because
3272        // the primary was filtered out as circuit-broken/disabled, the backup
3273        // gets items immediately — no waiting for the dead primary.
3274        let q = SharedWorkQueue::new();
3275        q.submit_items(vec![make_item("j1", "a", "file.rar")]);
3276
3277        let higher: Vec<String> = vec![]; // primary filtered out by caller
3278        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        // Backup: both items need primary first → workable=0.
3291        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        // Primary: both items are workable.
3297        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    // -----------------------------------------------------------------------
3380    // ConnectionTracker (Phase 4 — semaphore-backed slots)
3381    // -----------------------------------------------------------------------
3382
3383    #[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        // Synchronous release on drop.
3391        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        // Third acquire on a 1-slot pool must block. Wrap with a short
3404        // timeout — if it does NOT time out, the cap was breached.
3405        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        // No more capacity — but grow to 4 and we should be able to take 2
3423        // more without releasing the existing slots.
3424        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        // Shrink to 1 — old semaphore is replaced.
3438        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        // The new pool starts empty (1 permit available, 0 in use). Old
3445        // permit holder is no longer counted in `total()` because its
3446        // semaphore is orphaned.
3447        assert_eq!(t.total(), 0);
3448
3449        // We can still acquire from the new pool.
3450        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}