Skip to main content

rustlavel_queue/
worker.rs

1//! The worker loop: reserve, run, and then delete, retry, or bury.
2//!
3//! What `queue:work` runs. A worker is deliberately dull — it owns no state
4//! beyond its options, so N of them against one queue is the same code run N
5//! times, and a worker that dies mid-job leaves the job recoverable rather than
6//! lost.
7
8use crate::job::{JobRegistry, ReservedJob};
9use crate::queue::Queue;
10use rustlavel_core::events::{self, Event};
11use rustlavel_core::{Json, Result};
12use std::sync::Arc;
13use std::sync::atomic::{AtomicBool, Ordering};
14use std::time::{Duration, Instant};
15use tokio::sync::watch;
16
17/// How long a worker waits before asking an empty queue again.
18pub const DEFAULT_POLL_INTERVAL: Duration = Duration::from_millis(500);
19
20/// The ceiling on exponential backoff. Without one, a job on its fifteenth
21/// attempt would be scheduled a year out and never seen again.
22pub const DEFAULT_BACKOFF_CAP: Duration = Duration::from_secs(3600);
23
24/// The longest a worker waits between attempts when the *queue itself* is
25/// failing — not a job.
26///
27/// Its own constant, and far shorter than [`DEFAULT_BACKOFF_CAP`], because the
28/// two answer different questions. A job that failed is waiting on something
29/// of its own, and an hour is a reasonable thing to wait for it. A database
30/// that deadlocked, dropped a connection or failed over is back in seconds,
31/// and a worker that slept an hour afterwards would leave a healthy queue
32/// unattended for an hour.
33pub const MAX_QUEUE_ERROR_BACKOFF: Duration = Duration::from_secs(30);
34
35/// What happened to one job.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum Outcome {
38    /// It succeeded and was deleted.
39    Processed,
40    /// It failed and went back on the queue for another attempt.
41    Released,
42    /// It failed for the last time and went to the dead-letter store.
43    Failed,
44}
45
46/// A tally of what a worker did, returned when it stops.
47#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
48pub struct WorkerStats {
49    pub processed: u64,
50    pub released: u64,
51    pub failed: u64,
52}
53
54impl WorkerStats {
55    /// Every job attempt this worker made.
56    pub fn attempts(&self) -> u64 {
57        self.processed + self.released + self.failed
58    }
59
60    fn record(&mut self, outcome: Outcome) {
61        match outcome {
62            Outcome::Processed => self.processed += 1,
63            Outcome::Released => self.released += 1,
64            Outcome::Failed => self.failed += 1,
65        }
66    }
67
68    fn merge(&mut self, other: WorkerStats) {
69        self.processed += other.processed;
70        self.released += other.released;
71        self.failed += other.failed;
72    }
73}
74
75/// A shutdown signal shared by every worker in a pool.
76///
77/// Cloning shares one signal, so `Ctrl+C` handled once stops the whole pool.
78#[derive(Clone)]
79pub struct Shutdown {
80    flag: Arc<AtomicBool>,
81    sender: Arc<watch::Sender<bool>>,
82}
83
84impl Default for Shutdown {
85    fn default() -> Self {
86        Shutdown::new()
87    }
88}
89
90impl Shutdown {
91    pub fn new() -> Self {
92        let (sender, _) = watch::channel(false);
93        Shutdown { flag: Arc::new(AtomicBool::new(false)), sender: Arc::new(sender) }
94    }
95
96    /// Ask every worker to stop after the job it currently holds.
97    pub fn signal(&self) {
98        // The flag is set *before* the notification, so a worker that
99        // subscribes and then reads the flag can never miss both.
100        self.flag.store(true, Ordering::SeqCst);
101        let _ = self.sender.send(true);
102    }
103
104    pub fn is_signalled(&self) -> bool {
105        self.flag.load(Ordering::SeqCst)
106    }
107
108    /// Stop the pool when the process is interrupted — what `queue:work` wants.
109    pub fn on_ctrl_c(&self) {
110        let signal = self.clone();
111        tokio::spawn(async move {
112            if tokio::signal::ctrl_c().await.is_ok() {
113                rustlavel_core::info!("shutting down: finishing the job in hand");
114                signal.signal();
115            }
116        });
117    }
118
119    /// Wait up to `duration`. Returns true when shutdown arrived first, so a
120    /// worker on an idle queue stops promptly instead of sitting out its poll.
121    pub async fn wait_for(&self, duration: Duration) -> bool {
122        let mut receiver = self.sender.subscribe();
123        if self.is_signalled() {
124            return true;
125        }
126
127        tokio::select! {
128            _ = receiver.changed() => true,
129            _ = tokio::time::sleep(duration) => self.is_signalled(),
130        }
131    }
132}
133
134/// How a worker behaves.
135#[derive(Debug, Clone)]
136pub struct WorkerOptions {
137    /// The queue to work. One worker works one queue.
138    pub queue: String,
139    /// How long to wait after finding nothing.
140    pub poll_interval: Duration,
141    /// Stop after this many attempts. `None` means run until shutdown; `Some(1)`
142    /// is `queue:work --once`.
143    pub max_jobs: Option<u64>,
144    /// The ceiling on retry backoff.
145    pub backoff_cap: Duration,
146}
147
148impl Default for WorkerOptions {
149    fn default() -> Self {
150        WorkerOptions {
151            queue: crate::job::DEFAULT_QUEUE.to_string(),
152            poll_interval: DEFAULT_POLL_INTERVAL,
153            max_jobs: None,
154            backoff_cap: DEFAULT_BACKOFF_CAP,
155        }
156    }
157}
158
159/// One worker. Cloning is cheap and gives a second worker on the same queue.
160#[derive(Clone)]
161pub struct Worker {
162    queue: Arc<dyn Queue>,
163    registry: Arc<JobRegistry>,
164    options: WorkerOptions,
165    /// Where jobs report how far they have got, when there is somewhere.
166    progress: Option<Arc<dyn crate::progress::ProgressStore>>,
167}
168
169impl Worker {
170    pub fn new(queue: Arc<dyn Queue>, registry: Arc<JobRegistry>) -> Self {
171        // The same hook rustlavel-http installs for handlers: it records where a
172        // panic happened and keeps the default backtrace off stderr for panics
173        // we are about to report ourselves.
174        rustlavel_http::panic::install_hook();
175
176        Worker { queue, registry, options: WorkerOptions::default(), progress: None }
177    }
178
179    /// Give jobs somewhere to report progress. The web process reads the same
180    /// store, so it must be one both can reach — the database one, unless the
181    /// worker runs inside the web process.
182    pub fn reporting_to(mut self, store: Arc<dyn crate::progress::ProgressStore>) -> Self {
183        self.progress = Some(store);
184        self
185    }
186
187    pub fn on_queue(mut self, queue: impl Into<String>) -> Self {
188        self.options.queue = queue.into();
189        self
190    }
191
192    pub fn poll_interval(mut self, interval: Duration) -> Self {
193        self.options.poll_interval = interval;
194        self
195    }
196
197    /// Stop after `count` attempts, whatever the shutdown signal says.
198    pub fn max_jobs(mut self, count: u64) -> Self {
199        self.options.max_jobs = Some(count);
200        self
201    }
202
203    pub fn backoff_cap(mut self, cap: Duration) -> Self {
204        self.options.backoff_cap = cap;
205        self
206    }
207
208    pub fn options(&self) -> &WorkerOptions {
209        &self.options
210    }
211
212    /// Work until shutdown is signalled or `max_jobs` is reached.
213    ///
214    /// The shutdown check is at the top of the loop and nowhere else. That is
215    /// the entire graceful-shutdown guarantee: a job already reserved is always
216    /// run to completion, because there is no point at which the loop can
217    /// abandon one.
218    ///
219    /// **An error from the queue is not an error from the worker.** This is a
220    /// long-running process and keeping going is the only thing it is for, so
221    /// a failure to reach the queue — a deadlock, a dropped connection, a
222    /// failover — is logged and retried with a growing pause, and the loop
223    /// carries on. It used to propagate straight out: one deadlock on one poll
224    /// ended the worker, and [`run_pool`] joining its tasks with `?` ended the
225    /// whole `queue:work` process, mid-queue, with jobs still waiting.
226    ///
227    /// The pause starts at the poll interval, doubles, and is capped at
228    /// [`MAX_QUEUE_ERROR_BACKOFF`]; it resets the first time the queue answers.
229    /// It is cut short by shutdown, like every other wait here.
230    ///
231    /// The `Result` stays in the signature so callers do not change, but this
232    /// no longer returns `Err` for anything the queue did.
233    pub async fn run(&self, shutdown: Shutdown) -> Result<WorkerStats> {
234        let mut stats = WorkerStats::default();
235        let mut failures: u32 = 0;
236
237        loop {
238            if shutdown.is_signalled() {
239                break;
240            }
241            if self.options.max_jobs.is_some_and(|max| stats.attempts() >= max) {
242                break;
243            }
244
245            match self.run_once().await {
246                Ok(Some(outcome)) => {
247                    failures = 0;
248                    stats.record(outcome);
249                }
250                Ok(None) => {
251                    failures = 0;
252                    if shutdown.wait_for(self.options.poll_interval).await {
253                        break;
254                    }
255                }
256                Err(error) => {
257                    failures = failures.saturating_add(1);
258                    let pause = backoff(
259                        self.options.poll_interval,
260                        failures,
261                        MAX_QUEUE_ERROR_BACKOFF,
262                    );
263                    rustlavel_core::error!(
264                        "queue `{}` failed (attempt {failures} in a row); retrying in {}ms: {error}",
265                        self.options.queue,
266                        pause.as_millis()
267                    );
268                    if shutdown.wait_for(pause).await {
269                        break;
270                    }
271                }
272            }
273        }
274
275        Ok(stats)
276    }
277
278    /// Reserve and run at most one job. `None` when the queue had nothing ready.
279    pub async fn run_once(&self) -> Result<Option<Outcome>> {
280        let Some(reserved) = self.queue.pop(&self.options.queue).await? else {
281            return Ok(None);
282        };
283        Ok(Some(self.process(reserved).await?))
284    }
285
286    /// Run one reserved job and settle its fate.
287    pub async fn process(&self, reserved: ReservedJob) -> Result<Outcome> {
288        let started = Instant::now();
289        let result = self.run_handler(&reserved).await;
290        let elapsed = started.elapsed();
291
292        match result {
293            Ok(()) => {
294                self.queue.delete(&reserved).await?;
295
296                // The chain advances only on success, and the next step is
297                // pushed *after* this one is deleted: a crash between the two
298                // costs a step rather than duplicating one.
299                if let Some(next) = reserved.job.next_in_chain() {
300                    self.queue.push(next).await?;
301                }
302                self.close_progress(&reserved, None).await;
303
304                record(
305                    "queue.processed",
306                    self.queue.driver(),
307                    &reserved,
308                    elapsed,
309                    |event| event,
310                );
311                Ok(Outcome::Processed)
312            }
313            Err(error) => {
314                let dead = reserved.is_last_attempt();
315
316                if dead {
317                    self.queue.fail(&reserved, &error).await?;
318                    self.close_progress(&reserved, Some(&error)).await;
319                    rustlavel_core::error!(
320                        "job `{}` failed after {} attempt(s) and was moved to failed_jobs: {error}",
321                        reserved.job.name,
322                        reserved.attempts
323                    );
324                } else {
325                    let delay = backoff(
326                        reserved.job.retry_after,
327                        reserved.attempts,
328                        self.options.backoff_cap,
329                    );
330                    self.queue.release(&reserved, delay).await?;
331                    rustlavel_core::warn!(
332                        "job `{}` failed on attempt {} of {} and will retry in {}s: {error}",
333                        reserved.job.name,
334                        reserved.attempts,
335                        reserved.job.max_tries,
336                        delay.as_secs()
337                    );
338                }
339
340                // Reported on every failed attempt, not only the last, so
341                // Telescope can show a job that is thrashing before it dies.
342                // `dead_lettered` is the flag that separates the two.
343                record("queue.failed", self.queue.driver(), &reserved, elapsed, |event| {
344                    event.with("exception", error.as_str()).with("dead_lettered", dead)
345                });
346
347                Ok(if dead { Outcome::Failed } else { Outcome::Released })
348            }
349        }
350    }
351
352    /// Mark a job finished, or failed for good, whatever it last reported.
353    ///
354    /// The job's own reports are hints; this is the fact. A job that crashed
355    /// at 40% is not at 40%, and a job that returned `Ok` is at 100% whether
356    /// or not it said so. Under the chain's key too, but only when the chain
357    /// is over — a step finishing is not the pipeline finishing.
358    async fn close_progress(&self, reserved: &ReservedJob, failure: Option<&str>) {
359        let Some(store) = &self.progress else { return };
360
361        let last = store.get(&reserved.id).await.ok().flatten();
362        let closed = crate::progress::Progress {
363            percent: if failure.is_none() { 100 } else { last.as_ref().map_or(0, |p| p.percent) },
364            stage: match failure {
365                None => "finished".to_string(),
366                Some(_) => last.as_ref().map_or_else(|| "failed".to_string(), |p| p.stage.clone()),
367            },
368            updated_at: crate::time::unix_now().max(0) as u64,
369            finished: true,
370            failed: failure.map(str::to_string),
371        };
372
373        let _ = store.set(&reserved.id, closed.clone()).await;
374
375        let chain_is_over = failure.is_some() || reserved.job.then.is_empty();
376        if let (Some(chain), true) = (&reserved.job.chain, chain_is_over) {
377            let _ = store.set(&format!("chain:{chain}"), closed).await;
378        }
379    }
380
381    /// Run the handler, turning every way it can go wrong into one `Err`.
382    ///
383    /// A panicking job must not take the worker with it: one bad payload would
384    /// otherwise stop every other job on the queue. Caught with the same
385    /// technique rustlavel-http uses for a panicking handler, so a panic across
386    /// an await point is caught too, and treated as an ordinary failure — it
387    /// retries and it dead-letters, exactly like a returned error.
388    async fn run_handler(&self, reserved: &ReservedJob) -> std::result::Result<(), String> {
389        let ctx = crate::progress::JobContext::new(
390            reserved.id.clone(),
391            reserved.job.chain.clone(),
392            self.progress.clone(),
393        );
394        let future = self.registry.run_with(&reserved.job.name, reserved.job.payload.clone(), ctx);
395
396        match rustlavel_http::panic::catch(future).await {
397            Ok(Ok(())) => Ok(()),
398            Ok(Err(error)) => Err(error.to_string()),
399            Err(payload) => {
400                let location = rustlavel_http::panic::take_location()
401                    .map(|at| format!(" at {}:{}:{}", at.file, at.line, at.column))
402                    .unwrap_or_default();
403                Err(format!("the job panicked{location}: {payload}"))
404            }
405        }
406    }
407}
408
409/// Run `count` copies of a worker concurrently, all watching one shutdown
410/// signal, and return their combined tally once every one has stopped.
411///
412/// This is `queue:work --workers=N`. They share a queue, not any state: the
413/// only thing keeping two of them off the same job is the driver's reservation.
414pub async fn run_pool(worker: Worker, count: usize, shutdown: Shutdown) -> Result<WorkerStats> {
415    let mut handles = Vec::with_capacity(count.max(1));
416
417    for _ in 0..count.max(1) {
418        let worker = worker.clone();
419        let shutdown = shutdown.clone();
420        handles.push(tokio::spawn(async move { worker.run(shutdown).await }));
421    }
422
423    let mut total = WorkerStats::default();
424    for handle in handles {
425        let stats = handle
426            .await
427            .map_err(|e| rustlavel_core::Error::msg(format!("a worker task did not finish: {e}")))??;
428        total.merge(stats);
429    }
430
431    Ok(total)
432}
433
434/// How long to wait before the next attempt.
435///
436/// Exponential: the job's own `retry_after`, doubled once per attempt already
437/// made, capped. A dependency that is down stays down for a while, and hammering
438/// it every minute helps nobody.
439pub fn backoff(retry_after: Duration, attempts: u32, cap: Duration) -> Duration {
440    // Shifting by 32 or more is undefined, and by then the cap has won anyway.
441    let doublings = attempts.saturating_sub(1).min(20);
442    let factor = 1u32 << doublings;
443
444    retry_after.saturating_mul(factor).min(cap)
445}
446
447/// Dispatch a worker event, if anyone is listening.
448fn record(
449    kind: &'static str,
450    driver: &'static str,
451    reserved: &ReservedJob,
452    elapsed: Duration,
453    extend: impl FnOnce(Event) -> Event,
454) {
455    if !events::has_subscribers() {
456        return;
457    }
458
459    let event = Event::new(kind)
460        .took(elapsed)
461        .with("job", reserved.job.name.as_str())
462        .with("queue", reserved.job.queue.as_str())
463        .with("id", reserved.id.as_str())
464        .with("driver", driver)
465        .with("attempt", reserved.attempts)
466        .with("max_tries", reserved.job.max_tries);
467
468    extend(event).dispatch();
469}
470
471/// A convenience for a dashboard: the worker events as JSON.
472pub fn outcome_to_json(outcome: Outcome) -> Json {
473    Json::from(match outcome {
474        Outcome::Processed => "processed",
475        Outcome::Released => "released",
476        Outcome::Failed => "failed",
477    })
478}
479
480#[cfg(test)]
481mod tests {
482    use super::*;
483    use crate::job::{Job, QueuedJob};
484    use crate::memory::MemoryQueue;
485    use crate::queue::QueueExt;
486    use crate::tests_support::{
487        CountingJob, FailingJob, FlakyJob, PanickingJob, SlowJob, counter, registry,
488    };
489
490    /// A worker over a fresh in-memory queue, with every fixture registered.
491    fn worker() -> (Arc<MemoryQueue>, Worker) {
492        let queue = Arc::new(MemoryQueue::new());
493        let worker = Worker::new(queue.clone(), Arc::new(registry()))
494            .poll_interval(Duration::from_millis(5));
495        (queue, worker)
496    }
497
498    #[tokio::test]
499    async fn a_worker_runs_a_job_and_removes_it() {
500        let (queue, worker) = worker();
501        let tally = counter();
502        queue.dispatch(&CountingJob::new(tally, 7)).await.unwrap();
503
504        assert_eq!(worker.run_once().await.unwrap(), Some(Outcome::Processed));
505
506        assert_eq!(tally.runs(), 1);
507        assert_eq!(tally.total(), 7);
508        assert_eq!(queue.size("default").await.unwrap(), 0);
509        assert!(queue.failed_jobs().await.unwrap().is_empty());
510    }
511
512    #[tokio::test]
513    async fn an_empty_queue_gives_the_worker_nothing_to_do() {
514        let (_queue, worker) = worker();
515        assert_eq!(worker.run_once().await.unwrap(), None);
516    }
517
518    #[tokio::test]
519    async fn a_worker_only_takes_from_the_queue_it_was_pointed_at() {
520        let (queue, worker) = worker();
521        let tally = counter();
522        queue
523            .push_on("emails", CountingJob::new(tally, 1).to_queued())
524            .await
525            .unwrap();
526
527        assert_eq!(worker.run_once().await.unwrap(), None, "nothing on `default`");
528
529        let emails = worker.clone().on_queue("emails");
530        assert_eq!(emails.run_once().await.unwrap(), Some(Outcome::Processed));
531        assert_eq!(tally.runs(), 1);
532    }
533
534    #[tokio::test]
535    async fn a_failing_job_is_retried_and_then_dead_lettered() {
536        let (queue, worker) = worker();
537        let tally = counter();
538        queue.dispatch(&FailingJob::new(tally, 3)).await.unwrap();
539
540        // Three attempts: two releases back onto the queue, then the grave.
541        assert_eq!(worker.run_once().await.unwrap(), Some(Outcome::Released));
542        assert_eq!(worker.run_once().await.unwrap(), Some(Outcome::Released));
543        assert_eq!(worker.run_once().await.unwrap(), Some(Outcome::Failed));
544        assert_eq!(worker.run_once().await.unwrap(), None, "nothing is left to run");
545
546        assert_eq!(tally.runs(), 3, "it ran exactly as many times as it had tries");
547        assert_eq!(queue.size("default").await.unwrap(), 0);
548
549        let failed = queue.failed_jobs().await.unwrap();
550        assert_eq!(failed.len(), 1);
551        assert_eq!(failed[0].name, "failing");
552        assert_eq!(failed[0].attempts, 3);
553        assert!(failed[0].error.contains("attempt 3 went wrong"), "{}", failed[0].error);
554    }
555
556    #[tokio::test]
557    async fn a_job_that_recovers_never_reaches_the_dead_letter_store() {
558        let (queue, worker) = worker();
559        let tally = counter();
560        queue.dispatch(&FlakyJob::new(tally, 3, 5)).await.unwrap();
561
562        assert_eq!(worker.run_once().await.unwrap(), Some(Outcome::Released));
563        assert_eq!(worker.run_once().await.unwrap(), Some(Outcome::Released));
564        assert_eq!(worker.run_once().await.unwrap(), Some(Outcome::Processed));
565
566        assert_eq!(tally.runs(), 3);
567        assert!(queue.failed_jobs().await.unwrap().is_empty());
568    }
569
570    #[tokio::test]
571    async fn a_job_with_one_try_is_buried_on_its_first_failure() {
572        let (queue, worker) = worker();
573        let tally = counter();
574        queue.dispatch(&FailingJob::new(tally, 1)).await.unwrap();
575
576        assert_eq!(worker.run_once().await.unwrap(), Some(Outcome::Failed));
577        assert_eq!(queue.failed_jobs().await.unwrap().len(), 1);
578    }
579
580    #[tokio::test]
581    async fn a_panicking_job_fails_cleanly_instead_of_killing_the_worker() {
582        let (queue, worker) = worker();
583        let tally = counter();
584        let healthy = counter();
585
586        queue.dispatch(&PanickingJob::new(tally, 1)).await.unwrap();
587        queue.dispatch(&CountingJob::new(healthy, 1)).await.unwrap();
588
589        assert_eq!(worker.run_once().await.unwrap(), Some(Outcome::Failed));
590
591        let failed = queue.failed_jobs().await.unwrap();
592        assert_eq!(failed.len(), 1);
593        assert!(
594            failed[0].error.contains("this job panicked on purpose"),
595            "the panic message should survive: {}",
596            failed[0].error
597        );
598
599        // The worker is still alive and the next job is unaffected.
600        assert_eq!(worker.run_once().await.unwrap(), Some(Outcome::Processed));
601        assert_eq!(healthy.runs(), 1);
602    }
603
604    #[tokio::test]
605    async fn a_job_with_no_registered_handler_fails_rather_than_vanishing() {
606        let (queue, worker) = worker();
607        queue.push(QueuedJob::new("never-registered", Json::Null).with_tries(1)).await.unwrap();
608
609        assert_eq!(worker.run_once().await.unwrap(), Some(Outcome::Failed));
610
611        let failed = queue.failed_jobs().await.unwrap();
612        assert!(failed[0].error.contains("no handler is registered"), "{}", failed[0].error);
613    }
614
615    #[tokio::test]
616    async fn max_jobs_stops_the_loop_without_a_shutdown_signal() {
617        let (queue, worker) = worker();
618        let tally = counter();
619        for _ in 0..5 {
620            queue.dispatch(&CountingJob::new(tally, 1)).await.unwrap();
621        }
622
623        let stats = worker.max_jobs(2).run(Shutdown::new()).await.unwrap();
624
625        assert_eq!(stats.processed, 2);
626        assert_eq!(tally.runs(), 2);
627        assert_eq!(queue.size("default").await.unwrap(), 3, "the rest are still waiting");
628    }
629
630    /// A queue whose database goes away for a moment: the first `pop`s fail,
631    /// then it recovers. Every other call goes straight to the real queue.
632    ///
633    /// This is what a MySQL deadlock (error 1213), a dropped connection or a
634    /// failover looks like from the worker's side — and every one of them is
635    /// documented by the database as "try again".
636    struct Flaky {
637        inner: MemoryQueue,
638        pops_to_fail: std::sync::atomic::AtomicUsize,
639        pops_seen: std::sync::atomic::AtomicUsize,
640    }
641
642    impl Flaky {
643        fn failing_first(n: usize) -> Arc<Flaky> {
644            Arc::new(Flaky {
645                inner: MemoryQueue::new(),
646                pops_to_fail: std::sync::atomic::AtomicUsize::new(n),
647                pops_seen: std::sync::atomic::AtomicUsize::new(0),
648            })
649        }
650    }
651
652    impl crate::queue::Queue for Flaky {
653        fn driver(&self) -> &'static str {
654            "flaky"
655        }
656
657        fn push(&self, job: QueuedJob) -> crate::job::BoxFuture<'_, Result<String>> {
658            self.inner.push(job)
659        }
660
661        fn pop<'a>(
662            &'a self,
663            queue: &'a str,
664        ) -> crate::job::BoxFuture<'a, Result<Option<crate::job::ReservedJob>>> {
665            use std::sync::atomic::Ordering::SeqCst;
666            self.pops_seen.fetch_add(1, SeqCst);
667            let failing = self
668                .pops_to_fail
669                .fetch_update(SeqCst, SeqCst, |n| n.checked_sub(1))
670                .is_ok();
671            if failing {
672                return Box::pin(async {
673                    Err(rustlavel_core::Error::msg("deadlock found when trying to get lock"))
674                });
675            }
676            self.inner.pop(queue)
677        }
678
679        fn size<'a>(&'a self, queue: &'a str) -> crate::job::BoxFuture<'a, Result<u64>> {
680            self.inner.size(queue)
681        }
682
683        fn delete<'a>(&'a self, job: &'a crate::job::ReservedJob) -> crate::job::BoxFuture<'a, Result<()>> {
684            self.inner.delete(job)
685        }
686
687        fn release<'a>(
688            &'a self,
689            job: &'a crate::job::ReservedJob,
690            delay: Duration,
691        ) -> crate::job::BoxFuture<'a, Result<()>> {
692            self.inner.release(job, delay)
693        }
694
695        fn fail<'a>(
696            &'a self,
697            job: &'a crate::job::ReservedJob,
698            error: &'a str,
699        ) -> crate::job::BoxFuture<'a, Result<()>> {
700            self.inner.fail(job, error)
701        }
702
703        fn failed_jobs(&self) -> crate::job::BoxFuture<'_, Result<Vec<crate::job::FailedJob>>> {
704            self.inner.failed_jobs()
705        }
706
707        fn clear<'a>(&'a self, queue: &'a str) -> crate::job::BoxFuture<'a, Result<u64>> {
708            self.inner.clear(queue)
709        }
710    }
711
712    /// **A worker outlives a database that blinks.**
713    ///
714    /// `queue:work` is a long-running process, and the only thing it exists to
715    /// do is keep going. It used to propagate any error from the queue straight
716    /// out of `run`, so one deadlock on one poll ended the worker — and
717    /// `run_pool`, joining its tasks with `??`, ended the whole process. Found
718    /// the day a MySQL deadlock in the queue's own reclaim query reached it.
719    ///
720    /// A *job* failing was always handled; this is the queue itself failing.
721    #[tokio::test]
722    async fn a_worker_survives_the_queue_failing_and_then_recovering() {
723        let queue = Flaky::failing_first(3);
724        let tally = counter();
725        queue.inner.dispatch(&CountingJob::new(tally, 7)).await.unwrap();
726
727        let worker = Worker::new(queue.clone(), Arc::new(registry()))
728            .poll_interval(Duration::from_millis(5));
729
730        let shutdown = Shutdown::new();
731        let signal = shutdown.clone();
732        // `Counter` is `Copy`, so the task takes its own copy and `tally` stays
733        // usable for the assertions below.
734        let watcher = tokio::spawn(async move {
735            for _ in 0..400 {
736                if tally.runs() == 1 {
737                    break;
738                }
739                tokio::time::sleep(Duration::from_millis(5)).await;
740            }
741            signal.signal();
742        });
743
744        let stats = worker.run(shutdown).await.expect("the worker must not exit on a queue error");
745        watcher.await.unwrap();
746
747        assert_eq!(tally.runs(), 1, "the job waiting behind the failures never ran");
748        assert_eq!(stats.processed, 1);
749        assert!(
750            queue.pops_seen.load(std::sync::atomic::Ordering::SeqCst) >= 4,
751            "the worker gave up instead of polling again"
752        );
753    }
754
755    /// A queue that never recovers must not make the worker deaf to shutdown.
756    /// The pause is a `wait_for`, so a signal ends it at once — without that,
757    /// stopping a worker whose database is down would take up to
758    /// `MAX_QUEUE_ERROR_BACKOFF`, and a deploy would hang on it.
759    #[tokio::test]
760    async fn shutdown_ends_the_pause_after_a_queue_error() {
761        let queue = Flaky::failing_first(usize::MAX);
762        let worker = Worker::new(queue.clone(), Arc::new(registry()))
763            // Long enough that only a signal can finish the test in time.
764            .poll_interval(Duration::from_secs(20));
765
766        let shutdown = Shutdown::new();
767        let signal = shutdown.clone();
768        tokio::spawn(async move {
769            tokio::time::sleep(Duration::from_millis(50)).await;
770            signal.signal();
771        });
772
773        let started = Instant::now();
774        worker.run(shutdown).await.expect("a queue error is not a worker error");
775
776        assert!(
777            started.elapsed() < Duration::from_secs(5),
778            "the worker sat out its backoff instead of stopping: {:?}",
779            started.elapsed()
780        );
781        assert_eq!(queue.pops_seen.load(std::sync::atomic::Ordering::SeqCst), 1);
782    }
783
784    /// The pause grows, but not without limit: a database that comes back
785    /// after an hour must find a worker still polling, not asleep.
786    #[test]
787    fn the_pause_after_a_queue_error_is_capped_in_seconds_not_hours() {
788        let poll = Duration::from_millis(500);
789        assert_eq!(backoff(poll, 1, MAX_QUEUE_ERROR_BACKOFF), Duration::from_millis(500));
790        assert_eq!(backoff(poll, 2, MAX_QUEUE_ERROR_BACKOFF), Duration::from_secs(1));
791        assert_eq!(backoff(poll, 3, MAX_QUEUE_ERROR_BACKOFF), Duration::from_secs(2));
792        // Hundreds of failures in a row, and still only the cap.
793        assert_eq!(backoff(poll, 500, MAX_QUEUE_ERROR_BACKOFF), MAX_QUEUE_ERROR_BACKOFF);
794        assert!(MAX_QUEUE_ERROR_BACKOFF < DEFAULT_BACKOFF_CAP, "this must not inherit the job cap");
795    }
796
797    #[tokio::test]
798    async fn an_already_signalled_shutdown_starts_no_work_at_all() {
799        let (queue, worker) = worker();
800        let tally = counter();
801        queue.dispatch(&CountingJob::new(tally, 1)).await.unwrap();
802
803        let shutdown = Shutdown::new();
804        shutdown.signal();
805
806        let stats = worker.run(shutdown).await.unwrap();
807
808        assert_eq!(stats, WorkerStats::default());
809        assert_eq!(tally.runs(), 0);
810        assert_eq!(queue.size("default").await.unwrap(), 1);
811    }
812
813    #[tokio::test]
814    async fn shutdown_lets_the_job_in_hand_finish_first() {
815        let (queue, worker) = worker();
816        let tally = counter();
817        queue.dispatch(&SlowJob::new(tally, 150)).await.unwrap();
818        queue.dispatch(&CountingJob::new(tally, 1)).await.unwrap();
819
820        let shutdown = Shutdown::new();
821        let signal = shutdown.clone();
822        tokio::spawn(async move {
823            // Land in the middle of the slow job, not before it starts.
824            tokio::time::sleep(Duration::from_millis(40)).await;
825            signal.signal();
826        });
827
828        let stats = worker.run(shutdown).await.unwrap();
829
830        assert_eq!(stats.processed, 1, "the slow job ran to completion");
831        assert_eq!(tally.runs(), 1);
832        assert_eq!(queue.size("default").await.unwrap(), 1, "the second job was left alone");
833    }
834
835    #[tokio::test]
836    async fn an_idle_worker_wakes_up_for_shutdown_instead_of_waiting_out_its_poll() {
837        let (_queue, worker) = worker();
838        let worker = worker.poll_interval(Duration::from_secs(30));
839
840        let shutdown = Shutdown::new();
841        let signal = shutdown.clone();
842        tokio::spawn(async move {
843            tokio::time::sleep(Duration::from_millis(20)).await;
844            signal.signal();
845        });
846
847        let started = Instant::now();
848        worker.run(shutdown).await.unwrap();
849
850        assert!(
851            started.elapsed() < Duration::from_secs(5),
852            "an idle worker should stop as soon as it is asked, not after its poll interval"
853        );
854    }
855
856    #[tokio::test]
857    async fn several_workers_share_one_queue_and_run_each_job_once() {
858        let (queue, worker) = worker();
859        let tally = counter();
860        for _ in 0..40 {
861            queue.dispatch(&CountingJob::new(tally, 1)).await.unwrap();
862        }
863
864        let shutdown = Shutdown::new();
865        let signal = shutdown.clone();
866        let drain = queue.clone();
867        tokio::spawn(async move {
868            while drain.size("default").await.unwrap_or(0) > 0 {
869                tokio::time::sleep(Duration::from_millis(5)).await;
870            }
871            signal.signal();
872        });
873
874        let stats = run_pool(worker, 4, shutdown).await.unwrap();
875
876        assert_eq!(stats.processed, 40);
877        assert_eq!(tally.runs(), 40, "no job ran twice and none was skipped");
878        assert_eq!(tally.total(), 40);
879    }
880
881    #[tokio::test]
882    async fn the_worker_reports_what_it_did_on_the_event_bus() {
883        // The event bus is process-wide, so this test cannot share it with
884        // another that also subscribes; every assertion here filters by the job
885        // name it dispatched, which nothing else uses.
886        let (queue, worker) = worker();
887        let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
888        let sink = Arc::clone(&seen);
889
890        events::subscribe(move |event: &Event| {
891            if event.field("job").and_then(Json::as_str) == Some("failing") {
892                sink.lock()
893                    .unwrap()
894                    .push((event.kind, event.field("attempt").and_then(Json::as_i64)));
895            }
896        });
897
898        queue.dispatch(&FailingJob::new(counter(), 2)).await.unwrap();
899        worker.run_once().await.unwrap();
900        worker.run_once().await.unwrap();
901
902        let seen = seen.lock().unwrap().clone();
903        assert!(seen.contains(&("queue.pushed", None)), "{seen:?}");
904        assert!(seen.contains(&("queue.failed", Some(1))), "{seen:?}");
905        assert!(seen.contains(&("queue.failed", Some(2))), "{seen:?}");
906
907        events::clear_subscribers();
908    }
909
910    #[test]
911    fn backoff_doubles_per_attempt_and_then_stops_at_the_cap() {
912        let base = Duration::from_secs(10);
913        let cap = Duration::from_secs(300);
914
915        assert_eq!(backoff(base, 1, cap), Duration::from_secs(10));
916        assert_eq!(backoff(base, 2, cap), Duration::from_secs(20));
917        assert_eq!(backoff(base, 3, cap), Duration::from_secs(40));
918        assert_eq!(backoff(base, 4, cap), Duration::from_secs(80));
919        assert_eq!(backoff(base, 10, cap), cap, "the cap wins eventually");
920        assert_eq!(backoff(base, 5000, cap), cap, "and never overflows");
921    }
922
923    #[test]
924    fn backoff_of_zero_stays_zero_so_a_test_can_retry_immediately() {
925        assert_eq!(backoff(Duration::ZERO, 9, DEFAULT_BACKOFF_CAP), Duration::ZERO);
926    }
927
928    #[test]
929    fn stats_add_up_across_a_pool() {
930        let mut stats = WorkerStats { processed: 1, released: 2, failed: 3 };
931        stats.merge(WorkerStats { processed: 10, released: 20, failed: 30 });
932
933        assert_eq!(stats, WorkerStats { processed: 11, released: 22, failed: 33 });
934        assert_eq!(stats.attempts(), 66);
935    }
936
937    #[test]
938    fn outcomes_render_for_a_dashboard() {
939        assert_eq!(outcome_to_json(Outcome::Processed).as_str(), Some("processed"));
940        assert_eq!(outcome_to_json(Outcome::Released).as_str(), Some("released"));
941        assert_eq!(outcome_to_json(Outcome::Failed).as_str(), Some("failed"));
942    }
943}