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}