ruststream_lapin/broker.rs
1//! The broker ladder: [`LapinBroker`] -> [`ConnectedLapinBroker`] -> [`ClosedLapinBroker`].
2//!
3//! Construction is synchronous and I/O-free; the connection is dialled by the consuming
4//! [`Broker::connect`], and the connected form is the only value carrying a subscribe or publish
5//! surface. [`ConnectedBroker::shutdown`] consumes it in turn and returns the terminal witness.
6
7// Without the `testing` feature a connection link has one variant, so a `match` on it has a
8// single arm; the matches stay so that the in-process arm has its place when the feature is on.
9#![cfg_attr(
10 not(feature = "testing"),
11 allow(clippy::infallible_destructuring_match)
12)]
13
14use std::fmt;
15#[cfg(feature = "testing")]
16use std::future::ready;
17use std::num::NonZeroU16;
18use std::sync::Arc;
19use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
20
21use futures::StreamExt;
22use lapin::options::{BasicConsumeOptions, BasicQosOptions};
23use lapin::runtime::default_runtime;
24use lapin::types::{FieldTable, ShortString};
25#[cfg(feature = "testing")]
26use lapin::uri::AMQPUri;
27use lapin::{Channel, ChannelState, Connection, ConnectionProperties, ConnectionState, ErrorKind};
28#[cfg(feature = "testing")]
29use ruststream::testing::{Coordinator, InProcess, TestableBroker};
30use ruststream::{
31 AddressedCopies, Broker, ConnectedBroker, DefaultPublish, DescribeServer, ServerSpec, Subscribe,
32};
33#[cfg(feature = "testing")]
34use ruststream::{OutgoingMessage, RawMessage};
35use tokio::runtime::Handle;
36use tokio::sync::watch;
37
38use crate::channel::ChannelCell;
39use crate::convert;
40use crate::delay::DelayContext;
41use crate::error::AmqpError;
42#[cfg(feature = "testing")]
43use crate::in_process::{self, Bus};
44#[cfg(feature = "testing")]
45use crate::live_routes::LiveRoutes;
46use crate::publish_policy::{LapinPublish, LapinPublishPolicy};
47#[cfg(feature = "testing")]
48use crate::publish_step::LapinPublishOptions;
49use crate::queue::{QueueDescriptor, RabbitQueue, declared_arguments};
50use crate::requester::{LapinRequest, LapinRequester};
51use crate::subscriber::LapinSubscriber;
52use crate::topology;
53
54/// Open with Lapin's socket/TLS connector, notifying consumers of exhausted TCP retries.
55pub(crate) async fn dial(
56 uri: &str,
57 properties: ConnectionProperties,
58 tcp_retries: usize,
59) -> Result<(Connection, watch::Sender<bool>), AmqpError> {
60 use lapin::tcp::{AMQPUriTcpExt, OwnedTLSConfig};
61
62 let uri = uri
63 .parse()
64 .map_err(|err: String| AmqpError::Connect(err.into()))?;
65 let recovery = watch::channel(false).0;
66 let terminal = recovery.clone();
67 let failures = AtomicUsize::new(0);
68 let runtime = default_runtime().map_err(AmqpError::connect)?;
69 let connection = Connection::connector(
70 uri,
71 runtime,
72 async move |uri, runtime| {
73 let result = AMQPUriTcpExt::connect_with_config_async(
74 &uri,
75 OwnedTLSConfig::default().as_ref(),
76 &runtime,
77 )
78 .await;
79 match result {
80 Ok(stream) => {
81 failures.store(0, Ordering::Release);
82 Ok(stream)
83 }
84 Err(error) => {
85 let shutdown = runtime.is_runtime_shutdown_error(&error);
86 // shortcut: Lapin leaves exhausted TCP retries reconnecting; remove when it exposes terminal failure.
87 if shutdown || failures.fetch_add(1, Ordering::AcqRel) >= tcp_retries {
88 terminal.send_replace(true);
89 }
90 Err(if shutdown {
91 ErrorKind::RuntimeShutdownError(Arc::new(error)).into()
92 } else {
93 error.into()
94 })
95 }
96 }
97 },
98 properties,
99 )
100 .await
101 .map_err(AmqpError::connect)?;
102 Ok((connection, recovery))
103}
104
105/// The protocol name the `AsyncAPI` document reports for this crate.
106pub(crate) const PROTOCOL: &str = "amqp";
107
108/// The wire version behind that name. `RabbitMQ` speaks AMQP 0.9.1, and AMQP 1.0 is a different
109/// protocol with a binding key of its own, so the version is what tells a reader which one a
110/// client has to speak here.
111pub(crate) const PROTOCOL_VERSION: &str = "0.9.1";
112
113/// The live connection plus the shared fire-and-forget publish channel.
114///
115/// Held behind an [`Arc`] by the connected broker and by every publisher, requester, and
116/// subscriber paired off it, so they all speak over the same connection.
117pub(crate) struct AmqpConnection {
118 connection: Connection,
119 publish_channel: ChannelCell<Channel>,
120 closed: AtomicBool,
121 recovery: watch::Sender<bool>,
122 auto_recover: bool,
123 /// The runtime `connect` ran on. Every task the broker starts runs here, such as a
124 /// requester's reply dispatcher, whichever thread makes the request that starts it.
125 runtime: Handle,
126 /// What the test harness needs to tell which subscription a publish through an exchange
127 /// reaches.
128 #[cfg(feature = "testing")]
129 routes: LiveRoutes,
130}
131
132impl AmqpConnection {
133 pub(crate) fn new(
134 connection: Connection,
135 publish_channel: Channel,
136 runtime: Handle,
137 auto_recover: bool,
138 recovery: watch::Sender<bool>,
139 ) -> Arc<Self> {
140 let conn = Arc::new(Self {
141 connection,
142 publish_channel: ChannelCell::holding(publish_channel),
143 closed: AtomicBool::new(false),
144 recovery,
145 auto_recover,
146 runtime,
147 #[cfg(feature = "testing")]
148 routes: LiveRoutes::default(),
149 });
150 if auto_recover {
151 let events = conn.connection.events_listener();
152 let weak = Arc::downgrade(&conn);
153 conn.runtime.spawn(async move {
154 tokio::pin!(events);
155 while let Some(event) = events.next().await {
156 if !matches!(event, lapin::Event::Connected | lapin::Event::Error(_)) {
157 continue;
158 }
159 let Some(conn) = weak.upgrade() else { return };
160 if conn.failed() {
161 conn.recovery.send_replace(true);
162 return;
163 }
164 // Wake cleanup on reconnect without marking a healthy connection terminal.
165 conn.recovery.send_modify(|_| {});
166 }
167 });
168 }
169 conn
170 }
171
172 pub(crate) const fn auto_recover(&self) -> bool {
173 self.auto_recover
174 }
175
176 /// Wakes consumers and shutdown cleanup; true means recovery failed permanently.
177 pub(crate) fn recovery(&self) -> watch::Receiver<bool> {
178 self.recovery.subscribe()
179 }
180
181 /// Whether Lapin has finished trying to reconnect.
182 pub(crate) fn failed(&self) -> bool {
183 let status = self.connection.status();
184 *self.recovery.borrow() || status.errored() || status.closed() || status.closing()
185 }
186
187 /// Lapin cannot close while reconnecting; keep ownership until it can close or retries end.
188 fn close_after_recovery(self: &Arc<Self>) {
189 let cleanup = Arc::clone(self);
190 let mut recovery = self.recovery();
191 self.runtime.spawn(async move {
192 while !cleanup.failed() {
193 if cleanup.connection.status().connected() {
194 match cleanup.connection.close(200, "OK".into()).await {
195 Ok(()) => return,
196 Err(err) if already_closing(&err) => return,
197 Err(err) => {
198 tracing::debug!(%err, "closing recovered connection after shutdown");
199 }
200 }
201 }
202 if recovery.changed().await.is_err() {
203 return;
204 }
205 }
206 });
207 }
208
209 /// What this connection saw of publishes, bindings and deliveries through exchanges.
210 #[cfg(feature = "testing")]
211 pub(crate) const fn routes(&self) -> &LiveRoutes {
212 &self.routes
213 }
214
215 /// The runtime the broker connected on.
216 pub(crate) const fn runtime(&self) -> &Handle {
217 &self.runtime
218 }
219
220 /// The connection, or [`AmqpError::Closed`] once the broker has shut down.
221 ///
222 /// Why this stays a runtime check: handles paired before the shutdown alias the connection
223 /// and may outlive it, and the typed ladder can only rule out misuse through the owner's
224 /// handle.
225 pub(crate) fn live_connection(&self, target: &str) -> Result<&Connection, AmqpError> {
226 self.ensure_live(target)?;
227 Ok(&self.connection)
228 }
229
230 /// The shared publish channel, or [`AmqpError::Closed`] once the broker has shut down.
231 ///
232 /// Opened again when the one held here is gone. A channel-level error closes the channel and
233 /// leaves the connection up - publishing to an exchange that does not exist is enough, and
234 /// the fire-and-forget publisher does not see that answer - so a channel kept for the
235 /// connection's lifetime would turn one mistyped exchange into a service that never publishes
236 /// again until it is restarted.
237 pub(crate) async fn live_publish_channel(&self, target: &str) -> Result<Channel, AmqpError> {
238 self.ensure_live(target)?;
239 self.publish_channel
240 .get(async move || {
241 self.connection
242 .create_channel()
243 .await
244 .map_err(AmqpError::publish)
245 })
246 .await
247 }
248
249 /// Whether the broker has shut this connection down.
250 pub(crate) fn is_closed(&self) -> bool {
251 self.closed.load(Ordering::Acquire)
252 }
253
254 /// `Ok` while the connection is live, [`AmqpError::Closed`] once the broker has shut down.
255 pub(crate) fn ensure_live(&self, target: &str) -> Result<(), AmqpError> {
256 if self.is_closed() {
257 return Err(AmqpError::closed(target));
258 }
259 Ok(())
260 }
261}
262
263impl fmt::Debug for AmqpConnection {
264 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
265 f.debug_struct("AmqpConnection")
266 .field("closed", &self.closed.load(Ordering::Relaxed))
267 .finish_non_exhaustive()
268 }
269}
270
271/// What a connected broker and every handle paired off it speak over: the live connection, or,
272/// under the `testing` feature, the in-process transport the test harness connected instead.
273///
274/// Without the feature there is one variant, so the type is the connection handle itself and
275/// every `match` on it is irrefutable: a production build carries no second transport and no
276/// branch to it.
277#[derive(Debug, Clone)]
278pub(crate) enum Link {
279 Amqp(Arc<AmqpConnection>),
280 #[cfg(feature = "testing")]
281 InProcess(Arc<Bus>),
282}
283
284// The zero-cost promise of the in-process mode, held by the compiler: a build without it gives the
285// link exactly the size of the connection handle it wraps.
286#[cfg(not(feature = "testing"))]
287const _: () = assert!(
288 size_of::<Link>() == size_of::<Arc<AmqpConnection>>(),
289 "the production link must stay pointer-sized"
290);
291
292/// A `RabbitMQ` broker backed by [`lapin`](https://docs.rs/lapin): configuration captured, no I/O
293/// performed yet.
294///
295/// [`new`](Self::new) is synchronous and records only the connection settings, so a `RabbitMQ`
296/// service is assembled with the synchronous `#[ruststream::app]` builder like any other broker.
297/// The runtime calls [`Broker::connect`] once at startup, which consumes this value and yields
298/// the [`ConnectedLapinBroker`] witness: subscriptions, publishers, and requesters exist only
299/// from there, so "not connected" is not representable.
300///
301/// By default the broker never creates infrastructure: descriptors describe the EXPECTED
302/// topology, and a missing queue is a subscribe error. Opt into declaration with
303/// [`declare_topology(true)`](Self::declare_topology).
304///
305/// # Examples
306///
307/// ```
308/// use ruststream_lapin::prelude::*;
309/// use serde::Deserialize;
310///
311/// #[derive(Deserialize)]
312/// struct Order {
313/// id: u64,
314/// }
315///
316/// #[subscriber("orders")]
317/// async fn handle(order: &Order) -> HandlerOutcome {
318/// println!("got order {}", order.id);
319/// HandlerOutcome::ack()
320/// }
321///
322/// fn app() -> RustStream {
323/// let broker = LapinBroker::new("amqp://localhost:5672")
324/// .prefetch(nonzero!(64))
325/// .declare_topology(true);
326/// RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(broker, |b| {
327/// b.include(handle);
328/// })
329/// }
330/// ```
331#[derive(Debug, Clone)]
332#[must_use]
333pub struct LapinBroker {
334 uri: String,
335 connection_name: Option<String>,
336 prefetch: Option<NonZeroU16>,
337 declare: bool,
338 auto_recover: bool,
339 recovery_retries: usize,
340}
341
342impl LapinBroker {
343 /// Records the connection URI; no I/O happens until [`Broker::connect`].
344 ///
345 /// The URI carries credentials, virtual host, and TLS scheme:
346 /// `amqp://user:pass@host:5672/vhost` (or `amqps://` with a TLS feature enabled).
347 pub fn new(uri: impl Into<String>) -> Self {
348 Self {
349 uri: uri.into(),
350 connection_name: None,
351 prefetch: None,
352 declare: false,
353 auto_recover: false,
354 recovery_retries: 16,
355 }
356 }
357
358 /// A connection name shown in the `RabbitMQ` management UI.
359 pub fn connection_name(mut self, name: impl Into<String>) -> Self {
360 self.connection_name = Some(name.into());
361 self
362 }
363
364 /// Enables automatic connection and topology recovery. Defaults to `false`.
365 ///
366 /// When enabled, Lapin retries with exponential backoff, including the initial connection.
367 /// The limit defaults to 16 retries; see [`recovery_retries`](Self::recovery_retries).
368 /// Failed publishes are not replayed and outstanding RPC deadlines still apply.
369 /// Server transactions require `auto_recover(false)` because their state is lost on reconnect.
370 #[must_use = "use the returned broker to apply the recovery setting"]
371 pub fn auto_recover(mut self, enabled: bool) -> Self {
372 self.auto_recover = enabled;
373 self
374 }
375
376 /// Sets the retry limit for initial connection and subsequent recovery. Defaults to 16.
377 ///
378 /// Only applies with [`auto_recover(true)`](Self::auto_recover). Zero allows the first
379 /// connection attempt but no retries. Successful connections reset the retry budget.
380 #[must_use = "use the returned broker to apply the retry limit"]
381 pub fn recovery_retries(mut self, retries: usize) -> Self {
382 self.recovery_retries = retries;
383 self
384 }
385
386 /// Caps unacknowledged deliveries in flight per subscription (`basic.qos`).
387 ///
388 /// This is the back-pressure window for subscriber streams; individual queue descriptors
389 /// can override it. Without it the server imposes no prefetch limit.
390 ///
391 /// The count is a [`NonZeroU16`] because AMQP reads `basic.qos(0)` as "no limit", the exact
392 /// opposite of a cap: the zero sentinel is unrepresentable here, and leaving the prefetch
393 /// unset is how "unlimited" is spelled.
394 pub fn prefetch(mut self, prefetch: NonZeroU16) -> Self {
395 self.prefetch = Some(prefetch);
396 self
397 }
398
399 /// Whether subscribing declares the descriptor's expected topology first. Defaults to
400 /// `false`: managing infrastructure is the user's job, so creation is a deliberate opt-in.
401 ///
402 /// When enabled, subscribing declares the bound exchanges (except the built-in `amq.*`
403 /// ones and the default exchange), the queue, and the bindings.
404 pub fn declare_topology(mut self, declare: bool) -> Self {
405 self.declare = declare;
406 self
407 }
408}
409
410impl Broker for LapinBroker {
411 type Error = AmqpError;
412 type Connected = ConnectedLapinBroker;
413
414 /// Opens the connection and its shared publish channel, consuming the configuration.
415 ///
416 /// # Errors
417 ///
418 /// Returns [`AmqpError::Connect`] when the URI cannot be parsed or the connection fails.
419 async fn connect(self) -> Result<Self::Connected, Self::Error> {
420 let retries = if self.auto_recover {
421 self.recovery_retries
422 } else {
423 0
424 };
425 let mut properties = ConnectionProperties::default()
426 .configure_backoff(|backoff| backoff.with_max_times(retries));
427 if self.auto_recover {
428 properties = properties.enable_auto_recover();
429 }
430 if let Some(name) = &self.connection_name {
431 properties = properties.with_connection_name(name.as_str().into());
432 }
433 let (connection, recovery) = dial(&self.uri, properties, retries).await?;
434 let publish_channel = connection
435 .create_channel()
436 .await
437 .map_err(AmqpError::connect)?;
438
439 Ok(ConnectedLapinBroker {
440 link: Link::Amqp(AmqpConnection::new(
441 connection,
442 publish_channel,
443 Handle::current(),
444 self.auto_recover,
445 recovery,
446 )),
447 uri: self.uri,
448 prefetch: self.prefetch,
449 declare: self.declare,
450 auto_recover: self.auto_recover,
451 })
452 }
453}
454
455/// The in-process mode: the connected form a test runs the production app against, carrying the
456/// in-process transport in place of the connection and every setting of this broker.
457///
458/// The URI is parsed as `connect` parses it, so a broker a service could not connect is not one a
459/// test can connect either.
460#[cfg(feature = "testing")]
461impl InProcess for LapinBroker {
462 fn connect_in_process(
463 self,
464 ) -> impl Future<Output = Result<Self::Connected, Self::Error>> + Send {
465 let connected = self
466 .uri
467 .parse::<AMQPUri>()
468 .map_err(|err| AmqpError::Connect(err.into()))
469 .map(|_| ConnectedLapinBroker {
470 link: Link::InProcess(Bus::new(Handle::current())),
471 uri: self.uri,
472 prefetch: self.prefetch,
473 declare: self.declare,
474 auto_recover: self.auto_recover,
475 });
476 ready(connected)
477 }
478}
479
480#[cfg(feature = "testing")]
481ruststream::register_testable_broker!(LapinBroker);
482
483/// `DescribeServer` reports the configured AMQP address, which is what the `AsyncAPI` document
484/// records for the service.
485///
486/// The document is published and shared, so the credentials an AMQP URI carries must not reach it.
487/// `ServerSpec::from_url` drops them, along with the scheme and the vhost path.
488impl DescribeServer for LapinBroker {
489 fn describe_server(&self) -> ServerSpec {
490 ServerSpec::from_url(&self.uri, PROTOCOL).protocol_version(PROTOCOL_VERSION)
491 }
492}
493
494/// The typed witness that [`Broker::connect`] succeeded: holds the live connection.
495///
496/// Everything connection-bound hangs off this value: subscriptions ([`Subscribe`],
497/// [`subscribe`](Self::subscribe)), publishers ([`publisher`](Self::publisher)), and requesters
498/// ([`requester`](Self::requester)). [`ConnectedBroker::shutdown`] consumes it, so a publish or
499/// subscribe after shutdown is a compile error for the owner of the handle.
500#[derive(Debug)]
501pub struct ConnectedLapinBroker {
502 link: Link,
503 uri: String,
504 prefetch: Option<NonZeroU16>,
505 declare: bool,
506 auto_recover: bool,
507}
508
509impl ConnectedLapinBroker {
510 /// Whether the connection is currently available, including in-process test connections.
511 ///
512 /// Returns `false` while disconnected or shut down. This is a connection check, not a probe
513 /// of every worker or queue; a successful reconnect can precede completion of topology replay.
514 #[must_use]
515 pub fn is_connected(&self) -> bool {
516 match &self.link {
517 Link::Amqp(conn) => {
518 !conn.is_closed() && !conn.failed() && conn.connection.status().connected()
519 }
520 #[cfg(feature = "testing")]
521 Link::InProcess(bus) => !bus.is_closed(),
522 }
523 }
524
525 pub(crate) const fn auto_recover(&self) -> bool {
526 self.auto_recover
527 }
528
529 /// What every handle paired off this broker speaks over.
530 pub(crate) fn link(&self) -> &Link {
531 &self.link
532 }
533
534 /// The `AsyncAPI` server description of the connection this broker dialled.
535 #[must_use]
536 pub fn server_spec(&self) -> ServerSpec {
537 ServerSpec::from_url(&self.uri, PROTOCOL).protocol_version(PROTOCOL_VERSION)
538 }
539
540 /// Opens a subscription for `def`, declaring its topology first when the broker opted in.
541 ///
542 /// Takes either queue descriptor: [`RabbitQueue`] or
543 /// [`RabbitQuorumQueue`](crate::RabbitQuorumQueue).
544 ///
545 /// # Errors
546 ///
547 /// Returns [`AmqpError::Closed`] after shutdown, [`AmqpError::Declare`] when opted-in
548 /// declaration fails, [`AmqpError::InvalidOptions`] for contradictory descriptor options and
549 /// for a retry declaration this queue cannot carry, and [`AmqpError::Subscribe`] when the
550 /// channel or consumer cannot be opened (for example the queue, or the waiting queue or
551 /// delayed exchange its [`delay`](crate::RabbitQueue::delay) names, does not exist and
552 /// declaration was not opted into).
553 pub async fn subscribe(&self, def: impl QueueDescriptor) -> Result<LapinSubscriber, AmqpError> {
554 let conn = match &self.link {
555 Link::Amqp(conn) => conn,
556 #[cfg(feature = "testing")]
557 Link::InProcess(bus) => return in_process::subscribe(bus, &def, self.declare),
558 };
559 let spec = def.spec();
560 // Before the channel: a declaration the queue cannot carry is the registration's mistake,
561 // and the service should not reach a live consumer with it.
562 def.check_retry(self.declare)?;
563
564 let channel = conn
565 .live_connection(&spec.name)?
566 .create_channel()
567 .await
568 .map_err(AmqpError::subscribe)?;
569
570 if self.declare {
571 let arguments = declared_arguments(spec, def.declared_retry());
572 topology::declare(&channel, spec, arguments).await?;
573 }
574 if let Some(prefetch) = spec.prefetch_or(self.prefetch) {
575 channel
576 .basic_qos(prefetch.get(), BasicQosOptions::default())
577 .await
578 .map_err(AmqpError::subscribe)?;
579 }
580
581 let queue = spec.name.clone();
582 #[cfg(feature = "testing")]
583 conn.routes().bound(&queue, &spec.bindings);
584 // A native delay backend re-publishes the delayed copy on the same channel the delivery is
585 // acked on, so no extra channel is created and the publish orders naturally before the
586 // ack (duplicate-not-loss).
587 let delay = match &spec.delay {
588 Some(delay) => {
589 let target = delay.target_for(&queue);
590 if !self.declare {
591 target.ensure_exists(&channel, &queue).await?;
592 }
593 Some(DelayContext::new(channel.clone(), target))
594 }
595 None => None,
596 };
597
598 let consumer = channel
599 .basic_consume(
600 convert::short(&queue, "queue name")?,
601 ShortString::default(),
602 BasicConsumeOptions::default(),
603 FieldTable::default(),
604 )
605 .await
606 .map_err(AmqpError::subscribe)?;
607
608 Ok(LapinSubscriber::new(
609 Arc::clone(conn),
610 channel,
611 consumer,
612 queue,
613 spec.batch_wait,
614 delay,
615 ))
616 }
617
618 /// A live publisher for `policy`.
619 ///
620 /// [`LapinPublish`] pairs into the fire-and-forget publisher,
621 /// [`ConfirmsPublish`](crate::ConfirmsPublish) into the confirm-transactional one, and
622 /// [`ServerTxPublish`](crate::ServerTxPublish) into the AMQP-server-transactional one. All
623 /// three are cheap to build and cheap to clone.
624 ///
625 /// # Examples
626 ///
627 /// ```no_run
628 /// use std::error::Error;
629 ///
630 /// use ruststream::Broker;
631 /// use ruststream_lapin::prelude::*;
632 /// use serde::Serialize;
633 ///
634 /// #[derive(Serialize, Outgoing)]
635 /// #[outgoing(name = "order.placed")]
636 /// struct OrderPlaced {
637 /// id: u64,
638 /// }
639 ///
640 /// // A one-off backfill outside any service: connect, publish, confirm.
641 /// async fn backfill(ids: &[u64]) -> Result<(), Box<dyn Error>> {
642 /// let connected = LapinBroker::new("amqp://localhost:5672").connect().await?;
643 /// let events = connected.publisher(LapinPublish::default().exchange("events").confirms());
644 /// for &id in ids {
645 /// events.message(&OrderPlaced { id }).publish().await?;
646 /// }
647 /// Ok(())
648 /// }
649 /// ```
650 #[must_use]
651 pub fn publisher<P: LapinPublishPolicy>(&self, policy: P) -> P::Live {
652 policy.bind(self)
653 }
654
655 /// A live request/reply client over `RabbitMQ` direct reply-to.
656 ///
657 /// The requester half of [`LapinRequest`]; [`publisher`](Self::publisher) accepts the same
658 /// policy, this accessor only names the result.
659 #[must_use]
660 pub fn requester(&self, policy: LapinRequest) -> LapinRequester {
661 policy.bind(self)
662 }
663}
664
665impl ConnectedBroker for ConnectedLapinBroker {
666 type Error = AmqpError;
667 type Closed = ClosedLapinBroker;
668
669 /// Closes the connection, consuming the connected form.
670 ///
671 /// # Errors
672 ///
673 /// Returns [`AmqpError::Connect`] when the close handshake fails.
674 async fn shutdown(self) -> Result<Self::Closed, Self::Error> {
675 let conn = match self.link {
676 Link::Amqp(conn) => conn,
677 #[cfg(feature = "testing")]
678 Link::InProcess(bus) => {
679 bus.close();
680 return Ok(ClosedLapinBroker { handshake: true });
681 }
682 };
683 // Marked closed before the handshake: a publisher aliasing the connection must not slip
684 // a message into a connection that is already going away.
685 conn.closed.store(true, Ordering::Release);
686 conn.recovery.send_modify(|_| {});
687 let handshake = conn.connection.status().connected();
688 if handshake {
689 match conn.connection.close(200, ShortString::from("OK")).await {
690 Ok(()) => {}
691 // A subscriber dropped just before the shutdown is still cancelling its consumer,
692 // and lapin refuses to close a channel it is already closing. The connection is
693 // going away either way, so reporting that as a teardown failure would make an
694 // orderly shutdown look broken - and would do so only sometimes, which is worse.
695 Err(err) if already_closing(&err) => {}
696 Err(_)
697 if conn.connection.status().reconnecting()
698 || conn.connection.status().connecting() =>
699 {
700 conn.close_after_recovery();
701 }
702 Err(err) => return Err(AmqpError::connect(err)),
703 }
704 } else if !conn.failed() {
705 conn.close_after_recovery();
706 }
707 Ok(ClosedLapinBroker { handshake })
708 }
709}
710
711// By-name subscription capability: the runtime's default `Name` source resolves through this for
712// the bare-string `#[subscriber("queue")]` form.
713//
714// `Self::subscribe` inside this impl would resolve to the trait method and recurse; the type
715// name is the only way to reach the inherent one.
716#[allow(clippy::use_self)]
717impl Subscribe for ConnectedLapinBroker {
718 type Subscriber = LapinSubscriber;
719
720 /// A queue name is an address as well as a subscription: on the default exchange a routing key
721 /// addresses the queue that carries it, so the service publishes a delayed redelivery back
722 /// under the name it subscribed to.
723 ///
724 /// The copy reaches the queue as long as the retry publisher sends on the default exchange,
725 /// which is what [`LapinPublish`] does unless [`exchange`](LapinPublish::exchange) says
726 /// otherwise; pointing that publisher at a topic exchange with no binding under the queue name
727 /// would send the copy nowhere, so bind it there or leave the retry publisher on the default
728 /// exchange.
729 type Copies = AddressedCopies;
730
731 /// Subscribes to the queue `name` with descriptor defaults (durable, shared).
732 async fn subscribe(&self, name: &str) -> Result<Self::Subscriber, Self::Error> {
733 ConnectedLapinBroker::subscribe(self, RabbitQueue::new(name)).await
734 }
735
736 // `declare_retry` keeps the default, which accepts the declaration and leaves the runtime to
737 // apply it. A cap RabbitMQ enforces itself is `x-delivery-limit` with a dead-letter route, and
738 // a queue takes both at declaration time: a name arriving here is a queue that already exists,
739 // so there is nothing to declare them on. `RabbitQuorumQueue` is where they become topology.
740}
741
742impl DefaultPublish for ConnectedLapinBroker {
743 type Policy = LapinPublish;
744}
745
746/// The harness's view of the in-process transport: what it injects, what it reads back, and the
747/// coordinator it counts the in-flight deliveries with.
748///
749/// # Panics
750///
751/// `inject` and `published` panic on a broker connected with `connect`: the harness drives only
752/// the connection `connect_in_process` produced, and a live connection has no log to read and no
753/// synchronous way to take a message.
754#[cfg(feature = "testing")]
755impl TestableBroker for ConnectedLapinBroker {
756 fn install_coordinator(&self, coordinator: Coordinator) {
757 if let Link::InProcess(bus) = &self.link {
758 bus.install(coordinator);
759 }
760 }
761
762 fn inject(&self, message: OutgoingMessage<'_>) {
763 let bus = self.bus("inject");
764 // An external producer publishes on the default exchange, the name addressing the queue,
765 // with nothing but its headers to frame.
766 if let Err(err) = bus.publish(
767 "",
768 message.name(),
769 message.payload(),
770 message.headers(),
771 &LapinPublishOptions::default(),
772 ) {
773 panic!(
774 "the injected message to {:?} is not one the server takes: {err}",
775 message.name()
776 );
777 }
778 }
779
780 fn published(&self, name: &str) -> Vec<RawMessage> {
781 self.bus("published").published(name)
782 }
783
784 /// A queue hands each message to one of its consumers, so a publish to a queue with two
785 /// subscriptions is owed by one of them, never by both.
786 ///
787 /// The broker's default publisher sends on the default exchange, which routes by queue name,
788 /// so the candidates are the subscriptions of `destination`'s name, in the order they opened.
789 /// In process the answer is the consumer the transport's rotation serves next. Against a
790 /// server the pick is the server's and not observable from here, so a live test opens one
791 /// subscription per queue.
792 ///
793 /// Against a server a publish through a named exchange is owed by the subscriptions whose
794 /// queues the service bound to it: by the binding key on a direct exchange, by the pattern on
795 /// a topic exchange, every one on a fanout exchange. A consistent-hash exchange hands a key to
796 /// the one queue its hash picks, so every bound queue owes the publish until the first
797 /// delivery of that key shows which one took it.
798 ///
799 /// # Panics
800 ///
801 /// Panics against a server when `destination` has more than one subscription: the harness
802 /// would wait on a consumer the server may not have picked, and time out without saying why.
803 fn routes(&self, destination: &str, subscriptions: &[&str]) -> Vec<usize> {
804 let candidates: Vec<usize> = subscriptions
805 .iter()
806 .enumerate()
807 .filter(|(_, name)| **name == destination)
808 .map(|(position, _)| position)
809 .collect();
810 let turn = match &self.link {
811 Link::InProcess(bus) => bus.next_consumer_of(destination),
812 Link::Amqp(conn) => {
813 assert!(
814 candidates.len() <= 1,
815 "queue {destination:?} has {} subscriptions, and RabbitMQ picks which consumer \
816 takes a message where the harness cannot see it: a live test opens one \
817 subscription per queue",
818 candidates.len()
819 );
820 let mut owed: Vec<usize> = candidates.first().copied().into_iter().collect();
821 for position in conn.routes().through_exchanges(destination, subscriptions) {
822 if !owed.contains(&position) {
823 owed.push(position);
824 }
825 }
826 return owed;
827 }
828 };
829 turn.and_then(|turn| candidates.get(turn).copied())
830 .into_iter()
831 .collect()
832 }
833}
834
835#[cfg(feature = "testing")]
836impl ConnectedLapinBroker {
837 /// The in-process transport, which is all the harness drives.
838 fn bus(&self, what: &str) -> &Arc<Bus> {
839 match &self.link {
840 Link::InProcess(bus) => bus,
841 Link::Amqp(_) => panic!(
842 "TestableBroker::{what} reached a broker connected with `connect`; the harness \
843 drives the connection `connect_in_process` produces"
844 ),
845 }
846 }
847}
848
849/// Whether this error says the close is already under way rather than that it failed.
850fn already_closing(err: &lapin::Error) -> bool {
851 matches!(
852 err.kind(),
853 ErrorKind::InvalidChannelState(ChannelState::Closing | ChannelState::Closed, _)
854 | ErrorKind::InvalidConnectionState(ConnectionState::Closing | ConnectionState::Closed)
855 )
856}
857
858pub(crate) fn check_server_tx(auto_recover: bool) -> Result<(), AmqpError> {
859 if auto_recover {
860 return Err(AmqpError::InvalidOptions(
861 "ServerTxPublisher does not support auto_recover(true): server transaction state is lost on reconnect. Use auto_recover(false) or LapinPublish::confirms().".to_owned(),
862 ));
863 }
864 Ok(())
865}
866
867/// The terminal witness returned by shutting down a [`ConnectedLapinBroker`].
868///
869/// It has no publish or subscribe surface; it carries whether the close handshake actually ran,
870/// which distinguishes an orderly teardown from closing a connection the server had already
871/// dropped.
872#[derive(Debug, Clone, Copy, PartialEq, Eq)]
873pub struct ClosedLapinBroker {
874 handshake: bool,
875}
876
877impl ClosedLapinBroker {
878 /// Whether the AMQP close handshake ran, as opposed to the connection already being down.
879 #[must_use]
880 pub const fn handshake(&self) -> bool {
881 self.handshake
882 }
883}
884
885#[cfg(test)]
886mod tests {
887 use super::{DescribeServer, LapinBroker};
888
889 fn described_host(uri: &str) -> String {
890 LapinBroker::new(uri)
891 .describe_server()
892 .host
893 .expect("an AMQP URI always names a host")
894 }
895
896 // The published document must carry the coordinate and nothing else, whatever an operator put
897 // in the connection URI: no password, no vhost.
898 #[test]
899 fn the_description_is_the_host_alone_whatever_the_uri_carries() {
900 assert_eq!(described_host("amqp://localhost:5672"), "localhost:5672");
901 assert_eq!(
902 described_host("amqp://user:pass@rabbit:5672/prod"),
903 "rabbit:5672"
904 );
905 assert_eq!(described_host("amqps://rabbit/vhost"), "rabbit");
906 assert_eq!(described_host("rabbit:5672"), "rabbit:5672");
907 // A vhost may contain an `@`, so the path is cut before the userinfo is.
908 assert_eq!(described_host("amqp://rabbit:5672/my@vhost"), "rabbit:5672");
909 // Both an `@` in the userinfo and one in the vhost: each is cut at its own step.
910 assert_eq!(
911 described_host("amqp://user:p@ss@rabbit:5672/my@vhost"),
912 "rabbit:5672"
913 );
914 assert_eq!(
915 described_host("amqp://rabbit:5672/prod?heartbeat=30"),
916 "rabbit:5672"
917 );
918 }
919
920 // `new` records the settings without connecting: no server is needed to build the broker or
921 // to describe it, which is what lets it slot into the synchronous app builder.
922 #[test]
923 fn new_performs_no_io_and_describes_the_configured_address() {
924 let spec = LapinBroker::new("amqp://127.0.0.1:5672").describe_server();
925 assert_eq!(spec.protocol, "amqp");
926 assert_eq!(spec.host.as_deref(), Some("127.0.0.1:5672"));
927 }
928}