Skip to main content

ullun/
jobs.rs

1//! In-process job queue with retries
2//!
3//! Work that must not block a request, may fail, and has to be tried again:
4//! sending webhooks, e-mails, thumbnails, imports. Jobs are persisted if you
5//! give the queue a file, retried with exponential backoff, and parked in a
6//! dead-letter list when they keep failing so that nothing disappears silently.
7//!
8//! ```no_run
9//! use std::time::Duration;
10//! use ullun::jobs::{JobError, JobQueue, QueueConfig};
11//! use ullun::prelude::*;
12//!
13//! #[derive(Clone, serde::Serialize, serde::Deserialize)]
14//! struct SendMail { to: String }
15//!
16//! # async fn run() {
17//! let queue: JobQueue<SendMail> = JobQueue::new(QueueConfig::new().max_attempts(5));
18//! let worker_queue = queue.clone();
19//!
20//! App::new()
21//!     .state(queue)
22//!     .post("/signup", |req: Request| async move {
23//!         req.state::<JobQueue<SendMail>>()?.enqueue(SendMail { to: "ann@example.com".into() });
24//!         Ok(Response::text("welcome"))        // the mail is sent later, with retries
25//!     })
26//!     .background(move |ctx: Context| async move {
27//!         worker_queue
28//!             .run(ctx.shutdown(), 4, |job| async move {
29//!                 // job.attempt is 1 on the first try
30//!                 send(&job.payload.to).await.map_err(|e| JobError::retry(e.to_string()))
31//!             })
32//!             .await;
33//!     })
34//!     .run("127.0.0.1:3000")
35//!     .await
36//!     .unwrap();
37//! # }
38//! # async fn send(_to: &str) -> std::result::Result<(), std::io::Error> { Ok(()) }
39//! ```
40//!
41//! **Delivery is at-least-once**: a job that was running when the process died
42//! is run again after a restart, so handlers must be safe to repeat (use the
43//! job id or your own key as an idempotency key towards other systems). The
44//! queue lives in one process; for several instances use a database-backed queue.
45
46use crate::shutdown::Shutdown;
47use serde::de::DeserializeOwned;
48use serde::{Deserialize, Serialize};
49use std::collections::{BTreeSet, HashMap};
50use std::future::Future;
51use std::path::PathBuf;
52use std::sync::{Arc, Mutex, MutexGuard};
53use std::time::{Duration, SystemTime, UNIX_EPOCH};
54use tokio::sync::Notify;
55
56pub type JobId = u64;
57
58fn system_now_ms() -> u64 {
59    SystemTime::now()
60        .duration_since(UNIX_EPOCH)
61        .map(|d| d.as_millis() as u64)
62        .unwrap_or(0)
63}
64
65/// Queue behaviour
66#[derive(Clone)]
67pub struct QueueConfig {
68    max_attempts: u32,
69    base_delay: Duration,
70    max_delay: Duration,
71    jitter: bool,
72    persist_path: Option<PathBuf>,
73    dead_letter_limit: usize,
74    clock: Arc<dyn Fn() -> u64 + Send + Sync>,
75}
76
77impl QueueConfig {
78    /// 5 attempts, backoff 1 s doubling up to 1 h with +-20 % jitter, nothing
79    /// persisted, the last 1000 dead jobs kept
80    pub fn new() -> Self {
81        Self {
82            max_attempts: 5,
83            base_delay: Duration::from_secs(1),
84            max_delay: Duration::from_secs(3600),
85            jitter: true,
86            persist_path: None,
87            dead_letter_limit: 1000,
88            clock: Arc::new(system_now_ms),
89        }
90    }
91
92    /// Total tries per job including the first (at least 1)
93    pub fn max_attempts(mut self, n: u32) -> Self {
94        self.max_attempts = n.max(1);
95        self
96    }
97
98    /// Delay before the first retry; it doubles with every failed attempt
99    pub fn base_delay(mut self, d: Duration) -> Self {
100        self.base_delay = d;
101        self
102    }
103
104    pub fn max_delay(mut self, d: Duration) -> Self {
105        self.max_delay = d;
106        self
107    }
108
109    /// Spread retries by +-20 % so that jobs failing together do not retry together
110    pub fn jitter(mut self, on: bool) -> Self {
111        self.jitter = on;
112        self
113    }
114
115    /// Keep the queue in this JSON file (written atomically after changes)
116    pub fn persist_to(mut self, path: impl Into<PathBuf>) -> Self {
117        self.persist_path = Some(path.into());
118        self
119    }
120
121    /// How many dead jobs to remember (the oldest are dropped)
122    pub fn dead_letter_limit(mut self, n: usize) -> Self {
123        self.dead_letter_limit = n;
124        self
125    }
126
127    /// Replace the time source, in milliseconds since the Unix epoch (tests)
128    pub fn clock(mut self, clock: impl Fn() -> u64 + Send + Sync + 'static) -> Self {
129        self.clock = Arc::new(clock);
130        self
131    }
132
133    /// Delay before retry number `attempt` (1 = first retry), without jitter
134    fn backoff(&self, attempt: u32) -> Duration {
135        let factor = 1u32
136            .checked_shl(attempt.saturating_sub(1).min(30))
137            .unwrap_or(u32::MAX);
138        self.base_delay.saturating_mul(factor).min(self.max_delay)
139    }
140}
141
142impl Default for QueueConfig {
143    fn default() -> Self {
144        Self::new()
145    }
146}
147
148/// How a handler reports failure
149#[derive(Debug, Clone)]
150pub struct JobError {
151    message: String,
152    kind: ErrorKind,
153}
154
155#[derive(Debug, Clone)]
156enum ErrorKind {
157    Retry(Option<Duration>),
158    Fatal,
159}
160
161impl JobError {
162    /// Try again later, after the queue's backoff
163    pub fn retry(message: impl Into<String>) -> Self {
164        Self {
165            message: message.into(),
166            kind: ErrorKind::Retry(None),
167        }
168    }
169
170    /// Try again after exactly `after` (e.g. a `Retry-After` the other side sent)
171    pub fn retry_after(message: impl Into<String>, after: Duration) -> Self {
172        Self {
173            message: message.into(),
174            kind: ErrorKind::Retry(Some(after)),
175        }
176    }
177
178    /// Do not retry: the job goes straight to the dead-letter list
179    pub fn fatal(message: impl Into<String>) -> Self {
180        Self {
181            message: message.into(),
182            kind: ErrorKind::Fatal,
183        }
184    }
185
186    pub fn message(&self) -> &str {
187        &self.message
188    }
189}
190
191impl From<String> for JobError {
192    fn from(message: String) -> Self {
193        Self::retry(message)
194    }
195}
196
197impl From<&str> for JobError {
198    fn from(message: &str) -> Self {
199        Self::retry(message)
200    }
201}
202
203#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
204#[serde(rename_all = "snake_case")]
205pub enum JobState {
206    Pending,
207    Running,
208    Dead,
209}
210
211/// A job as stored by the queue
212#[derive(Debug, Clone, Serialize, Deserialize)]
213pub struct JobRecord<J> {
214    pub id: JobId,
215    pub payload: J,
216    /// Failed attempts so far
217    pub attempts: u32,
218    pub state: JobState,
219    pub run_at_ms: u64,
220    pub created_at_ms: u64,
221    pub last_error: Option<String>,
222    pub key: Option<String>,
223}
224
225/// What a handler receives
226#[derive(Debug, Clone)]
227pub struct Job<J> {
228    pub id: JobId,
229    pub payload: J,
230    /// 1 on the first try, 2 on the first retry, ...
231    pub attempt: u32,
232    pub key: Option<String>,
233}
234
235/// Optional settings for [`JobQueue::enqueue_with`]
236#[derive(Debug, Clone, Default)]
237pub struct EnqueueOptions {
238    /// Do not run before this much time has passed
239    pub delay: Duration,
240    /// While a job with this key is waiting or running, enqueueing it again
241    /// returns the existing job instead of adding a duplicate
242    pub key: Option<String>,
243}
244
245/// Result of [`JobQueue::enqueue_with`]
246#[derive(Debug, Clone, Copy, PartialEq, Eq)]
247pub struct Enqueued {
248    pub id: JobId,
249    /// An existing job with the same key was returned
250    pub duplicate: bool,
251}
252
253/// Counters for dashboards and tests
254#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
255pub struct Stats {
256    pub pending: usize,
257    pub running: usize,
258    pub dead: usize,
259    /// Jobs finished successfully since start
260    pub completed: u64,
261    /// Failed attempts that were scheduled for another try since start
262    pub retried: u64,
263}
264
265struct State<J> {
266    next_id: JobId,
267    jobs: HashMap<JobId, JobRecord<J>>,
268    /// Pending jobs ordered by when they are due
269    due: BTreeSet<(u64, JobId)>,
270    keys: HashMap<String, JobId>,
271    completed: u64,
272    retried: u64,
273}
274
275struct Inner<J> {
276    state: Mutex<State<J>>,
277    wake: Notify,
278    dirty: Notify,
279    config: QueueConfig,
280}
281
282/// The queue; cheap to clone, all clones are the same queue
283pub struct JobQueue<J> {
284    inner: Arc<Inner<J>>,
285}
286
287impl<J> Clone for JobQueue<J> {
288    fn clone(&self) -> Self {
289        Self {
290            inner: self.inner.clone(),
291        }
292    }
293}
294
295#[derive(Serialize, Deserialize)]
296struct Snapshot<J> {
297    next_id: JobId,
298    jobs: Vec<JobRecord<J>>,
299}
300
301impl<J> JobQueue<J>
302where
303    J: Serialize + DeserializeOwned + Clone + Send + Sync + 'static,
304{
305    /// An empty queue, or the one saved in `persist_to`'s file. Jobs that were
306    /// running when the file was written are pending again.
307    pub fn new(config: QueueConfig) -> Self {
308        let mut state = State {
309            next_id: 1,
310            jobs: HashMap::new(),
311            due: BTreeSet::new(),
312            keys: HashMap::new(),
313            completed: 0,
314            retried: 0,
315        };
316        if let Some(path) = &config.persist_path {
317            match std::fs::read(path) {
318                Ok(bytes) => match serde_json::from_slice::<Snapshot<J>>(&bytes) {
319                    Ok(snapshot) => {
320                        state.next_id = snapshot.next_id.max(1);
321                        for mut job in snapshot.jobs {
322                            if job.state == JobState::Running {
323                                job.state = JobState::Pending;
324                            }
325                            if job.state == JobState::Pending {
326                                state.due.insert((job.run_at_ms, job.id));
327                                if let Some(k) = &job.key {
328                                    state.keys.insert(k.clone(), job.id);
329                                }
330                            }
331                            state.next_id = state.next_id.max(job.id + 1);
332                            state.jobs.insert(job.id, job);
333                        }
334                    }
335                    Err(e) => eprintln!("job queue: ignoring unreadable {}: {}", path.display(), e),
336                },
337                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
338                Err(e) => eprintln!("job queue: cannot read {}: {}", path.display(), e),
339            }
340        }
341        Self {
342            inner: Arc::new(Inner {
343                state: Mutex::new(state),
344                wake: Notify::new(),
345                dirty: Notify::new(),
346                config,
347            }),
348        }
349    }
350
351    fn lock(&self) -> MutexGuard<'_, State<J>> {
352        self.inner.state.lock().unwrap_or_else(|e| e.into_inner())
353    }
354
355    fn now_ms(&self) -> u64 {
356        (self.inner.config.clock)()
357    }
358
359    fn changed(&self) {
360        self.inner.dirty.notify_one();
361        self.inner.wake.notify_waiters();
362    }
363
364    /// Add a job to run as soon as a worker is free
365    pub fn enqueue(&self, payload: J) -> JobId {
366        self.enqueue_with(payload, EnqueueOptions::default()).id
367    }
368
369    pub fn enqueue_with(&self, payload: J, options: EnqueueOptions) -> Enqueued {
370        let now = self.now_ms();
371        let enqueued = {
372            let mut st = self.lock();
373            if let Some(existing) = options.key.as_ref().and_then(|k| st.keys.get(k)).copied() {
374                return Enqueued {
375                    id: existing,
376                    duplicate: true,
377                };
378            }
379            let id = st.next_id;
380            st.next_id += 1;
381            let run_at_ms = now + options.delay.as_millis() as u64;
382            if let Some(k) = &options.key {
383                st.keys.insert(k.clone(), id);
384            }
385            st.due.insert((run_at_ms, id));
386            st.jobs.insert(
387                id,
388                JobRecord {
389                    id,
390                    payload,
391                    attempts: 0,
392                    state: JobState::Pending,
393                    run_at_ms,
394                    created_at_ms: now,
395                    last_error: None,
396                    key: options.key,
397                },
398            );
399            Enqueued {
400                id,
401                duplicate: false,
402            }
403        };
404        self.changed();
405        enqueued
406    }
407
408    /// Counters
409    pub fn stats(&self) -> Stats {
410        let st = self.lock();
411        let mut stats = Stats {
412            completed: st.completed,
413            retried: st.retried,
414            ..Stats::default()
415        };
416        for job in st.jobs.values() {
417            match job.state {
418                JobState::Pending => stats.pending += 1,
419                JobState::Running => stats.running += 1,
420                JobState::Dead => stats.dead += 1,
421            }
422        }
423        stats
424    }
425
426    /// A job that is waiting, running or dead
427    pub fn get(&self, id: JobId) -> Option<JobRecord<J>> {
428        self.lock().jobs.get(&id).cloned()
429    }
430
431    /// Jobs that ran out of attempts (or failed fatally), oldest first
432    pub fn dead_jobs(&self) -> Vec<JobRecord<J>> {
433        let st = self.lock();
434        let mut dead: Vec<_> = st
435            .jobs
436            .values()
437            .filter(|j| j.state == JobState::Dead)
438            .cloned()
439            .collect();
440        dead.sort_by_key(|j| j.id);
441        dead
442    }
443
444    /// Give a dead job a fresh start; `false` if there is no such dead job
445    pub fn retry_dead(&self, id: JobId) -> bool {
446        let now = self.now_ms();
447        let revived = {
448            let mut st = self.lock();
449            let State {
450                jobs, due, keys, ..
451            } = &mut *st;
452            match jobs.get_mut(&id) {
453                Some(job) if job.state == JobState::Dead => {
454                    job.state = JobState::Pending;
455                    job.attempts = 0;
456                    job.run_at_ms = now;
457                    due.insert((now, id));
458                    if let Some(k) = &job.key {
459                        keys.insert(k.clone(), id);
460                    }
461                    true
462                }
463                _ => false,
464            }
465        };
466        if revived {
467            self.changed();
468        }
469        revived
470    }
471
472    /// Remove a dead job for good; `false` if there is no such dead job
473    pub fn delete_dead(&self, id: JobId) -> bool {
474        let removed = {
475            let mut st = self.lock();
476            if st.jobs.get(&id).is_some_and(|j| j.state == JobState::Dead) {
477                st.jobs.remove(&id);
478                true
479            } else {
480                false
481            }
482        };
483        if removed {
484            self.changed();
485        }
486        removed
487    }
488
489    /// Claim the next due job, if any
490    fn take_due(&self) -> Result<Job<J>, Option<u64>> {
491        let now = self.now_ms();
492        let mut st = self.lock();
493        let Some(&(run_at, id)) = st.due.first() else {
494            return Err(None);
495        };
496        if run_at > now {
497            return Err(Some(run_at - now));
498        }
499        st.due.remove(&(run_at, id));
500        let job = st.jobs.get_mut(&id).expect("due jobs exist");
501        job.state = JobState::Running;
502        Ok(Job {
503            id,
504            payload: job.payload.clone(),
505            attempt: job.attempts + 1,
506            key: job.key.clone(),
507        })
508    }
509
510    fn jitter(&self, delay: Duration) -> Duration {
511        if !self.inner.config.jitter {
512            return delay;
513        }
514        // uniformly within +-20 %
515        let r = crate::security::random_bytes(2)
516            .map(|b| u16::from_le_bytes([b[0], b[1]]))
517            .unwrap_or(32768);
518        let factor = 0.8 + (f64::from(r) / f64::from(u16::MAX)) * 0.4;
519        delay.mul_f64(factor)
520    }
521
522    fn finish(&self, id: JobId, result: Result<(), JobError>) {
523        let cfg = &self.inner.config;
524        let now = self.now_ms();
525        {
526            let mut st = self.lock();
527            let State {
528                jobs,
529                due,
530                keys,
531                completed,
532                retried,
533                ..
534            } = &mut *st;
535            let Some(job) = jobs.get_mut(&id) else { return };
536            match result {
537                Ok(()) => {
538                    if let Some(k) = &job.key {
539                        keys.remove(k);
540                    }
541                    jobs.remove(&id);
542                    *completed += 1;
543                }
544                Err(err) => {
545                    job.attempts += 1;
546                    job.last_error = Some(err.message.clone());
547                    let give_up =
548                        matches!(err.kind, ErrorKind::Fatal) || job.attempts >= cfg.max_attempts;
549                    if give_up {
550                        job.state = JobState::Dead;
551                        if let Some(k) = &job.key {
552                            keys.remove(k);
553                        }
554                    } else {
555                        let delay = match err.kind {
556                            ErrorKind::Retry(Some(after)) => after,
557                            _ => self.jitter(cfg.backoff(job.attempts)),
558                        };
559                        job.state = JobState::Pending;
560                        job.run_at_ms = now + delay.as_millis() as u64;
561                        due.insert((job.run_at_ms, id));
562                        *retried += 1;
563                    }
564                }
565            }
566            // keep only the most recent dead jobs
567            let dead = jobs.values().filter(|j| j.state == JobState::Dead).count();
568            if dead > cfg.dead_letter_limit {
569                let mut ids: Vec<JobId> = jobs
570                    .values()
571                    .filter(|j| j.state == JobState::Dead)
572                    .map(|j| j.id)
573                    .collect();
574                ids.sort_unstable();
575                for old in ids.into_iter().take(dead - cfg.dead_letter_limit) {
576                    jobs.remove(&old);
577                }
578            }
579        }
580        self.changed();
581    }
582
583    /// Run `workers` workers until `shutdown` fires; returns when the jobs that
584    /// were in flight have finished and the queue has been saved.
585    ///
586    /// Typically called from [`App::background`](crate::app::App::background).
587    /// A handler that panics counts as a failed attempt.
588    pub async fn run<F, Fut>(&self, shutdown: Shutdown, workers: usize, handler: F)
589    where
590        F: Fn(Job<J>) -> Fut + Send + Sync + 'static,
591        Fut: Future<Output = Result<(), JobError>> + Send + 'static,
592    {
593        let handler = Arc::new(handler);
594        let mut tasks = tokio::task::JoinSet::new();
595        for _ in 0..workers.max(1) {
596            let (queue, handler, shutdown) = (self.clone(), handler.clone(), shutdown.clone());
597            tasks.spawn(async move { queue.worker(shutdown, handler).await });
598        }
599        if self.inner.config.persist_path.is_some() {
600            let (queue, shutdown) = (self.clone(), shutdown.clone());
601            tasks.spawn(async move { queue.persister(shutdown).await });
602        }
603        while tasks.join_next().await.is_some() {}
604        self.persist_now().await;
605    }
606
607    async fn worker<F, Fut>(&self, shutdown: Shutdown, handler: Arc<F>)
608    where
609        F: Fn(Job<J>) -> Fut + Send + Sync + 'static,
610        Fut: Future<Output = Result<(), JobError>> + Send + 'static,
611    {
612        loop {
613            if shutdown.is_triggered() {
614                return;
615            }
616            match self.take_due() {
617                Ok(job) => {
618                    let id = job.id;
619                    let fut = handler(job);
620                    // a separate task, so that a panic is a failed attempt, not a dead worker
621                    let outcome = tokio::spawn(fut).await;
622                    let result = match outcome {
623                        Ok(r) => r,
624                        Err(_) => Err(JobError::retry("handler panicked")),
625                    };
626                    self.finish(id, result);
627                }
628                Err(next_in) => {
629                    let nap = Duration::from_millis(next_in.unwrap_or(500).min(500));
630                    tokio::select! {
631                        _ = self.inner.wake.notified() => {}
632                        _ = tokio::time::sleep(nap) => {}
633                        _ = shutdown.wait() => return,
634                    }
635                }
636            }
637        }
638    }
639
640    async fn persister(&self, shutdown: Shutdown) {
641        loop {
642            tokio::select! {
643                _ = self.inner.dirty.notified() => {}
644                _ = shutdown.wait() => return,
645            }
646            // coalesce bursts of changes into one write
647            tokio::time::sleep(Duration::from_millis(50)).await;
648            self.persist_now().await;
649        }
650    }
651
652    /// Write the queue to its file now (also done automatically while running)
653    pub async fn persist_now(&self) {
654        let Some(path) = &self.inner.config.persist_path else {
655            return;
656        };
657        let bytes = {
658            let st = self.lock();
659            serde_json::to_vec(&Snapshot {
660                next_id: st.next_id,
661                jobs: st.jobs.values().cloned().collect(),
662            })
663        };
664        let Ok(bytes) = bytes else { return };
665        let tmp = path.with_extension("tmp");
666        if tokio::fs::write(&tmp, bytes).await.is_ok() {
667            let _ = tokio::fs::rename(&tmp, path).await;
668        }
669    }
670}
671
672#[cfg(test)]
673mod tests {
674    use super::*;
675    use std::sync::atomic::{AtomicU32, AtomicUsize, Ordering};
676    use tokio::sync::watch;
677
678    type Q = JobQueue<String>;
679
680    fn fast() -> QueueConfig {
681        QueueConfig::new()
682            .base_delay(Duration::from_millis(20))
683            .max_delay(Duration::from_millis(200))
684            .jitter(false)
685    }
686
687    /// A queue with workers running in the background; `stop()` ends them
688    struct Running {
689        queue: Q,
690        stop: watch::Sender<bool>,
691        done: tokio::task::JoinHandle<()>,
692    }
693
694    impl Running {
695        fn start<F, Fut>(queue: Q, workers: usize, handler: F) -> Self
696        where
697            F: Fn(Job<String>) -> Fut + Send + Sync + 'static,
698            Fut: Future<Output = Result<(), JobError>> + Send + 'static,
699        {
700            let (stop, rx) = watch::channel(false);
701            let q = queue.clone();
702            let done =
703                tokio::spawn(async move { q.run(Shutdown::new(rx), workers, handler).await });
704            Self { queue, stop, done }
705        }
706
707        async fn stop(self) {
708            let _ = self.stop.send(true);
709            tokio::time::timeout(Duration::from_secs(5), self.done)
710                .await
711                .expect("workers did not stop")
712                .unwrap();
713        }
714    }
715
716    async fn until(mut cond: impl FnMut() -> bool) {
717        let deadline = std::time::Instant::now() + Duration::from_secs(5);
718        while !cond() {
719            assert!(
720                std::time::Instant::now() < deadline,
721                "condition not reached in 5 s"
722            );
723            tokio::time::sleep(Duration::from_millis(5)).await;
724        }
725    }
726
727    #[test]
728    fn backoff_doubles_and_is_capped() {
729        let cfg = QueueConfig::new()
730            .base_delay(Duration::from_millis(100))
731            .max_delay(Duration::from_secs(1));
732        let ms: Vec<u128> = (1..=6).map(|a| cfg.backoff(a).as_millis()).collect();
733        assert_eq!(ms, [100, 200, 400, 800, 1000, 1000]);
734        assert_eq!(cfg.backoff(0).as_millis(), 100);
735        assert_eq!(cfg.backoff(1000).as_millis(), 1000); // no overflow
736    }
737
738    #[test]
739    fn jitter_stays_within_twenty_percent() {
740        let q = Q::new(QueueConfig::new());
741        for _ in 0..200 {
742            let d = q.jitter(Duration::from_millis(1000)).as_millis();
743            assert!((800..=1200).contains(&d), "{d}");
744        }
745        let still = Q::new(QueueConfig::new().jitter(false));
746        assert_eq!(still.jitter(Duration::from_millis(1000)).as_millis(), 1000);
747    }
748
749    #[tokio::test]
750    async fn jobs_run_once_and_are_removed() {
751        let seen = Arc::new(Mutex::new(Vec::new()));
752        let s = seen.clone();
753        let r = Running::start(Q::new(fast()), 2, move |job| {
754            let s = s.clone();
755            async move {
756                s.lock().unwrap().push((job.payload, job.attempt));
757                Ok(())
758            }
759        });
760        for n in ["a", "b", "c"] {
761            r.queue.enqueue(n.to_string());
762        }
763        until(|| r.queue.stats().completed == 3).await;
764        let mut got = seen.lock().unwrap().clone();
765        got.sort();
766        assert_eq!(got, [("a".into(), 1), ("b".into(), 1), ("c".into(), 1)]);
767        assert_eq!(
768            r.queue.stats(),
769            Stats {
770                completed: 3,
771                ..Stats::default()
772            }
773        );
774        r.stop().await;
775    }
776
777    #[tokio::test]
778    async fn failures_are_retried_with_backoff_then_succeed() {
779        let attempts = Arc::new(Mutex::new(Vec::new()));
780        let a = attempts.clone();
781        let r = Running::start(Q::new(fast()), 1, move |job| {
782            let a = a.clone();
783            async move {
784                a.lock().unwrap().push(std::time::Instant::now());
785                if job.attempt < 3 {
786                    Err(JobError::retry(format!("try {}", job.attempt)))
787                } else {
788                    Ok(())
789                }
790            }
791        });
792        let id = r.queue.enqueue("flaky".into());
793        until(|| r.queue.stats().completed == 1).await;
794        assert!(r.queue.get(id).is_none());
795        assert_eq!(r.queue.stats().retried, 2);
796        let times = attempts.lock().unwrap().clone();
797        assert_eq!(times.len(), 3);
798        // waited about 20 ms, then about 40 ms
799        assert!(times[1] - times[0] >= Duration::from_millis(18));
800        assert!(times[2] - times[1] >= Duration::from_millis(36));
801        r.stop().await;
802    }
803
804    #[tokio::test]
805    async fn exhausted_and_fatal_jobs_go_to_the_dead_letter_list() {
806        let r = Running::start(Q::new(fast().max_attempts(3)), 2, |job| async move {
807            if job.payload == "fatal" {
808                Err(JobError::fatal("bad payload"))
809            } else {
810                Err(JobError::from("still broken"))
811            }
812        });
813        let flaky = r.queue.enqueue("flaky".into());
814        let fatal = r.queue.enqueue("fatal".into());
815        until(|| r.queue.stats().dead == 2).await;
816
817        let dead = r.queue.dead_jobs();
818        assert_eq!(
819            dead.iter().map(|j| j.id).collect::<Vec<_>>(),
820            [flaky, fatal]
821        );
822        assert_eq!(
823            (dead[0].attempts, dead[0].last_error.as_deref()),
824            (3, Some("still broken"))
825        );
826        assert_eq!(
827            (dead[1].attempts, dead[1].last_error.as_deref()),
828            (1, Some("bad payload"))
829        );
830        assert_eq!(r.queue.stats().running + r.queue.stats().pending, 0);
831
832        // dead jobs can be revived or deleted
833        assert!(!r.queue.retry_dead(9999));
834        assert!(r.queue.delete_dead(fatal));
835        assert!(!r.queue.delete_dead(fatal));
836        assert!(r.queue.retry_dead(flaky));
837        until(|| {
838            r.queue
839                .get(flaky)
840                .is_some_and(|j| j.state == JobState::Dead && j.attempts == 3)
841        })
842        .await;
843        r.stop().await;
844    }
845
846    #[tokio::test]
847    async fn retry_after_overrides_the_backoff_and_panics_count_as_failures() {
848        let tries = Arc::new(AtomicU32::new(0));
849        let t = tries.clone();
850        let r = Running::start(
851            Q::new(fast().base_delay(Duration::from_secs(60))),
852            1,
853            move |job| {
854                let t = t.clone();
855                async move {
856                    match job.payload.as_str() {
857                        "after" => {
858                            if t.fetch_add(1, Ordering::SeqCst) == 0 {
859                                Err(JobError::retry_after("later", Duration::from_millis(30)))
860                            } else {
861                                Ok(())
862                            }
863                        }
864                        _ => panic!("boom"),
865                    }
866                }
867            },
868        );
869        r.queue.enqueue("after".into());
870        // would take a minute without the explicit delay
871        until(|| r.queue.stats().completed == 1).await;
872
873        let id = r.queue.enqueue("panics".into());
874        until(|| r.queue.get(id).is_some_and(|j| j.attempts == 1)).await;
875        assert_eq!(
876            r.queue.get(id).unwrap().last_error.as_deref(),
877            Some("handler panicked")
878        );
879        // the worker survived: it still processes jobs
880        r.queue.enqueue("after".into());
881        until(|| r.queue.stats().completed == 2).await;
882        r.stop().await;
883    }
884
885    #[tokio::test]
886    async fn delayed_jobs_wait_and_keys_deduplicate() {
887        let ran = Arc::new(AtomicUsize::new(0));
888        let n = ran.clone();
889        let r = Running::start(Q::new(fast()), 1, move |_job| {
890            let n = n.clone();
891            async move {
892                n.fetch_add(1, Ordering::SeqCst);
893                Ok(())
894            }
895        });
896        let started = std::time::Instant::now();
897        let first = r.queue.enqueue_with(
898            "x".into(),
899            EnqueueOptions {
900                delay: Duration::from_millis(150),
901                key: Some("k".into()),
902            },
903        );
904        let again = r.queue.enqueue_with(
905            "y".into(),
906            EnqueueOptions {
907                key: Some("k".into()),
908                ..Default::default()
909            },
910        );
911        assert!(!first.duplicate && again.duplicate);
912        assert_eq!(first.id, again.id);
913
914        tokio::time::sleep(Duration::from_millis(60)).await;
915        assert_eq!(ran.load(Ordering::SeqCst), 0, "not due yet");
916        until(|| ran.load(Ordering::SeqCst) == 1).await;
917        assert!(started.elapsed() >= Duration::from_millis(140));
918
919        // once the job is done the key is free again
920        until(|| r.queue.stats().completed == 1).await;
921        let reuse = r.queue.enqueue_with(
922            "z".into(),
923            EnqueueOptions {
924                key: Some("k".into()),
925                ..Default::default()
926            },
927        );
928        assert!(!reuse.duplicate && reuse.id != first.id);
929        until(|| ran.load(Ordering::SeqCst) == 2).await;
930        r.stop().await;
931    }
932
933    #[tokio::test]
934    async fn concurrency_never_exceeds_the_worker_count() {
935        let (current, peak) = (Arc::new(AtomicUsize::new(0)), Arc::new(AtomicUsize::new(0)));
936        let (c, p) = (current.clone(), peak.clone());
937        let r = Running::start(Q::new(fast()), 3, move |_job| {
938            let (c, p) = (c.clone(), p.clone());
939            async move {
940                let now = c.fetch_add(1, Ordering::SeqCst) + 1;
941                p.fetch_max(now, Ordering::SeqCst);
942                tokio::time::sleep(Duration::from_millis(30)).await;
943                c.fetch_sub(1, Ordering::SeqCst);
944                Ok(())
945            }
946        });
947        for i in 0..12 {
948            r.queue.enqueue(format!("job{i}"));
949        }
950        until(|| r.queue.stats().completed == 12).await;
951        assert_eq!(peak.load(Ordering::SeqCst), 3);
952        r.stop().await;
953    }
954
955    #[tokio::test]
956    async fn shutdown_lets_running_jobs_finish_and_leaves_the_rest_queued() {
957        let started = Arc::new(Notify::new());
958        let (s, finished) = (started.clone(), Arc::new(AtomicUsize::new(0)));
959        let f = finished.clone();
960        let r = Running::start(Q::new(fast()), 1, move |_job| {
961            let (s, f) = (s.clone(), f.clone());
962            async move {
963                s.notify_one();
964                tokio::time::sleep(Duration::from_millis(150)).await;
965                f.fetch_add(1, Ordering::SeqCst);
966                Ok(())
967            }
968        });
969        r.queue.enqueue("first".into());
970        r.queue.enqueue("second".into());
971        started.notified().await;
972
973        let queue = r.queue.clone();
974        r.stop().await; // returns only after the running job completed
975        assert_eq!(finished.load(Ordering::SeqCst), 1);
976        let stats = queue.stats();
977        assert_eq!((stats.completed, stats.pending, stats.running), (1, 1, 0));
978    }
979
980    #[tokio::test]
981    async fn the_queue_survives_a_restart() {
982        let file = std::env::temp_dir().join(format!("ullun-jobs-{}.json", std::process::id()));
983        std::fs::remove_file(&file).ok();
984        let cfg = || fast().persist_to(file.clone());
985
986        // jobs are enqueued but never run; one is "running" when the process dies
987        let q = Q::new(cfg());
988        let a = q.enqueue("a".into());
989        let b = q
990            .enqueue_with(
991                "b".into(),
992                EnqueueOptions {
993                    key: Some("bk".into()),
994                    delay: Duration::from_secs(60),
995                },
996            )
997            .id;
998        let claimed = q.take_due().map(|j| j.id).unwrap_or(0);
999        assert_eq!(claimed, a);
1000        q.persist_now().await;
1001        drop(q);
1002
1003        let q2 = Q::new(cfg());
1004        assert_eq!(q2.stats().pending, 2);
1005        assert_eq!(
1006            q2.get(a).unwrap().state,
1007            JobState::Pending,
1008            "interrupted jobs are run again"
1009        );
1010        assert_eq!(q2.get(b).unwrap().payload, "b");
1011        // keys and ids carry over
1012        assert!(
1013            q2.enqueue_with(
1014                "dup".into(),
1015                EnqueueOptions {
1016                    key: Some("bk".into()),
1017                    ..Default::default()
1018                }
1019            )
1020            .duplicate
1021        );
1022        assert!(q2.enqueue("new".into()) > b);
1023
1024        // and the revived queue works
1025        let seen = Arc::new(Mutex::new(Vec::new()));
1026        let s = seen.clone();
1027        let r = Running::start(q2, 1, move |job| {
1028            let s = s.clone();
1029            async move {
1030                s.lock().unwrap().push(job.payload);
1031                Ok(())
1032            }
1033        });
1034        until(|| seen.lock().unwrap().len() >= 2).await;
1035        let got = seen.lock().unwrap().clone();
1036        assert!(got.contains(&"a".to_string()) && got.contains(&"new".to_string()));
1037        r.stop().await;
1038
1039        // the file reflects what is left (the delayed job "b")
1040        let q3 = Q::new(cfg());
1041        assert_eq!(q3.stats().pending, 1);
1042
1043        std::fs::write(&file, b"garbage").unwrap();
1044        assert_eq!(
1045            Q::new(cfg()).stats(),
1046            Stats::default(),
1047            "a corrupt file must not stop startup"
1048        );
1049        std::fs::remove_file(&file).ok();
1050    }
1051
1052    #[tokio::test]
1053    async fn only_the_newest_dead_jobs_are_kept() {
1054        let r = Running::start(
1055            Q::new(fast().max_attempts(1).dead_letter_limit(2)),
1056            1,
1057            |_job| async { Err(JobError::fatal("no")) },
1058        );
1059        let ids: Vec<JobId> = (0..4).map(|i| r.queue.enqueue(format!("j{i}"))).collect();
1060        until(|| {
1061            let s = r.queue.stats();
1062            s.pending == 0 && s.running == 0 && s.dead == 2
1063        })
1064        .await;
1065        let dead: Vec<JobId> = r.queue.dead_jobs().iter().map(|j| j.id).collect();
1066        assert_eq!(dead, ids[2..]);
1067        r.stop().await;
1068    }
1069}