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}