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}