1use 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#[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 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 pub fn max_attempts(mut self, n: u32) -> Self {
94 self.max_attempts = n.max(1);
95 self
96 }
97
98 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 pub fn jitter(mut self, on: bool) -> Self {
111 self.jitter = on;
112 self
113 }
114
115 pub fn persist_to(mut self, path: impl Into<PathBuf>) -> Self {
117 self.persist_path = Some(path.into());
118 self
119 }
120
121 pub fn dead_letter_limit(mut self, n: usize) -> Self {
123 self.dead_letter_limit = n;
124 self
125 }
126
127 pub fn clock(mut self, clock: impl Fn() -> u64 + Send + Sync + 'static) -> Self {
129 self.clock = Arc::new(clock);
130 self
131 }
132
133 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#[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 pub fn retry(message: impl Into<String>) -> Self {
164 Self {
165 message: message.into(),
166 kind: ErrorKind::Retry(None),
167 }
168 }
169
170 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
213pub struct JobRecord<J> {
214 pub id: JobId,
215 pub payload: J,
216 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#[derive(Debug, Clone)]
227pub struct Job<J> {
228 pub id: JobId,
229 pub payload: J,
230 pub attempt: u32,
232 pub key: Option<String>,
233}
234
235#[derive(Debug, Clone, Default)]
237pub struct EnqueueOptions {
238 pub delay: Duration,
240 pub key: Option<String>,
243}
244
245#[derive(Debug, Clone, Copy, PartialEq, Eq)]
247pub struct Enqueued {
248 pub id: JobId,
249 pub duplicate: bool,
251}
252
253#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
255pub struct Stats {
256 pub pending: usize,
257 pub running: usize,
258 pub dead: usize,
259 pub completed: u64,
261 pub retried: u64,
263}
264
265struct State<J> {
266 next_id: JobId,
267 jobs: HashMap<JobId, JobRecord<J>>,
268 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
282pub 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 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 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 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 pub fn get(&self, id: JobId) -> Option<JobRecord<J>> {
428 self.lock().jobs.get(&id).cloned()
429 }
430
431 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 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 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 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 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 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 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 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 tokio::time::sleep(Duration::from_millis(50)).await;
648 self.persist_now().await;
649 }
650 }
651
652 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 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); }
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 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 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 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 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 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; 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 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 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 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 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}