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 stamps `#[must_use]` with no message on the boxed future it
31// returns, and a newer clippy rejects that as double_must_use because the
32// boxed future is already must-use. The attribute is macro-generated, so the
33// suppression has to sit on the trait rather than the method.
34#[allow(clippy::double_must_use)]
35#[async_trait::async_trait]
36pub trait DispatchEngine: Send + Sync {
37    /// Spawn workers for all currently enabled servers and start the
38    /// supervisor task. Idempotent (safe to call more than once).
39    fn start(&self);
40
41    /// Register a new job and begin dispatching its unfinished articles.
42    /// Progress is streamed to `progress_tx` as `ProgressUpdate`s; the
43    /// channel is closed when the job reaches a terminal state.
44    fn submit_job(&self, job: &NzbJob, progress_tx: mpsc::Sender<ProgressUpdate>);
45
46    /// Pause dispatch for `job_id`. In-flight articles finish; no new work
47    /// is popped for this job until [`resume_job`](Self::resume_job).
48    fn pause_job(&self, job_id: &str);
49
50    /// Resume a paused job.
51    fn resume_job(&self, job_id: &str);
52
53    /// Cancel `job_id`. In-flight articles may still complete but their
54    /// results are discarded; no terminal progress update is emitted beyond
55    /// the one triggered by cancellation.
56    fn cancel_job(&self, job_id: &str);
57
58    /// Abort `job_id` with a human-readable reason. Emits
59    /// [`ProgressUpdate::JobAborted`] once outstanding articles drain.
60    /// Returns `true` only for the caller that won terminal ownership.
61    fn abort_job(&self, job_id: &str, reason: String) -> bool;
62
63    /// Is `job_id` currently known to the dispatcher?
64    fn has_job(&self, job_id: &str) -> bool;
65
66    /// Active download time `job_id` has accumulated in this dispatcher,
67    /// excluding pauses, or `None` when the job is not registered. Lets the
68    /// queue checkpoint elapsed time so a restart can carry it forward.
69    fn job_active_secs(&self, _job_id: &str) -> Option<f64> {
70        None
71    }
72
73    /// Release a terminal job's dispatcher and assembler resources before
74    /// post-processing opens the completed files.
75    fn release_completed_job(&self, job_id: &str);
76
77    /// Replace server configuration and reconcile connection budgets/workers.
78    fn update_servers(&self, servers: Vec<ServerConfig>);
79
80    /// Per-server allocated worker slots and configured limits.
81    fn connection_snapshot(&self) -> Vec<(String, usize, usize)>;
82
83    /// Per-server connections actively transferring articles and limits.
84    fn active_connection_snapshot(&self) -> Vec<(String, usize, usize)>;
85
86    /// Total allocated worker slots across current server pools.
87    fn connection_total(&self) -> usize;
88
89    /// Re-read the server list and adjust workers to match. Call after any
90    /// mutation to the server config (add, remove, enable, disable, resize).
91    fn reconcile_servers(&self);
92
93    /// Override the idle-worker eviction threshold. Tests shrink this to
94    /// make the watchdog converge in seconds; production uses the default.
95    fn set_max_worker_idle(&self, d: Duration);
96
97    /// Lifetime count of worker evictions performed by the heartbeat
98    /// watchdog. Increases by 1 each time the supervisor reclaims a stalled
99    /// worker. Useful for tests and observability.
100    fn eviction_count(&self) -> u64;
101
102    /// Snapshot of per-server lifetime attempt counters. Used by the
103    /// queue manager to emit a diagnostic breakdown alongside a job abort
104    /// — distinguishes "server returned 430 for every article" (dead NZB)
105    /// from "server had auth errors" (transient). Default is empty for
106    /// engines that don't track per-server stats.
107    fn server_stats_snapshot(&self) -> Vec<(String, ServerAttemptStats)> {
108        Vec::new()
109    }
110
111    /// Gracefully shut down: stop accepting new work, signal all workers,
112    /// and wait for the supervisor to exit.
113    async fn shutdown(&self);
114}
115
116/// Per-server lifetime counters reported via
117/// [`DispatchEngine::server_stats_snapshot`]. `not_found` is the strongest
118/// signal for a dead NZB; `transient_failed` separates "missing articles"
119/// from "server flaky / auth issues".
120#[derive(Debug, Clone, Copy, Default)]
121pub struct ServerAttemptStats {
122    pub attempted: u64,
123    pub succeeded: u64,
124    pub not_found: u64,
125    pub transient_failed: u64,
126}
127
128// ---------------------------------------------------------------------------
129// DispatchHandle — wraps Arc<WorkerPool> to implement DispatchEngine.
130//
131// Why a wrapper: several WorkerPool methods have `self: &Arc<Self>` receivers
132// (they spawn tasks that clone the Arc). That signature is incompatible with
133// a `dyn`-object trait method. The wrapper holds `Arc<WorkerPool>` and can
134// call the concrete Arc-receiver methods on it.
135// ---------------------------------------------------------------------------
136
137/// Dynamic-dispatch wrapper around `Arc<WorkerPool>` — the one concrete
138/// [`DispatchEngine`] impl today. Extract this into `nzb-dispatch` in Phase B.
139pub struct DispatchHandle(Arc<WorkerPool>);
140
141impl DispatchHandle {
142    pub fn new(
143        servers: Arc<Mutex<Vec<ServerConfig>>>,
144        bandwidth: Arc<BandwidthLimiter>,
145        article_timeout_secs: u64,
146    ) -> Self {
147        let tracker = Arc::new(ConnectionTracker::new());
148        for server in servers.lock().iter() {
149            tracker.set_limit(&server.id, &server.name, server.connections as usize);
150        }
151        Self(WorkerPool::new(
152            servers,
153            bandwidth,
154            tracker,
155            article_timeout_secs,
156        ))
157    }
158}
159
160#[async_trait::async_trait]
161impl DispatchEngine for DispatchHandle {
162    fn start(&self) {
163        self.0.start();
164    }
165
166    fn submit_job(&self, job: &NzbJob, progress_tx: mpsc::Sender<ProgressUpdate>) {
167        let enabled_server_ids = self
168            .0
169            .servers
170            .lock()
171            .iter()
172            .filter(|server| server.enabled)
173            .map(|server| server.id.clone())
174            .collect::<Vec<_>>();
175        let (ctx, items) = build_job_submission(job, progress_tx, &enabled_server_ids);
176        self.0.submit_job(ctx, items);
177    }
178
179    fn pause_job(&self, job_id: &str) {
180        self.0.pause_job(job_id);
181    }
182
183    fn resume_job(&self, job_id: &str) {
184        self.0.resume_job(job_id);
185    }
186
187    fn cancel_job(&self, job_id: &str) {
188        self.0.cancel_job(job_id);
189    }
190
191    fn abort_job(&self, job_id: &str, reason: String) -> bool {
192        self.0.abort_job(job_id, reason)
193    }
194
195    fn has_job(&self, job_id: &str) -> bool {
196        self.0.has_job(job_id)
197    }
198
199    fn job_active_secs(&self, job_id: &str) -> Option<f64> {
200        self.0.job_active_secs(job_id)
201    }
202
203    fn release_completed_job(&self, job_id: &str) {
204        self.0.release_completed_job(job_id);
205    }
206
207    fn update_servers(&self, servers: Vec<ServerConfig>) {
208        let new_ids: std::collections::HashSet<_> =
209            servers.iter().map(|server| server.id.clone()).collect();
210        for (old_id, _, _) in self.0.conn_tracker().snapshot() {
211            if !new_ids.contains(&old_id) {
212                self.0.conn_tracker().remove_server(&old_id);
213            }
214        }
215        for server in &servers {
216            self.0
217                .conn_tracker()
218                .set_limit(&server.id, &server.name, server.connections as usize);
219        }
220        *self.0.servers.lock() = servers;
221        self.0.reconcile_servers();
222    }
223
224    fn connection_snapshot(&self) -> Vec<(String, usize, usize)> {
225        self.0.conn_tracker().snapshot()
226    }
227
228    fn active_connection_snapshot(&self) -> Vec<(String, usize, usize)> {
229        self.0.conn_tracker().connected_snapshot()
230    }
231
232    fn connection_total(&self) -> usize {
233        self.0.conn_tracker().total()
234    }
235
236    fn reconcile_servers(&self) {
237        self.0.reconcile_servers();
238    }
239
240    fn set_max_worker_idle(&self, d: Duration) {
241        self.0.set_max_worker_idle(d);
242    }
243
244    fn eviction_count(&self) -> u64 {
245        self.0.eviction_count()
246    }
247
248    async fn shutdown(&self) {
249        self.0.shutdown().await;
250    }
251}