Skip to main content

queuey_rabbitmq/
topology.rs

1//! Pure helpers describing the RabbitMQ topology used by this backend.
2//!
3//! For a logical queue `q` the backend maintains two long-lived broker queues
4//! plus one short-lived *hold* queue per distinct delay:
5//!
6//! | queue | role | arguments |
7//! |---|---|---|
8//! | `q` | main work queue | `x-message-ttl` when [`QueueConfig::message_ttl`] is set, `x-max-priority` when [`QueueConfig::max_priority`] is `Some` |
9//! | `q.dead` | dead-letter queue | none |
10//! | `q.deferred.{ttl_ms}` | hold queue for one delay | `x-message-ttl = ttl_ms`, `x-dead-letter-exchange = ""`, `x-dead-letter-routing-key = q`, `x-expires = 2 * ttl_ms` |
11//!
12//! Every wait, whether a retry backoff, a delayed enqueue or a deferral, goes
13//! through a hold queue. There is no shared wait queue with per-message
14//! expirations, and this is why:
15//!
16//! # Why hold queues instead of per-message `expiration`
17//!
18//! A classic queue only ever expires the message at its *head*. Messages behind
19//! it are not examined until it has gone, so in a shared wait queue a message
20//! with a five-minute expiration at the head holds back every one-second
21//! expiration behind it (head-of-line blocking). Exponential backoff produces
22//! exactly that mix, so retries were the worst-hit case.
23//!
24//! A hold queue never sets a per-message `expiration`. Instead the delay is
25//! baked into the *name* of the queue the message waits in (`q.deferred.30000`
26//! holds every 30-second wait for `q`), and the wait is the queue-wide
27//! `x-message-ttl`. Every message in one hold queue therefore has the same TTL,
28//! so they expire in exactly the order they were published and the head is
29//! always the message that is due next. A short wait can never be stuck behind a
30//! long one, because the two live in different queues. The price is one queue
31//! per distinct delay, which is why delays are rounded up to a granularity:
32//! [`RabbitMqOptions::retry_granularity`](crate::RabbitMqOptions::retry_granularity)
33//! for retries and delayed enqueues,
34//! [`RabbitMqOptions::deferred_granularity`](crate::RabbitMqOptions::deferred_granularity)
35//! for deferrals (both default to one second), so `29.2s` and `30s` share
36//! `q.deferred.30000`.
37//!
38//! Retries and deferrals share the hold queues: the arguments depend only on the
39//! delay, and what differs between the two, the message priority, travels on the
40//! message and only matters once it is back on `q`. A retry returns at priority
41//! `0` and joins the back of the queue; a deferral returns at the queue's top
42//! priority and overtakes the backlog.
43//!
44//! # Why a hold queue's arguments depend only on its name
45//!
46//! The TTL is in the name, and every other argument is derived from it or from
47//! the main queue, so two processes running different builds of an application
48//! compute the *same* arguments for `q.deferred.30000`. That matters because
49//! RabbitMQ refuses a declaration whose arguments differ from the existing
50//! queue's (`PRECONDITION_FAILED`, which closes the declaring channel): a
51//! tunable in `x-expires` would deadlock the two processes against each other
52//! for ever, one of them unable to schedule a single wait. Hence `x-expires = 2 * ttl_ms`
53//! and nothing else: an idle hold queue deletes itself one TTL after the last
54//! publish to it, and every declare resets that timer, which is why the
55//! queue is redeclared before every publish.
56//!
57//! `x-expires` must also be strictly greater than `x-message-ttl`, or the broker
58//! could delete a queue that still owes a message. `2 * ttl_ms` satisfies that
59//! for every TTL up to [`MAX_DEFERRAL_MS`], which is why a longer delay is
60//! refused rather than silently clamped.
61//!
62//! Everything in this module is pure: it never touches a connection, so it can
63//! be unit-tested without a broker.
64
65use std::time::Duration;
66
67use lapin::{
68    options::QueueDeclareOptions,
69    types::{AMQPValue, FieldTable, LongString},
70};
71use queuey_core::QueueConfig;
72
73/// Default suffix appended to a queue name to build its dead-letter queue.
74pub const DEFAULT_DEAD_SUFFIX: &str = ".dead";
75
76/// Default infix between a queue name and a hold queue's TTL.
77///
78/// A hold queue is named `{q}{suffix}.{ttl_ms}`, so with the default a 30-second
79/// wait (retry or deferral) of `myapp.emails` happens in
80/// `myapp.emails.deferred.30000`. The name says "deferred" because deferrals
81/// were the first user; retries and delayed enqueues share the very same queues.
82pub const DEFAULT_DEFERRED_SUFFIX: &str = ".deferred";
83
84/// Header carrying the dead-lettering reason on messages routed to `q.dead`.
85pub const HEADER_DEATH_REASON: &str = "x-death-reason";
86
87/// Header carrying the originating queue name on messages routed to `q.dead`.
88pub const HEADER_ORIGINAL_QUEUE: &str = "x-original-queue";
89
90/// Header carrying the number of attempts made before dead-lettering.
91pub const HEADER_ATTEMPTS: &str = "x-attempts";
92
93/// Header mirroring [`queuey_core::Envelope::attempt`] on every publish.
94pub const HEADER_ATTEMPT: &str = "x-attempt";
95
96/// Header mirroring [`queuey_core::Envelope::deferrals`] on every publish.
97///
98/// Purely informational, like [`HEADER_ATTEMPT`]: the body is the source of
99/// truth. In particular the `x-death` header RabbitMQ stamps on a message it
100/// expired out of a hold queue is ignored.
101pub const HEADER_DEFERRALS: &str = "x-deferrals";
102
103/// Queue argument naming the exchange used for dead-lettering.
104pub const ARG_DEAD_LETTER_EXCHANGE: &str = "x-dead-letter-exchange";
105
106/// Queue argument naming the routing key used for dead-lettering.
107pub const ARG_DEAD_LETTER_ROUTING_KEY: &str = "x-dead-letter-routing-key";
108
109/// Queue argument setting a queue-wide message time-to-live in milliseconds.
110pub const ARG_MESSAGE_TTL: &str = "x-message-ttl";
111
112/// Queue argument declaring how many priority levels a queue supports.
113///
114/// Set on the main queue `q` from [`QueueConfig::max_priority`]. Never set on
115/// `q.dead` or a hold queue: those are strictly FIFO holding pens and a priority
116/// queue costs the broker an index per level.
117pub const ARG_MAX_PRIORITY: &str = "x-max-priority";
118
119/// Queue argument making the broker delete a queue after it has been unused for
120/// that many milliseconds.
121///
122/// Only used for hold queues, so a delay that is never used again does not leave
123/// an empty queue behind for ever.
124pub const ARG_EXPIRES: &str = "x-expires";
125
126/// Largest time-to-live RabbitMQ accepts, in milliseconds (~49.7 days).
127///
128/// `x-message-ttl` and `x-expires` are parsed as unsigned 32-bit millisecond
129/// counts. A larger value is refused with `PRECONDITION_FAILED`, which closes
130/// the channel, so this crate clamps a queue TTL rather than letting a huge
131/// [`Duration`] take the channel down.
132pub const MAX_TTL_MS: u32 = u32::MAX;
133
134/// Longest delay this backend will hold, in milliseconds (~24.8 days).
135///
136/// Applies to every wait that goes through a hold queue: retry backoffs, delayed
137/// enqueues and deferrals alike.
138///
139/// A hold queue is declared with `x-expires = 2 * x-message-ttl` (see the module
140/// docs for why the factor is fixed rather than configurable), and `x-expires`
141/// has the same 32-bit millisecond domain as `x-message-ttl`. Half of
142/// [`MAX_TTL_MS`] is therefore the largest TTL for which the doubled expiry
143/// still fits and still stays strictly above the TTL, so the broker can never
144/// delete a hold queue that still owes a message.
145///
146/// A longer delay is **refused**, not clamped: clamping would release the job
147/// early, and "never early" is the one timing guarantee a hold makes. See
148/// [`deferred_ttl_ms`].
149pub const MAX_DEFERRAL_MS: u32 = MAX_TTL_MS / 2;
150
151/// Name of the dead-letter queue for `queue`.
152///
153/// ```
154/// use queuey_rabbitmq::topology::{dead_queue_name, DEFAULT_DEAD_SUFFIX};
155/// assert_eq!(dead_queue_name("myapp.emails", DEFAULT_DEAD_SUFFIX), "myapp.emails.dead");
156/// ```
157#[must_use]
158pub fn dead_queue_name(queue: &str, suffix: &str) -> String {
159    format!("{queue}{suffix}")
160}
161
162/// Name of the hold queue holding `ttl_ms`-long waits of `queue`.
163///
164/// The TTL is part of the name on purpose: one queue per distinct delay is what
165/// makes a hold queue drain strictly in order (see the module docs). Retries and
166/// deferrals with the same rounded delay share the queue.
167///
168/// ```
169/// use queuey_rabbitmq::topology::{deferred_queue_name, DEFAULT_DEFERRED_SUFFIX};
170/// assert_eq!(
171///     deferred_queue_name("myapp.emails", DEFAULT_DEFERRED_SUFFIX, 30_000),
172///     "myapp.emails.deferred.30000"
173/// );
174/// ```
175#[must_use]
176pub fn deferred_queue_name(queue: &str, suffix: &str, ttl_ms: u32) -> String {
177    format!("{queue}{suffix}.{ttl_ms}")
178}
179
180/// The hold queue TTL for `delay`: `delay` rounded **up** to a whole multiple of
181/// `granularity`, at least one whole step, or [`None`] when that lands past
182/// [`MAX_DEFERRAL_MS`].
183///
184/// Rounding up is what bounds the number of hold queues: with the default
185/// one-second granularity every delay between `29.001s` and `30s` maps to
186/// `30000`, so a burst of `Retry-After: 30` responses shares one queue, and a
187/// jittered exponential backoff creates at most one queue per whole second of
188/// its range. Rounding *up* rather than to nearest guarantees the job never
189/// comes back early, and [`None`] rather than a clamp at the top end is the same
190/// guarantee: a delay this backend cannot hold is refused, never shortened. The
191/// rounding itself can push a delay over the cap, so a `delay` just under
192/// [`MAX_DEFERRAL_MS`] may still be refused.
193///
194/// `granularity` is itself clamped to `[1ms, MAX_DEFERRAL_MS]`, so a zero or
195/// absurd granularity is corrected rather than causing a division by zero or an
196/// argument RabbitMQ would refuse. Library code must never panic on a value that
197/// merely came out of a config file.
198///
199/// ```
200/// use std::time::Duration;
201/// use queuey_rabbitmq::topology::deferred_ttl_ms;
202///
203/// let second = Duration::from_secs(1);
204/// assert_eq!(deferred_ttl_ms(Duration::from_secs(30), second), Some(30_000));
205/// assert_eq!(deferred_ttl_ms(Duration::from_millis(29_200), second), Some(30_000));
206/// assert_eq!(deferred_ttl_ms(Duration::from_millis(5), second), Some(1_000));
207/// // 30 days is past what a hold queue can express.
208/// assert_eq!(deferred_ttl_ms(Duration::from_secs(30 * 86_400), second), None);
209/// ```
210#[must_use]
211pub fn deferred_ttl_ms(delay: Duration, granularity: Duration) -> Option<u32> {
212    let max = u128::from(MAX_DEFERRAL_MS);
213    let step = millis_ceil(granularity).clamp(1, max);
214    // `step <= max`, so a delay of zero still waits one whole step.
215    let ttl = millis_ceil(delay)
216        .div_ceil(step)
217        .saturating_mul(step)
218        .max(step);
219    (ttl <= max).then(|| u32::try_from(ttl).unwrap_or(MAX_DEFERRAL_MS))
220}
221
222/// Declaration arguments for the main queue `q`.
223///
224/// * `x-message-ttl` when [`QueueConfig::message_ttl`] is set (in whole
225///   milliseconds, rounded up, at least 1).
226/// * `x-max-priority` when [`QueueConfig::max_priority`] is `Some`, so deferred
227///   jobs can come back ahead of the backlog.
228///
229/// Both are *declaration* arguments, which RabbitMQ refuses to change on an
230/// existing queue (`PRECONDITION_FAILED`, which closes the declaring channel).
231/// Adding `x-max-priority` to a queue that predates this feature therefore
232/// requires deleting the queue or setting `max_priority = 0`.
233#[must_use]
234pub fn queue_args(config: &QueueConfig) -> FieldTable {
235    let mut args = FieldTable::default();
236    if let Some(ttl) = config.message_ttl {
237        args.insert(
238            ARG_MESSAGE_TTL.into(),
239            AMQPValue::LongLongInt(ttl_millis(ttl)),
240        );
241    }
242    if let Some(levels) = config.max_priority {
243        // `x-max-priority` is validated by RabbitMQ as an integer of any width;
244        // an unsigned byte is the exact domain of `QueueConfig::max_priority`.
245        args.insert(ARG_MAX_PRIORITY.into(), AMQPValue::ShortShortUInt(levels));
246    }
247    args
248}
249
250/// Declaration arguments for the hold queue `q.deferred.{ttl_ms}`.
251///
252/// * `x-message-ttl = ttl_ms`: the delay itself, queue-wide, so the queue
253///   drains in publish order.
254/// * `x-dead-letter-exchange = ""` plus `x-dead-letter-routing-key = q`: an
255///   expired message goes straight back onto the main queue.
256/// * `x-expires = 2 * ttl_ms`: an idle hold queue deletes itself one TTL after
257///   the last publish to it, so a delay that never recurs leaves nothing
258///   behind. Every declare resets that timer, which is why the queue is
259///   redeclared before every publish.
260///
261/// The arguments are a pure function of the hold queue's *name*: `ttl_ms` is in
262/// the name, and the routing key and durability come from `config`, which is the
263/// main queue the name is derived from. Nothing here is tunable, on purpose:
264/// two processes with different settings would compute different arguments for
265/// the same queue name and lock each other out with `PRECONDITION_FAILED`
266/// for ever. See the module docs.
267///
268/// Never carries `x-max-priority`: a hold queue must stay FIFO, and the priority
269/// only matters once the message is back on `q`.
270#[must_use]
271pub fn deferred_queue_args(config: &QueueConfig, ttl_ms: u32) -> FieldTable {
272    let mut args = FieldTable::default();
273    args.insert(
274        ARG_MESSAGE_TTL.into(),
275        AMQPValue::LongLongInt(ttl_ms.into()),
276    );
277    args.insert(
278        ARG_DEAD_LETTER_EXCHANGE.into(),
279        AMQPValue::LongString(LongString::from("")),
280    );
281    args.insert(
282        ARG_DEAD_LETTER_ROUTING_KEY.into(),
283        AMQPValue::LongString(LongString::from(config.name.as_str())),
284    );
285    // `ttl_ms` comes from `deferred_ttl_ms`, so it is at most `MAX_DEFERRAL_MS`
286    // and the double fits. The clamp is for a hand-rolled call with a bigger
287    // value: an `x-expires` past `u32::MAX` is a `PRECONDITION_FAILED`, not a
288    // long timeout.
289    let expires = u64::from(ttl_ms)
290        .saturating_mul(2)
291        .min(u64::from(MAX_TTL_MS));
292    args.insert(
293        ARG_EXPIRES.into(),
294        AMQPValue::LongLongInt(i64::try_from(expires).unwrap_or(i64::from(MAX_TTL_MS))),
295    );
296    args
297}
298
299/// Declaration options for a queue that is never exclusive or auto-deleted.
300///
301/// Shared by the backend's own declarations and by the on-demand hold queue
302/// declaration on the declaration channel, so the two can never disagree about
303/// the flags and provoke a `PRECONDITION_FAILED`.
304pub(crate) fn declare_options(durable: bool) -> QueueDeclareOptions {
305    QueueDeclareOptions {
306        passive: false,
307        durable,
308        exclusive: false,
309        auto_delete: false,
310        nowait: false,
311    }
312}
313
314/// Declaration arguments for the dead-letter queue `q.dead`.
315///
316/// Deliberately empty: dead-lettered messages are terminal and must not expire
317/// or be routed onwards without an operator looking at them.
318#[must_use]
319pub fn dead_queue_args(_config: &QueueConfig) -> FieldTable {
320    FieldTable::default()
321}
322
323/// Whole milliseconds for `ttl`, rounded up, clamped to `[1, MAX_TTL_MS]`.
324///
325/// The upper bound is RabbitMQ's, not this crate's: a TTL past `u32::MAX` ms is
326/// answered with `PRECONDITION_FAILED` and closes the declaring channel.
327fn ttl_millis(ttl: Duration) -> i64 {
328    let ms = ttl
329        .as_nanos()
330        .div_ceil(1_000_000)
331        .clamp(1, u128::from(MAX_TTL_MS));
332    i64::try_from(ms).unwrap_or(i64::from(MAX_TTL_MS))
333}
334
335/// Whole milliseconds in `duration`, rounded up. Unclamped, so callers can
336/// decide what their own bounds are.
337fn millis_ceil(duration: Duration) -> u128 {
338    duration.as_nanos().div_ceil(1_000_000)
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    fn config(name: &str) -> QueueConfig {
346        QueueConfig::new(name)
347    }
348
349    #[test]
350    fn dead_name_without_prefix() {
351        assert_eq!(
352            dead_queue_name("emails", DEFAULT_DEAD_SUFFIX),
353            "emails.dead"
354        );
355    }
356
357    #[test]
358    fn dead_name_with_prefix() {
359        assert_eq!(
360            dead_queue_name("myapp.emails", DEFAULT_DEAD_SUFFIX),
361            "myapp.emails.dead"
362        );
363    }
364
365    #[test]
366    fn custom_suffixes_are_honoured() {
367        assert_eq!(dead_queue_name("q", "-dlq"), "q-dlq");
368    }
369
370    /// `QueueConfig::new` is a priority queue by default; most of the existing
371    /// argument assertions predate that and only care about the TTL.
372    fn plain(name: &str) -> QueueConfig {
373        QueueConfig::new(name).max_priority(0)
374    }
375
376    #[test]
377    fn queue_args_are_empty_without_ttl_or_priorities() {
378        let args = queue_args(&plain("emails"));
379        assert!(args.inner().is_empty(), "unexpected args: {args:?}");
380        assert!(!args.contains_key(ARG_MESSAGE_TTL));
381        assert!(!args.contains_key(ARG_MAX_PRIORITY));
382    }
383
384    #[test]
385    fn queue_args_carry_message_ttl() {
386        let cfg = plain("emails").message_ttl(Duration::from_secs(30));
387        let args = queue_args(&cfg);
388        assert_eq!(
389            args.inner().get(ARG_MESSAGE_TTL),
390            Some(&AMQPValue::LongLongInt(30_000))
391        );
392        assert_eq!(args.inner().len(), 1);
393    }
394
395    #[test]
396    fn queue_args_carry_max_priority_when_the_queue_has_priorities() {
397        // The `QueueConfig` default is 10 levels.
398        let args = queue_args(&config("emails"));
399        assert_eq!(
400            args.inner().get(ARG_MAX_PRIORITY),
401            Some(&AMQPValue::ShortShortUInt(10))
402        );
403        assert_eq!(args.inner().len(), 1);
404
405        let args = queue_args(&config("emails").max_priority(255));
406        assert_eq!(
407            args.inner().get(ARG_MAX_PRIORITY),
408            Some(&AMQPValue::ShortShortUInt(255))
409        );
410    }
411
412    #[test]
413    fn queue_args_omit_max_priority_when_priorities_are_off() {
414        let cfg = config("emails").max_priority(0);
415        assert_eq!(cfg.max_priority, None);
416        assert!(!queue_args(&cfg).contains_key(ARG_MAX_PRIORITY));
417    }
418
419    #[test]
420    fn queue_args_carry_ttl_and_priority_together() {
421        let cfg = config("emails")
422            .message_ttl(Duration::from_secs(5))
423            .max_priority(3);
424        let args = queue_args(&cfg);
425        assert_eq!(
426            args.inner().get(ARG_MESSAGE_TTL),
427            Some(&AMQPValue::LongLongInt(5_000))
428        );
429        assert_eq!(
430            args.inner().get(ARG_MAX_PRIORITY),
431            Some(&AMQPValue::ShortShortUInt(3))
432        );
433        assert_eq!(args.inner().len(), 2);
434    }
435
436    #[test]
437    fn only_the_main_queue_gets_priorities() {
438        let cfg = config("emails");
439        assert!(!dead_queue_args(&cfg).contains_key(ARG_MAX_PRIORITY));
440        assert!(!deferred_queue_args(&cfg, 1_000).contains_key(ARG_MAX_PRIORITY));
441    }
442
443    #[test]
444    fn sub_millisecond_ttl_rounds_up_to_one() {
445        let cfg = config("emails").message_ttl(Duration::from_nanos(1));
446        let args = queue_args(&cfg);
447        assert_eq!(
448            args.inner().get(ARG_MESSAGE_TTL),
449            Some(&AMQPValue::LongLongInt(1))
450        );
451    }
452
453    #[test]
454    fn zero_ttl_is_clamped_to_one_millisecond() {
455        let cfg = config("emails").message_ttl(Duration::ZERO);
456        let args = queue_args(&cfg);
457        assert_eq!(
458            args.inner().get(ARG_MESSAGE_TTL),
459            Some(&AMQPValue::LongLongInt(1))
460        );
461    }
462
463    #[test]
464    fn huge_ttl_is_clamped_to_what_rabbitmq_accepts() {
465        // Not `i64::MAX`: RabbitMQ rejects anything past `u32::MAX` ms with
466        // PRECONDITION_FAILED and closes the declaring channel.
467        let cfg = config("emails").message_ttl(Duration::MAX);
468        let args = queue_args(&cfg);
469        assert_eq!(
470            args.inner().get(ARG_MESSAGE_TTL),
471            Some(&AMQPValue::LongLongInt(i64::from(MAX_TTL_MS)))
472        );
473    }
474
475    #[test]
476    fn ttl_exactly_at_the_limit_is_kept_verbatim() {
477        let cfg = config("emails").message_ttl(Duration::from_millis(u64::from(MAX_TTL_MS)));
478        let args = queue_args(&cfg);
479        assert_eq!(
480            args.inner().get(ARG_MESSAGE_TTL),
481            Some(&AMQPValue::LongLongInt(i64::from(MAX_TTL_MS)))
482        );
483    }
484
485    #[test]
486    fn ttl_one_millisecond_past_the_limit_is_clamped() {
487        let cfg = config("emails").message_ttl(Duration::from_millis(u64::from(MAX_TTL_MS) + 1));
488        let args = queue_args(&cfg);
489        assert_eq!(
490            args.inner().get(ARG_MESSAGE_TTL),
491            Some(&AMQPValue::LongLongInt(i64::from(MAX_TTL_MS)))
492        );
493    }
494
495    #[test]
496    fn dead_args_are_empty() {
497        assert!(dead_queue_args(&config("emails")).inner().is_empty());
498    }
499
500    // -- deferral ----------------------------------------------------------
501
502    const SECOND: Duration = Duration::from_secs(1);
503
504    #[test]
505    fn deferred_name_puts_the_ttl_in_the_queue_name() {
506        assert_eq!(
507            deferred_queue_name("emails", DEFAULT_DEFERRED_SUFFIX, 30_000),
508            "emails.deferred.30000"
509        );
510        assert_eq!(
511            deferred_queue_name("myapp.emails", DEFAULT_DEFERRED_SUFFIX, 1_000),
512            "myapp.emails.deferred.1000"
513        );
514    }
515
516    #[test]
517    fn deferred_name_honours_a_custom_suffix() {
518        assert_eq!(deferred_queue_name("q", "-hold", 250), "q-hold.250");
519    }
520
521    #[test]
522    fn deferred_names_differ_per_ttl_which_is_the_whole_point() {
523        let short = deferred_queue_name("q", DEFAULT_DEFERRED_SUFFIX, 1_000);
524        let long = deferred_queue_name("q", DEFAULT_DEFERRED_SUFFIX, 60_000);
525        assert_ne!(short, long);
526    }
527
528    #[test]
529    fn an_exact_multiple_of_the_granularity_is_kept_verbatim() {
530        assert_eq!(deferred_ttl_ms(SECOND, SECOND), Some(1_000));
531        assert_eq!(
532            deferred_ttl_ms(Duration::from_secs(30), SECOND),
533            Some(30_000)
534        );
535        assert_eq!(
536            deferred_ttl_ms(Duration::from_millis(750), Duration::from_millis(250)),
537            Some(750)
538        );
539    }
540
541    #[test]
542    fn a_partial_step_rounds_up_so_a_job_never_returns_early() {
543        assert_eq!(
544            deferred_ttl_ms(Duration::from_millis(29_200), SECOND),
545            Some(30_000)
546        );
547        assert_eq!(
548            deferred_ttl_ms(Duration::from_millis(1_001), SECOND),
549            Some(2_000)
550        );
551        assert_eq!(
552            deferred_ttl_ms(Duration::from_millis(1_999), SECOND),
553            Some(2_000)
554        );
555        // Sub-millisecond remainders count as a partial millisecond, then a
556        // partial step.
557        assert_eq!(
558            deferred_ttl_ms(Duration::from_micros(1_000_001), SECOND),
559            Some(2_000)
560        );
561    }
562
563    #[test]
564    fn a_delay_below_the_granularity_becomes_one_step() {
565        assert_eq!(
566            deferred_ttl_ms(Duration::from_millis(5), SECOND),
567            Some(1_000)
568        );
569        assert_eq!(
570            deferred_ttl_ms(Duration::from_nanos(1), SECOND),
571            Some(1_000)
572        );
573        // Zero is not "publish it straight back": a deferral always waits.
574        assert_eq!(deferred_ttl_ms(Duration::ZERO, SECOND), Some(1_000));
575    }
576
577    #[test]
578    fn a_delay_past_the_cap_is_refused_rather_than_released_early() {
579        // Clamping here is what the old code did, and it broke the one timing
580        // guarantee a deferral makes.
581        assert_eq!(deferred_ttl_ms(Duration::MAX, SECOND), None);
582        assert_eq!(
583            deferred_ttl_ms(Duration::from_secs(30 * 86_400), SECOND),
584            None
585        );
586        assert_eq!(
587            deferred_ttl_ms(
588                Duration::from_millis(u64::from(MAX_DEFERRAL_MS) + 1),
589                SECOND
590            ),
591            None
592        );
593    }
594
595    #[test]
596    fn the_cap_itself_is_accepted_at_a_matching_granularity() {
597        assert_eq!(
598            deferred_ttl_ms(
599                Duration::from_millis(u64::from(MAX_DEFERRAL_MS)),
600                Duration::from_millis(1)
601            ),
602            Some(MAX_DEFERRAL_MS)
603        );
604    }
605
606    #[test]
607    fn rounding_up_can_itself_cross_the_cap_and_is_then_refused() {
608        // Just under the cap, but the next whole second is past it.
609        let just_under = Duration::from_millis(u64::from(MAX_DEFERRAL_MS) - 1);
610        assert_eq!(deferred_ttl_ms(just_under, SECOND), None);
611    }
612
613    #[test]
614    fn a_zero_granularity_is_clamped_to_one_millisecond_rather_than_panicking() {
615        assert_eq!(
616            deferred_ttl_ms(Duration::from_millis(7), Duration::ZERO),
617            Some(7)
618        );
619        assert_eq!(deferred_ttl_ms(Duration::ZERO, Duration::ZERO), Some(1));
620        // Sub-millisecond granularities land in the same clamp.
621        assert_eq!(
622            deferred_ttl_ms(Duration::from_millis(7), Duration::from_nanos(1)),
623            Some(7)
624        );
625    }
626
627    #[test]
628    fn an_absurd_granularity_is_clamped_to_the_cap_not_past_it() {
629        assert_eq!(
630            deferred_ttl_ms(SECOND, Duration::MAX),
631            Some(MAX_DEFERRAL_MS)
632        );
633    }
634
635    #[test]
636    fn deferred_args_hold_for_the_ttl_then_dead_letter_onto_the_main_queue() {
637        let args = deferred_queue_args(&config("myapp.emails"), 30_000);
638        assert_eq!(
639            args.inner().get(ARG_MESSAGE_TTL),
640            Some(&AMQPValue::LongLongInt(30_000))
641        );
642        assert_eq!(
643            args.inner().get(ARG_DEAD_LETTER_EXCHANGE),
644            Some(&AMQPValue::LongString(LongString::from("")))
645        );
646        assert_eq!(
647            args.inner().get(ARG_DEAD_LETTER_ROUTING_KEY),
648            Some(&AMQPValue::LongString(LongString::from("myapp.emails")))
649        );
650        assert_eq!(
651            args.inner().get(ARG_EXPIRES),
652            Some(&AMQPValue::LongLongInt(60_000))
653        );
654        assert_eq!(args.inner().len(), 4);
655    }
656
657    #[test]
658    fn deferred_args_depend_only_on_the_hold_queue_name() {
659        // Two callers that disagree about *everything* except the main queue's
660        // name and the TTL must still compute byte-identical arguments, or the
661        // broker locks one of them out of `q.deferred.1000` for ever.
662        let one = config("q").message_ttl(Duration::from_secs(99)).prefetch(1);
663        let two = config("q").max_priority(0).prefetch(200);
664        assert_eq!(
665            deferred_queue_args(&one, 1_000),
666            deferred_queue_args(&two, 1_000)
667        );
668    }
669
670    #[test]
671    fn expires_is_always_strictly_greater_than_the_ttl() {
672        for ttl in [1_u32, 1_000, 30_000, MAX_DEFERRAL_MS] {
673            let args = deferred_queue_args(&config("q"), ttl);
674            let AMQPValue::LongLongInt(expires) = args.inner().get(ARG_EXPIRES).expect("x-expires")
675            else {
676                panic!("x-expires is not a long long int");
677            };
678            assert!(
679                *expires > i64::from(ttl),
680                "x-expires {expires} must outlive the {ttl}ms TTL"
681            );
682            assert!(
683                *expires <= i64::from(MAX_TTL_MS),
684                "x-expires {expires} is past what RabbitMQ accepts"
685            );
686        }
687    }
688
689    #[test]
690    fn expires_is_clamped_when_doubling_the_ttl_would_overflow() {
691        // Unreachable through `deferred_ttl_ms`, which caps at `MAX_DEFERRAL_MS`;
692        // guarded anyway so a hand-rolled call cannot produce an argument
693        // RabbitMQ answers with PRECONDITION_FAILED.
694        let args = deferred_queue_args(&config("q"), MAX_TTL_MS);
695        assert_eq!(
696            args.inner().get(ARG_EXPIRES),
697            Some(&AMQPValue::LongLongInt(i64::from(MAX_TTL_MS)))
698        );
699    }
700
701    #[test]
702    fn deferred_args_never_carry_a_queue_ttl_of_their_own_from_the_config() {
703        // The hold queue's TTL is the delay, not the main queue's message TTL.
704        let cfg = config("q").message_ttl(Duration::from_secs(99));
705        let args = deferred_queue_args(&cfg, 2_000);
706        assert_eq!(
707            args.inner().get(ARG_MESSAGE_TTL),
708            Some(&AMQPValue::LongLongInt(2_000))
709        );
710    }
711
712    #[test]
713    fn declare_options_are_shared_and_never_auto_delete() {
714        let durable = declare_options(true);
715        assert!(durable.durable);
716        assert!(!durable.auto_delete);
717        assert!(!durable.exclusive);
718        assert!(!durable.passive);
719        assert!(!durable.nowait);
720        assert!(!declare_options(false).durable);
721    }
722}