Skip to main content

ruststream_lapin/
requester.rs

1//! Request/reply over `RabbitMQ` direct reply-to (`amq.rabbitmq.reply-to`).
2
3use std::collections::HashMap;
4use std::sync::atomic::{AtomicU64, Ordering};
5use std::sync::{Arc, Mutex, Weak};
6use std::time::Duration;
7
8use futures::StreamExt;
9use lapin::Channel;
10use lapin::options::{BasicConsumeOptions, BasicPublishOptions};
11use lapin::types::{FieldTable, ShortString};
12use ruststream::{OutgoingMessage, PairError, PublishPolicy, Publisher, RequestReply};
13use tokio::sync::{OnceCell, oneshot};
14
15use crate::broker::{AmqpConnection, ConnectedLapinBroker};
16use crate::convert;
17use crate::error::AmqpError;
18use crate::message::LapinMessage;
19use crate::publish_policy::{LapinPublishPolicy, PublishOptions};
20
21/// The pseudo-queue `RabbitMQ` rewrites per-request for direct reply-to.
22const REPLY_TO: &str = "amq.rabbitmq.reply-to";
23
24type Pending = Mutex<HashMap<String, oneshot::Sender<LapinMessage>>>;
25
26/// The request/reply policy: pure declaration, constructible anywhere, pairing into
27/// [`LapinRequester`].
28///
29/// Requests are published transient (delivery mode 1) by default: a request nobody is waiting
30/// for after the timeout gains nothing from surviving a broker restart. Opt into persistence
31/// with [`persistent(true)`](Self::persistent).
32///
33/// # Examples
34///
35/// ```
36/// use ruststream_lapin::LapinRequest;
37///
38/// let inventory = LapinRequest::default().exchange("rpc");
39/// # let _ = inventory;
40/// ```
41#[derive(Debug, Clone, PartialEq, Eq)]
42#[must_use]
43pub struct LapinRequest(PublishOptions);
44
45impl Default for LapinRequest {
46    fn default() -> Self {
47        Self(PublishOptions {
48            persistent: false,
49            ..PublishOptions::default()
50        })
51    }
52}
53
54impl LapinRequest {
55    /// Publishes requests to `exchange` instead of the default exchange.
56    pub fn exchange(mut self, exchange: impl Into<String>) -> Self {
57        self.0.exchange = exchange.into();
58        self
59    }
60
61    /// Whether requests are marked persistent (delivery mode 2). Defaults to `false`.
62    pub fn persistent(mut self, persistent: bool) -> Self {
63        self.0.persistent = persistent;
64        self
65    }
66}
67
68impl PublishPolicy<ConnectedLapinBroker> for LapinRequest {
69    type Live = LapinRequester;
70
71    async fn pair(self, connected: &ConnectedLapinBroker) -> Result<Self::Live, PairError> {
72        Ok(self.bind(connected))
73    }
74}
75
76impl LapinPublishPolicy for LapinRequest {
77    fn bind(self, connected: &ConnectedLapinBroker) -> Self::Live {
78        LapinRequester {
79            conn: Arc::clone(connected.connection()),
80            options: self.0,
81            state: Arc::new(OnceCell::new()),
82            pending: Arc::new(Mutex::new(HashMap::new())),
83            next_id: Arc::new(AtomicU64::new(0)),
84        }
85    }
86}
87
88/// The live request/reply client over `RabbitMQ` direct reply-to.
89///
90/// [`request`](RequestReply::request) publishes to the routing key named by
91/// [`OutgoingMessage::name`] (on the default exchange unless the policy's
92/// [`exchange`](LapinRequest::exchange) says otherwise) with `reply-to` set to the direct
93/// reply-to pseudo-queue and a generated `correlation-id`; the responder replies by publishing
94/// to the `reply-to` it received, echoing the `correlation-id`.
95///
96/// Direct reply-to is at-most-once: replies live in channel state on one broker node, so a
97/// dropped requester channel loses in-flight replies. The per-request timeout is the recovery
98/// mechanism.
99///
100/// Obtained by pairing [`LapinRequest`] with a connected broker
101/// ([`ConnectedLapinBroker::requester`](crate::ConnectedLapinBroker::requester)). Clones share
102/// the reply consumer and the pending-request table; like every live handle it aliases the
103/// connection and reports [`AmqpError::Closed`] once the broker has shut down.
104#[derive(Debug, Clone)]
105pub struct LapinRequester {
106    conn: Arc<AmqpConnection>,
107    options: PublishOptions,
108    state: Arc<OnceCell<ReqState>>,
109    pending: Arc<Pending>,
110    next_id: Arc<AtomicU64>,
111}
112
113#[derive(Debug)]
114struct ReqState {
115    channel: Channel,
116}
117
118impl LapinRequester {
119    /// Opens the requester channel and starts the reply consumer, once.
120    ///
121    /// The consumer MUST be up before the first publish carrying the direct reply-to address;
122    /// `RabbitMQ` rejects such a publish with `PRECONDITION_FAILED` otherwise. Opening it on
123    /// first use rather than at pairing time keeps pairing a synchronous constructor call and
124    /// leaves an unused requester without a channel.
125    async fn state(&self, target: &str) -> Result<&ReqState, AmqpError> {
126        self.state
127            .get_or_try_init(|| async {
128                let channel = self
129                    .conn
130                    .live_connection(target)?
131                    .create_channel()
132                    .await
133                    .map_err(AmqpError::request)?;
134                let consumer = channel
135                    .basic_consume(
136                        ShortString::from(REPLY_TO),
137                        ShortString::default(),
138                        BasicConsumeOptions {
139                            no_ack: true,
140                            ..BasicConsumeOptions::default()
141                        },
142                        FieldTable::default(),
143                    )
144                    .await
145                    .map_err(AmqpError::request)?;
146
147                // The task exits when the channel closes (consumer stream ends) or when every
148                // requester clone is gone (Weak upgrade fails on the next reply).
149                let pending = Arc::downgrade(&self.pending);
150                tokio::spawn(dispatch_replies(consumer, pending));
151
152                Ok(ReqState { channel })
153            })
154            .await
155    }
156}
157
158async fn dispatch_replies(mut consumer: lapin::Consumer, pending: Weak<Pending>) {
159    while let Some(delivery) = consumer.next().await {
160        let Ok(delivery) = delivery else {
161            // The channel is failing; consuming further would spin. Outstanding requests fail
162            // by timeout.
163            return;
164        };
165        let Some(pending) = pending.upgrade() else {
166            return;
167        };
168        let correlation_id = delivery
169            .properties
170            .correlation_id()
171            .as_ref()
172            .map(ShortString::as_str);
173        let Some(correlation_id) = correlation_id else {
174            tracing::debug!("dropping direct reply-to delivery without a correlation-id");
175            continue;
176        };
177        let waiter = pending
178            .lock()
179            .expect("pending requests mutex poisoned")
180            .remove(correlation_id);
181        match waiter {
182            // The receiver may have timed out concurrently; nothing to do then.
183            Some(tx) => drop(tx.send(LapinMessage::from_delivery_no_ack(delivery))),
184            None => {
185                tracing::debug!(
186                    correlation_id,
187                    "dropping direct reply-to delivery with no waiter"
188                );
189            }
190        }
191    }
192}
193
194impl Publisher for LapinRequester {
195    type Error = AmqpError;
196
197    /// Publishes `msg` on the requester channel without expecting a reply.
198    ///
199    /// # Errors
200    ///
201    /// Returns [`AmqpError::Closed`] once the broker has shut down and [`AmqpError::Publish`]
202    /// when the channel rejects the frame.
203    ///
204    /// # Cancel safety
205    ///
206    /// Not cancel safe: dropping the future may leave the message published or not.
207    async fn publish(&self, msg: OutgoingMessage<'_>) -> Result<(), Self::Error> {
208        self.conn.ensure_live(msg.name())?;
209        let state = self.state(msg.name()).await?;
210        let properties = convert::properties_for_publish(msg.headers(), self.options.persistent)?;
211        let _confirm = state
212            .channel
213            .basic_publish(
214                convert::short(&self.options.exchange, "exchange name")?,
215                convert::short(msg.name(), "routing key")?,
216                BasicPublishOptions::default(),
217                msg.payload(),
218                properties,
219            )
220            .await
221            .map_err(AmqpError::publish)?;
222        Ok(())
223    }
224}
225
226impl RequestReply for LapinRequester {
227    type Reply = LapinMessage;
228
229    /// Sends `msg` and awaits the correlated reply.
230    ///
231    /// # Errors
232    ///
233    /// Returns [`AmqpError::RequestTimeout`] when no reply arrives within `timeout`,
234    /// [`AmqpError::Closed`] once the broker has shut down, and [`AmqpError::Request`] /
235    /// [`AmqpError::Publish`] on channel failures.
236    ///
237    /// # Cancel safety
238    ///
239    /// Cancel safe for the caller's state: dropping the future abandons the pending slot and a
240    /// late reply is discarded. The request itself may still have been published.
241    async fn request(
242        &self,
243        msg: OutgoingMessage<'_>,
244        timeout: Duration,
245    ) -> Result<Self::Reply, Self::Error> {
246        self.conn.ensure_live(msg.name())?;
247        let state = self.state(msg.name()).await?;
248
249        let correlation_id = format!("rs-{}", self.next_id.fetch_add(1, Ordering::Relaxed));
250        let (tx, rx) = oneshot::channel();
251        {
252            let mut pending = self
253                .pending
254                .lock()
255                .expect("pending requests mutex poisoned");
256            pending.insert(correlation_id.clone(), tx);
257        }
258        // Every failure path below must reclaim the slot, or it leaks until shutdown.
259        let cleanup = || {
260            let mut pending = self
261                .pending
262                .lock()
263                .expect("pending requests mutex poisoned");
264            pending.remove(&correlation_id);
265        };
266
267        let properties =
268            match convert::properties_for_publish(msg.headers(), self.options.persistent) {
269                Ok(properties) => properties
270                    .with_reply_to(ShortString::from(REPLY_TO))
271                    .with_correlation_id(ShortString::from(correlation_id.clone())),
272                Err(err) => {
273                    cleanup();
274                    return Err(err);
275                }
276            };
277        let exchange = match convert::short(&self.options.exchange, "exchange name") {
278            Ok(exchange) => exchange,
279            Err(err) => {
280                cleanup();
281                return Err(err);
282            }
283        };
284        let routing_key = match convert::short(msg.name(), "routing key") {
285            Ok(routing_key) => routing_key,
286            Err(err) => {
287                cleanup();
288                return Err(err);
289            }
290        };
291
292        let published = state
293            .channel
294            .basic_publish(
295                exchange,
296                routing_key,
297                BasicPublishOptions::default(),
298                msg.payload(),
299                properties,
300            )
301            .await;
302        if let Err(err) = published {
303            cleanup();
304            return Err(AmqpError::publish(err));
305        }
306
307        match tokio::time::timeout(timeout, rx).await {
308            Ok(Ok(reply)) => Ok(reply),
309            // The dispatch task dropped the sender: the reply channel died under us.
310            Ok(Err(_)) => {
311                cleanup();
312                Err(AmqpError::Request(
313                    "the reply consumer stopped before a reply arrived".into(),
314                ))
315            }
316            Err(_) => {
317                cleanup();
318                Err(AmqpError::RequestTimeout(timeout))
319            }
320        }
321    }
322}