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/// What happened to one job.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum Outcome {
27    /// It succeeded and was deleted.
28    Processed,
29    /// It failed and went back on the queue for another attempt.
30    Released,
31    /// It failed for the last time and went to the dead-letter store.
32    Failed,
33}
34
35/// A tally of what a worker did, returned when it stops.
36#[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    /// Every job attempt this worker made.
45    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/// A shutdown signal shared by every worker in a pool.
65///
66/// Cloning shares one signal, so `Ctrl+C` handled once stops the whole pool.
67#[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    /// Ask every worker to stop after the job it currently holds.
86    pub fn signal(&self) {
87        // The flag is set *before* the notification, so a worker that
88        // subscribes and then reads the flag can never miss both.
89        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    /// Stop the pool when the process is interrupted — what `queue:work` wants.
98    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    /// Wait up to `duration`. Returns true when shutdown arrived first, so a
109    /// worker on an idle queue stops promptly instead of sitting out its poll.
110    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/// How a worker behaves.
124#[derive(Debug, Clone)]
125pub struct WorkerOptions {
126    /// The queue to work. One worker works one queue.
127    pub queue: String,
128    /// How long to wait after finding nothing.
129    pub poll_interval: Duration,
130    /// Stop after this many attempts. `None` means run until shutdown; `Some(1)`
131    /// is `queue:work --once`.
132    pub max_jobs: Option<u64>,
133    /// The ceiling on retry backoff.
134    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/// One worker. Cloning is cheap and gives a second worker on the same queue.
149#[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        // The same hook rustlavel-http installs for handlers: it records where a
159        // panic happened and keeps the default backtrace off stderr for panics
160        // we are about to report ourselves.
161        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    /// Stop after `count` attempts, whatever the shutdown signal says.
177    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    /// Work until shutdown is signalled or `max_jobs` is reached.
192    ///
193    /// The shutdown check is at the top of the loop and nowhere else. That is
194    /// the entire graceful-shutdown guarantee: a job already reserved is always
195    /// run to completion, because there is no point at which the loop can
196    /// abandon one.
197    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    /// Reserve and run at most one job. `None` when the queue had nothing ready.
219    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    /// Run one reserved job and settle its fate.
227    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                // Reported on every failed attempt, not only the last, so
271                // Telescope can show a job that is thrashing before it dies.
272                // `dead_lettered` is the flag that separates the two.
273                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    /// Run the handler, turning every way it can go wrong into one `Err`.
283    ///
284    /// A panicking job must not take the worker with it: one bad payload would
285    /// otherwise stop every other job on the queue. Caught with the same
286    /// technique rustlavel-http uses for a panicking handler, so a panic across
287    /// an await point is caught too, and treated as an ordinary failure — it
288    /// retries and it dead-letters, exactly like a returned error.
289    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
305/// Run `count` copies of a worker concurrently, all watching one shutdown
306/// signal, and return their combined tally once every one has stopped.
307///
308/// This is `queue:work --workers=N`. They share a queue, not any state: the
309/// only thing keeping two of them off the same job is the driver's reservation.
310pub 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
330/// How long to wait before the next attempt.
331///
332/// Exponential: the job's own `retry_after`, doubled once per attempt already
333/// made, capped. A dependency that is down stays down for a while, and hammering
334/// it every minute helps nobody.
335pub fn backoff(retry_after: Duration, attempts: u32, cap: Duration) -> Duration {
336    // Shifting by 32 or more is undefined, and by then the cap has won anyway.
337    let doublings = attempts.saturating_sub(1).min(20);
338    let factor = 1u32 << doublings;
339
340    retry_after.saturating_mul(factor).min(cap)
341}
342
343/// Dispatch a worker event, if anyone is listening.
344fn 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
367/// A convenience for a dashboard: the worker events as JSON.
368pub 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    /// A worker over a fresh in-memory queue, with every fixture registered.
387    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        // Three attempts: two releases back onto the queue, then the grave.
437        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        // The worker is still alive and the next job is unaffected.
496        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            // Land in the middle of the slow job, not before it starts.
553            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        // The event bus is process-wide, so this test cannot share it with
613        // another that also subscribes; every assertion here filters by the job
614        // name it dispatched, which nothing else uses.
615        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}