Skip to main content

blazingly_queue/
lib.rs

1#![forbid(unsafe_code)]
2
3//! Runtime-neutral queue seam with an in-memory conformance adapter.
4//!
5//! This crate defines contracts only and stays dependency free. Vendor
6//! adapters for NATS, `RabbitMQ`, Kafka, and SQS live in separate adapter
7//! packages outside this repository and implement [`Queue`] there.
8//! [`MemoryQueue`] exists for tests and local development, and [`Worker`]
9//! supplies the retry, backoff, and dead-letter policy every adapter shares.
10//!
11//! The seam is deliberately cross-thread. [`QueueFuture`] carries a `Send`
12//! bound and [`Queue`] requires `Send + Sync` so a [`Worker`] can run on a
13//! multi-threaded pool next to the thread-per-core HTTP path. Operation
14//! handlers stay unaffected: a `Send` future is usable from the framework's
15//! thread-local executor, so publishing from a handler still compiles. An
16//! adapter that can only run on one thread must own that thread internally and
17//! hand back a `Send` future.
18
19use std::collections::{BTreeMap, HashMap, VecDeque};
20use std::fmt;
21use std::future::Future;
22use std::pin::Pin;
23use std::sync::{Arc, Mutex};
24use std::time::Duration;
25
26pub type QueueFuture<T> = Pin<Box<dyn Future<Output = Result<T, QueueError>> + Send + 'static>>;
27
28/// Header naming the topic a dead-lettered delivery came from.
29pub const DEAD_LETTER_SOURCE_HEADER: &str = "blazingly-dead-letter-source";
30/// Header carrying the attempt count of a dead-lettered delivery.
31pub const DEAD_LETTER_ATTEMPT_HEADER: &str = "blazingly-dead-letter-attempt";
32/// Header carrying the last handler failure of a dead-lettered delivery.
33pub const DEAD_LETTER_REASON_HEADER: &str = "blazingly-dead-letter-reason";
34
35/// Queue payload shared by vendor adapters.
36#[derive(Clone, Debug, Eq, PartialEq)]
37pub struct Message {
38    pub body: Vec<u8>,
39    pub headers: BTreeMap<String, String>,
40}
41
42impl Message {
43    #[must_use]
44    pub fn new(body: impl Into<Vec<u8>>) -> Self {
45        Self {
46            body: body.into(),
47            headers: BTreeMap::new(),
48        }
49    }
50
51    #[must_use]
52    pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
53        self.headers.insert(name.into(), value.into());
54        self
55    }
56}
57
58/// One at-least-once delivery.
59#[derive(Clone, Debug, Eq, PartialEq)]
60pub struct Delivery {
61    pub receipt: String,
62    pub attempt: u32,
63    pub message: Message,
64}
65
66/// Adapter surface implemented by NATS, `RabbitMQ`, Kafka, SQS, and test
67/// queues.
68pub trait Queue: Clone + Send + Sync + 'static {
69    /// Publishes one message to a topic.
70    fn publish(&self, topic: &str, message: Message) -> QueueFuture<()>;
71
72    /// Takes the next visible delivery from a topic, if any.
73    fn receive(&self, topic: &str) -> QueueFuture<Option<Delivery>>;
74
75    /// Settles a delivery so it is never redelivered.
76    fn ack(&self, receipt: &str) -> QueueFuture<()>;
77
78    /// Returns a delivery to its topic, invisible until `delay` has elapsed.
79    ///
80    /// The adapter raises the delivery's attempt count. It does not cap the
81    /// attempts: the ceiling and the dead-letter route belong to [`Worker`].
82    fn nack(&self, receipt: &str, delay: Duration) -> QueueFuture<()>;
83}
84
85/// Cloneable DI wrapper that keeps application code vendor-neutral.
86#[derive(Clone)]
87pub struct QueueClient<Adapter> {
88    adapter: Adapter,
89}
90
91impl<Adapter> QueueClient<Adapter> {
92    #[must_use]
93    pub const fn new(adapter: Adapter) -> Self {
94        Self { adapter }
95    }
96
97    #[must_use]
98    pub const fn adapter(&self) -> &Adapter {
99        &self.adapter
100    }
101}
102
103impl<Adapter: Queue> QueueClient<Adapter> {
104    pub fn publish(&self, topic: &str, message: Message) -> QueueFuture<()> {
105        self.adapter.publish(topic, message)
106    }
107
108    pub fn receive(&self, topic: &str) -> QueueFuture<Option<Delivery>> {
109        self.adapter.receive(topic)
110    }
111
112    pub fn ack(&self, receipt: &str) -> QueueFuture<()> {
113        self.adapter.ack(receipt)
114    }
115
116    pub fn nack(&self, receipt: &str, delay: Duration) -> QueueFuture<()> {
117        self.adapter.nack(receipt, delay)
118    }
119}
120
121/// Deterministic in-memory adapter for tests and local development.
122///
123/// Visibility delays run on a logical clock advanced by [`MemoryQueue::advance`]
124/// so retry behaviour is testable without a runtime timer.
125#[derive(Clone, Default)]
126pub struct MemoryQueue {
127    state: Arc<Mutex<MemoryState>>,
128}
129
130#[derive(Default)]
131struct MemoryState {
132    next_receipt: u64,
133    now: Duration,
134    topics: HashMap<String, VecDeque<PendingDelivery>>,
135    in_flight: HashMap<String, (String, Delivery)>,
136}
137
138struct PendingDelivery {
139    visible_at: Duration,
140    delivery: Delivery,
141}
142
143impl MemoryQueue {
144    /// Advances the logical clock so delayed deliveries become visible.
145    pub fn advance(&self, elapsed: Duration) {
146        let mut state = self.lock();
147        state.now = state.now.saturating_add(elapsed);
148    }
149
150    /// Returns the deliveries waiting on a topic, visible or not.
151    #[must_use]
152    pub fn depth(&self, topic: &str) -> usize {
153        self.lock().topics.get(topic).map_or(0, VecDeque::len)
154    }
155
156    /// Returns the deliveries currently checked out and unsettled.
157    #[must_use]
158    pub fn in_flight(&self) -> usize {
159        self.lock().in_flight.len()
160    }
161
162    fn lock(&self) -> std::sync::MutexGuard<'_, MemoryState> {
163        self.state
164            .lock()
165            .unwrap_or_else(std::sync::PoisonError::into_inner)
166    }
167}
168
169impl Queue for MemoryQueue {
170    fn publish(&self, topic: &str, message: Message) -> QueueFuture<()> {
171        let topic = topic.to_owned();
172        let state = Arc::clone(&self.state);
173        Box::pin(async move {
174            let mut state = state
175                .lock()
176                .unwrap_or_else(std::sync::PoisonError::into_inner);
177            state.next_receipt = state.next_receipt.wrapping_add(1);
178            let delivery = Delivery {
179                receipt: format!("memory-{}", state.next_receipt),
180                attempt: 1,
181                message,
182            };
183            let visible_at = state.now;
184            state
185                .topics
186                .entry(topic)
187                .or_default()
188                .push_back(PendingDelivery {
189                    visible_at,
190                    delivery,
191                });
192            Ok(())
193        })
194    }
195
196    fn receive(&self, topic: &str) -> QueueFuture<Option<Delivery>> {
197        let topic = topic.to_owned();
198        let state = Arc::clone(&self.state);
199        Box::pin(async move {
200            let mut state = state
201                .lock()
202                .unwrap_or_else(std::sync::PoisonError::into_inner);
203            let now = state.now;
204            let delivery = state.topics.get_mut(&topic).and_then(|pending| {
205                let index = pending
206                    .iter()
207                    .position(|candidate| candidate.visible_at <= now)?;
208                pending.remove(index).map(|entry| entry.delivery)
209            });
210            if let Some(delivery) = &delivery {
211                state
212                    .in_flight
213                    .insert(delivery.receipt.clone(), (topic, delivery.clone()));
214            }
215            Ok(delivery)
216        })
217    }
218
219    fn ack(&self, receipt: &str) -> QueueFuture<()> {
220        let receipt = receipt.to_owned();
221        let state = Arc::clone(&self.state);
222        Box::pin(async move {
223            state
224                .lock()
225                .unwrap_or_else(std::sync::PoisonError::into_inner)
226                .in_flight
227                .remove(&receipt)
228                .ok_or(QueueError::UnknownReceipt(receipt))?;
229            Ok(())
230        })
231    }
232
233    fn nack(&self, receipt: &str, delay: Duration) -> QueueFuture<()> {
234        let receipt = receipt.to_owned();
235        let state = Arc::clone(&self.state);
236        Box::pin(async move {
237            let mut state = state
238                .lock()
239                .unwrap_or_else(std::sync::PoisonError::into_inner);
240            let (topic, mut delivery) = state
241                .in_flight
242                .remove(&receipt)
243                .ok_or(QueueError::UnknownReceipt(receipt))?;
244            delivery.attempt = delivery.attempt.saturating_add(1);
245            let visible_at = state.now.saturating_add(delay);
246            state
247                .topics
248                .entry(topic)
249                .or_default()
250                .push_back(PendingDelivery {
251                    visible_at,
252                    delivery,
253                });
254            Ok(())
255        })
256    }
257}
258
259/// Failure returned by a worker job handler.
260#[derive(Clone, Debug, Eq, PartialEq)]
261pub struct JobError {
262    message: String,
263    retryable: bool,
264}
265
266impl JobError {
267    /// Creates a failure the worker retries until the policy is exhausted.
268    #[must_use]
269    pub fn retryable(message: impl Into<String>) -> Self {
270        Self {
271            message: message.into(),
272            retryable: true,
273        }
274    }
275
276    /// Creates a failure the worker dead-letters without another attempt.
277    #[must_use]
278    pub fn permanent(message: impl Into<String>) -> Self {
279        Self {
280            message: message.into(),
281            retryable: false,
282        }
283    }
284
285    /// Returns the failure message.
286    #[must_use]
287    pub fn message(&self) -> &str {
288        &self.message
289    }
290
291    /// Reports whether the worker may attempt the delivery again.
292    #[must_use]
293    pub const fn is_retryable(&self) -> bool {
294        self.retryable
295    }
296}
297
298impl fmt::Display for JobError {
299    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
300        formatter.write_str(&self.message)
301    }
302}
303
304impl std::error::Error for JobError {}
305
306/// Bounded exponential backoff applied between delivery attempts.
307#[derive(Clone, Copy, Debug, Eq, PartialEq)]
308pub struct RetryPolicy {
309    max_attempts: u32,
310    initial_backoff: Duration,
311    max_backoff: Duration,
312    multiplier: u32,
313}
314
315impl RetryPolicy {
316    /// Creates a policy that stops after `max_attempts` deliveries.
317    ///
318    /// Values below one are clamped to one, so a delivery is always attempted.
319    #[must_use]
320    pub const fn new(max_attempts: u32) -> Self {
321        Self {
322            max_attempts: if max_attempts == 0 { 1 } else { max_attempts },
323            initial_backoff: Duration::from_secs(1),
324            max_backoff: Duration::from_secs(60),
325            multiplier: 2,
326        }
327    }
328
329    /// Sets the delay applied before the second attempt.
330    #[must_use]
331    pub const fn with_initial_backoff(mut self, initial_backoff: Duration) -> Self {
332        self.initial_backoff = initial_backoff;
333        self
334    }
335
336    /// Caps how long the backoff can grow.
337    #[must_use]
338    pub const fn with_max_backoff(mut self, max_backoff: Duration) -> Self {
339        self.max_backoff = max_backoff;
340        self
341    }
342
343    /// Sets the growth factor between attempts, clamped to at least one.
344    #[must_use]
345    pub const fn with_multiplier(mut self, multiplier: u32) -> Self {
346        self.multiplier = if multiplier == 0 { 1 } else { multiplier };
347        self
348    }
349
350    /// Returns the attempt ceiling.
351    #[must_use]
352    pub const fn max_attempts(self) -> u32 {
353        self.max_attempts
354    }
355
356    /// Returns the delay applied before `attempt` is retried.
357    #[must_use]
358    pub fn backoff(self, attempt: u32) -> Duration {
359        let exponent = attempt.saturating_sub(1).min(31);
360        let factor = self.multiplier.checked_pow(exponent).unwrap_or(u32::MAX);
361        self.initial_backoff
362            .saturating_mul(factor)
363            .min(self.max_backoff)
364    }
365
366    /// Reports whether `attempt` has reached the ceiling.
367    #[must_use]
368    pub const fn is_exhausted(self, attempt: u32) -> bool {
369        attempt >= self.max_attempts
370    }
371}
372
373impl Default for RetryPolicy {
374    fn default() -> Self {
375        Self::new(5)
376    }
377}
378
379/// Destination for deliveries that exhausted their attempts.
380#[derive(Clone, Debug, Default, Eq, PartialEq)]
381pub enum DeadLetter {
382    /// Republish the message to another topic on the same queue.
383    Topic(String),
384    /// Acknowledge and drop the message.
385    #[default]
386    Discard,
387}
388
389/// Outcome of one [`Worker::step`] cycle.
390#[derive(Clone, Debug, Eq, PartialEq)]
391pub enum WorkerStep {
392    /// No delivery was visible on the topic.
393    Idle,
394    /// The handler succeeded and the delivery was acknowledged.
395    Completed {
396        /// Receipt of the settled delivery.
397        receipt: String,
398        /// Attempt number that succeeded.
399        attempt: u32,
400    },
401    /// The handler failed and the delivery was scheduled for another attempt.
402    Retried {
403        /// Receipt of the returned delivery.
404        receipt: String,
405        /// Attempt number that failed.
406        attempt: u32,
407        /// Delay applied before the delivery becomes visible again.
408        delay: Duration,
409    },
410    /// The delivery reached the dead-letter destination.
411    DeadLettered {
412        /// Receipt of the settled delivery.
413        receipt: String,
414        /// Attempt number that failed last.
415        attempt: u32,
416        /// Failure that ended the delivery.
417        reason: JobError,
418    },
419}
420
421/// Runtime-neutral consumer loop over one [`Queue`] topic.
422///
423/// The worker owns the retry ceiling, the exponential backoff, and the
424/// dead-letter route; the adapter only has to honour the visibility delay
425/// passed to [`Queue::nack`]. It never spawns a thread or reads a clock, so it
426/// runs unchanged on any executor.
427pub struct Worker<Adapter, Handler> {
428    queue: Adapter,
429    topic: String,
430    handler: Handler,
431    retry: RetryPolicy,
432    dead_letter: DeadLetter,
433    idle_backoff: Duration,
434}
435
436impl<Adapter, Handler> Worker<Adapter, Handler> {
437    /// Creates a worker that consumes `topic` with `handler`.
438    #[must_use]
439    pub fn new(queue: Adapter, topic: impl Into<String>, handler: Handler) -> Self {
440        Self {
441            queue,
442            topic: topic.into(),
443            handler,
444            retry: RetryPolicy::default(),
445            dead_letter: DeadLetter::Discard,
446            idle_backoff: Duration::from_millis(100),
447        }
448    }
449
450    /// Replaces the retry policy.
451    #[must_use]
452    pub const fn with_retry(mut self, retry: RetryPolicy) -> Self {
453        self.retry = retry;
454        self
455    }
456
457    /// Replaces the dead-letter destination.
458    #[must_use]
459    pub fn with_dead_letter(mut self, dead_letter: DeadLetter) -> Self {
460        self.dead_letter = dead_letter;
461        self
462    }
463
464    /// Sets how long [`Worker::run`] waits after an empty poll.
465    #[must_use]
466    pub const fn with_idle_backoff(mut self, idle_backoff: Duration) -> Self {
467        self.idle_backoff = idle_backoff;
468        self
469    }
470
471    /// Returns the consumed topic.
472    #[must_use]
473    pub fn topic(&self) -> &str {
474        &self.topic
475    }
476}
477
478impl<Adapter, Handler, HandlerFuture> Worker<Adapter, Handler>
479where
480    Adapter: Queue,
481    Handler: Fn(Delivery) -> HandlerFuture + Send + Sync,
482    HandlerFuture: Future<Output = Result<(), JobError>> + Send,
483{
484    /// Runs one receive, dispatch, and settle cycle.
485    ///
486    /// # Errors
487    ///
488    /// Returns the adapter failure raised by receive, ack, nack, or the
489    /// dead-letter publish. A handler failure is not an error: it is settled by
490    /// the retry policy and reported in the [`WorkerStep`].
491    pub async fn step(&self) -> Result<WorkerStep, QueueError> {
492        let Some(delivery) = self.queue.receive(&self.topic).await? else {
493            return Ok(WorkerStep::Idle);
494        };
495        let receipt = delivery.receipt.clone();
496        let attempt = delivery.attempt;
497        let message = delivery.message.clone();
498        let Err(reason) = (self.handler)(delivery).await else {
499            self.queue.ack(&receipt).await?;
500            return Ok(WorkerStep::Completed { receipt, attempt });
501        };
502        if reason.is_retryable() && !self.retry.is_exhausted(attempt) {
503            let delay = self.retry.backoff(attempt);
504            self.queue.nack(&receipt, delay).await?;
505            return Ok(WorkerStep::Retried {
506                receipt,
507                attempt,
508                delay,
509            });
510        }
511        self.route_dead_letter(&receipt, attempt, message, &reason)
512            .await?;
513        Ok(WorkerStep::DeadLettered {
514            receipt,
515            attempt,
516            reason,
517        })
518    }
519
520    /// Runs cycles until the adapter fails, awaiting `sleep` on an empty poll.
521    ///
522    /// The caller owns the loop's lifetime and its timer: race this future
523    /// against a shutdown signal from its own runtime.
524    ///
525    /// # Errors
526    ///
527    /// Returns the first adapter failure raised by a cycle.
528    pub async fn run<Sleep, SleepFuture>(&self, sleep: Sleep) -> Result<(), QueueError>
529    where
530        Sleep: Fn(Duration) -> SleepFuture,
531        SleepFuture: Future<Output = ()>,
532    {
533        loop {
534            if self.step().await? == WorkerStep::Idle {
535                sleep(self.idle_backoff).await;
536            }
537        }
538    }
539
540    async fn route_dead_letter(
541        &self,
542        receipt: &str,
543        attempt: u32,
544        message: Message,
545        reason: &JobError,
546    ) -> Result<(), QueueError> {
547        if let DeadLetter::Topic(destination) = &self.dead_letter {
548            let message = message
549                .with_header(DEAD_LETTER_SOURCE_HEADER, self.topic.clone())
550                .with_header(DEAD_LETTER_ATTEMPT_HEADER, attempt.to_string())
551                .with_header(DEAD_LETTER_REASON_HEADER, reason.message());
552            self.queue.publish(destination, message).await?;
553        }
554        self.queue.ack(receipt).await
555    }
556}
557
558#[derive(Clone, Debug, Eq, PartialEq)]
559pub enum QueueError {
560    Unavailable(String),
561    UnknownReceipt(String),
562}
563
564impl fmt::Display for QueueError {
565    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
566        match self {
567            Self::Unavailable(message) => write!(formatter, "queue unavailable: {message}"),
568            Self::UnknownReceipt(receipt) => write!(formatter, "unknown receipt `{receipt}`"),
569        }
570    }
571}
572
573impl std::error::Error for QueueError {}
574
575#[cfg(test)]
576mod tests {
577    use super::*;
578    use futures_lite::future;
579    use std::cell::Cell;
580
581    fn worker_queue() -> MemoryQueue {
582        let queue = MemoryQueue::default();
583        future::block_on(queue.publish("jobs", Message::new("one"))).expect("publish");
584        queue
585    }
586
587    #[test]
588    fn memory_adapter_redelivers_nacked_messages_after_the_delay() {
589        let queue = QueueClient::new(MemoryQueue::default());
590        future::block_on(queue.publish("jobs", Message::new("one"))).expect("publish");
591        let first = future::block_on(queue.receive("jobs"))
592            .expect("receive")
593            .expect("delivery");
594        future::block_on(queue.nack(&first.receipt, Duration::from_secs(5))).expect("nack");
595        assert!(
596            future::block_on(queue.receive("jobs"))
597                .expect("receive")
598                .is_none()
599        );
600        queue.adapter().advance(Duration::from_secs(5));
601        let second = future::block_on(queue.receive("jobs"))
602            .expect("receive")
603            .expect("redelivery");
604        assert_eq!(second.attempt, 2);
605        assert_eq!(second.message.body, b"one");
606        future::block_on(queue.ack(&second.receipt)).expect("ack");
607        assert_eq!(queue.adapter().in_flight(), 0);
608    }
609
610    #[test]
611    fn unknown_receipts_are_rejected() {
612        let queue = MemoryQueue::default();
613        assert_eq!(
614            future::block_on(queue.nack("missing", Duration::ZERO)),
615            Err(QueueError::UnknownReceipt("missing".to_owned()))
616        );
617        assert_eq!(
618            future::block_on(queue.ack("missing")),
619            Err(QueueError::UnknownReceipt("missing".to_owned()))
620        );
621    }
622
623    #[test]
624    fn worker_acknowledges_successful_deliveries() {
625        let queue = worker_queue();
626        let worker = Worker::new(queue.clone(), "jobs", |_delivery| async { Ok(()) });
627        let step = future::block_on(worker.step()).expect("step");
628        assert!(matches!(step, WorkerStep::Completed { attempt: 1, .. }));
629        assert_eq!(queue.depth("jobs"), 0);
630        assert_eq!(queue.in_flight(), 0);
631        assert_eq!(
632            future::block_on(worker.step()).expect("step"),
633            WorkerStep::Idle
634        );
635    }
636
637    #[test]
638    fn worker_retries_with_exponential_backoff_then_dead_letters() {
639        let queue = worker_queue();
640        let worker = Worker::new(queue.clone(), "jobs", |_delivery| async {
641            Err(JobError::retryable("boom"))
642        })
643        .with_retry(
644            RetryPolicy::new(3)
645                .with_initial_backoff(Duration::from_secs(1))
646                .with_max_backoff(Duration::from_secs(4)),
647        )
648        .with_dead_letter(DeadLetter::Topic("jobs.dead".to_owned()));
649
650        let first = future::block_on(worker.step()).expect("first attempt");
651        assert_eq!(
652            first,
653            WorkerStep::Retried {
654                receipt: "memory-1".to_owned(),
655                attempt: 1,
656                delay: Duration::from_secs(1),
657            }
658        );
659        assert_eq!(
660            future::block_on(worker.step()).expect("step"),
661            WorkerStep::Idle
662        );
663
664        queue.advance(Duration::from_secs(1));
665        let second = future::block_on(worker.step()).expect("second attempt");
666        assert_eq!(
667            second,
668            WorkerStep::Retried {
669                receipt: "memory-1".to_owned(),
670                attempt: 2,
671                delay: Duration::from_secs(2),
672            }
673        );
674
675        queue.advance(Duration::from_secs(2));
676        let third = future::block_on(worker.step()).expect("third attempt");
677        assert_eq!(
678            third,
679            WorkerStep::DeadLettered {
680                receipt: "memory-1".to_owned(),
681                attempt: 3,
682                reason: JobError::retryable("boom"),
683            }
684        );
685        assert_eq!(queue.depth("jobs"), 0);
686        assert_eq!(queue.in_flight(), 0);
687
688        let dead = future::block_on(queue.receive("jobs.dead"))
689            .expect("receive")
690            .expect("dead letter");
691        assert_eq!(dead.message.body, b"one");
692        assert_eq!(
693            dead.message.headers.get(DEAD_LETTER_SOURCE_HEADER),
694            Some(&"jobs".to_owned())
695        );
696        assert_eq!(
697            dead.message.headers.get(DEAD_LETTER_ATTEMPT_HEADER),
698            Some(&"3".to_owned())
699        );
700        assert_eq!(
701            dead.message.headers.get(DEAD_LETTER_REASON_HEADER),
702            Some(&"boom".to_owned())
703        );
704    }
705
706    #[test]
707    fn permanent_failures_skip_the_retry_budget() {
708        let queue = worker_queue();
709        let worker = Worker::new(queue.clone(), "jobs", |_delivery| async {
710            Err(JobError::permanent("poison"))
711        })
712        .with_retry(RetryPolicy::new(10))
713        .with_dead_letter(DeadLetter::Topic("jobs.dead".to_owned()));
714        let step = future::block_on(worker.step()).expect("step");
715        assert!(matches!(step, WorkerStep::DeadLettered { attempt: 1, .. }));
716        assert_eq!(queue.depth("jobs"), 0);
717        assert_eq!(queue.depth("jobs.dead"), 1);
718    }
719
720    #[test]
721    fn discarded_dead_letters_are_acknowledged() {
722        let queue = worker_queue();
723        let worker = Worker::new(queue.clone(), "jobs", |_delivery| async {
724            Err(JobError::permanent("poison"))
725        })
726        .with_retry(RetryPolicy::new(1));
727        assert_eq!(worker.topic(), "jobs");
728        future::block_on(worker.step()).expect("step");
729        assert_eq!(queue.depth("jobs"), 0);
730        assert_eq!(queue.in_flight(), 0);
731    }
732
733    #[derive(Clone, Default)]
734    struct FlakyQueue {
735        polls: Arc<Mutex<u32>>,
736    }
737
738    impl Queue for FlakyQueue {
739        fn publish(&self, _topic: &str, _message: Message) -> QueueFuture<()> {
740            Box::pin(async { Ok(()) })
741        }
742
743        fn receive(&self, _topic: &str) -> QueueFuture<Option<Delivery>> {
744            let polls = Arc::clone(&self.polls);
745            Box::pin(async move {
746                let mut polls = polls
747                    .lock()
748                    .unwrap_or_else(std::sync::PoisonError::into_inner);
749                *polls += 1;
750                if *polls > 2 {
751                    return Err(QueueError::Unavailable("closed".to_owned()));
752                }
753                Ok(None)
754            })
755        }
756
757        fn ack(&self, _receipt: &str) -> QueueFuture<()> {
758            Box::pin(async { Ok(()) })
759        }
760
761        fn nack(&self, _receipt: &str, _delay: Duration) -> QueueFuture<()> {
762            Box::pin(async { Ok(()) })
763        }
764    }
765
766    #[test]
767    fn run_idles_between_empty_polls_and_reports_adapter_failures() {
768        let worker = Worker::new(FlakyQueue::default(), "jobs", |_delivery| async { Ok(()) })
769            .with_idle_backoff(Duration::from_millis(5));
770        let idles = Cell::new(0_u32);
771        let error = future::block_on(worker.run(|delay| {
772            assert_eq!(delay, Duration::from_millis(5));
773            idles.set(idles.get() + 1);
774            async {}
775        }))
776        .expect_err("adapter failure");
777        assert_eq!(error, QueueError::Unavailable("closed".to_owned()));
778        assert_eq!(idles.get(), 2);
779    }
780
781    #[test]
782    fn backoff_grows_and_saturates() {
783        let policy = RetryPolicy::new(4)
784            .with_initial_backoff(Duration::from_millis(100))
785            .with_max_backoff(Duration::from_secs(1))
786            .with_multiplier(3);
787        assert_eq!(policy.backoff(1), Duration::from_millis(100));
788        assert_eq!(policy.backoff(2), Duration::from_millis(300));
789        assert_eq!(policy.backoff(3), Duration::from_millis(900));
790        assert_eq!(policy.backoff(4), Duration::from_secs(1));
791        assert_eq!(policy.backoff(u32::MAX), Duration::from_secs(1));
792        assert_eq!(policy.max_attempts(), 4);
793        assert!(policy.is_exhausted(4));
794        assert!(!policy.is_exhausted(3));
795        assert_eq!(RetryPolicy::new(0).max_attempts(), 1);
796        assert_eq!(
797            RetryPolicy::new(2).with_multiplier(0).backoff(3),
798            Duration::from_secs(1)
799        );
800    }
801}