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
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum Outcome {
27 Processed,
29 Released,
31 Failed,
33}
34
35#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
37pub struct WorkerStats {
38 pub processed: u64,
39 pub released: u64,
40 pub failed: u64,
41}
42
43impl WorkerStats {
44 pub fn attempts(&self) -> u64 {
46 self.processed + self.released + self.failed
47 }
48
49 fn record(&mut self, outcome: Outcome) {
50 match outcome {
51 Outcome::Processed => self.processed += 1,
52 Outcome::Released => self.released += 1,
53 Outcome::Failed => self.failed += 1,
54 }
55 }
56
57 fn merge(&mut self, other: WorkerStats) {
58 self.processed += other.processed;
59 self.released += other.released;
60 self.failed += other.failed;
61 }
62}
63
64#[derive(Clone)]
68pub struct Shutdown {
69 flag: Arc<AtomicBool>,
70 sender: Arc<watch::Sender<bool>>,
71}
72
73impl Default for Shutdown {
74 fn default() -> Self {
75 Shutdown::new()
76 }
77}
78
79impl Shutdown {
80 pub fn new() -> Self {
81 let (sender, _) = watch::channel(false);
82 Shutdown { flag: Arc::new(AtomicBool::new(false)), sender: Arc::new(sender) }
83 }
84
85 pub fn signal(&self) {
87 self.flag.store(true, Ordering::SeqCst);
90 let _ = self.sender.send(true);
91 }
92
93 pub fn is_signalled(&self) -> bool {
94 self.flag.load(Ordering::SeqCst)
95 }
96
97 pub fn on_ctrl_c(&self) {
99 let signal = self.clone();
100 tokio::spawn(async move {
101 if tokio::signal::ctrl_c().await.is_ok() {
102 rustlavel_core::info!("shutting down: finishing the job in hand");
103 signal.signal();
104 }
105 });
106 }
107
108 pub async fn wait_for(&self, duration: Duration) -> bool {
111 let mut receiver = self.sender.subscribe();
112 if self.is_signalled() {
113 return true;
114 }
115
116 tokio::select! {
117 _ = receiver.changed() => true,
118 _ = tokio::time::sleep(duration) => self.is_signalled(),
119 }
120 }
121}
122
123#[derive(Debug, Clone)]
125pub struct WorkerOptions {
126 pub queue: String,
128 pub poll_interval: Duration,
130 pub max_jobs: Option<u64>,
133 pub backoff_cap: Duration,
135}
136
137impl Default for WorkerOptions {
138 fn default() -> Self {
139 WorkerOptions {
140 queue: crate::job::DEFAULT_QUEUE.to_string(),
141 poll_interval: DEFAULT_POLL_INTERVAL,
142 max_jobs: None,
143 backoff_cap: DEFAULT_BACKOFF_CAP,
144 }
145 }
146}
147
148#[derive(Clone)]
150pub struct Worker {
151 queue: Arc<dyn Queue>,
152 registry: Arc<JobRegistry>,
153 options: WorkerOptions,
154}
155
156impl Worker {
157 pub fn new(queue: Arc<dyn Queue>, registry: Arc<JobRegistry>) -> Self {
158 rustlavel_http::panic::install_hook();
162
163 Worker { queue, registry, options: WorkerOptions::default() }
164 }
165
166 pub fn on_queue(mut self, queue: impl Into<String>) -> Self {
167 self.options.queue = queue.into();
168 self
169 }
170
171 pub fn poll_interval(mut self, interval: Duration) -> Self {
172 self.options.poll_interval = interval;
173 self
174 }
175
176 pub fn max_jobs(mut self, count: u64) -> Self {
178 self.options.max_jobs = Some(count);
179 self
180 }
181
182 pub fn backoff_cap(mut self, cap: Duration) -> Self {
183 self.options.backoff_cap = cap;
184 self
185 }
186
187 pub fn options(&self) -> &WorkerOptions {
188 &self.options
189 }
190
191 pub async fn run(&self, shutdown: Shutdown) -> Result<WorkerStats> {
198 let mut stats = WorkerStats::default();
199
200 loop {
201 if shutdown.is_signalled() {
202 break;
203 }
204 if self.options.max_jobs.is_some_and(|max| stats.attempts() >= max) {
205 break;
206 }
207
208 if let Some(outcome) = self.run_once().await? {
209 stats.record(outcome);
210 } else if shutdown.wait_for(self.options.poll_interval).await {
211 break;
212 }
213 }
214
215 Ok(stats)
216 }
217
218 pub async fn run_once(&self) -> Result<Option<Outcome>> {
220 let Some(reserved) = self.queue.pop(&self.options.queue).await? else {
221 return Ok(None);
222 };
223 Ok(Some(self.process(reserved).await?))
224 }
225
226 pub async fn process(&self, reserved: ReservedJob) -> Result<Outcome> {
228 let started = Instant::now();
229 let result = self.run_handler(&reserved).await;
230 let elapsed = started.elapsed();
231
232 match result {
233 Ok(()) => {
234 self.queue.delete(&reserved).await?;
235 record(
236 "queue.processed",
237 self.queue.driver(),
238 &reserved,
239 elapsed,
240 |event| event,
241 );
242 Ok(Outcome::Processed)
243 }
244 Err(error) => {
245 let dead = reserved.is_last_attempt();
246
247 if dead {
248 self.queue.fail(&reserved, &error).await?;
249 rustlavel_core::error!(
250 "job `{}` failed after {} attempt(s) and was moved to failed_jobs: {error}",
251 reserved.job.name,
252 reserved.attempts
253 );
254 } else {
255 let delay = backoff(
256 reserved.job.retry_after,
257 reserved.attempts,
258 self.options.backoff_cap,
259 );
260 self.queue.release(&reserved, delay).await?;
261 rustlavel_core::warn!(
262 "job `{}` failed on attempt {} of {} and will retry in {}s: {error}",
263 reserved.job.name,
264 reserved.attempts,
265 reserved.job.max_tries,
266 delay.as_secs()
267 );
268 }
269
270 record("queue.failed", self.queue.driver(), &reserved, elapsed, |event| {
274 event.with("exception", error.as_str()).with("dead_lettered", dead)
275 });
276
277 Ok(if dead { Outcome::Failed } else { Outcome::Released })
278 }
279 }
280 }
281
282 async fn run_handler(&self, reserved: &ReservedJob) -> std::result::Result<(), String> {
290 let future = self.registry.run(&reserved.job.name, reserved.job.payload.clone());
291
292 match rustlavel_http::panic::catch(future).await {
293 Ok(Ok(())) => Ok(()),
294 Ok(Err(error)) => Err(error.to_string()),
295 Err(payload) => {
296 let location = rustlavel_http::panic::take_location()
297 .map(|at| format!(" at {}:{}:{}", at.file, at.line, at.column))
298 .unwrap_or_default();
299 Err(format!("the job panicked{location}: {payload}"))
300 }
301 }
302 }
303}
304
305pub async fn run_pool(worker: Worker, count: usize, shutdown: Shutdown) -> Result<WorkerStats> {
311 let mut handles = Vec::with_capacity(count.max(1));
312
313 for _ in 0..count.max(1) {
314 let worker = worker.clone();
315 let shutdown = shutdown.clone();
316 handles.push(tokio::spawn(async move { worker.run(shutdown).await }));
317 }
318
319 let mut total = WorkerStats::default();
320 for handle in handles {
321 let stats = handle
322 .await
323 .map_err(|e| rustlavel_core::Error::msg(format!("a worker task did not finish: {e}")))??;
324 total.merge(stats);
325 }
326
327 Ok(total)
328}
329
330pub fn backoff(retry_after: Duration, attempts: u32, cap: Duration) -> Duration {
336 let doublings = attempts.saturating_sub(1).min(20);
338 let factor = 1u32 << doublings;
339
340 retry_after.saturating_mul(factor).min(cap)
341}
342
343fn record(
345 kind: &'static str,
346 driver: &'static str,
347 reserved: &ReservedJob,
348 elapsed: Duration,
349 extend: impl FnOnce(Event) -> Event,
350) {
351 if !events::has_subscribers() {
352 return;
353 }
354
355 let event = Event::new(kind)
356 .took(elapsed)
357 .with("job", reserved.job.name.as_str())
358 .with("queue", reserved.job.queue.as_str())
359 .with("id", reserved.id.as_str())
360 .with("driver", driver)
361 .with("attempt", reserved.attempts)
362 .with("max_tries", reserved.job.max_tries);
363
364 extend(event).dispatch();
365}
366
367pub fn outcome_to_json(outcome: Outcome) -> Json {
369 Json::from(match outcome {
370 Outcome::Processed => "processed",
371 Outcome::Released => "released",
372 Outcome::Failed => "failed",
373 })
374}
375
376#[cfg(test)]
377mod tests {
378 use super::*;
379 use crate::job::{Job, QueuedJob};
380 use crate::memory::MemoryQueue;
381 use crate::queue::QueueExt;
382 use crate::tests_support::{
383 CountingJob, FailingJob, FlakyJob, PanickingJob, SlowJob, counter, registry,
384 };
385
386 fn worker() -> (Arc<MemoryQueue>, Worker) {
388 let queue = Arc::new(MemoryQueue::new());
389 let worker = Worker::new(queue.clone(), Arc::new(registry()))
390 .poll_interval(Duration::from_millis(5));
391 (queue, worker)
392 }
393
394 #[tokio::test]
395 async fn a_worker_runs_a_job_and_removes_it() {
396 let (queue, worker) = worker();
397 let tally = counter();
398 queue.dispatch(&CountingJob::new(tally, 7)).await.unwrap();
399
400 assert_eq!(worker.run_once().await.unwrap(), Some(Outcome::Processed));
401
402 assert_eq!(tally.runs(), 1);
403 assert_eq!(tally.total(), 7);
404 assert_eq!(queue.size("default").await.unwrap(), 0);
405 assert!(queue.failed_jobs().await.unwrap().is_empty());
406 }
407
408 #[tokio::test]
409 async fn an_empty_queue_gives_the_worker_nothing_to_do() {
410 let (_queue, worker) = worker();
411 assert_eq!(worker.run_once().await.unwrap(), None);
412 }
413
414 #[tokio::test]
415 async fn a_worker_only_takes_from_the_queue_it_was_pointed_at() {
416 let (queue, worker) = worker();
417 let tally = counter();
418 queue
419 .push_on("emails", CountingJob::new(tally, 1).to_queued())
420 .await
421 .unwrap();
422
423 assert_eq!(worker.run_once().await.unwrap(), None, "nothing on `default`");
424
425 let emails = worker.clone().on_queue("emails");
426 assert_eq!(emails.run_once().await.unwrap(), Some(Outcome::Processed));
427 assert_eq!(tally.runs(), 1);
428 }
429
430 #[tokio::test]
431 async fn a_failing_job_is_retried_and_then_dead_lettered() {
432 let (queue, worker) = worker();
433 let tally = counter();
434 queue.dispatch(&FailingJob::new(tally, 3)).await.unwrap();
435
436 assert_eq!(worker.run_once().await.unwrap(), Some(Outcome::Released));
438 assert_eq!(worker.run_once().await.unwrap(), Some(Outcome::Released));
439 assert_eq!(worker.run_once().await.unwrap(), Some(Outcome::Failed));
440 assert_eq!(worker.run_once().await.unwrap(), None, "nothing is left to run");
441
442 assert_eq!(tally.runs(), 3, "it ran exactly as many times as it had tries");
443 assert_eq!(queue.size("default").await.unwrap(), 0);
444
445 let failed = queue.failed_jobs().await.unwrap();
446 assert_eq!(failed.len(), 1);
447 assert_eq!(failed[0].name, "failing");
448 assert_eq!(failed[0].attempts, 3);
449 assert!(failed[0].error.contains("attempt 3 went wrong"), "{}", failed[0].error);
450 }
451
452 #[tokio::test]
453 async fn a_job_that_recovers_never_reaches_the_dead_letter_store() {
454 let (queue, worker) = worker();
455 let tally = counter();
456 queue.dispatch(&FlakyJob::new(tally, 3, 5)).await.unwrap();
457
458 assert_eq!(worker.run_once().await.unwrap(), Some(Outcome::Released));
459 assert_eq!(worker.run_once().await.unwrap(), Some(Outcome::Released));
460 assert_eq!(worker.run_once().await.unwrap(), Some(Outcome::Processed));
461
462 assert_eq!(tally.runs(), 3);
463 assert!(queue.failed_jobs().await.unwrap().is_empty());
464 }
465
466 #[tokio::test]
467 async fn a_job_with_one_try_is_buried_on_its_first_failure() {
468 let (queue, worker) = worker();
469 let tally = counter();
470 queue.dispatch(&FailingJob::new(tally, 1)).await.unwrap();
471
472 assert_eq!(worker.run_once().await.unwrap(), Some(Outcome::Failed));
473 assert_eq!(queue.failed_jobs().await.unwrap().len(), 1);
474 }
475
476 #[tokio::test]
477 async fn a_panicking_job_fails_cleanly_instead_of_killing_the_worker() {
478 let (queue, worker) = worker();
479 let tally = counter();
480 let healthy = counter();
481
482 queue.dispatch(&PanickingJob::new(tally, 1)).await.unwrap();
483 queue.dispatch(&CountingJob::new(healthy, 1)).await.unwrap();
484
485 assert_eq!(worker.run_once().await.unwrap(), Some(Outcome::Failed));
486
487 let failed = queue.failed_jobs().await.unwrap();
488 assert_eq!(failed.len(), 1);
489 assert!(
490 failed[0].error.contains("this job panicked on purpose"),
491 "the panic message should survive: {}",
492 failed[0].error
493 );
494
495 assert_eq!(worker.run_once().await.unwrap(), Some(Outcome::Processed));
497 assert_eq!(healthy.runs(), 1);
498 }
499
500 #[tokio::test]
501 async fn a_job_with_no_registered_handler_fails_rather_than_vanishing() {
502 let (queue, worker) = worker();
503 queue.push(QueuedJob::new("never-registered", Json::Null).with_tries(1)).await.unwrap();
504
505 assert_eq!(worker.run_once().await.unwrap(), Some(Outcome::Failed));
506
507 let failed = queue.failed_jobs().await.unwrap();
508 assert!(failed[0].error.contains("no handler is registered"), "{}", failed[0].error);
509 }
510
511 #[tokio::test]
512 async fn max_jobs_stops_the_loop_without_a_shutdown_signal() {
513 let (queue, worker) = worker();
514 let tally = counter();
515 for _ in 0..5 {
516 queue.dispatch(&CountingJob::new(tally, 1)).await.unwrap();
517 }
518
519 let stats = worker.max_jobs(2).run(Shutdown::new()).await.unwrap();
520
521 assert_eq!(stats.processed, 2);
522 assert_eq!(tally.runs(), 2);
523 assert_eq!(queue.size("default").await.unwrap(), 3, "the rest are still waiting");
524 }
525
526 #[tokio::test]
527 async fn an_already_signalled_shutdown_starts_no_work_at_all() {
528 let (queue, worker) = worker();
529 let tally = counter();
530 queue.dispatch(&CountingJob::new(tally, 1)).await.unwrap();
531
532 let shutdown = Shutdown::new();
533 shutdown.signal();
534
535 let stats = worker.run(shutdown).await.unwrap();
536
537 assert_eq!(stats, WorkerStats::default());
538 assert_eq!(tally.runs(), 0);
539 assert_eq!(queue.size("default").await.unwrap(), 1);
540 }
541
542 #[tokio::test]
543 async fn shutdown_lets_the_job_in_hand_finish_first() {
544 let (queue, worker) = worker();
545 let tally = counter();
546 queue.dispatch(&SlowJob::new(tally, 150)).await.unwrap();
547 queue.dispatch(&CountingJob::new(tally, 1)).await.unwrap();
548
549 let shutdown = Shutdown::new();
550 let signal = shutdown.clone();
551 tokio::spawn(async move {
552 tokio::time::sleep(Duration::from_millis(40)).await;
554 signal.signal();
555 });
556
557 let stats = worker.run(shutdown).await.unwrap();
558
559 assert_eq!(stats.processed, 1, "the slow job ran to completion");
560 assert_eq!(tally.runs(), 1);
561 assert_eq!(queue.size("default").await.unwrap(), 1, "the second job was left alone");
562 }
563
564 #[tokio::test]
565 async fn an_idle_worker_wakes_up_for_shutdown_instead_of_waiting_out_its_poll() {
566 let (_queue, worker) = worker();
567 let worker = worker.poll_interval(Duration::from_secs(30));
568
569 let shutdown = Shutdown::new();
570 let signal = shutdown.clone();
571 tokio::spawn(async move {
572 tokio::time::sleep(Duration::from_millis(20)).await;
573 signal.signal();
574 });
575
576 let started = Instant::now();
577 worker.run(shutdown).await.unwrap();
578
579 assert!(
580 started.elapsed() < Duration::from_secs(5),
581 "an idle worker should stop as soon as it is asked, not after its poll interval"
582 );
583 }
584
585 #[tokio::test]
586 async fn several_workers_share_one_queue_and_run_each_job_once() {
587 let (queue, worker) = worker();
588 let tally = counter();
589 for _ in 0..40 {
590 queue.dispatch(&CountingJob::new(tally, 1)).await.unwrap();
591 }
592
593 let shutdown = Shutdown::new();
594 let signal = shutdown.clone();
595 let drain = queue.clone();
596 tokio::spawn(async move {
597 while drain.size("default").await.unwrap_or(0) > 0 {
598 tokio::time::sleep(Duration::from_millis(5)).await;
599 }
600 signal.signal();
601 });
602
603 let stats = run_pool(worker, 4, shutdown).await.unwrap();
604
605 assert_eq!(stats.processed, 40);
606 assert_eq!(tally.runs(), 40, "no job ran twice and none was skipped");
607 assert_eq!(tally.total(), 40);
608 }
609
610 #[tokio::test]
611 async fn the_worker_reports_what_it_did_on_the_event_bus() {
612 let (queue, worker) = worker();
616 let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
617 let sink = Arc::clone(&seen);
618
619 events::subscribe(move |event: &Event| {
620 if event.field("job").and_then(Json::as_str) == Some("failing") {
621 sink.lock()
622 .unwrap()
623 .push((event.kind, event.field("attempt").and_then(Json::as_i64)));
624 }
625 });
626
627 queue.dispatch(&FailingJob::new(counter(), 2)).await.unwrap();
628 worker.run_once().await.unwrap();
629 worker.run_once().await.unwrap();
630
631 let seen = seen.lock().unwrap().clone();
632 assert!(seen.contains(&("queue.pushed", None)), "{seen:?}");
633 assert!(seen.contains(&("queue.failed", Some(1))), "{seen:?}");
634 assert!(seen.contains(&("queue.failed", Some(2))), "{seen:?}");
635
636 events::clear_subscribers();
637 }
638
639 #[test]
640 fn backoff_doubles_per_attempt_and_then_stops_at_the_cap() {
641 let base = Duration::from_secs(10);
642 let cap = Duration::from_secs(300);
643
644 assert_eq!(backoff(base, 1, cap), Duration::from_secs(10));
645 assert_eq!(backoff(base, 2, cap), Duration::from_secs(20));
646 assert_eq!(backoff(base, 3, cap), Duration::from_secs(40));
647 assert_eq!(backoff(base, 4, cap), Duration::from_secs(80));
648 assert_eq!(backoff(base, 10, cap), cap, "the cap wins eventually");
649 assert_eq!(backoff(base, 5000, cap), cap, "and never overflows");
650 }
651
652 #[test]
653 fn backoff_of_zero_stays_zero_so_a_test_can_retry_immediately() {
654 assert_eq!(backoff(Duration::ZERO, 9, DEFAULT_BACKOFF_CAP), Duration::ZERO);
655 }
656
657 #[test]
658 fn stats_add_up_across_a_pool() {
659 let mut stats = WorkerStats { processed: 1, released: 2, failed: 3 };
660 stats.merge(WorkerStats { processed: 10, released: 20, failed: 30 });
661
662 assert_eq!(stats, WorkerStats { processed: 11, released: 22, failed: 33 });
663 assert_eq!(stats.attempts(), 66);
664 }
665
666 #[test]
667 fn outcomes_render_for_a_dashboard() {
668 assert_eq!(outcome_to_json(Outcome::Processed).as_str(), Some("processed"));
669 assert_eq!(outcome_to_json(Outcome::Released).as_str(), Some("released"));
670 assert_eq!(outcome_to_json(Outcome::Failed).as_str(), Some("failed"));
671 }
672}