Skip to main content

queuey_rabbitmq/
options.rs

1//! Tunables for [`RabbitMqBackend`](crate::RabbitMqBackend).
2
3use std::{sync::Arc, time::Duration};
4
5use lapin::ConnectionProperties;
6
7use crate::{
8    reconnect::{ReconnectPolicy, default_policy},
9    topology::{DEFAULT_DEAD_SUFFIX, DEFAULT_DEFERRED_SUFFIX},
10};
11
12/// Default for [`RabbitMqOptions::retry_granularity`] and
13/// [`RabbitMqOptions::deferred_granularity`].
14const DEFAULT_GRANULARITY: Duration = Duration::from_secs(1);
15
16/// Configuration for [`RabbitMqBackend::with_options`](crate::RabbitMqBackend::with_options).
17///
18/// ```
19/// use queuey_rabbitmq::RabbitMqOptions;
20///
21/// let options = RabbitMqOptions::default()
22///     .dead_suffix("-dlq")
23///     .declare_dead_letter_queues(false);
24/// assert_eq!(options.dead_suffix, "-dlq");
25/// ```
26#[derive(Clone, Debug)]
27pub struct RabbitMqOptions {
28    /// Handshake properties passed to `lapin::Connection::connect`.
29    pub connection_properties: ConnectionProperties,
30
31    /// Suffix appended to a queue name to name its dead-letter queue.
32    ///
33    /// Defaults to [`DEFAULT_DEAD_SUFFIX`] (`".dead"`).
34    pub dead_suffix: String,
35
36    /// Whether [`declare`](queuey_core::Backend::declare) also declares
37    /// the `q.dead` queues.
38    ///
39    /// Defaults to `true`. Set to `false` when dead-letter queues are managed
40    /// out of band (policies, an operator-owned topology, a different broker
41    /// vhost).
42    ///
43    /// This flag also selects *how* a message is dead-lettered, because this
44    /// backend never publishes to a queue it does not own:
45    ///
46    /// * `true`: [`Delivery::dead_letter`](queuey_core::Delivery::dead_letter)
47    ///   publishes the envelope to `q.dead` with the `x-death-*` headers and
48    ///   then acks the original.
49    /// * `false`: nothing is published. The original is rejected with
50    ///   `requeue = false`, so the broker applies whatever
51    ///   `x-dead-letter-exchange` policy the operator put on `q`, and drops the
52    ///   message if there is none. The reason is logged at `WARN`, since it is
53    ///   not recorded anywhere else.
54    ///
55    /// The same choice governs a message whose body is not a valid envelope.
56    pub declare_dead_letter_queues: bool,
57
58    /// Infix between a queue name and a hold queue's TTL.
59    ///
60    /// Defaults to [`DEFAULT_DEFERRED_SUFFIX`] (`".deferred"`), so a 30-second
61    /// wait on `myapp.emails` happens in `myapp.emails.deferred.30000`. Retries,
62    /// delayed enqueues and deferrals all wait in these hold queues; see
63    /// [`crate::topology`] for why the TTL is part of the name.
64    pub deferred_suffix: String,
65
66    /// Step that retry backoffs and
67    /// [`Producer::enqueue_after`](queuey_core::Producer::enqueue_after) delays
68    /// are rounded **up** to.
69    ///
70    /// Defaults to one second. Every distinct rounded delay gets its own hold
71    /// queue, so this is the knob that trades backoff precision for the number
72    /// of queues on the broker. It matters most for exponential backoff with
73    /// jitter, which produces a different delay for every retry: with the
74    /// default, a policy capped at five minutes can create at most 300 hold
75    /// queues per work queue, and a granularity of ten seconds brings that down
76    /// to 30. Idle hold queues delete themselves, so this bounds the number that
77    /// exist at once, not a total.
78    ///
79    /// Separate from [`deferred_granularity`](Self::deferred_granularity) on
80    /// purpose: a backoff is a heuristic that tolerates coarse rounding, a
81    /// `Retry-After` is a contract that may not.
82    ///
83    /// A retry is never released *early*: rounding is always up, a delay
84    /// shorter than the granularity still waits one full step, and a delay that
85    /// rounds up past
86    /// [`MAX_DEFERRAL_MS`](crate::topology::MAX_DEFERRAL_MS) (~24.8 days) is
87    /// refused instead of being shortened. A zero (or sub-millisecond) value is
88    /// clamped to one millisecond rather than rejected, exactly as for
89    /// [`deferred_granularity`](Self::deferred_granularity).
90    pub retry_granularity: Duration,
91
92    /// Step that deferral delays are rounded **up** to.
93    ///
94    /// Defaults to one second. Every distinct rounded delay gets its own hold
95    /// queue, so this is the knob that trades precision for the number of queues
96    /// on the broker: with the default, `Retry-After: 30` and a computed `29.2s`
97    /// delay share `q.deferred.30000`, and no deferral can create more than
98    /// `MAX_TTL_MS / 1000` queues per work queue.
99    ///
100    /// A deferral is never released *early*: rounding is always up, a delay
101    /// shorter than the granularity still waits one full step, and a delay that
102    /// rounds up past
103    /// [`MAX_DEFERRAL_MS`](crate::topology::MAX_DEFERRAL_MS) (~24.8 days) is
104    /// refused instead of being shortened.
105    ///
106    /// A zero (or sub-millisecond) value is clamped to one millisecond by
107    /// [`deferred_ttl_ms`](crate::topology::deferred_ttl_ms) rather than
108    /// rejected, because a backend constructor must not panic on a config value, but
109    /// one millisecond of granularity means up to one hold queue per distinct
110    /// millisecond, which is almost never what you want.
111    ///
112    /// Note what is *not* here: nothing tunes a hold queue's `x-expires`. Its
113    /// arguments are a pure function of its name (`x-expires = 2 * ttl`), so two
114    /// processes configured differently still agree on `q.deferred.30000`
115    /// instead of locking each other out with `PRECONDITION_FAILED`. Both
116    /// granularities are safe to tune because they only change *which* hold
117    /// queue a delay lands in, never that queue's arguments.
118    pub deferred_granularity: Duration,
119
120    /// How a lost connection is recovered, or [`None`] to fail instead.
121    ///
122    /// Defaults to [`BackoffPolicy::default`](crate::BackoffPolicy): retry
123    /// forever with a jittered exponential backoff. Any
124    /// [`ReconnectPolicy`] implementation can go here, so pacing that a backoff
125    /// curve cannot express (a circuit breaker, a schedule, a different answer
126    /// for an authentication failure than for a refused connection) is a matter
127    /// of writing one. A dropped connection is then invisible to job code
128    /// and to [`Worker::run`](queuey_core::Worker::run), which keeps running:
129    /// publishes wait for the connection to come back, and consumers
130    /// resubscribe on it.
131    ///
132    /// Two consequences worth knowing before relying on it:
133    ///
134    /// * **A broker that never comes back looks like a stall, not an error.**
135    ///   With the default unlimited policy nothing ever returns
136    ///   `Err`; the reconnect attempts are logged at `WARN`. Set
137    ///   [`BackoffPolicy::max_attempts`](crate::BackoffPolicy::max_attempts) if
138    ///   the worker should exit instead.
139    /// * **Jobs in flight across an outage are redelivered.** The broker
140    ///   requeues everything that was unacknowledged when the connection went
141    ///   down, so a job whose handler was still running is run again on the new
142    ///   connection, and the settle its first run eventually attempts fails
143    ///   (counted by
144    ///   [`WorkerHandle::settle_failures`](queuey_core::WorkerHandle::settle_failures)).
145    ///   That is the at-least-once contract this backend already has, but an
146    ///   outage is when it actually bites.
147    ///
148    /// Set to [`None`] for the original behaviour: the connection is not
149    /// rebuilt, consumer streams end, and `Worker::run` returns an error.
150    pub reconnect: Option<Arc<dyn ReconnectPolicy>>,
151}
152
153impl Default for RabbitMqOptions {
154    fn default() -> Self {
155        Self {
156            connection_properties: ConnectionProperties::default(),
157            dead_suffix: DEFAULT_DEAD_SUFFIX.to_owned(),
158            declare_dead_letter_queues: true,
159            deferred_suffix: DEFAULT_DEFERRED_SUFFIX.to_owned(),
160            retry_granularity: DEFAULT_GRANULARITY,
161            deferred_granularity: DEFAULT_GRANULARITY,
162            reconnect: Some(default_policy()),
163        }
164    }
165}
166
167impl RabbitMqOptions {
168    /// Replace the connection handshake properties.
169    #[must_use]
170    pub fn connection_properties(mut self, properties: ConnectionProperties) -> Self {
171        self.connection_properties = properties;
172        self
173    }
174
175    /// Replace the dead-letter queue suffix.
176    #[must_use]
177    pub fn dead_suffix(mut self, suffix: impl Into<String>) -> Self {
178        self.dead_suffix = suffix.into();
179        self
180    }
181
182    /// Enable or disable declaring `q.dead` queues.
183    #[must_use]
184    pub fn declare_dead_letter_queues(mut self, declare: bool) -> Self {
185        self.declare_dead_letter_queues = declare;
186        self
187    }
188
189    /// Replace the hold queue infix.
190    #[must_use]
191    pub fn deferred_suffix(mut self, suffix: impl Into<String>) -> Self {
192        self.deferred_suffix = suffix.into();
193        self
194    }
195
196    /// Replace the step retry backoffs and delayed enqueues are rounded up to.
197    ///
198    /// A zero or sub-millisecond value is *clamped* to one millisecond when the
199    /// TTL is computed, not rejected here: this is a builder, and library code
200    /// does not panic on configuration.
201    #[must_use]
202    pub fn retry_granularity(mut self, granularity: Duration) -> Self {
203        self.retry_granularity = granularity;
204        self
205    }
206
207    /// Replace the step deferral delays are rounded up to.
208    ///
209    /// A zero or sub-millisecond value is *clamped* to one millisecond when the
210    /// TTL is computed, not rejected here: this is a builder, and library code
211    /// does not panic on configuration.
212    #[must_use]
213    pub fn deferred_granularity(mut self, granularity: Duration) -> Self {
214        self.deferred_granularity = granularity;
215        self
216    }
217
218    /// Replace the reconnection policy, or pass [`None`] to disable
219    /// reconnection entirely.
220    ///
221    /// Takes anything that is already an [`Arc<dyn ReconnectPolicy>`]; use
222    /// [`reconnect_with`](Self::reconnect_with) to pass a policy by value.
223    ///
224    /// ```
225    /// use std::sync::Arc;
226    /// use queuey_rabbitmq::{BackoffPolicy, RabbitMqOptions, ReconnectPolicy};
227    ///
228    /// let policy: Arc<dyn ReconnectPolicy> =
229    ///     Arc::new(BackoffPolicy::default().max_attempts(Some(5)));
230    /// let bounded = RabbitMqOptions::default().reconnect(Some(policy));
231    /// assert!(bounded.reconnect.is_some());
232    ///
233    /// // Or fail fast, as this backend did before reconnection existed.
234    /// let never = RabbitMqOptions::default().reconnect(None);
235    /// assert!(never.reconnect.is_none());
236    /// ```
237    ///
238    /// [`Arc<dyn ReconnectPolicy>`]: ReconnectPolicy
239    #[must_use]
240    pub fn reconnect(mut self, policy: Option<Arc<dyn ReconnectPolicy>>) -> Self {
241        self.reconnect = policy;
242        self
243    }
244
245    /// Reconnect according to `policy`, wrapping it for you.
246    ///
247    /// The common case: the backend stores policies behind an [`Arc`] because
248    /// every consumer and publisher consults the same one, but a caller building
249    /// options should not have to say so.
250    ///
251    /// ```
252    /// use std::time::Duration;
253    /// use queuey_rabbitmq::{Attempt, BackoffPolicy, RabbitMqOptions, ReconnectPolicy};
254    ///
255    /// // The built-in policy, tuned.
256    /// let bounded = RabbitMqOptions::default()
257    ///     .reconnect_with(BackoffPolicy::default().max_attempts(Some(5)));
258    ///
259    /// // Or one of your own.
260    /// #[derive(Debug)]
261    /// struct EverySecond;
262    /// impl ReconnectPolicy for EverySecond {
263    ///     fn next_delay(&self, _: Attempt<'_>) -> Option<Duration> {
264    ///         Some(Duration::from_secs(1))
265    ///     }
266    /// }
267    /// let steady = RabbitMqOptions::default().reconnect_with(EverySecond);
268    /// assert!(steady.reconnect.is_some());
269    /// ```
270    #[must_use]
271    pub fn reconnect_with(mut self, policy: impl ReconnectPolicy + 'static) -> Self {
272        self.reconnect = Some(Arc::new(policy));
273        self
274    }
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    #[test]
282    fn defaults_match_the_documented_topology() {
283        let options = RabbitMqOptions::default();
284        assert_eq!(options.dead_suffix, ".dead");
285        assert!(options.declare_dead_letter_queues);
286        assert_eq!(options.deferred_suffix, ".deferred");
287        assert_eq!(options.retry_granularity, Duration::from_secs(1));
288        assert_eq!(options.deferred_granularity, Duration::from_secs(1));
289        assert!(
290            options.reconnect.is_some(),
291            "a dropped connection is recovered by default"
292        );
293    }
294
295    #[test]
296    fn reconnection_can_be_bounded_or_turned_off() {
297        use crate::reconnect::{Attempt, BackoffPolicy, Rebuilding};
298
299        let bounded = RabbitMqOptions::default()
300            .reconnect_with(BackoffPolicy::default().max_attempts(Some(1)));
301        let policy = bounded.reconnect.expect("a policy");
302        assert!(
303            policy
304                .next_delay(Attempt::first(Rebuilding::Connection))
305                .is_some(),
306            "one attempt is allowed"
307        );
308
309        let never = RabbitMqOptions::default().reconnect(None);
310        assert!(never.reconnect.is_none());
311        // Turning it off must not disturb the rest of the configuration.
312        assert_eq!(never.dead_suffix, ".dead");
313        assert_eq!(never.retry_granularity, Duration::from_secs(1));
314    }
315
316    #[test]
317    fn a_custom_policy_can_replace_the_built_in_one() {
318        use crate::reconnect::Attempt;
319
320        #[derive(Debug)]
321        struct Never;
322        impl ReconnectPolicy for Never {
323            fn next_delay(&self, _: Attempt<'_>) -> Option<Duration> {
324                None
325            }
326        }
327
328        let options = RabbitMqOptions::default().reconnect_with(Never);
329        let policy = options.reconnect.expect("a policy");
330        assert_eq!(
331            policy.next_delay(Attempt::first(crate::reconnect::Rebuilding::Connection)),
332            None
333        );
334        // `Debug` survives into the options, so configuration stays printable.
335        assert!(format!("{policy:?}").contains("Never"));
336    }
337
338    #[test]
339    fn deferral_tunables_can_be_overridden() {
340        let options = RabbitMqOptions::default()
341            .deferred_suffix("-hold")
342            .deferred_granularity(Duration::from_millis(250));
343        assert_eq!(options.deferred_suffix, "-hold");
344        assert_eq!(options.deferred_granularity, Duration::from_millis(250));
345        // And they are independent of the retry / dead-letter tunables.
346        assert_eq!(options.retry_granularity, Duration::from_secs(1));
347        assert_eq!(options.dead_suffix, ".dead");
348    }
349
350    #[test]
351    fn retry_granularity_is_independent_of_the_deferral_granularity() {
352        // Coarsening backoff rounding must not touch `Retry-After` precision.
353        let options = RabbitMqOptions::default().retry_granularity(Duration::from_secs(10));
354        assert_eq!(options.retry_granularity, Duration::from_secs(10));
355        assert_eq!(options.deferred_granularity, Duration::from_secs(1));
356    }
357
358    #[test]
359    fn a_zero_granularity_is_accepted_and_clamped_later_not_panicked_on() {
360        let options = RabbitMqOptions::default().deferred_granularity(Duration::ZERO);
361        assert_eq!(options.deferred_granularity, Duration::ZERO);
362        // The clamp lives in `deferred_ttl_ms`, so nothing here can panic.
363        assert_eq!(
364            crate::topology::deferred_ttl_ms(
365                Duration::from_millis(7),
366                options.deferred_granularity
367            ),
368            Some(7)
369        );
370    }
371
372    #[test]
373    fn suffixes_can_be_overridden() {
374        let options = RabbitMqOptions::default()
375            .dead_suffix("-dlq")
376            .deferred_suffix("-hold");
377        assert_eq!(options.dead_suffix, "-dlq");
378        assert_eq!(options.deferred_suffix, "-hold");
379        assert!(options.declare_dead_letter_queues);
380    }
381
382    #[test]
383    fn dead_letter_declaration_can_be_disabled() {
384        let options = RabbitMqOptions::default().declare_dead_letter_queues(false);
385        assert!(!options.declare_dead_letter_queues);
386        // Turning declaration off must not change the names.
387        assert_eq!(options.dead_suffix, ".dead");
388    }
389
390    #[test]
391    fn connection_properties_can_be_replaced() {
392        let options = RabbitMqOptions::default()
393            .connection_properties(ConnectionProperties::default().with_locale("nl_NL".into()));
394        assert!(format!("{:?}", options.connection_properties).contains("nl_NL"));
395    }
396}