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}