Skip to main content

nzb_dispatch/
dispatch_engine.rs

1//! `DispatchEngine` trait — boundary between the job queue and the article dispatcher.
2//!
3//! This is the contract the queue layer depends on; it hides the concrete
4//! `WorkerPool` implementation. The reverse direction (dispatcher → queue) is
5//! already a decoupled channel (`mpsc::Sender<ProgressUpdate>`), so only this
6//! one trait is needed to cleanly separate the two layers.
7//!
8//! A `DispatchEngine` is responsible for turning an [`NzbJob`] into article
9//! fetches against the configured NNTP servers and reporting progress via
10//! the per-job [`ProgressUpdate`] channel. It must be able to pause, resume,
11//! cancel, and abort individual jobs, reconcile its worker set with the
12//! server list, and shut down gracefully.
13
14use std::sync::Arc;
15use std::time::Duration;
16
17use tokio::sync::mpsc;
18
19use crate::bandwidth::BandwidthLimiter;
20use crate::download_engine::{ConnectionTracker, ProgressUpdate, WorkerPool, build_job_submission};
21use nzb_core::config::ServerConfig;
22use nzb_core::models::NzbJob;
23use parking_lot::Mutex;
24
25/// Article-dispatch engine: accepts jobs, drives NNTP fetches, emits progress.
26///
27/// Constructed by the facade/queue layer and owned as `Arc<dyn DispatchEngine>`.
28/// All methods are `&self`; implementations use interior mutability to allow
29/// concurrent use across tokio tasks.
30#[async_trait::async_trait]
31pub trait DispatchEngine: Send + Sync {
32    /// Spawn workers for all currently enabled servers and start the
33    /// supervisor task. Idempotent (safe to call more than once).
34    fn start(&self);
35
36    /// Register a new job and begin dispatching its unfinished articles.
37    /// Progress is streamed to `progress_tx` as `ProgressUpdate`s; the
38    /// channel is closed when the job reaches a terminal state.
39    fn submit_job(&self, job: &NzbJob, progress_tx: mpsc::Sender<ProgressUpdate>);
40
41    /// Pause dispatch for `job_id`. In-flight articles finish; no new work
42    /// is popped for this job until [`resume_job`](Self::resume_job).
43    fn pause_job(&self, job_id: &str);
44
45    /// Resume a paused job.
46    fn resume_job(&self, job_id: &str);
47
48    /// Cancel `job_id`. In-flight articles may still complete but their
49    /// results are discarded; no terminal progress update is emitted beyond
50    /// the one triggered by cancellation.
51    fn cancel_job(&self, job_id: &str);
52
53    /// Abort `job_id` with a human-readable reason. Emits
54    /// [`ProgressUpdate::JobAborted`] once outstanding articles drain.
55    /// Returns `true` only for the caller that won terminal ownership.
56    fn abort_job(&self, job_id: &str, reason: String) -> bool;
57
58    /// Is `job_id` currently known to the dispatcher?
59    fn has_job(&self, job_id: &str) -> bool;
60
61    /// Release a terminal job's dispatcher and assembler resources before
62    /// post-processing opens the completed files.
63    fn release_completed_job(&self, job_id: &str);
64
65    /// Replace server configuration and reconcile connection budgets/workers.
66    fn update_servers(&self, servers: Vec<ServerConfig>);
67
68    /// Per-server allocated worker slots and configured limits.
69    fn connection_snapshot(&self) -> Vec<(String, usize, usize)>;
70
71    /// Per-server connections actively transferring articles and limits.
72    fn active_connection_snapshot(&self) -> Vec<(String, usize, usize)>;
73
74    /// Total allocated worker slots across current server pools.
75    fn connection_total(&self) -> usize;
76
77    /// Re-read the server list and adjust workers to match. Call after any
78    /// mutation to the server config (add, remove, enable, disable, resize).
79    fn reconcile_servers(&self);
80
81    /// Override the idle-worker eviction threshold. Tests shrink this to
82    /// make the watchdog converge in seconds; production uses the default.
83    fn set_max_worker_idle(&self, d: Duration);
84
85    /// Lifetime count of worker evictions performed by the heartbeat
86    /// watchdog. Increases by 1 each time the supervisor reclaims a stalled
87    /// worker. Useful for tests and observability.
88    fn eviction_count(&self) -> u64;
89
90    /// Snapshot of per-server lifetime attempt counters. Used by the
91    /// queue manager to emit a diagnostic breakdown alongside a job abort
92    /// — distinguishes "server returned 430 for every article" (dead NZB)
93    /// from "server had auth errors" (transient). Default is empty for
94    /// engines that don't track per-server stats.
95    fn server_stats_snapshot(&self) -> Vec<(String, ServerAttemptStats)> {
96        Vec::new()
97    }
98
99    /// Gracefully shut down: stop accepting new work, signal all workers,
100    /// and wait for the supervisor to exit.
101    async fn shutdown(&self);
102}
103
104/// Per-server lifetime counters reported via
105/// [`DispatchEngine::server_stats_snapshot`]. `not_found` is the strongest
106/// signal for a dead NZB; `transient_failed` separates "missing articles"
107/// from "server flaky / auth issues".
108#[derive(Debug, Clone, Copy, Default)]
109pub struct ServerAttemptStats {
110    pub attempted: u64,
111    pub succeeded: u64,
112    pub not_found: u64,
113    pub transient_failed: u64,
114}
115
116// ---------------------------------------------------------------------------
117// DispatchHandle — wraps Arc<WorkerPool> to implement DispatchEngine.
118//
119// Why a wrapper: several WorkerPool methods have `self: &Arc<Self>` receivers
120// (they spawn tasks that clone the Arc). That signature is incompatible with
121// a `dyn`-object trait method. The wrapper holds `Arc<WorkerPool>` and can
122// call the concrete Arc-receiver methods on it.
123// ---------------------------------------------------------------------------
124
125/// Dynamic-dispatch wrapper around `Arc<WorkerPool>` — the one concrete
126/// [`DispatchEngine`] impl today. Extract this into `nzb-dispatch` in Phase B.
127pub struct DispatchHandle(Arc<WorkerPool>);
128
129impl DispatchHandle {
130    pub fn new(
131        servers: Arc<Mutex<Vec<ServerConfig>>>,
132        bandwidth: Arc<BandwidthLimiter>,
133        article_timeout_secs: u64,
134    ) -> Self {
135        let tracker = Arc::new(ConnectionTracker::new());
136        for server in servers.lock().iter() {
137            tracker.set_limit(&server.id, &server.name, server.connections as usize);
138        }
139        Self(WorkerPool::new(
140            servers,
141            bandwidth,
142            tracker,
143            article_timeout_secs,
144        ))
145    }
146}
147
148#[async_trait::async_trait]
149impl DispatchEngine for DispatchHandle {
150    fn start(&self) {
151        self.0.start();
152    }
153
154    fn submit_job(&self, job: &NzbJob, progress_tx: mpsc::Sender<ProgressUpdate>) {
155        let (ctx, items) = build_job_submission(job, progress_tx);
156        self.0.submit_job(ctx, items);
157    }
158
159    fn pause_job(&self, job_id: &str) {
160        self.0.pause_job(job_id);
161    }
162
163    fn resume_job(&self, job_id: &str) {
164        self.0.resume_job(job_id);
165    }
166
167    fn cancel_job(&self, job_id: &str) {
168        self.0.cancel_job(job_id);
169    }
170
171    fn abort_job(&self, job_id: &str, reason: String) -> bool {
172        self.0.abort_job(job_id, reason)
173    }
174
175    fn has_job(&self, job_id: &str) -> bool {
176        self.0.has_job(job_id)
177    }
178
179    fn release_completed_job(&self, job_id: &str) {
180        self.0.release_completed_job(job_id);
181    }
182
183    fn update_servers(&self, servers: Vec<ServerConfig>) {
184        let new_ids: std::collections::HashSet<_> =
185            servers.iter().map(|server| server.id.clone()).collect();
186        for (old_id, _, _) in self.0.conn_tracker().snapshot() {
187            if !new_ids.contains(&old_id) {
188                self.0.conn_tracker().remove_server(&old_id);
189            }
190        }
191        for server in &servers {
192            self.0
193                .conn_tracker()
194                .set_limit(&server.id, &server.name, server.connections as usize);
195        }
196        *self.0.servers.lock() = servers;
197        self.0.reconcile_servers();
198    }
199
200    fn connection_snapshot(&self) -> Vec<(String, usize, usize)> {
201        self.0.conn_tracker().snapshot()
202    }
203
204    fn active_connection_snapshot(&self) -> Vec<(String, usize, usize)> {
205        self.0.conn_tracker().connected_snapshot()
206    }
207
208    fn connection_total(&self) -> usize {
209        self.0.conn_tracker().total()
210    }
211
212    fn reconcile_servers(&self) {
213        self.0.reconcile_servers();
214    }
215
216    fn set_max_worker_idle(&self, d: Duration) {
217        self.0.set_max_worker_idle(d);
218    }
219
220    fn eviction_count(&self) -> u64 {
221        self.0.eviction_count()
222    }
223
224    async fn shutdown(&self) {
225        self.0.shutdown().await;
226    }
227}