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