Skip to main content

rullst_core/queue/
worker.rs

1//! Bounded background queue worker and lifecycle handle.
2
3use super::{Queue, QueueDriver, QueueError, QueuedJob};
4use crate::error_buffer::{ERROR_BUFFER_CAPACITY, ErrorBuffer, ErrorReporter, error_buffer};
5use serde_json::Value;
6use std::collections::HashMap;
7use std::future::Future;
8use std::pin::Pin;
9use std::sync::Arc;
10use std::time::Duration;
11use tokio::sync::watch;
12use tokio::task::{JoinHandle, JoinSet};
13
14mod execution;
15
16/// Delay before a job handed back for lack of a handler can be claimed again.
17pub(crate) const UNHANDLED_JOB_RETRY_DELAY: Duration = Duration::from_secs(5);
18
19/// Type alias for asynchronous job handler closures.
20pub type JobHandler = Box<
21    dyn Fn(
22            Value,
23        ) -> Pin<
24            Box<dyn Future<Output = Result<(), Box<dyn std::error::Error + Send + Sync>>> + Send>,
25        > + Send
26        + Sync,
27>;
28
29/// Polls a queue and executes registered handlers with bounded concurrency.
30pub struct Worker {
31    driver: Arc<Box<dyn QueueDriver>>,
32    handlers: HashMap<String, Arc<JobHandler>>,
33    poll_interval: Duration,
34    max_concurrency: usize,
35    job_timeout: Duration,
36    stalled_after: Duration,
37}
38
39impl Worker {
40    /// Creates a worker with 16 concurrent slots and five-minute leases.
41    pub fn new(queue: &Queue) -> Self {
42        Self {
43            driver: queue.driver_ref(),
44            handlers: HashMap::new(),
45            poll_interval: Duration::from_secs(1),
46            max_concurrency: 16,
47            job_timeout: Duration::from_secs(300),
48            stalled_after: Duration::from_secs(600),
49        }
50    }
51
52    /// Sets the idle polling interval in milliseconds.
53    pub fn poll_interval(mut self, milliseconds: u64) -> Self {
54        self.poll_interval = Duration::from_millis(milliseconds);
55        self
56    }
57
58    /// Sets the hard upper bound for simultaneously executing handlers.
59    pub fn max_concurrency(mut self, limit: usize) -> Self {
60        self.max_concurrency = limit;
61        self
62    }
63
64    /// Sets the maximum duration of an individual handler execution.
65    ///
66    /// A handler still running at the deadline is aborted and its job is
67    /// failed as timed out. A handler that cannot be interrupted (for example
68    /// one that blocks its thread) and then returns is recorded from its own
69    /// result instead.
70    pub fn job_timeout(mut self, timeout: Duration) -> Self {
71        self.job_timeout = timeout;
72        self
73    }
74
75    /// Sets the age after which a processing lease counts as stalled.
76    ///
77    /// The worker returns stalled leases to pending when it starts and then
78    /// every `min(age, 60 s)` (at least every second). Recovery is queue-wide:
79    /// it covers every processing lease in the shared SQLite table or Redis
80    /// namespace, including leases held by other workers. `age` must exceed
81    /// this worker's [`Self::job_timeout`], and every worker sharing a queue
82    /// must use an `age` longer than the longest `job_timeout` among them, or
83    /// a worker with a short `age` requeues another worker's running job and
84    /// it runs twice.
85    pub fn stalled_after(mut self, age: Duration) -> Self {
86        self.stalled_after = age;
87        self
88    }
89
90    /// Registers a handler for a job name.
91    pub fn register<F, Fut>(&mut self, name: impl Into<String>, handler: F) -> &mut Self
92    where
93        F: Fn(Value) -> Fut + Send + Sync + 'static,
94        Fut: Future<Output = Result<(), Box<dyn std::error::Error + Send + Sync>>> + Send + 'static,
95    {
96        let boxed: JobHandler = Box::new(move |payload| Box::pin(handler(payload)));
97        self.handlers.insert(name.into(), Arc::new(boxed));
98        self
99    }
100
101    /// Starts the polling loop and returns an observable lifecycle handle.
102    ///
103    /// # Errors
104    /// Returns a typed error for zero concurrency, a zero polling interval, or
105    /// invocation outside an active Tokio runtime. No task is spawned on error.
106    #[cfg_attr(mutants, mutants::skip)]
107    pub fn run(&self) -> Result<WorkerHandle, QueueError> {
108        if self.max_concurrency == 0 {
109            return Err(QueueError::InvalidConfiguration(
110                "max_concurrency must be greater than zero".to_string(),
111            ));
112        }
113        if self.poll_interval.is_zero() {
114            return Err(QueueError::InvalidConfiguration(
115                "poll_interval must be greater than zero".to_string(),
116            ));
117        }
118        if self.stalled_after <= self.job_timeout {
119            return Err(QueueError::InvalidConfiguration(
120                "stalled_after must be greater than job_timeout to protect active jobs".to_string(),
121            ));
122        }
123        let runtime =
124            tokio::runtime::Handle::try_current().map_err(|_| QueueError::RuntimeUnavailable)?;
125        let (shutdown, shutdown_rx) = watch::channel(false);
126        let (errors_tx, errors) = error_buffer(ERROR_BUFFER_CAPACITY, "queue_worker");
127        let task = runtime.spawn(run_worker_loop(
128            Arc::clone(&self.driver),
129            self.handlers.clone(),
130            self.poll_interval,
131            self.max_concurrency,
132            self.job_timeout,
133            self.stalled_after,
134            shutdown_rx,
135            errors_tx,
136        ));
137
138        Ok(WorkerHandle {
139            shutdown,
140            task: Some(task),
141            errors,
142        })
143    }
144}
145
146/// Owns a running queue worker and exposes asynchronous processing failures.
147///
148/// The handle buffers at most 256 undrained errors. When the buffer is full,
149/// newer errors are dropped, counted by [`Self::dropped_errors`] and emitted as
150/// `tracing` warnings, so a handle that is kept alive but never drained does
151/// not grow memory. Drain [`Self::next_error`] to observe every failure.
152#[must_use = "dropping the worker handle immediately stops queue processing"]
153pub struct WorkerHandle {
154    shutdown: watch::Sender<bool>,
155    task: Option<JoinHandle<()>>,
156    errors: ErrorBuffer<QueueError>,
157}
158
159impl WorkerHandle {
160    /// Waits for the next driver, handler, timeout, or state-transition error.
161    pub async fn next_error(&mut self) -> Option<QueueError> {
162        self.errors.next().await
163    }
164
165    /// Returns an already reported worker error without waiting.
166    pub fn try_next_error(&mut self) -> Option<QueueError> {
167        self.errors.try_next()
168    }
169
170    /// Returns how many errors were dropped because the buffer was full.
171    pub fn dropped_errors(&self) -> u64 {
172        self.errors.dropped()
173    }
174
175    /// Stops polling, cancels active handlers, and requeues interrupted jobs
176    /// when the driver supports recoverable processing states.
177    ///
178    /// Only a handler that was actually cancelled is requeued. A handler that
179    /// finishes before its cancellation takes effect is completed or failed
180    /// from its own result, so a success is not run a second time.
181    ///
182    /// A claim that is already in flight is allowed to finish, so shutdown can
183    /// wait for one `pop` call. A job claimed after shutdown was requested is
184    /// requeued rather than dispatched. Dropping the handle instead aborts the
185    /// worker immediately; a claim interrupted that way is returned only by
186    /// stalled-lease recovery.
187    ///
188    /// # Errors
189    /// Returns the first processing error reported before shutdown.
190    pub async fn shutdown(mut self) -> Result<(), QueueError> {
191        let _ = self.shutdown.send(true);
192        let mut first_error = self.try_next_error();
193        if let Some(task) = self.task.take()
194            && let Err(error) = task.await
195            && !error.is_cancelled()
196            && first_error.is_none()
197        {
198            first_error = Some(QueueError::WorkerTask(error.to_string()));
199        }
200        first_error = first_error.or_else(|| self.try_next_error());
201        match first_error {
202            Some(error) => Err(error),
203            None => Ok(()),
204        }
205    }
206
207    fn abort(&mut self) {
208        let _ = self.shutdown.send(true);
209        if let Some(task) = self.task.take() {
210            task.abort();
211        }
212    }
213}
214
215impl Drop for WorkerHandle {
216    fn drop(&mut self) {
217        self.abort();
218    }
219}
220
221#[allow(clippy::too_many_arguments)]
222#[cfg_attr(mutants, mutants::skip)]
223async fn run_worker_loop(
224    driver: Arc<Box<dyn QueueDriver>>,
225    handlers: HashMap<String, Arc<JobHandler>>,
226    poll_interval: Duration,
227    max_concurrency: usize,
228    job_timeout: Duration,
229    stalled_after: Duration,
230    mut shutdown: watch::Receiver<bool>,
231    errors: ErrorReporter<QueueError>,
232) {
233    match driver.recover_stalled(stalled_after).await {
234        Ok(_) | Err(QueueError::Unsupported(_)) => {}
235        Err(error) => {
236            errors.report(error);
237        }
238    }
239
240    let recovery_task = tokio::spawn(recovery_loop(
241        Arc::clone(&driver),
242        stalled_after,
243        shutdown.clone(),
244        errors.clone(),
245    ));
246    let mut recovery_task = AbortOnDrop(recovery_task);
247
248    let mut jobs = JoinSet::new();
249    loop {
250        if shutdown_requested(&shutdown) {
251            break;
252        }
253        if jobs.len() >= max_concurrency {
254            tokio::select! {
255                outcome = jobs.join_next() => report_outcome(outcome, &errors),
256                _ = wait_for_shutdown(&mut shutdown) => break,
257            }
258            continue;
259        }
260
261        let popped = claim_next(&**driver, &mut jobs, &errors).await;
262        if shutdown_requested(&shutdown) {
263            release_claim_after_shutdown(popped, &**driver, &errors).await;
264            break;
265        }
266        match popped {
267            Ok(Some(job)) => {
268                dispatch_job(
269                    job,
270                    &handlers,
271                    Arc::clone(&driver),
272                    job_timeout,
273                    shutdown.clone(),
274                    &mut jobs,
275                    &errors,
276                )
277                .await
278            }
279            Ok(None) => {
280                tokio::select! {
281                    _ = tokio::time::sleep(poll_interval) => {}
282                    outcome = jobs.join_next(), if !jobs.is_empty() => {
283                        report_outcome(outcome, &errors);
284                    }
285                    _ = wait_for_shutdown(&mut shutdown) => break,
286                }
287            }
288            Err(error) => {
289                errors.report(error);
290                tokio::select! {
291                    _ = tokio::time::sleep(poll_interval) => {}
292                    _ = wait_for_shutdown(&mut shutdown) => break,
293                }
294            }
295        }
296    }
297
298    while let Some(outcome) = jobs.join_next().await {
299        report_outcome(Some(outcome), &errors);
300    }
301    if let Err(error) = (&mut recovery_task.0).await
302        && !error.is_cancelled()
303    {
304        errors.report(QueueError::WorkerTask(error.to_string()));
305    }
306}
307
308/// Runs one `pop` to completion.
309///
310/// The built-in drivers commit a claim before the future resolves (an SQLite
311/// `UPDATE ... RETURNING`, a Redis `EVAL`), so dropping the future would leave
312/// the job in the processing state until stalled-lease recovery. The claim is
313/// therefore never raced against shutdown; handlers that finish meanwhile are
314/// still reported because `JoinSet::join_next` is cancel-safe.
315async fn claim_next(
316    driver: &dyn QueueDriver,
317    jobs: &mut JoinSet<Result<(), QueueError>>,
318    errors: &ErrorReporter<QueueError>,
319) -> Result<Option<QueuedJob>, QueueError> {
320    let mut claim = driver.pop();
321    loop {
322        tokio::select! {
323            popped = &mut claim => return popped,
324            outcome = jobs.join_next(), if !jobs.is_empty() => report_outcome(outcome, errors),
325        }
326    }
327}
328
329/// Returns a job claimed while shutdown was requested to the pending state
330/// instead of dispatching it.
331async fn release_claim_after_shutdown(
332    popped: Result<Option<QueuedJob>, QueueError>,
333    driver: &dyn QueueDriver,
334    errors: &ErrorReporter<QueueError>,
335) {
336    match popped {
337        Ok(Some(job)) => {
338            if let Err(error) = driver
339                .requeue_attempt(&job.id, job.attempts, "worker shutdown before dispatch")
340                .await
341            {
342                errors.report(state_error(&job.id, "requeue_claim_after_shutdown", error));
343            }
344        }
345        Ok(None) => {}
346        Err(error) => {
347            errors.report(error);
348        }
349    }
350}
351
352async fn recovery_loop(
353    driver: Arc<Box<dyn QueueDriver>>,
354    stale_after: Duration,
355    mut shutdown: watch::Receiver<bool>,
356    errors: ErrorReporter<QueueError>,
357) {
358    let interval = stale_after
359        .min(Duration::from_secs(60))
360        .max(Duration::from_secs(1));
361    loop {
362        tokio::select! {
363            _ = tokio::time::sleep(interval) => {}
364            _ = wait_for_shutdown(&mut shutdown) => break,
365        }
366        match driver.recover_stalled(stale_after).await {
367            Ok(_) => {}
368            Err(QueueError::Unsupported(_)) => break,
369            Err(error) => {
370                errors.report(error);
371            }
372        }
373    }
374}
375
376async fn dispatch_job(
377    job: QueuedJob,
378    handlers: &HashMap<String, Arc<JobHandler>>,
379    driver: Arc<Box<dyn QueueDriver>>,
380    timeout: Duration,
381    shutdown: watch::Receiver<bool>,
382    jobs: &mut JoinSet<Result<(), QueueError>>,
383    errors: &ErrorReporter<QueueError>,
384) {
385    let Some(handler) = handlers.get(&job.name).cloned() else {
386        hand_back_unhandled(&job, &**driver, errors).await;
387        return;
388    };
389
390    jobs.spawn(execution::execute_job(
391        driver, handler, job, timeout, shutdown,
392    ));
393}
394
395/// Returns a job this worker has no handler for to the queue, so a worker
396/// that registered its name can claim it. The delay keeps this worker from
397/// re-claiming the job in a hot loop; `HandlerNotFound` is still reported.
398/// Drivers without delayed requeue keep the previous behaviour and fail it.
399async fn hand_back_unhandled(
400    job: &QueuedJob,
401    driver: &dyn QueueDriver,
402    errors: &ErrorReporter<QueueError>,
403) {
404    let missing = QueueError::HandlerNotFound(job.name.clone());
405    let reason = missing.to_string();
406    let transition = match driver
407        .requeue_attempt_after(&job.id, job.attempts, &reason, UNHANDLED_JOB_RETRY_DELAY)
408        .await
409    {
410        Err(QueueError::Unsupported(_)) => driver
411            .mark_failed_attempt(&job.id, job.attempts, &reason)
412            .await
413            .map_err(|error| state_error(&job.id, "mark_failed", error)),
414        deferred => deferred.map_err(|error| state_error(&job.id, "requeue_unhandled", error)),
415    };
416    match transition {
417        Ok(()) => errors.report(missing),
418        Err(error) => errors.report(error),
419    }
420}
421
422fn report_outcome(
423    outcome: Option<Result<Result<(), QueueError>, tokio::task::JoinError>>,
424    errors: &ErrorReporter<QueueError>,
425) {
426    match outcome {
427        Some(Ok(Err(error))) => {
428            errors.report(error);
429        }
430        Some(Err(error)) => {
431            errors.report(QueueError::WorkerTask(error.to_string()));
432        }
433        Some(Ok(Ok(()))) | None => {}
434    }
435}
436
437fn state_error(job_id: &str, operation: &'static str, error: QueueError) -> QueueError {
438    QueueError::StateTransition {
439        job_id: job_id.to_string(),
440        operation,
441        message: error.to_string(),
442    }
443}
444
445fn shutdown_requested(shutdown: &watch::Receiver<bool>) -> bool {
446    *shutdown.borrow()
447}
448
449async fn wait_for_shutdown(shutdown: &mut watch::Receiver<bool>) {
450    while !shutdown_requested(shutdown) {
451        if shutdown.changed().await.is_err() {
452            break;
453        }
454    }
455}
456
457fn duration_millis_u64(duration: Duration) -> u64 {
458    u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
459}
460
461struct AbortOnDrop<T>(JoinHandle<T>);
462
463impl<T> Drop for AbortOnDrop<T> {
464    fn drop(&mut self) {
465        self.0.abort();
466    }
467}