Skip to main content

ruststream_lapin/
requester.rs

1//! Request/reply over `RabbitMQ` direct reply-to (`amq.rabbitmq.reply-to`).
2
3// Without the `testing` feature a connection link has one variant, so a `match` on it has a
4// single arm; the matches stay so that the in-process arm has its place when the feature is on.
5#![cfg_attr(
6    not(feature = "testing"),
7    allow(clippy::infallible_destructuring_match)
8)]
9
10use std::collections::HashMap;
11use std::future::ready;
12use std::sync::atomic::{AtomicU64, Ordering};
13use std::sync::{Arc, Mutex, Weak};
14use std::time::Duration;
15
16use futures::StreamExt;
17use futures::future::Either;
18use lapin::Channel;
19use lapin::options::{BasicConsumeOptions, BasicPublishOptions};
20use lapin::types::{FieldTable, ShortString};
21#[cfg(feature = "asyncapi")]
22use ruststream::asyncapi::Bindings;
23use ruststream::{Lend, OutgoingMessage, PairError, PublishPolicy, Publisher, RequestReply};
24use tokio::sync::oneshot;
25use tokio::time::timeout as timeout_future;
26
27use crate::broker::{AmqpConnection, ConnectedLapinBroker, Link};
28use crate::channel::{ChannelCell, Holds};
29use crate::convert;
30use crate::error::AmqpError;
31#[cfg(feature = "testing")]
32use crate::in_process;
33use crate::message::LapinMessage;
34use crate::publish_policy::{LapinPublishPolicy, PublishOptions, publish_policy_bindings};
35use crate::publish_step::LapinPublishOptions;
36use crate::recovery::RecoveringConsumer;
37
38/// The pseudo-queue `RabbitMQ` rewrites per-request for direct reply-to.
39pub(crate) const REPLY_TO: &str = "amq.rabbitmq.reply-to";
40
41type Pending = Mutex<HashMap<String, oneshot::Sender<LapinMessage>>>;
42
43/// The request/reply policy: pure declaration, constructible anywhere, pairing into
44/// [`LapinRequester`].
45///
46/// Requests are published transient (delivery mode 1) by default: a request nobody is waiting
47/// for after the timeout gains nothing from surviving a broker restart. Opt into persistence
48/// with [`persistent(true)`](Self::persistent).
49///
50/// # Examples
51///
52/// ```
53/// use std::time::Duration;
54///
55/// use ruststream::OutgoingMessage;
56/// use ruststream_lapin::prelude::*;
57/// use serde::Deserialize;
58///
59/// #[derive(Deserialize)]
60/// struct Order {
61///     sku: String,
62/// }
63///
64/// #[subscriber("orders")]
65/// async fn place(order: &Order, Out(inventory): Out<impl RequestReply>) -> HandlerOutcome {
66///     let ask = OutgoingMessage::new("inventory.check", order.sku.as_bytes());
67///     match inventory.request(ask, Duration::from_secs(2)).await {
68///         Ok(reply) if reply.payload() == b"in-stock" => HandlerOutcome::ack(),
69///         Ok(_) => HandlerOutcome::drop(),
70///         Err(_) => HandlerOutcome::retry(),
71///     }
72/// }
73///
74/// fn app() -> RustStream {
75///     let broker = LapinBroker::new("amqp://localhost:5672");
76///     RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(broker, |b| {
77///         b.include(place)
78///             .out(DefaultSlot, LapinRequest::default().exchange("rpc"))
79///             .build();
80///     })
81/// }
82/// ```
83#[derive(Debug, Clone, PartialEq, Eq)]
84#[must_use]
85pub struct LapinRequest(PublishOptions);
86
87impl Default for LapinRequest {
88    fn default() -> Self {
89        Self(PublishOptions {
90            defaults: LapinPublishOptions {
91                persistent: Some(false),
92                ..LapinPublishOptions::default()
93            },
94            ..PublishOptions::default()
95        })
96    }
97}
98
99impl LapinRequest {
100    /// What this policy hands the requester it pairs into.
101    ///
102    /// The requester takes the value by move at `bind`; this borrow is for the document, which
103    /// reads it.
104    #[cfg(feature = "asyncapi")]
105    pub(crate) const fn publish_options(&self) -> &PublishOptions {
106        &self.0
107    }
108
109    /// Publishes requests to `exchange` instead of the default exchange.
110    pub fn exchange(mut self, exchange: impl Into<String>) -> Self {
111        self.0.exchange = exchange.into();
112        self
113    }
114
115    /// Whether requests are marked persistent (delivery mode 2). Defaults to `false`.
116    ///
117    /// One request departs from this with the publish builder's
118    /// [`persistent`](crate::LapinPublishSteps::persistent) step.
119    pub fn persistent(mut self, persistent: bool) -> Self {
120        self.0.defaults.persistent = Some(persistent);
121        self
122    }
123
124    /// The AMQP `priority` property requests carry. Unset by default.
125    ///
126    /// One request departs from this with the publish builder's
127    /// [`priority`](crate::LapinPublishSteps::priority) step.
128    pub fn priority(mut self, priority: u8) -> Self {
129        self.0.defaults.priority = Some(priority);
130        self
131    }
132
133    /// The AMQP per-message `expiration` (TTL) requests carry: the broker drops one once `ttl`
134    /// has passed without it being consumed. Unset by default.
135    ///
136    /// Pair it with the per-request timeout: a request that expires on the broker fails with
137    /// [`AmqpError::RequestTimeout`] at the caller, without a responder ever seeing it.
138    ///
139    /// One request departs from this with the publish builder's
140    /// [`expiration`](crate::LapinPublishSteps::expiration) step.
141    pub fn expiration(mut self, ttl: Duration) -> Self {
142        self.0.defaults.expiration = Some(ttl);
143        self
144    }
145}
146
147impl PublishPolicy<ConnectedLapinBroker> for LapinRequest {
148    type Live = LapinRequester;
149
150    fn pair(
151        self,
152        connected: &ConnectedLapinBroker,
153    ) -> impl Future<Output = Result<Self::Live, PairError>> {
154        ready(Ok(self.bind(connected)))
155    }
156
157    publish_policy_bindings!();
158}
159
160impl LapinPublishPolicy for LapinRequest {
161    fn bind(self, connected: &ConnectedLapinBroker) -> Self::Live {
162        LapinRequester {
163            link: connected.link().clone(),
164            options: self.0,
165            state: Arc::new(ChannelCell::new()),
166            pending: Arc::new(Mutex::new(HashMap::new())),
167            next_id: Arc::new(AtomicU64::new(0)),
168        }
169    }
170}
171
172/// The live request/reply client over `RabbitMQ` direct reply-to.
173///
174/// [`request`](RequestReply::request) publishes to the routing key named by
175/// [`OutgoingMessage::name`] (on the default exchange unless the policy's
176/// [`exchange`](LapinRequest::exchange) says otherwise) with `reply-to` set to the direct
177/// reply-to pseudo-queue and a generated `correlation-id`; the responder replies by publishing
178/// to the `reply-to` it received, echoing the `correlation-id`.
179///
180/// Direct reply-to is at-most-once: replies live in channel state on one broker node, so a
181/// dropped requester channel loses in-flight replies. The per-request timeout is the recovery
182/// mechanism.
183///
184/// Obtained by pairing [`LapinRequest`] with a connected broker
185/// ([`ConnectedLapinBroker::requester`](crate::ConnectedLapinBroker::requester)). Clones share
186/// the reply consumer and the pending-request table; like every live handle it aliases the
187/// connection and reports [`AmqpError::Closed`] once the broker has shut down.
188#[derive(Debug, Clone)]
189pub struct LapinRequester {
190    link: Link,
191    options: PublishOptions,
192    state: Arc<ChannelCell<ReqState>>,
193    pending: Arc<Pending>,
194    next_id: Arc<AtomicU64>,
195}
196
197/// The requester's channel and the reply consumer running on it.
198#[derive(Debug, Clone)]
199struct ReqState {
200    channel: Channel,
201}
202
203impl Holds for ReqState {
204    fn channel(&self) -> &Channel {
205        &self.channel
206    }
207}
208
209impl LapinRequester {
210    /// Opens the requester channel and starts the reply consumer, and does it again once the
211    /// broker closes that channel.
212    ///
213    /// The consumer MUST be up before the first publish carrying the direct reply-to address;
214    /// `RabbitMQ` rejects such a publish with `PRECONDITION_FAILED` otherwise. Opening it on
215    /// first use rather than at pairing time keeps pairing a synchronous constructor call and
216    /// leaves an unused requester without a channel.
217    ///
218    /// A channel-level error closes the channel and takes the reply consumer with it. The
219    /// requests in flight on it are lost, which is what the per-request timeout is for; what the
220    /// requester must not do is stop working for every later request too.
221    async fn state(&self, conn: &Arc<AmqpConnection>, target: &str) -> Result<ReqState, AmqpError> {
222        self.state
223            .get(async move || {
224                let channel = conn
225                    .live_connection(target)?
226                    .create_channel()
227                    .await
228                    .map_err(AmqpError::request)?;
229                let consumer = channel
230                    .basic_consume(
231                        ShortString::from(REPLY_TO),
232                        ShortString::default(),
233                        BasicConsumeOptions {
234                            no_ack: true,
235                            ..BasicConsumeOptions::default()
236                        },
237                        FieldTable::default(),
238                    )
239                    .await
240                    .map_err(AmqpError::request)?;
241
242                // The task survives reconnects and exits on a terminal channel failure or when every
243                // requester clone is gone (Weak upgrade fails on the next reply). It runs on the
244                // runtime the broker connected on: the request that starts it may come from a
245                // handler on a dedicated thread, whose runtime may stop while later requests
246                // still wait on this dispatcher.
247                let pending = Arc::downgrade(&self.pending);
248                // Replies only need to wait through reconnects when recovery is enabled.
249                let consumer = if conn.auto_recover() {
250                    Either::Right(RecoveringConsumer::new(
251                        Arc::clone(conn),
252                        channel.clone(),
253                        consumer,
254                    ))
255                } else {
256                    Either::Left(consumer)
257                };
258                conn.runtime().spawn(dispatch_replies(consumer, pending));
259
260                Ok(ReqState { channel })
261            })
262            .await
263    }
264}
265
266async fn dispatch_replies(
267    mut consumer: Either<lapin::Consumer, RecoveringConsumer>,
268    pending: Weak<Pending>,
269) {
270    loop {
271        let delivery = match &mut consumer {
272            Either::Left(consumer) => consumer
273                .next()
274                .await
275                .map(|item| item.map_err(AmqpError::consume)),
276            Either::Right(consumer) => consumer.next().await,
277        };
278        let Some(delivery) = delivery else { return };
279        let Ok(delivery) = delivery else {
280            // The channel is failing; consuming further would spin. Outstanding requests fail
281            // by timeout.
282            return;
283        };
284        let Some(pending) = pending.upgrade() else {
285            return;
286        };
287        let correlation_id = delivery
288            .properties
289            .correlation_id()
290            .as_ref()
291            .map(ShortString::as_str);
292        let Some(correlation_id) = correlation_id else {
293            tracing::debug!("dropping direct reply-to delivery without a correlation-id");
294            continue;
295        };
296        let waiter = pending
297            .lock()
298            .expect("pending requests mutex poisoned")
299            .remove(correlation_id);
300        match waiter {
301            // The receiver may have timed out concurrently; nothing to do then.
302            Some(tx) => drop(tx.send(LapinMessage::from_delivery_no_ack(delivery))),
303            None => {
304                tracing::debug!(
305                    correlation_id,
306                    "dropping direct reply-to delivery with no waiter"
307                );
308            }
309        }
310    }
311}
312
313impl Publisher for LapinRequester {
314    /// `basic_publish` takes the payload as `&[u8]` and copies it into the frame it sends, so
315    /// this publisher reads the bytes and keeps nothing.
316    type Payload = Lend;
317    type Error = AmqpError;
318    type Options = LapinPublishOptions;
319
320    /// Publishes `msg` on the requester channel without expecting a reply.
321    ///
322    /// The AMQP basic properties are `options` resolved over the policy's defaults.
323    ///
324    /// # Errors
325    ///
326    /// Returns [`AmqpError::Closed`] once the broker has shut down and [`AmqpError::Publish`]
327    /// when the channel rejects the frame.
328    ///
329    /// # Cancel safety
330    ///
331    /// Not cancel safe: dropping the future may leave the message published or not.
332    async fn publish(
333        &self,
334        msg: OutgoingMessage<'_>,
335        options: Option<&Self::Options>,
336    ) -> Result<(), Self::Error> {
337        let conn = match &self.link {
338            Link::Amqp(conn) => conn,
339            #[cfg(feature = "testing")]
340            Link::InProcess(bus) => {
341                return bus.publish(
342                    &self.options.exchange,
343                    msg.name(),
344                    msg.payload(),
345                    msg.headers(),
346                    &self.options.resolve(options),
347                );
348            }
349        };
350        conn.ensure_live(msg.name())?;
351        let state = self.state(conn, msg.name()).await?;
352        let properties =
353            convert::properties_for_publish(msg.headers(), &self.options.resolve(options))?;
354        #[cfg(feature = "testing")]
355        conn.routes().published(&self.options.exchange, msg.name());
356        let _confirm = state
357            .channel
358            .basic_publish(
359                convert::short(&self.options.exchange, "exchange name")?,
360                convert::short(msg.name(), "routing key")?,
361                BasicPublishOptions::default(),
362                msg.payload(),
363                properties,
364            )
365            .await
366            .map_err(AmqpError::publish)?;
367        Ok(())
368    }
369}
370
371impl RequestReply for LapinRequester {
372    type Reply = LapinMessage;
373
374    /// Sends `msg` and awaits the correlated reply.
375    ///
376    /// # Errors
377    ///
378    /// Returns [`AmqpError::RequestTimeout`] when no reply arrives within `timeout`,
379    /// [`AmqpError::Closed`] once the broker has shut down, and [`AmqpError::Request`] /
380    /// [`AmqpError::Publish`] on channel failures.
381    ///
382    /// # Cancel safety
383    ///
384    /// Cancel safe for the caller's state: dropping the future abandons the pending slot and a
385    /// late reply is discarded. The request itself may still have been published.
386    async fn request(
387        &self,
388        msg: OutgoingMessage<'_>,
389        timeout: Duration,
390    ) -> Result<Self::Reply, Self::Error> {
391        let conn = match &self.link {
392            Link::Amqp(conn) => conn,
393            #[cfg(feature = "testing")]
394            Link::InProcess(bus) => {
395                return in_process::request(bus, &self.options, msg, timeout).await;
396            }
397        };
398        conn.ensure_live(msg.name())?;
399        let state = self.state(conn, msg.name()).await?;
400
401        let correlation_id = format!("rs-{}", self.next_id.fetch_add(1, Ordering::Relaxed));
402        let (tx, rx) = oneshot::channel();
403        {
404            let mut pending = self
405                .pending
406                .lock()
407                .expect("pending requests mutex poisoned");
408            pending.insert(correlation_id.clone(), tx);
409        }
410        // Every failure path below must reclaim the slot, or it leaks until shutdown.
411        let cleanup = || {
412            let mut pending = self
413                .pending
414                .lock()
415                .expect("pending requests mutex poisoned");
416            pending.remove(&correlation_id);
417        };
418
419        let properties =
420            match convert::properties_for_publish(msg.headers(), &self.options.resolve(None)) {
421                Ok(properties) => properties
422                    .with_reply_to(ShortString::from(REPLY_TO))
423                    .with_correlation_id(ShortString::from(correlation_id.clone())),
424                Err(err) => {
425                    cleanup();
426                    return Err(err);
427                }
428            };
429        let exchange = match convert::short(&self.options.exchange, "exchange name") {
430            Ok(exchange) => exchange,
431            Err(err) => {
432                cleanup();
433                return Err(err);
434            }
435        };
436        let routing_key = match convert::short(msg.name(), "routing key") {
437            Ok(routing_key) => routing_key,
438            Err(err) => {
439                cleanup();
440                return Err(err);
441            }
442        };
443
444        #[cfg(feature = "testing")]
445        conn.routes().published(&self.options.exchange, msg.name());
446        let published = state
447            .channel
448            .basic_publish(
449                exchange,
450                routing_key,
451                BasicPublishOptions::default(),
452                msg.payload(),
453                properties,
454            )
455            .await;
456        if let Err(err) = published {
457            cleanup();
458            return Err(AmqpError::publish(err));
459        }
460
461        match timeout_future(timeout, rx).await {
462            Ok(Ok(reply)) => Ok(reply),
463            // The dispatch task dropped the sender: the reply channel died under us.
464            Ok(Err(_)) => {
465                cleanup();
466                Err(AmqpError::Request(
467                    "the reply consumer stopped before a reply arrived".into(),
468                ))
469            }
470            Err(_) => {
471                cleanup();
472                Err(AmqpError::RequestTimeout(timeout))
473            }
474        }
475    }
476}