Skip to main content

ruststream_lapin/
delay.rs

1//! Durable delayed retry, either through a TTL waiting queue and dead-letter exchange, or through
2//! the delayed-message-exchange plugin.
3//!
4//! `retry_after(delay)` (a handler returning
5//! [`HandlerResult::retry_after`](ruststream::runtime::HandlerResult::retry_after), or a message
6//! `nack_after`-ed) asks the broker to redeliver a message no sooner than `delay`. AMQP has no
7//! native per-message delay, so without a delay queue the runtime falls back to core's
8//! broker-agnostic deferred re-publish, which is at-most-once over the delay window and keeps the
9//! delayed copy in the service process.
10//!
11//! A subscription that opts in with [`RabbitQueue::delay`](crate::RabbitQueue::delay) makes
12//! `nack_after` native (`supports_nack_after` reports `true`) and keeps the delayed copy on the
13//! broker. Two backends are offered by [`Delay`]: the stock TTL waiting queue, and (behind the
14//! `plugin-dme` feature) the delayed-message-exchange plugin.
15
16use std::fmt;
17use std::time::Duration;
18
19use lapin::Channel;
20use lapin::options::BasicPublishOptions;
21use lapin::types::ShortString;
22use ruststream::Headers;
23
24use crate::convert;
25use crate::error::AmqpError;
26
27/// How a subscription handles `retry_after` / `nack_after` delays.
28///
29/// Passed to [`RabbitQueue::delay`](crate::RabbitQueue::delay). There is no default that enables
30/// it: a waiting queue or delayed exchange is infrastructure the user owns, so opting in is
31/// explicit.
32///
33/// # Examples
34///
35/// ```
36/// use ruststream_lapin::{Delay, RabbitQueue};
37///
38/// // Waiting queue named `orders.retry` (the default derived from the origin queue):
39/// let orders = RabbitQueue::new("orders").delay(Delay::dlx_ttl());
40/// // Or an explicit waiting-queue name:
41/// let named = RabbitQueue::new("orders").delay(Delay::dlx_ttl_named("orders.wait"));
42/// # let _ = (orders, named);
43/// ```
44#[derive(Debug, Clone, PartialEq, Eq)]
45#[non_exhaustive]
46pub enum Delay {
47    /// Route delayed redeliveries through a TTL waiting queue whose dead-letter target is the
48    /// origin queue.
49    ///
50    /// # Classic-queue caveat
51    ///
52    /// A classic queue releases expired messages only from its head, so a short-TTL message stuck
53    /// behind a long-TTL one waits for the long one. Use one waiting queue per delay class (or a
54    /// quorum queue, which does not have this head-of-line constraint) when delays vary widely, or
55    /// switch to [`plugin_dme`](Self::plugin_dme), which has no head-of-line constraint.
56    DlxTtl {
57        /// The waiting queue name; `None` derives `<origin>.retry`.
58        waiting_queue: Option<String>,
59    },
60
61    /// Route delayed redeliveries through the
62    /// [`rabbitmq_delayed_message_exchange`](https://github.com/rabbitmq/rabbitmq-delayed-message-exchange)
63    /// plugin: the message is re-published to an `x-delayed-message` exchange with an `x-delay`
64    /// header, and the plugin releases it to the origin queue after the delay. Unlike the classic
65    /// waiting queue, mixed delays do not block each other. Requires the plugin on the broker, so
66    /// it is behind the `plugin-dme` feature.
67    #[cfg(feature = "plugin-dme")]
68    DelayedMessageExchange {
69        /// The delayed-message exchange name; `None` derives `<origin>.delay`.
70        exchange: Option<String>,
71    },
72}
73
74impl Delay {
75    /// A TTL waiting queue named `<origin>.retry`.
76    #[must_use]
77    pub const fn dlx_ttl() -> Self {
78        Self::DlxTtl {
79            waiting_queue: None,
80        }
81    }
82
83    /// A TTL waiting queue with an explicit name.
84    #[must_use]
85    pub fn dlx_ttl_named(name: impl Into<String>) -> Self {
86        Self::DlxTtl {
87            waiting_queue: Some(name.into()),
88        }
89    }
90
91    /// A delayed-message-exchange named `<origin>.delay`.
92    #[cfg(feature = "plugin-dme")]
93    #[must_use]
94    pub const fn plugin_dme() -> Self {
95        Self::DelayedMessageExchange { exchange: None }
96    }
97
98    /// A delayed-message-exchange with an explicit name (share one across queues if you like).
99    #[cfg(feature = "plugin-dme")]
100    #[must_use]
101    pub fn plugin_dme_named(name: impl Into<String>) -> Self {
102        Self::DelayedMessageExchange {
103            exchange: Some(name.into()),
104        }
105    }
106
107    /// Resolves the delay target for `origin`, applying the per-backend default names.
108    pub(crate) fn target_for(&self, origin: &str) -> DelayTarget {
109        match self {
110            Self::DlxTtl {
111                waiting_queue: Some(name),
112            } => DelayTarget::WaitingQueue {
113                waiting_queue: name.clone(),
114            },
115            Self::DlxTtl {
116                waiting_queue: None,
117            } => DelayTarget::WaitingQueue {
118                waiting_queue: format!("{origin}.retry"),
119            },
120            #[cfg(feature = "plugin-dme")]
121            Self::DelayedMessageExchange { exchange } => DelayTarget::DelayedExchange {
122                exchange: exchange
123                    .clone()
124                    .unwrap_or_else(|| format!("{origin}.delay")),
125                routing_key: origin.to_owned(),
126            },
127        }
128    }
129}
130
131/// Where and how a delayed re-publish goes, resolved from the [`Delay`] backend.
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub(crate) enum DelayTarget {
134    /// Publish to the default exchange with the waiting queue as the routing key and a per-message
135    /// `expiration`; the waiting queue dead-letters back to the origin when the TTL fires.
136    WaitingQueue { waiting_queue: String },
137    /// Publish to the delayed-message exchange with the origin as the routing key and an `x-delay`
138    /// header; the plugin releases the message to the origin after the delay.
139    #[cfg(feature = "plugin-dme")]
140    DelayedExchange {
141        exchange: String,
142        routing_key: String,
143    },
144}
145
146/// The resolved delay wiring a subscriber threads into each delivery, so a message can honor a
147/// native `nack_after`. Cheap to clone (the channel is a handle).
148#[derive(Clone)]
149pub(crate) struct DelayContext {
150    channel: Channel,
151    target: DelayTarget,
152}
153
154impl DelayContext {
155    pub(crate) fn new(channel: Channel, target: DelayTarget) -> Self {
156        Self { channel, target }
157    }
158
159    /// Re-publishes `payload` so the broker redelivers it to the origin queue after `delay`.
160    ///
161    /// Sent on the delivery's own channel, so it orders before the original ack.
162    pub(crate) async fn republish(
163        &self,
164        payload: &[u8],
165        headers: &Headers,
166        delay: Duration,
167    ) -> Result<(), AmqpError> {
168        match &self.target {
169            DelayTarget::WaitingQueue { waiting_queue } => {
170                let properties = convert::properties_for_publish(headers, true)?
171                    .with_expiration(ShortString::from(expiration_millis(delay)));
172                self.channel
173                    .basic_publish(
174                        ShortString::default(),
175                        convert::short(waiting_queue, "waiting queue name")?,
176                        BasicPublishOptions::default(),
177                        payload,
178                        properties,
179                    )
180                    .await
181                    .map_err(AmqpError::publish)?;
182            }
183            #[cfg(feature = "plugin-dme")]
184            DelayTarget::DelayedExchange {
185                exchange,
186                routing_key,
187            } => {
188                use lapin::types::{AMQPValue, FieldTable};
189
190                let mut properties = convert::properties_for_publish(headers, true)?;
191                let mut table = properties
192                    .headers()
193                    .clone()
194                    .unwrap_or_else(FieldTable::default);
195                // The plugin reads `x-delay` (milliseconds) and holds the message for that long.
196                let millis = i64::try_from(delay.as_millis()).unwrap_or(i64::MAX);
197                table.insert(ShortString::from("x-delay"), AMQPValue::LongLongInt(millis));
198                properties = properties.with_headers(table);
199                self.channel
200                    .basic_publish(
201                        convert::short(exchange, "delayed exchange name")?,
202                        convert::short(routing_key, "routing key")?,
203                        BasicPublishOptions::default(),
204                        payload,
205                        properties,
206                    )
207                    .await
208                    .map_err(AmqpError::publish)?;
209            }
210        }
211        Ok(())
212    }
213}
214
215/// Renders a `delay` as milliseconds (as a string), for the AMQP per-message `expiration`.
216fn expiration_millis(delay: Duration) -> String {
217    u64::try_from(delay.as_millis())
218        .unwrap_or(u64::MAX)
219        .to_string()
220}
221
222impl fmt::Debug for DelayContext {
223    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
224        f.debug_struct("DelayContext")
225            .field("target", &self.target)
226            .finish_non_exhaustive()
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::{Delay, DelayTarget, expiration_millis};
233
234    #[test]
235    fn dlx_ttl_target_defaults_to_origin_dot_retry() {
236        assert_eq!(
237            Delay::dlx_ttl().target_for("orders"),
238            DelayTarget::WaitingQueue {
239                waiting_queue: "orders.retry".to_owned()
240            }
241        );
242        assert_eq!(
243            Delay::dlx_ttl_named("orders.wait").target_for("orders"),
244            DelayTarget::WaitingQueue {
245                waiting_queue: "orders.wait".to_owned()
246            }
247        );
248    }
249
250    #[cfg(feature = "plugin-dme")]
251    #[test]
252    fn dme_target_defaults_to_origin_dot_delay() {
253        assert_eq!(
254            Delay::plugin_dme().target_for("orders"),
255            DelayTarget::DelayedExchange {
256                exchange: "orders.delay".to_owned(),
257                routing_key: "orders".to_owned(),
258            }
259        );
260    }
261
262    #[test]
263    fn expiration_renders_milliseconds() {
264        assert_eq!(
265            expiration_millis(std::time::Duration::from_millis(1500)),
266            "1500"
267        );
268        assert_eq!(expiration_millis(std::time::Duration::from_secs(2)), "2000");
269    }
270}