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