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