1use 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
17pub const DEFAULT_POLL_INTERVAL: Duration = Duration::from_millis(500);
19
20pub const DEFAULT_BACKOFF_CAP: Duration = Duration::from_secs(3600);
23
24pub const MAX_QUEUE_ERROR_BACKOFF: Duration = Duration::from_secs(30);
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum Outcome {
38 Processed,
40 Released,
42 Failed,
44}
45
46#[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 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#[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 pub fn signal(&self) {
98 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 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 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#[derive(Debug, Clone)]
136pub struct WorkerOptions {
137 pub queue: String,
139 pub poll_interval: Duration,
141 pub max_jobs: Option<u64>,
144 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#[derive(Clone)]
161pub struct Worker {
162 queue: Arc<dyn Queue>,
163 registry: Arc<JobRegistry>,
164 options: WorkerOptions,
165 progress: Option<Arc<dyn crate::progress::ProgressStore>>,
167}
168
169impl Worker {
170 pub fn new(queue: Arc<dyn Queue>, registry: Arc<JobRegistry>) -> Self {
171 rustlavel_http::panic::install_hook();
175
176 Worker { queue, registry, options: WorkerOptions::default(), progress: None }
177 }
178
179 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 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 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 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 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 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 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 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 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
409pub 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
434pub fn backoff(retry_after: Duration, attempts: u32, cap: Duration) -> Duration {
440 let doublings = attempts.saturating_sub(1).min(20);
442 let factor = 1u32 << doublings;
443
444 retry_after.saturating_mul(factor).min(cap)
445}
446
447fn 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
471pub 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 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 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 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 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 #[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 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 #[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 .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 #[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 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 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 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}