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}