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//! [`HandlerOutcome::retry_after`](ruststream::runtime::HandlerOutcome::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 bytes::Bytes;
20use lapin::Channel;
21use lapin::options::{BasicPublishOptions, QueueDeclareOptions};
22use lapin::types::{FieldTable, ShortString};
23use ruststream::runtime::RETRY_COUNT_HEADER;
24use ruststream::{HeaderMap, Str};
25use thiserror::Error;
26
27use crate::convert;
28use crate::error::AmqpError;
29
30/// How a subscription handles `retry_after` / `nack_after` delays.
31///
32/// Passed to [`RabbitQueue::delay`](crate::RabbitQueue::delay). A waiting queue or a delayed
33/// exchange is infrastructure the user owns, so a subscription opts in explicitly.
34///
35/// # Examples
36///
37/// ```
38/// use std::time::Duration;
39///
40/// use ruststream_lapin::prelude::*;
41/// use serde::Deserialize;
42///
43/// #[derive(Deserialize)]
44/// struct Order {
45///     id: u64,
46///     paid: bool,
47/// }
48///
49/// // An unpaid order waits a minute in `orders.retry` on the broker, then comes back here.
50/// #[subscriber(RabbitQueue::new("orders").delay(Delay::dlx_ttl()))]
51/// async fn fulfil(order: &Order) -> HandlerOutcome {
52///     if !order.paid {
53///         return HandlerOutcome::retry_after(Duration::from_mins(1));
54///     }
55///     tracing::info!(order.id, "fulfilling");
56///     HandlerOutcome::ack()
57/// }
58///
59/// fn app() -> RustStream {
60///     let broker = LapinBroker::new("amqp://localhost:5672").declare_topology(true);
61///     RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(broker, |b| {
62///         b.include(fulfil);
63///     })
64/// }
65/// ```
66#[derive(Debug, Clone, PartialEq, Eq)]
67#[non_exhaustive]
68pub enum Delay {
69    /// Route delayed redeliveries through a TTL waiting queue whose dead-letter target is the
70    /// origin queue.
71    ///
72    /// # Mixed delays
73    ///
74    /// A queue releases expired messages only from its head, classic and quorum queues alike, so a
75    /// short-TTL message behind a long-TTL one waits for the long one. When delays vary widely, use
76    /// one waiting queue per delay class ([`dlx_ttl_named`](Self::dlx_ttl_named)), or the
77    /// delayed-message-exchange plugin (`Delay::plugin_dme`, feature `plugin-dme`), where a short
78    /// delay never waits behind a long one.
79    DlxTtl {
80        /// The waiting queue name; `None` derives `<origin>.retry`.
81        waiting_queue: Option<String>,
82    },
83
84    /// Route delayed redeliveries through the
85    /// [`rabbitmq_delayed_message_exchange`](https://github.com/rabbitmq/rabbitmq-delayed-message-exchange)
86    /// plugin: the message is re-published to an `x-delayed-message` exchange with an `x-delay`
87    /// header, and the plugin releases it to the origin queue after the delay. Unlike a waiting
88    /// queue, mixed delays do not block each other. Requires the plugin on the broker, so
89    /// it is behind the `plugin-dme` feature.
90    #[cfg(feature = "plugin-dme")]
91    DelayedMessageExchange {
92        /// The delayed-message exchange name; `None` derives `<origin>.delay`.
93        exchange: Option<String>,
94    },
95}
96
97impl Delay {
98    /// A TTL waiting queue named `<origin>.retry`.
99    #[must_use]
100    pub const fn dlx_ttl() -> Self {
101        Self::DlxTtl {
102            waiting_queue: None,
103        }
104    }
105
106    /// A TTL waiting queue with an explicit name.
107    #[must_use]
108    pub fn dlx_ttl_named(name: impl Into<String>) -> Self {
109        Self::DlxTtl {
110            waiting_queue: Some(name.into()),
111        }
112    }
113
114    /// A delayed-message-exchange named `<origin>.delay`.
115    #[cfg(feature = "plugin-dme")]
116    #[must_use]
117    pub const fn plugin_dme() -> Self {
118        Self::DelayedMessageExchange { exchange: None }
119    }
120
121    /// A delayed-message-exchange with an explicit name (share one across queues if you like).
122    #[cfg(feature = "plugin-dme")]
123    #[must_use]
124    pub fn plugin_dme_named(name: impl Into<String>) -> Self {
125        Self::DelayedMessageExchange {
126            exchange: Some(name.into()),
127        }
128    }
129
130    /// Resolves the delay target for `origin`, applying the per-backend default names.
131    pub(crate) fn target_for(&self, origin: &str) -> DelayTarget {
132        match self {
133            Self::DlxTtl {
134                waiting_queue: Some(name),
135            } => DelayTarget::WaitingQueue {
136                waiting_queue: name.clone(),
137            },
138            Self::DlxTtl {
139                waiting_queue: None,
140            } => DelayTarget::WaitingQueue {
141                waiting_queue: format!("{origin}.retry"),
142            },
143            #[cfg(feature = "plugin-dme")]
144            Self::DelayedMessageExchange { exchange } => DelayTarget::DelayedExchange {
145                exchange: exchange
146                    .clone()
147                    .unwrap_or_else(|| format!("{origin}.delay")),
148                routing_key: origin.to_owned(),
149            },
150        }
151    }
152}
153
154/// Where and how a delayed re-publish goes, resolved from the [`Delay`] backend.
155#[derive(Debug, Clone, PartialEq, Eq)]
156pub(crate) enum DelayTarget {
157    /// Publish to the default exchange with the waiting queue as the routing key and a per-message
158    /// `expiration`; the waiting queue dead-letters back to the origin when the TTL fires.
159    WaitingQueue { waiting_queue: String },
160    /// Publish to the delayed-message exchange with the origin as the routing key and an `x-delay`
161    /// header; the plugin releases the message to the origin after the delay.
162    #[cfg(feature = "plugin-dme")]
163    DelayedExchange {
164        exchange: String,
165        routing_key: String,
166    },
167}
168
169impl DelayTarget {
170    /// Checks, once, that the waiting queue or delayed exchange exists on the server, for a
171    /// subscription whose broker does not declare it.
172    ///
173    /// The delayed copy is published without waiting for the server's answer and the original is
174    /// acked after it. A copy sent to a waiting queue that does not exist is dropped by the server
175    /// (the default exchange routes it nowhere), and one sent to a delayed exchange that does not
176    /// exist closes the channel the subscription consumes on. Checking at startup turns both into
177    /// a subscription that refuses to open.
178    ///
179    /// A missing target closes `channel` (a passive declaration answers "not found" that way), so
180    /// it runs on the subscription's own channel before the consumer opens: the subscription fails
181    /// with it either way.
182    pub(crate) async fn ensure_exists(
183        &self,
184        channel: &Channel,
185        subscription: &str,
186    ) -> Result<(), AmqpError> {
187        let missing = |what: &'static str, target: &str, source: lapin::Error| {
188            AmqpError::Subscribe(Box::new(MissingDelayTarget {
189                subscription: subscription.to_owned(),
190                what,
191                target: target.to_owned(),
192                source,
193            }))
194        };
195        match self {
196            Self::WaitingQueue { waiting_queue } => {
197                channel
198                    .queue_declare(
199                        convert::short(waiting_queue, "waiting queue name")?,
200                        QueueDeclareOptions {
201                            passive: true,
202                            ..QueueDeclareOptions::default()
203                        },
204                        FieldTable::default(),
205                    )
206                    .await
207                    .map_err(|err| missing("waiting queue", waiting_queue, err))?;
208            }
209            #[cfg(feature = "plugin-dme")]
210            Self::DelayedExchange { exchange, .. } => {
211                use lapin::ExchangeKind;
212                use lapin::options::ExchangeDeclareOptions;
213
214                channel
215                    .exchange_declare(
216                        convert::short(exchange, "delayed exchange name")?,
217                        ExchangeKind::Custom("x-delayed-message".to_owned()),
218                        ExchangeDeclareOptions {
219                            passive: true,
220                            ..ExchangeDeclareOptions::default()
221                        },
222                        FieldTable::default(),
223                    )
224                    .await
225                    .map_err(|err| missing("delayed exchange", exchange, err))?;
226            }
227        }
228        Ok(())
229    }
230}
231
232/// A delay target the subscription's broker does not declare, and the server does not have.
233#[derive(Debug, Error)]
234#[error(
235    "subscription {subscription:?} delays its retries through the {what} {target:?}, which the \
236     server does not have; provision it, or let the broker declare it with \
237     declare_topology(true)"
238)]
239struct MissingDelayTarget {
240    subscription: String,
241    what: &'static str,
242    target: String,
243    #[source]
244    source: lapin::Error,
245}
246
247/// The resolved delay wiring a subscriber threads into each delivery, so a message can honor a
248/// native `nack_after`. Cheap to clone (the channel is a handle).
249#[derive(Clone)]
250pub(crate) struct DelayContext {
251    channel: Channel,
252    target: DelayTarget,
253}
254
255impl DelayContext {
256    pub(crate) fn new(channel: Channel, target: DelayTarget) -> Self {
257        Self { channel, target }
258    }
259
260    /// Re-publishes `payload` so the broker redelivers it to the origin queue after `delay`.
261    ///
262    /// Sent on the delivery's own channel, so it orders before the original ack.
263    ///
264    /// The copy carries the framework's retry count, raised by one. The waiting queue releases a
265    /// new message, and the server counts a new message from zero, so this header is the only
266    /// record that the delivery has been round before - the same record the runtime keeps on the
267    /// copies it publishes itself.
268    pub(crate) async fn republish(
269        &self,
270        payload: &[u8],
271        headers: &HeaderMap,
272        delay: Duration,
273    ) -> Result<(), AmqpError> {
274        // A redelivery has no call site to adjust anything, so the copy carries what the original
275        // delivery reported.
276        let options = convert::redelivery_options(headers);
277        let headers = &counted_again(headers);
278        match &self.target {
279            DelayTarget::WaitingQueue { waiting_queue } => {
280                // The waiting queue's own TTL replaces whatever expiration the delivery carried:
281                // the delay is what the copy waits for there.
282                let properties = convert::properties_for_publish(headers, &options)?
283                    .with_expiration(convert::expiration_millis(delay));
284                self.channel
285                    .basic_publish(
286                        ShortString::default(),
287                        convert::short(waiting_queue, "waiting queue name")?,
288                        BasicPublishOptions::default(),
289                        payload,
290                        properties,
291                    )
292                    .await
293                    .map_err(AmqpError::publish)?;
294            }
295            #[cfg(feature = "plugin-dme")]
296            DelayTarget::DelayedExchange {
297                exchange,
298                routing_key,
299            } => {
300                use lapin::types::{AMQPValue, FieldTable};
301
302                let mut properties = convert::properties_for_publish(headers, &options)?;
303                let mut table = properties
304                    .headers()
305                    .clone()
306                    .unwrap_or_else(FieldTable::default);
307                // The plugin reads `x-delay` (milliseconds) and holds the message for that long.
308                let millis = i64::try_from(delay.as_millis()).unwrap_or(i64::MAX);
309                table.insert(ShortString::from("x-delay"), AMQPValue::LongLongInt(millis));
310                properties = properties.with_headers(table);
311                self.channel
312                    .basic_publish(
313                        convert::short(exchange, "delayed exchange name")?,
314                        convert::short(routing_key, "routing key")?,
315                        BasicPublishOptions::default(),
316                        payload,
317                        properties,
318                    )
319                    .await
320                    .map_err(AmqpError::publish)?;
321            }
322        }
323        Ok(())
324    }
325}
326
327/// The delivery's headers with the framework's retry count raised by one; an absent count is zero,
328/// so the first copy leaves with one.
329pub(crate) fn counted_again(headers: &HeaderMap) -> HeaderMap {
330    let spent = headers
331        .get_str(RETRY_COUNT_HEADER)
332        .and_then(|count| count.parse::<u64>().ok())
333        .unwrap_or(0);
334    let mut headers = headers.clone();
335    headers.insert(
336        Str::from_static(RETRY_COUNT_HEADER),
337        Bytes::from(spent.saturating_add(1).to_string()),
338    );
339    headers
340}
341
342impl fmt::Debug for DelayContext {
343    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
344        f.debug_struct("DelayContext")
345            .field("target", &self.target)
346            .finish_non_exhaustive()
347    }
348}
349
350#[cfg(test)]
351mod tests {
352    use super::{Delay, DelayTarget};
353
354    #[test]
355    fn dlx_ttl_target_defaults_to_origin_dot_retry() {
356        assert_eq!(
357            Delay::dlx_ttl().target_for("orders"),
358            DelayTarget::WaitingQueue {
359                waiting_queue: "orders.retry".to_owned()
360            }
361        );
362        assert_eq!(
363            Delay::dlx_ttl_named("orders.wait").target_for("orders"),
364            DelayTarget::WaitingQueue {
365                waiting_queue: "orders.wait".to_owned()
366            }
367        );
368    }
369
370    #[cfg(feature = "plugin-dme")]
371    #[test]
372    fn dme_target_defaults_to_origin_dot_delay() {
373        assert_eq!(
374            Delay::plugin_dme().target_for("orders"),
375            DelayTarget::DelayedExchange {
376                exchange: "orders.delay".to_owned(),
377                routing_key: "orders".to_owned(),
378            }
379        );
380    }
381}