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}