rust_web_server/jobs/mod.rs
1//! Background job queue for one-shot, fire-and-forget work triggered from
2//! request handlers — e.g. "send a confirmation email after signup" — without
3//! blocking the response.
4//!
5//! Two flavors:
6//! - [`JobQueue`] — an in-memory worker pool. Simple and dependency-free, but
7//! queued and retrying jobs are lost if the process crashes or restarts.
8//! - [`PersistentJobQueue`] (requires a `model-sqlite` / `model-postgres` /
9//! `model-mysql` feature) — persists jobs via the model layer so pending
10//! and retrying jobs survive a restart.
11//!
12//! # Example
13//!
14//! ```rust
15//! use rust_web_server::jobs::JobQueue;
16//!
17//! let queue = JobQueue::new(4); // 4 worker threads
18//! queue.submit(|| {
19//! println!("sending welcome email...");
20//! Ok(())
21//! });
22//! queue.join(); // wait for in-flight jobs to finish (tests/shutdown only)
23//! ```
24
25#[cfg(test)]
26mod tests;
27
28use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
29use std::sync::mpsc::{self, Sender};
30use std::sync::Arc;
31use std::thread;
32use std::time::Duration;
33
34// ── Job ──────────────────────────────────────────────────────────────────────
35
36/// A unit of background work.
37///
38/// Implement `run` directly for named, stateful jobs, or rely on the blanket
39/// implementation below to submit a plain closure.
40pub trait Job: Send {
41 /// Perform the work. Return `Err(message)` to trigger a retry (subject to
42 /// the queue's retry policy).
43 fn run(&self) -> Result<(), String>;
44
45 /// Human-readable name used in retry/failure log lines. Defaults to `"job"`.
46 fn name(&self) -> &str {
47 "job"
48 }
49}
50
51impl<F> Job for F
52where
53 F: Fn() -> Result<(), String> + Send,
54{
55 fn run(&self) -> Result<(), String> {
56 self()
57 }
58}
59
60struct Envelope {
61 job: Box<dyn Job>,
62 attempt: u32,
63}
64
65// ── JobQueue ─────────────────────────────────────────────────────────────────
66
67/// An in-memory background job queue backed by a fixed-size worker pool.
68///
69/// Jobs that return `Err` are retried with exponential backoff (default: 3
70/// retries, 500 ms initial backoff, 2x multiplier) — the same worker thread
71/// that picked up the job sleeps between attempts, so a job that retries
72/// repeatedly occupies one worker for the duration of its backoff; size the
73/// pool with that in mind. Queued and retrying jobs are lost if the process
74/// exits; see [`PersistentJobQueue`] for crash-safe persistence.
75pub struct JobQueue {
76 sender: Sender<Envelope>,
77 workers: Vec<thread::JoinHandle<()>>,
78 max_retries: Arc<AtomicU32>,
79 initial_backoff_ms: Arc<AtomicU64>,
80 backoff_multiplier: Arc<AtomicU32>,
81}
82
83impl JobQueue {
84 /// Create a queue backed by `workers` worker threads.
85 ///
86 /// Default retry policy: 3 retries, 500 ms initial backoff, 2x multiplier.
87 /// Adjust it with [`max_retries`](Self::max_retries) / [`backoff`](Self::backoff)
88 /// before submitting jobs.
89 pub fn new(workers: usize) -> Self {
90 assert!(workers > 0, "JobQueue requires at least one worker");
91
92 let (sender, receiver) = mpsc::channel::<Envelope>();
93 let receiver = Arc::new(std::sync::Mutex::new(receiver));
94
95 let max_retries = Arc::new(AtomicU32::new(3));
96 let initial_backoff_ms = Arc::new(AtomicU64::new(500));
97 let backoff_multiplier = Arc::new(AtomicU32::new(2));
98
99 let mut handles = Vec::with_capacity(workers);
100 for _ in 0..workers {
101 let receiver = Arc::clone(&receiver);
102 let max_retries = Arc::clone(&max_retries);
103 let initial_backoff_ms = Arc::clone(&initial_backoff_ms);
104 let backoff_multiplier = Arc::clone(&backoff_multiplier);
105 handles.push(thread::spawn(move || {
106 worker_loop(receiver, max_retries, initial_backoff_ms, backoff_multiplier);
107 }));
108 }
109
110 JobQueue {
111 sender,
112 workers: handles,
113 max_retries,
114 initial_backoff_ms,
115 backoff_multiplier,
116 }
117 }
118
119 /// Override the number of retries after the initial attempt (default: 3).
120 pub fn max_retries(self, n: u32) -> Self {
121 self.max_retries.store(n, Ordering::Relaxed);
122 self
123 }
124
125 /// Override the backoff policy: `initial` delay before the first retry,
126 /// doubled (or scaled by `multiplier`) on each subsequent attempt.
127 pub fn backoff(self, initial: Duration, multiplier: u32) -> Self {
128 self.initial_backoff_ms.store(initial.as_millis() as u64, Ordering::Relaxed);
129 self.backoff_multiplier.store(multiplier.max(1), Ordering::Relaxed);
130 self
131 }
132
133 /// Submit a job for background execution. Returns immediately.
134 pub fn submit(&self, job: impl Job + 'static) {
135 let _ = self.sender.send(Envelope { job: Box::new(job), attempt: 0 });
136 }
137
138 /// Stop accepting new jobs and wait for every worker to finish its
139 /// current job (including any retries already in progress). Consumes
140 /// the queue.
141 pub fn join(self) {
142 let JobQueue { sender, workers, .. } = self;
143 drop(sender);
144 for handle in workers {
145 let _ = handle.join();
146 }
147 }
148}
149
150fn worker_loop(
151 receiver: Arc<std::sync::Mutex<mpsc::Receiver<Envelope>>>,
152 max_retries: Arc<AtomicU32>,
153 initial_backoff_ms: Arc<AtomicU64>,
154 backoff_multiplier: Arc<AtomicU32>,
155) {
156 loop {
157 let received = {
158 let lock = receiver.lock().unwrap();
159 lock.recv()
160 };
161 let mut envelope = match received {
162 Ok(envelope) => envelope,
163 Err(_) => break, // sender dropped -> queue shut down
164 };
165
166 loop {
167 match envelope.job.run() {
168 Ok(()) => break,
169 Err(e) => {
170 let max = max_retries.load(Ordering::Relaxed);
171 if envelope.attempt >= max {
172 eprintln!(
173 "[jobs] '{}' failed permanently after {} attempt(s): {}",
174 envelope.job.name(),
175 envelope.attempt + 1,
176 e
177 );
178 break;
179 }
180
181 let initial_ms = initial_backoff_ms.load(Ordering::Relaxed);
182 let multiplier = backoff_multiplier.load(Ordering::Relaxed).max(1) as u64;
183 let delay = Duration::from_millis(
184 initial_ms.saturating_mul(multiplier.saturating_pow(envelope.attempt)),
185 );
186 eprintln!(
187 "[jobs] '{}' failed (attempt {}/{}): {} — retrying in {:?}",
188 envelope.job.name(),
189 envelope.attempt + 1,
190 max + 1,
191 e,
192 delay
193 );
194 thread::sleep(delay);
195 envelope.attempt += 1;
196 }
197 }
198 }
199 }
200}
201
202// ── PersistentJobQueue ───────────────────────────────────────────────────────
203
204#[cfg(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql"))]
205mod persistent;
206#[cfg(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql"))]
207pub use persistent::{EnqueueOptions, JobInfo, PersistentJobQueue};