Skip to main content

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};