Skip to main content

rainier_queue/
worker.rs

1//! The [`Worker`] loop, and the events it fires.
2
3use std::sync::atomic::{AtomicBool, Ordering};
4use std::sync::Arc;
5use std::time::Duration;
6
7use rainier_cache::LockManager;
8use rainier_container::Container;
9use rainier_events::Dispatcher;
10use rainier_support::{Error, Result};
11
12use crate::job::{JobContext, JobRegistry, QueuedJob};
13use crate::queue::Queue;
14
15/// Fired before a job runs.
16#[derive(Debug, Clone)]
17pub struct JobProcessing {
18    /// The job about to run.
19    pub job: QueuedJob,
20}
21
22/// Fired after a job succeeds.
23#[derive(Debug, Clone)]
24pub struct JobProcessed {
25    /// The job that ran.
26    pub job: QueuedJob,
27    /// How long `handle` took.
28    pub duration: Duration,
29}
30
31/// Fired when a job fails but has attempts left.
32#[derive(Debug, Clone)]
33pub struct JobReleased {
34    /// The job going back on the queue.
35    pub job: QueuedJob,
36    /// How long before it may run again.
37    pub delay: Duration,
38    /// Why it failed.
39    pub error: String,
40}
41
42/// Fired when a job fails on its last attempt.
43#[derive(Debug, Clone)]
44pub struct JobFailed {
45    /// The job that gave up.
46    pub job: QueuedJob,
47    /// Why it failed.
48    pub error: String,
49}
50
51/// What one turn of the loop did.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum Outcome {
54    /// Nothing was waiting.
55    Idle,
56    /// A job ran and succeeded.
57    Processed,
58    /// A job failed and went back on the queue.
59    Released,
60    /// A job failed for the last time.
61    Failed,
62}
63
64/// How a worker behaves.
65#[derive(Debug, Clone)]
66pub struct WorkerOptions {
67    /// The queues to serve, in priority order — earlier ones are drained
68    /// first, which is how a `high` queue gets ahead of `default`.
69    pub queues: Vec<String>,
70    /// How long to wait before looking again when every queue is empty.
71    pub sleep: Duration,
72    /// Stop after this many jobs. `None` runs forever.
73    pub max_jobs: Option<u64>,
74    /// Stop as soon as the queues are empty, rather than waiting for more.
75    pub stop_when_empty: bool,
76    /// Give up on a job that runs longer than this.
77    pub timeout: Option<Duration>,
78}
79
80impl Default for WorkerOptions {
81    fn default() -> Self {
82        Self {
83            queues: vec!["default".to_string()],
84            sleep: Duration::from_secs(1),
85            max_jobs: None,
86            stop_when_empty: false,
87            timeout: Some(Duration::from_secs(60)),
88        }
89    }
90}
91
92impl WorkerOptions {
93    /// Serve these queues, in priority order.
94    pub fn queues(mut self, queues: impl IntoIterator<Item = impl Into<String>>) -> Self {
95        self.queues = queues.into_iter().map(Into::into).collect();
96        self
97    }
98
99    /// Wait this long between polls of an empty queue.
100    pub fn sleep(mut self, sleep: Duration) -> Self {
101        self.sleep = sleep;
102        self
103    }
104
105    /// Stop after `max` jobs — for a worker that should recycle periodically.
106    pub fn max_jobs(mut self, max: u64) -> Self {
107        self.max_jobs = Some(max);
108        self
109    }
110
111    /// Return as soon as there is nothing left to do.
112    pub fn stop_when_empty(mut self) -> Self {
113        self.stop_when_empty = true;
114        self
115    }
116
117    /// Abandon a job that exceeds this duration.
118    pub fn timeout(mut self, timeout: Option<Duration>) -> Self {
119        self.timeout = timeout;
120        self
121    }
122}
123
124/// What a worker run did in total.
125#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
126pub struct WorkerStats {
127    /// Jobs that succeeded.
128    pub processed: u64,
129    /// Jobs that failed but were retried.
130    pub released: u64,
131    /// Jobs that gave up.
132    pub failed: u64,
133    /// Turns of the loop that found nothing.
134    pub idles: u64,
135}
136
137impl WorkerStats {
138    /// How many jobs finished, one way or the other.
139    pub fn total(&self) -> u64 {
140        self.processed + self.failed
141    }
142}
143
144/// Pulls jobs off a queue and runs them.
145pub struct Worker {
146    queue: Arc<dyn Queue>,
147    registry: Arc<JobRegistry>,
148    container: Arc<Container>,
149    events: Option<Arc<Dispatcher>>,
150    options: WorkerOptions,
151    stopping: AtomicBool,
152    /// Where a finished job's [`unique_id`](crate::Job::unique_id) lock is
153    /// released.
154    ///
155    /// `None` means uniqueness is not enforced here, and a job carrying a key
156    /// simply keeps its lock until `UNIQUE_FOR` expires — degraded rather than
157    /// broken.
158    locks: Option<LockManager>,
159}
160
161impl Worker {
162    /// Enforce [`unique_id`](crate::Job::unique_id) releases with `locks`.
163    ///
164    /// The same manager the dispatching side uses, or the release will not find
165    /// the key. `Worker::from_manager` wires it for you.
166    pub fn with_locks(mut self, locks: LockManager) -> Self {
167        self.locks = Some(locks);
168        self
169    }
170
171    /// A worker serving `queue`, running the jobs `registry` knows.
172    pub fn new(
173        queue: Arc<dyn Queue>,
174        registry: Arc<JobRegistry>,
175        container: Arc<Container>,
176    ) -> Self {
177        Self {
178            queue,
179            registry,
180            container,
181            events: None,
182            locks: None,
183            options: WorkerOptions::default(),
184            stopping: AtomicBool::new(false),
185        }
186    }
187
188    /// Fire lifecycle events through `events`.
189    pub fn with_events(mut self, events: Arc<Dispatcher>) -> Self {
190        self.events = Some(events);
191        self
192    }
193
194    /// Configure the loop.
195    pub fn with_options(mut self, options: WorkerOptions) -> Self {
196        self.options = options;
197        self
198    }
199
200    /// Ask the loop to stop after the job in flight.
201    pub fn stop(&self) {
202        self.stopping.store(true, Ordering::SeqCst);
203    }
204
205    /// Whether a stop has been requested.
206    pub fn is_stopping(&self) -> bool {
207        self.stopping.load(Ordering::SeqCst)
208    }
209
210    /// Reserve and run one job from the first queue that has one.
211    pub async fn run_next(&self) -> Result<Outcome> {
212        for queue in &self.options.queues {
213            if let Some(job) = self.queue.reserve(queue).await? {
214                return self.process(job).await;
215            }
216        }
217        Ok(Outcome::Idle)
218    }
219
220    /// Run until stopped, drained, or the job limit is reached.
221    ///
222    /// Between jobs the worker flushes the container's **scoped** bindings, so
223    /// one job cannot leak per-request-shaped state into the next. That is the
224    /// long-running-process hazard a per-request framework never has to think
225    /// about.
226    pub async fn run(&self) -> Result<WorkerStats> {
227        let mut stats = WorkerStats::default();
228
229        loop {
230            if self.is_stopping() {
231                break;
232            }
233            if self.options.max_jobs.is_some_and(|max| stats.total() >= max) {
234                break;
235            }
236
237            match self.run_next().await? {
238                Outcome::Idle => {
239                    stats.idles += 1;
240                    if self.options.stop_when_empty {
241                        break;
242                    }
243                    tokio::time::sleep(self.options.sleep).await;
244                }
245                Outcome::Processed => stats.processed += 1,
246                Outcome::Released => stats.released += 1,
247                Outcome::Failed => stats.failed += 1,
248            }
249
250            self.container.flush_scoped();
251        }
252
253        Ok(stats)
254    }
255
256    async fn process(&self, job: QueuedJob) -> Result<Outcome> {
257        self.dispatch(JobProcessing { job: job.clone() }).await;
258
259        let context = Arc::new(JobContext::new(
260            Arc::clone(&self.container),
261            job.id.clone(),
262            job.queue.clone(),
263            job.attempts,
264            job.max_attempts,
265        ));
266
267        let started = std::time::Instant::now();
268        let outcome = match self.options.timeout {
269            Some(timeout) => {
270                match tokio::time::timeout(timeout, self.registry.run(&job, Arc::clone(&context)))
271                    .await
272                {
273                    Ok(result) => result,
274                    Err(_) => {
275                        Err(Error::internal(format!("the job exceeded its {timeout:?} timeout")))
276                    }
277                }
278            }
279            None => self.registry.run(&job, Arc::clone(&context)).await,
280        };
281
282        match outcome {
283            Ok(()) => {
284                self.queue.acknowledge(&job).await?;
285                self.release_uniqueness(&job).await;
286
287                let duration = started.elapsed();
288                tracing::info!(job = %job.name, id = %job.id, ?duration, "job processed");
289                self.dispatch(JobProcessed { job, duration }).await;
290                Ok(Outcome::Processed)
291            }
292            Err(error) => self.handle_failure(job, error).await,
293        }
294    }
295
296    /// Release a finished job's uniqueness lock, if it held one.
297    ///
298    /// Deliberately **not** released when a job is merely *released for
299    /// retry* — it is still pending, and a duplicate dispatched in the gap is
300    /// exactly what uniqueness is meant to drop.
301    ///
302    /// A failure here is logged rather than propagated: the job is done, and
303    /// turning "the cache blinked" into "the job failed" would re-run work that
304    /// already succeeded. The lock expires on its own.
305    async fn release_uniqueness(&self, job: &QueuedJob) {
306        let (Some(key), Some(locks)) = (&job.unique_key, &self.locks) else { return };
307
308        // `force_release`, because the token belongs to the process that
309        // dispatched this — long gone, and it never handed it over. Nothing
310        // else takes this key.
311        if let Err(e) = locks.lock(key.clone(), Duration::from_secs(1)).force_release().await {
312            tracing::warn!(
313                job = %job.name,
314                key,
315                error = %e.message(),
316                "could not release the uniqueness lock; it will expire on its TTL"
317            );
318        }
319    }
320
321    async fn handle_failure(&self, job: QueuedJob, error: Error) -> Result<Outcome> {
322        let message = error.to_string();
323
324        if job.is_exhausted() {
325            self.queue.fail(&job, &message).await?;
326
327            // Released on final failure too. Holding the lock until
328            // `UNIQUE_FOR` would mean one permanent failure blocks every later
329            // dispatch of that id — including the one that fixes it.
330            self.release_uniqueness(&job).await;
331
332            tracing::error!(job = %job.name, id = %job.id, error = %message, "job failed");
333            self.dispatch(JobFailed { job, error: message }).await;
334            return Ok(Outcome::Failed);
335        }
336
337        // `attempts` counts what has been tried, so the backoff for the *next*
338        // attempt is indexed by it directly.
339        let delay = backoff_for(job.attempts);
340        self.queue.release(&job, delay).await?;
341        tracing::warn!(
342            job = %job.name,
343            id = %job.id,
344            attempt = job.attempts,
345            of = job.max_attempts,
346            ?delay,
347            error = %message,
348            "job failed; retrying"
349        );
350        self.dispatch(JobReleased { job, delay, error: message }).await;
351        Ok(Outcome::Released)
352    }
353
354    /// Worker events are informational, so a failing listener is logged and
355    /// otherwise ignored — a broken metrics hook must not stop the queue.
356    async fn dispatch<E: Send + Sync + 'static>(&self, event: E) {
357        if let Some(events) = &self.events {
358            events.dispatch_quietly(event).await;
359        }
360    }
361}
362
363/// The retry delay after `attempts` failures: 1s, 2s, 4s, …, capped at 64s.
364///
365/// The per-job [`Job::backoff`](crate::Job::backoff) cannot be consulted here —
366/// the worker has only the job's *name*, not its type — so this mirrors the
367/// same default curve.
368fn backoff_for(attempts: u32) -> Duration {
369    Duration::from_secs(1u64 << attempts.clamp(1, 6))
370}
371
372impl std::fmt::Debug for Worker {
373    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
374        f.debug_struct("Worker")
375            .field("queue", &self.queue.name())
376            .field("queues", &self.options.queues)
377            .field("stopping", &self.is_stopping())
378            .finish()
379    }
380}
381
382#[cfg(test)]
383// These tests hold `SERIAL` across their awaits on purpose: the `Flaky` job
384// coordinates through process-wide statics, so two of them running at once
385// would see each other's configuration. Safe here because `#[tokio::test]`
386// runs on a current-thread runtime, so the guard never crosses a thread.
387#[allow(clippy::await_holding_lock)]
388mod tests {
389    use super::*;
390    use crate::job::{Job, QueuedJob};
391    use crate::queue::MemoryQueue;
392    use serde::{Deserialize, Serialize};
393    use std::sync::atomic::AtomicU32;
394    use std::sync::Mutex;
395
396    /// The `Flaky` job coordinates through process-wide statics, so the tests
397    /// that configure it must not interleave.
398    static SERIAL: Mutex<()> = Mutex::new(());
399    static ATTEMPTS: AtomicU32 = AtomicU32::new(0);
400    static SUCCEED_FROM: AtomicU32 = AtomicU32::new(1);
401
402    #[derive(Serialize, Deserialize)]
403    struct Flaky;
404
405    #[async_trait::async_trait]
406    impl Job for Flaky {
407        const NAME: &'static str = "test.flaky";
408        const TRIES: u32 = 3;
409
410        async fn handle(&self, context: &JobContext) -> Result<()> {
411            ATTEMPTS.fetch_add(1, Ordering::SeqCst);
412            if context.attempt() >= SUCCEED_FROM.load(Ordering::SeqCst) {
413                Ok(())
414            } else {
415                Err(Error::internal("not yet"))
416            }
417        }
418    }
419
420    #[derive(Serialize, Deserialize)]
421    struct Slow;
422
423    #[async_trait::async_trait]
424    impl Job for Slow {
425        const NAME: &'static str = "test.slow";
426        const TRIES: u32 = 1;
427
428        async fn handle(&self, _: &JobContext) -> Result<()> {
429            tokio::time::sleep(Duration::from_secs(30)).await;
430            Ok(())
431        }
432    }
433
434    fn registry() -> Arc<JobRegistry> {
435        Arc::new(JobRegistry::new().with::<Flaky>().with::<Slow>())
436    }
437
438    fn worker(queue: Arc<MemoryQueue>) -> Worker {
439        Worker::new(queue, registry(), Arc::new(Container::new()))
440            .with_options(WorkerOptions::default().stop_when_empty())
441    }
442
443    /// Take the serial lock and configure `Flaky` for this test. The guard
444    /// must be bound, or the lock is released immediately.
445    fn reset(succeed_from: u32) -> std::sync::MutexGuard<'static, ()> {
446        let guard = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
447        ATTEMPTS.store(0, Ordering::SeqCst);
448        SUCCEED_FROM.store(succeed_from, Ordering::SeqCst);
449        guard
450    }
451
452    #[tokio::test]
453    async fn an_empty_queue_is_idle() {
454        let queue = Arc::new(MemoryQueue::new());
455        assert_eq!(worker(queue).run_next().await.unwrap(), Outcome::Idle);
456    }
457
458    #[tokio::test]
459    async fn a_successful_job_is_acknowledged() {
460        let _serial = reset(1);
461        let queue = Arc::new(MemoryQueue::new());
462        queue.push(QueuedJob::from_job(&Flaky).unwrap()).await.unwrap();
463
464        assert_eq!(worker(Arc::clone(&queue)).run_next().await.unwrap(), Outcome::Processed);
465        assert_eq!(queue.size("default").await.unwrap(), 0);
466        assert_eq!(queue.reserved_count(), 0);
467        assert!(queue.failed_jobs().is_empty());
468    }
469
470    #[tokio::test]
471    async fn a_failing_job_is_released_for_another_attempt() {
472        let _serial = reset(99); // never succeeds
473        let queue = Arc::new(MemoryQueue::new());
474        queue.push(QueuedJob::from_job(&Flaky).unwrap()).await.unwrap();
475
476        assert_eq!(worker(Arc::clone(&queue)).run_next().await.unwrap(), Outcome::Released);
477        assert_eq!(queue.size("default").await.unwrap(), 1, "it is back in the queue");
478        assert!(queue.failed_jobs().is_empty(), "not failed yet — it has attempts left");
479    }
480
481    #[tokio::test]
482    async fn a_job_that_exhausts_its_attempts_fails() {
483        let _serial = reset(99);
484        let queue = Arc::new(MemoryQueue::new());
485        let mut job = QueuedJob::from_job(&Flaky).unwrap();
486        job.attempts = job.max_attempts - 1; // the next reserve makes it the last
487
488        queue.push(job).await.unwrap();
489        assert_eq!(worker(Arc::clone(&queue)).run_next().await.unwrap(), Outcome::Failed);
490
491        let failed = queue.failed_jobs();
492        assert_eq!(failed.len(), 1);
493        assert!(failed[0].error.contains("not yet"), "{}", failed[0].error);
494        assert_eq!(queue.size("default").await.unwrap(), 0);
495    }
496
497    #[tokio::test]
498    async fn a_job_that_succeeds_on_retry_is_only_run_until_it_does() {
499        let _serial = reset(2); // fails once, then succeeds
500        let queue = Arc::new(MemoryQueue::new());
501        queue.push(QueuedJob::from_job(&Flaky).unwrap()).await.unwrap();
502
503        // Zero backoff so the retry is immediately available.
504        let worker = Worker::new(
505            Arc::clone(&queue) as Arc<dyn Queue>,
506            registry(),
507            Arc::new(Container::new()),
508        )
509        .with_options(WorkerOptions::default().stop_when_empty());
510
511        assert_eq!(worker.run_next().await.unwrap(), Outcome::Released);
512        // The release carries a backoff, so it is not available yet.
513        assert_eq!(worker.run_next().await.unwrap(), Outcome::Idle);
514        assert_eq!(ATTEMPTS.load(Ordering::SeqCst), 1);
515    }
516
517    #[tokio::test]
518    async fn a_run_drains_the_queue_and_reports_what_it_did() {
519        let _serial = reset(1);
520        let queue = Arc::new(MemoryQueue::new());
521        for _ in 0..3 {
522            queue.push(QueuedJob::from_job(&Flaky).unwrap()).await.unwrap();
523        }
524
525        let stats = worker(Arc::clone(&queue)).run().await.unwrap();
526        assert_eq!(stats.processed, 3);
527        assert_eq!(stats.failed, 0);
528        assert_eq!(stats.total(), 3);
529    }
530
531    #[tokio::test]
532    async fn a_worker_stops_after_its_job_limit() {
533        let _serial = reset(1);
534        let queue = Arc::new(MemoryQueue::new());
535        for _ in 0..5 {
536            queue.push(QueuedJob::from_job(&Flaky).unwrap()).await.unwrap();
537        }
538
539        let worker = Worker::new(
540            Arc::clone(&queue) as Arc<dyn Queue>,
541            registry(),
542            Arc::new(Container::new()),
543        )
544        .with_options(WorkerOptions::default().stop_when_empty().max_jobs(2));
545
546        assert_eq!(worker.run().await.unwrap().processed, 2);
547        assert_eq!(queue.size("default").await.unwrap(), 3, "the rest are still waiting");
548    }
549
550    #[tokio::test]
551    async fn queues_are_served_in_priority_order() {
552        let _serial = reset(1);
553        let queue = Arc::new(MemoryQueue::new());
554        queue.push(QueuedJob::from_job(&Flaky).unwrap().on_queue("low")).await.unwrap();
555        queue.push(QueuedJob::from_job(&Flaky).unwrap().on_queue("high")).await.unwrap();
556
557        let worker = Worker::new(
558            Arc::clone(&queue) as Arc<dyn Queue>,
559            registry(),
560            Arc::new(Container::new()),
561        )
562        .with_options(WorkerOptions::default().queues(["high", "low"]).stop_when_empty());
563
564        worker.run_next().await.unwrap();
565        assert_eq!(queue.size("high").await.unwrap(), 0, "the high queue drains first");
566        assert_eq!(queue.size("low").await.unwrap(), 1);
567    }
568
569    #[tokio::test]
570    async fn a_job_that_overruns_its_timeout_is_abandoned() {
571        let queue = Arc::new(MemoryQueue::new());
572        queue.push(QueuedJob::from_job(&Slow).unwrap()).await.unwrap();
573
574        let worker = Worker::new(
575            Arc::clone(&queue) as Arc<dyn Queue>,
576            registry(),
577            Arc::new(Container::new()),
578        )
579        .with_options(
580            WorkerOptions::default().stop_when_empty().timeout(Some(Duration::from_millis(50))),
581        );
582
583        // `Slow` gets one attempt, so a timeout is terminal.
584        assert_eq!(worker.run_next().await.unwrap(), Outcome::Failed);
585        assert!(queue.failed_jobs()[0].error.contains("timeout"));
586    }
587
588    #[tokio::test]
589    async fn an_unknown_job_fails_rather_than_stalling_the_worker() {
590        let queue = Arc::new(MemoryQueue::new());
591        let mut job = QueuedJob::from_job(&Flaky).unwrap();
592        job.name = "never.registered".into();
593        job.max_attempts = 1;
594        queue.push(job).await.unwrap();
595
596        assert_eq!(worker(Arc::clone(&queue)).run_next().await.unwrap(), Outcome::Failed);
597        assert!(queue.failed_jobs()[0].error.contains("never.registered"));
598    }
599
600    #[tokio::test]
601    async fn lifecycle_events_are_fired() {
602        let _serial = reset(1);
603        let queue = Arc::new(MemoryQueue::new());
604        queue.push(QueuedJob::from_job(&Flaky).unwrap()).await.unwrap();
605
606        let events = Arc::new(Dispatcher::new());
607        let log = Arc::new(Mutex::new(Vec::new()));
608
609        let sink = Arc::clone(&log);
610        events.listen(move |_: Arc<JobProcessing>| {
611            let sink = Arc::clone(&sink);
612            async move {
613                sink.lock().unwrap().push("processing");
614                Ok(())
615            }
616        });
617        let sink = Arc::clone(&log);
618        events.listen(move |_: Arc<JobProcessed>| {
619            let sink = Arc::clone(&sink);
620            async move {
621                sink.lock().unwrap().push("processed");
622                Ok(())
623            }
624        });
625
626        worker(Arc::clone(&queue)).with_events(events).run_next().await.unwrap();
627        assert_eq!(*log.lock().unwrap(), vec!["processing", "processed"]);
628    }
629
630    #[tokio::test]
631    async fn a_failing_event_listener_does_not_stop_the_queue() {
632        let _serial = reset(1);
633        let queue = Arc::new(MemoryQueue::new());
634        queue.push(QueuedJob::from_job(&Flaky).unwrap()).await.unwrap();
635
636        let events = Arc::new(Dispatcher::new());
637        events.listen(|_: Arc<JobProcessed>| async { Err(Error::internal("metrics down")) });
638
639        let outcome = worker(Arc::clone(&queue)).with_events(events).run_next().await.unwrap();
640        assert_eq!(outcome, Outcome::Processed, "the job still succeeded");
641    }
642
643    #[tokio::test]
644    async fn a_stopping_worker_leaves_the_loop() {
645        let queue = Arc::new(MemoryQueue::new());
646        let worker = worker(queue);
647        worker.stop();
648
649        assert!(worker.is_stopping());
650        assert_eq!(worker.run().await.unwrap(), WorkerStats::default());
651    }
652
653    #[test]
654    fn the_backoff_curve_grows_and_levels_off() {
655        assert_eq!(backoff_for(1), Duration::from_secs(2));
656        assert_eq!(backoff_for(2), Duration::from_secs(4));
657        assert_eq!(backoff_for(6), Duration::from_secs(64));
658        assert_eq!(backoff_for(50), Duration::from_secs(64), "capped");
659    }
660}