Skip to main content

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}