ruststream_pulsar/broker.rs
1//! The broker ladder: [`PulsarBroker`] -> [`ConnectedPulsarBroker`].
2//!
3//! Construction is synchronous and I/O-free; the client dials in the consuming
4//! [`Broker::connect`], and the connected form holds the live client directly. One shared cell
5//! remains so publishers can be handed out while the application is still being assembled,
6//! before `connect` runs.
7
8use std::collections::HashMap;
9use std::num::{NonZeroU32, NonZeroUsize};
10use std::sync::Arc;
11use std::sync::atomic::{AtomicBool, Ordering};
12use std::time::Duration;
13
14use pulsar::{Authentication, OperationRetryOptions, Pulsar, TokioExecutor};
15#[cfg(feature = "testing")]
16use ruststream::testing::{Coordinator, InProcess, TestableBroker};
17use ruststream::{
18 Broker, BrokerMoves, ConnectedBroker, DeclareRetryError, DefaultPublish, DescribeServer,
19 RetryDeclaration, ServerSpec, Subscribe,
20};
21#[cfg(feature = "testing")]
22use ruststream::{BytesMut, OutgoingMessage, RawMessage};
23use tokio::runtime::Handle;
24use tokio::sync::{Mutex, OnceCell};
25
26use crate::error::{PulsarError, box_err};
27#[cfg(feature = "testing")]
28use crate::in_process::{self, Bus, Route, Subscriptions};
29use crate::publisher::{PulsarProducer, PulsarPublish, PulsarPublisher};
30use crate::subscriber::PulsarSubscriber;
31use crate::subscription::{DeclaredRetries, PulsarSubscription};
32
33/// The largest payload a Pulsar server accepts by default: its `maxMessageSize` setting.
34pub(crate) const DEFAULT_MAX_MESSAGE_SIZE: usize = 5 * 1024 * 1024;
35
36/// How long the client keeps asking for an operation a server has not accepted yet.
37///
38/// The client retries an answer that says "not now" - a broker that is still coming up, a
39/// producer or a consumer the topic already has - rather than reporting it, and by default it
40/// retries for ever, five seconds apart. That is what waits out a broker restart, and it is also
41/// what makes a subscription a server refuses (an
42/// [`Exclusive`](crate::SubscriptionType::Exclusive) one another consumer holds) a call that
43/// never returns. [`attempts`](Self::attempts) puts a number on it.
44///
45/// The bound covers every retried operation of the connection, the consumer's own reconnect
46/// included, so a service that shortens it trades waiting out an outage for hearing about it.
47///
48/// # Examples
49///
50/// ```
51/// # mod demo {
52/// use std::time::Duration;
53///
54/// use ruststream_pulsar::prelude::*;
55/// # use serde::Deserialize;
56/// # #[derive(Deserialize)]
57/// # struct Order {
58/// # id: u64,
59/// # }
60/// # #[subscriber(PulsarSubscription::new("orders", "workers").subscription_type(SubscriptionType::Exclusive))]
61/// # async fn handle(order: &Order) -> HandlerOutcome {
62/// # tracing::info!(order.id, "accepted");
63/// # HandlerOutcome::ack()
64/// # }
65///
66/// #[ruststream::app]
67/// fn app() -> impl App {
68/// // Three tries a second apart, then startup reports what the server said instead of
69/// // waiting for the holder of the exclusive subscription to leave.
70/// let retries = OperationRetries::attempts(nonzero!(3u32)).delay(Duration::from_secs(1));
71/// RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(
72/// PulsarBroker::new("pulsar://localhost:6650").operation_retries(retries),
73/// |b| {
74/// b.include(handle);
75/// },
76/// )
77/// }
78/// # }
79/// # fn main() {}
80/// ```
81#[derive(Debug, Clone, PartialEq, Eq)]
82#[must_use]
83pub struct OperationRetries {
84 attempts: Option<NonZeroU32>,
85 delay: Duration,
86 timeout: Duration,
87}
88
89/// The client's own numbers, which apply unless a step below names another.
90const DEFAULT_RETRY_DELAY: Duration = Duration::from_secs(5);
91const DEFAULT_OPERATION_TIMEOUT: Duration = Duration::from_secs(30);
92
93impl OperationRetries {
94 /// Keeps asking for ever, which is the client's own behaviour.
95 pub const fn unbounded() -> Self {
96 Self {
97 attempts: None,
98 delay: DEFAULT_RETRY_DELAY,
99 timeout: DEFAULT_OPERATION_TIMEOUT,
100 }
101 }
102
103 /// Gives up after `attempts` tries and reports what the server answered.
104 ///
105 /// One attempt means the first answer is the final one.
106 pub const fn attempts(attempts: NonZeroU32) -> Self {
107 Self {
108 attempts: Some(attempts),
109 ..Self::unbounded()
110 }
111 }
112
113 /// Waits `delay` between tries. Defaults to five seconds.
114 pub const fn delay(mut self, delay: Duration) -> Self {
115 self.delay = delay;
116 self
117 }
118
119 /// Gives one try `timeout` to be answered at all. Defaults to thirty seconds.
120 pub const fn timeout(mut self, timeout: Duration) -> Self {
121 self.timeout = timeout;
122 self
123 }
124}
125
126impl Default for OperationRetries {
127 fn default() -> Self {
128 Self::unbounded()
129 }
130}
131
132impl From<OperationRetries> for OperationRetryOptions {
133 /// The client counts the retries that follow the first try, so a bound of `n` tries is
134 /// `n - 1` retries.
135 fn from(retries: OperationRetries) -> Self {
136 Self {
137 operation_timeout: retries.timeout,
138 retry_delay: retries.delay,
139 max_retries: retries.attempts.map(|attempts| attempts.get() - 1),
140 }
141 }
142}
143
144/// What a connected broker and every handle derived from it speak over: the client, or, under
145/// the `testing` feature, the in-process transport the test harness connected instead.
146///
147/// Without the feature there is one variant, so the type is the client itself and every `match`
148/// on it is irrefutable: a production build carries no second transport and no branch to it.
149pub(crate) enum Transport {
150 Client(Pulsar<TokioExecutor>),
151 #[cfg(feature = "testing")]
152 InProcess(Arc<Bus>),
153}
154
155// The zero-cost promise of the in-process mode, held by the compiler: a build without it gives
156// the transport exactly the size of the client it wraps.
157#[cfg(not(feature = "testing"))]
158const _: () = assert!(size_of::<Transport>() == size_of::<Pulsar<TokioExecutor>>());
159
160/// The live client state shared by the connected form and every handle derived from it.
161///
162/// Why runtime checks exist here at all: the client handle is `Clone` and would happily
163/// reconnect after our typed shutdown, and publishers may be handed out before `connect` and
164/// outlive `shutdown` (aliasing) - so the closed state is an explicit flag a stale handle
165/// trips over instead of silently succeeding.
166pub(crate) struct Core {
167 pub(crate) transport: Transport,
168 pub(crate) closed: AtomicBool,
169 /// Per-topic producers, shared by every publisher handle so shutdown can close them.
170 pub(crate) producers: Mutex<HashMap<String, Arc<Mutex<PulsarProducer>>>>,
171 /// The subscription a bare topic name joins; see [`PulsarBroker::default_subscription`].
172 default_subscription: Option<String>,
173 /// What registrations mounted by a bare topic name declared about their retries.
174 pub(crate) declared_retries: DeclaredRetries,
175 /// The largest payload a publish may carry; see [`PulsarBroker::max_message_size`].
176 pub(crate) max_message_size: usize,
177 /// The runtime `connect` ran on. Every task the broker starts runs here: a subscription's
178 /// driver, and through it a delayed retry, whichever thread settles the delivery.
179 pub(crate) runtime: Handle,
180 /// The subscriptions this connection opened, which the test harness's routing answer reads
181 /// on either transport.
182 #[cfg(feature = "testing")]
183 opened: Subscriptions,
184}
185
186impl Core {
187 fn new(transport: Transport, broker: &PulsarBroker, runtime: Handle) -> Self {
188 Self {
189 transport,
190 runtime,
191 closed: AtomicBool::new(false),
192 producers: Mutex::new(HashMap::new()),
193 default_subscription: broker.default_subscription.clone(),
194 declared_retries: DeclaredRetries::default(),
195 max_message_size: broker.max_message_size,
196 #[cfg(feature = "testing")]
197 opened: Subscriptions::default(),
198 }
199 }
200
201 pub(crate) fn ensure_open(&self) -> Result<(), PulsarError> {
202 if self.closed.load(Ordering::Acquire) {
203 return Err(PulsarError::NotConnected);
204 }
205 Ok(())
206 }
207}
208
209impl std::fmt::Debug for Core {
210 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
211 f.debug_struct("Core")
212 .field("closed", &self.closed.load(Ordering::Relaxed))
213 .finish_non_exhaustive()
214 }
215}
216
217pub(crate) type CoreCell = Arc<OnceCell<Arc<Core>>>;
218
219/// An Apache Pulsar broker for the `RustStream` messaging framework.
220///
221/// `new` is synchronous and records only configuration; the runtime dials once at startup via
222/// the consuming [`Broker::connect`]. That is what lets a service compose with the synchronous
223/// `#[ruststream::app]` builder.
224///
225/// # Examples
226///
227/// ```
228/// # mod demo {
229/// use std::env;
230///
231/// use ruststream_pulsar::prelude::*;
232/// # use serde::Deserialize;
233/// # #[derive(Deserialize)]
234/// # struct Order {
235/// # id: u64,
236/// # }
237/// # #[subscriber(PulsarSubscription::new("orders", "workers"))]
238/// # async fn handle(order: &Order) -> HandlerOutcome {
239/// # tracing::info!(order.id, "accepted");
240/// # HandlerOutcome::ack()
241/// # }
242///
243/// #[ruststream::app]
244/// fn app() -> impl App {
245/// let mut broker = PulsarBroker::new("pulsar+ssl://pulsar.acme.internal:6651");
246/// if let Ok(token) = env::var("PULSAR_TOKEN") {
247/// broker = broker.token(token);
248/// }
249/// RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(broker, |b| {
250/// b.include(handle);
251/// })
252/// }
253/// # }
254/// # fn main() {}
255/// ```
256///
257/// A test runs the app `main` runs. With the `testing` feature, `TestApp::start` connects this
258/// broker in process, with no server, and the test addresses it by its production type:
259///
260/// ```
261/// # #[cfg(feature = "testing")]
262/// # mod demo {
263/// use std::error::Error;
264///
265/// use ruststream::testing::TestApp;
266/// use ruststream_pulsar::prelude::*;
267/// use serde::{Deserialize, Serialize};
268///
269/// #[derive(Debug, Deserialize, Eq, Outgoing, PartialEq, Serialize)]
270/// struct Order {
271/// id: u64,
272/// quantity: u32,
273/// }
274///
275/// #[subscriber(PulsarSubscription::new("orders", "workers"))]
276/// async fn handle(order: &Order) -> HandlerOutcome {
277/// if order.quantity == 0 {
278/// return HandlerOutcome::drop();
279/// }
280/// HandlerOutcome::ack()
281/// }
282///
283/// /// The app `main` runs, and the one the tests hand the harness.
284/// pub fn app() -> RustStream {
285/// RustStream::new(AppInfo::new("orders", "0.1.0"))
286/// .with_broker(PulsarBroker::new("pulsar://localhost:6650"), |b| {
287/// b.include(handle);
288/// })
289/// }
290///
291/// pub async fn an_empty_order_is_dropped() -> Result<(), Box<dyn Error>> {
292/// let tb = TestApp::start(app()).await?;
293///
294/// // The publish returns once the handler it woke has settled.
295/// tb.broker::<PulsarBroker>()
296/// .message(&Order { id: 1, quantity: 0 })
297/// .to("orders")
298/// .publish()
299/// .await?;
300///
301/// tb.broker::<PulsarBroker>()
302/// .subscriber("orders")
303/// .assert_called_once()
304/// .with(&Order { id: 1, quantity: 0 })
305/// .settled(HandlerOutcome::drop());
306/// tb.shutdown().await?;
307/// Ok(())
308/// }
309/// # }
310/// # #[cfg(feature = "testing")]
311/// # #[tokio::main]
312/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
313/// # demo::an_empty_order_is_dropped().await
314/// # }
315/// # #[cfg(not(feature = "testing"))]
316/// # fn main() {}
317/// ```
318#[derive(Debug, Clone)]
319#[must_use]
320pub struct PulsarBroker {
321 url: String,
322 token: Option<String>,
323 default_subscription: Option<String>,
324 // None leaves the client's own retry behaviour in place, which is unbounded.
325 retries: Option<OperationRetries>,
326 max_message_size: usize,
327 // Shared with publishers handed out before connect; the consuming connect fills it.
328 cell: CoreCell,
329}
330
331impl PulsarBroker {
332 /// Records the service URL (`pulsar://` or `pulsar+ssl://`). No I/O.
333 pub fn new(url: impl Into<String>) -> Self {
334 Self {
335 url: url.into(),
336 token: None,
337 default_subscription: None,
338 retries: None,
339 max_message_size: DEFAULT_MAX_MESSAGE_SIZE,
340 cell: Arc::new(OnceCell::new()),
341 }
342 }
343
344 /// Authenticates with a JWT token.
345 pub fn token(mut self, token: impl Into<String>) -> Self {
346 self.token = Some(token.into());
347 self
348 }
349
350 /// The durable subscription a subscription by bare topic name joins.
351 ///
352 /// `#[subscriber("orders")]` names a topic and no subscription, and Pulsar has no anonymous
353 /// consumer, so the name comes from here. A subscription name is the cursor on the server:
354 /// every consumer under it shares one backlog, the handlers of this service and those of any
355 /// other service that names the same one. Name it after the service that owns the cursor.
356 /// A bare topic name on a broker without it fails at startup with
357 /// [`PulsarError::Invalid`] naming this setting; a [`PulsarSubscription`] names its own
358 /// subscription and needs none.
359 ///
360 /// # Examples
361 ///
362 /// ```
363 /// # mod demo {
364 /// use ruststream_pulsar::prelude::*;
365 /// # use serde::Deserialize;
366 /// # #[derive(Deserialize)]
367 /// # struct Order {
368 /// # id: u64,
369 /// # }
370 ///
371 /// /// Joins the `orders-worker` subscription on `orders`, with every other instance of the
372 /// /// service.
373 /// #[subscriber("orders")]
374 /// async fn handle(order: &Order) -> HandlerOutcome {
375 /// tracing::info!(order.id, "accepted");
376 /// HandlerOutcome::ack()
377 /// }
378 ///
379 /// #[ruststream::app]
380 /// fn app() -> impl App {
381 /// RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(
382 /// PulsarBroker::new("pulsar://localhost:6650").default_subscription("orders-worker"),
383 /// |b| {
384 /// b.include(handle);
385 /// },
386 /// )
387 /// }
388 /// # }
389 /// # fn main() {}
390 /// ```
391 pub fn default_subscription(mut self, subscription: impl Into<String>) -> Self {
392 self.default_subscription = Some(subscription.into());
393 self
394 }
395
396 /// Bounds how long the client keeps asking for an operation a server has not accepted.
397 ///
398 /// Left alone, the client retries such an answer for ever, five seconds apart, which is what
399 /// waits out a broker that is restarting. The same patience covers a subscription the server
400 /// refuses because another consumer holds it, so a second consumer of an
401 /// [`Exclusive`](crate::SubscriptionType::Exclusive) subscription neither takes it nor
402 /// reports anything - it waits, and the service does not start. A service that has to hear
403 /// about that instead bounds the tries here, and `subscribe` then returns
404 /// [`PulsarError::Subscribe`](PulsarError) naming the topic.
405 ///
406 /// The bound is the connection's, not one operation's: it covers the lookups, the producers
407 /// and the consumer's own reconnect after a disconnect, so a short bound turns a long outage
408 /// into a subscription that gives up rather than one that waits.
409 ///
410 /// # Examples
411 ///
412 /// ```
413 /// # mod demo {
414 /// use ruststream_pulsar::prelude::*;
415 /// # use serde::Deserialize;
416 /// # #[derive(Deserialize)]
417 /// # struct Order {
418 /// # id: u64,
419 /// # }
420 /// # #[subscriber(PulsarSubscription::new("orders", "workers").subscription_type(SubscriptionType::Exclusive))]
421 /// # async fn handle(order: &Order) -> HandlerOutcome {
422 /// # tracing::info!(order.id, "accepted");
423 /// # HandlerOutcome::ack()
424 /// # }
425 ///
426 /// #[ruststream::app]
427 /// fn app() -> impl App {
428 /// RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(
429 /// PulsarBroker::new("pulsar://localhost:6650")
430 /// .operation_retries(OperationRetries::attempts(nonzero!(3u32))),
431 /// |b| {
432 /// b.include(handle);
433 /// },
434 /// )
435 /// }
436 /// # }
437 /// # fn main() {}
438 /// ```
439 pub fn operation_retries(mut self, retries: OperationRetries) -> Self {
440 self.retries = Some(retries);
441 self
442 }
443
444 /// The largest payload a publish may carry, in bytes: the server's `maxMessageSize`, five
445 /// mebibytes unless the deployment raised or lowered it.
446 ///
447 /// A larger publish fails with [`PulsarError::Publish`] before it leaves the service. The
448 /// client does not check the size itself, and a server answers an oversized frame by
449 /// dropping the connection, which fails every producer and consumer sharing it; the
450 /// in-process mode refuses the same publishes.
451 ///
452 /// # Examples
453 ///
454 /// ```
455 /// # mod demo {
456 /// use ruststream_pulsar::prelude::*;
457 /// # use serde::Deserialize;
458 /// # #[derive(Deserialize)]
459 /// # struct Scan {
460 /// # id: u64,
461 /// # }
462 /// # #[subscriber(PulsarSubscription::new("scans", "archive"))]
463 /// # async fn archive(scan: &Scan) -> HandlerOutcome {
464 /// # tracing::info!(scan.id, "archived");
465 /// # HandlerOutcome::ack()
466 /// # }
467 ///
468 /// #[ruststream::app]
469 /// fn app() -> impl App {
470 /// // The cluster runs with `maxMessageSize=10485760`.
471 /// RustStream::new(AppInfo::new("archive", "0.1.0")).with_broker(
472 /// PulsarBroker::new("pulsar://localhost:6650")
473 /// .max_message_size(nonzero!(10_485_760usize)),
474 /// |b| {
475 /// b.include(archive);
476 /// },
477 /// )
478 /// }
479 /// # }
480 /// # fn main() {}
481 /// ```
482 pub fn max_message_size(mut self, bytes: NonZeroUsize) -> Self {
483 self.max_message_size = bytes.get();
484 self
485 }
486
487 /// A publisher sharing this broker's connection cell; buildable before `connect`.
488 #[must_use]
489 pub fn publisher(&self) -> PulsarPublisher {
490 PulsarPublisher::new(Arc::clone(&self.cell))
491 }
492}
493
494impl Broker for PulsarBroker {
495 type Error = PulsarError;
496 type Connected = ConnectedPulsarBroker;
497
498 async fn connect(self) -> Result<Self::Connected, Self::Error> {
499 let core = self
500 .cell
501 .get_or_try_init(async || {
502 let mut builder = Pulsar::builder(self.url.clone(), TokioExecutor);
503 if let Some(token) = &self.token {
504 builder = builder.with_auth(Authentication {
505 name: "token".to_owned(),
506 data: token.clone().into_bytes(),
507 });
508 }
509 if let Some(retries) = self.retries.clone() {
510 builder = builder.with_operation_retry_options(retries.into());
511 }
512 let client = builder
513 .build()
514 .await
515 .map_err(|e| PulsarError::Connect(box_err(e)))?;
516 Ok::<_, PulsarError>(Arc::new(Core::new(
517 Transport::Client(client),
518 &self,
519 Handle::current(),
520 )))
521 })
522 .await?
523 .clone();
524 Ok(ConnectedPulsarBroker {
525 core,
526 cell: self.cell,
527 })
528 }
529}
530
531/// The in-process mode: the connected form a test runs the production app against, carrying the
532/// in-process transport in place of the client and every setting of this broker.
533///
534/// The service URL is parsed as the client parses it, so a broker a service could not connect is
535/// not one a test can connect either. The connection cell is the one `connect` fills, so a
536/// publisher taken from the broker before the harness connected it publishes in process.
537#[cfg(feature = "testing")]
538impl InProcess for PulsarBroker {
539 async fn connect_in_process(self) -> Result<Self::Connected, Self::Error> {
540 in_process::check_url(&self.url)?;
541 let core = self
542 .cell
543 .get_or_init(async || {
544 let runtime = Handle::current();
545 Arc::new(Core::new(
546 Transport::InProcess(Bus::new(runtime.clone())),
547 &self,
548 runtime,
549 ))
550 })
551 .await
552 .clone();
553 Ok(ConnectedPulsarBroker {
554 core,
555 cell: self.cell,
556 })
557 }
558}
559
560#[cfg(feature = "testing")]
561ruststream::register_testable_broker!(PulsarBroker);
562
563/// The description carries the address a client dials and nothing else: the generated `AsyncAPI`
564/// document is published and shared, so a `user:password@` in the service URL must not reach it.
565/// `ServerSpec::from_url` is the framework's own reduction, so every broker crate drops the
566/// userinfo the same way.
567impl DescribeServer for PulsarBroker {
568 fn describe_server(&self) -> ServerSpec {
569 ServerSpec::from_url(&self.url, "pulsar")
570 }
571}
572
573/// The typed witness that `connect` succeeded: holds the live client directly.
574#[derive(Debug)]
575pub struct ConnectedPulsarBroker {
576 pub(crate) core: Arc<Core>,
577 // Keeps the cell of publishers handed out before connect alive and filled.
578 cell: CoreCell,
579}
580
581impl ConnectedPulsarBroker {
582 /// A publisher from the connected form. It rides the same cell-backed publisher type as
583 /// the early path; by now `connect` has filled the cell, so it resolves immediately.
584 #[must_use]
585 pub fn publisher(&self) -> PulsarPublisher {
586 PulsarPublisher::new(Arc::clone(&self.cell))
587 }
588
589 /// Opens the subscription described by `descriptor`.
590 ///
591 /// # Errors
592 ///
593 /// Returns [`PulsarError`] when the descriptor is invalid, the consumer cannot be created,
594 /// or the broker is shut down.
595 pub async fn subscribe_descriptor(
596 &self,
597 descriptor: PulsarSubscription,
598 ) -> Result<PulsarSubscriber, PulsarError> {
599 descriptor.validate()?;
600 self.core.ensure_open()?;
601 #[cfg(feature = "testing")]
602 let (route, name, subscription) = (
603 Route::of(&descriptor)?,
604 descriptor.source_name().to_owned(),
605 descriptor.subscription().to_owned(),
606 );
607 #[cfg(feature = "testing")]
608 let recorded = route.clone();
609 let subscriber = match &self.core.transport {
610 Transport::Client(client) => {
611 // The consumer is built on the runtime the broker connected on: the client starts
612 // the connection it needs on the runtime that asks, and a subscription opened from
613 // a runtime that stops (a dedicated thread's) would lose that connection with it.
614 let (client, core) = (client.clone(), Arc::clone(&self.core));
615 let topic = descriptor.display_topic();
616 self.core
617 .runtime
618 .spawn(async move { PulsarSubscriber::open(&client, descriptor, &core).await })
619 .await
620 .map_err(|err| PulsarError::Subscribe {
621 topic,
622 source: box_err(err),
623 })??
624 }
625 #[cfg(feature = "testing")]
626 Transport::InProcess(bus) => {
627 let (subscriber, consumer) = in_process::subscribe(bus, descriptor, route)?;
628 self.core
629 .opened
630 .record(name, subscription, recorded, Some(consumer));
631 return Ok(subscriber);
632 }
633 };
634 #[cfg(feature = "testing")]
635 self.core.opened.record(name, subscription, recorded, None);
636 Ok(subscriber)
637 }
638}
639
640impl ConnectedBroker for ConnectedPulsarBroker {
641 type Error = PulsarError;
642 type Closed = ();
643
644 async fn shutdown(self) -> Result<(), Self::Error> {
645 self.core.closed.store(true, Ordering::Release);
646 #[cfg(feature = "testing")]
647 if let Transport::InProcess(bus) = &self.core.transport {
648 bus.close();
649 }
650 // The client has no close of its own; producers are the handles holding broker-side
651 // state worth a clean goodbye.
652 let producers: Vec<_> = {
653 let mut map = self.core.producers.lock().await;
654 map.drain().map(|(_, producer)| producer).collect()
655 };
656 for producer in producers {
657 let mut producer = producer.lock().await;
658 if let Err(err) = Box::pin(producer.close()).await {
659 tracing::debug!(error = %err, "pulsar producer close failed");
660 }
661 }
662 Ok(())
663 }
664}
665
666impl Subscribe for ConnectedPulsarBroker {
667 type Subscriber = PulsarSubscriber;
668 /// A bare name opens the same consumer a descriptor does, so a spent delivery moves at the
669 /// client here too, under the policy the registration declared.
670 type Copies = BrokerMoves;
671
672 /// A bare name joins the broker's [default subscription](PulsarBroker::default_subscription),
673 /// so the handlers mounted by name compete on one durable cursor. A broker that names none
674 /// refuses with [`PulsarError::Invalid`] naming the setting.
675 async fn subscribe(&self, name: &str) -> Result<Self::Subscriber, Self::Error> {
676 let subscription = self.core.default_subscription.as_deref().ok_or_else(|| {
677 PulsarError::Invalid(format!(
678 "bare topic name '{name}' has no subscription to join: set \
679 `PulsarBroker::default_subscription(..)`, or mount \
680 `PulsarSubscription::new(topic, subscription)`"
681 ))
682 })?;
683 let descriptor = self.core.declared_retries.subscription(name, subscription);
684 self.subscribe_descriptor(descriptor).await
685 }
686
687 /// Maps the declaration onto the consumer this name opens: the limit and the topic become
688 /// the client's own `DeadLetterPolicy`, built in `subscribe` where the connection exists.
689 fn declare_retry(
690 &self,
691 name: &str,
692 declaration: &RetryDeclaration,
693 ) -> Result<(), DeclareRetryError> {
694 self.core.declared_retries.declare(name, declaration)
695 }
696}
697
698impl DefaultPublish for ConnectedPulsarBroker {
699 type Policy = PulsarPublish;
700}
701
702/// The harness's view of the connected broker: what it injects and reads back on the in-process
703/// transport, and, on either transport, which subscriptions a publish reaches.
704///
705/// # Panics
706///
707/// `inject` and `published` panic on a broker connected with `connect`: the harness drives only
708/// the transport `connect_in_process` produced, and a live connection has no log to read and no
709/// synchronous way to take a message. `inject` panics on a message a server refuses (a
710/// destination that is no topic name, a header value that is not UTF-8), which the trait gives no
711/// way to report.
712#[cfg(feature = "testing")]
713impl TestableBroker for ConnectedPulsarBroker {
714 fn install_coordinator(&self, coordinator: Coordinator) {
715 if let Transport::InProcess(bus) = &self.core.transport {
716 bus.install(coordinator);
717 }
718 }
719
720 fn inject(&self, message: OutgoingMessage<'_>) {
721 let bus = self.bus("inject");
722 // An injection stands in for an external producer; against a shut-down transport it is
723 // dropped, which is what a producer talking to a closed broker achieves in effect.
724 if self.core.ensure_open().is_err() {
725 return;
726 }
727 let name = message.name();
728 let owned = OutgoingMessage::produced(name, BytesMut::from(message.payload()))
729 .with_headers(message.headers().clone());
730 if let Err(err) = bus.publish(owned, None) {
731 panic!("the injected message to {name:?} is not one a server takes: {err}");
732 }
733 }
734
735 fn published(&self, name: &str) -> Vec<RawMessage> {
736 self.bus("published").published(name)
737 }
738
739 /// Pulsar's routing: a message reaches every subscription over its topic once, and within one
740 /// subscription one of its consumers, so each durable subscription the topic reaches is owed
741 /// the message once, by one of its consumers in `subscriptions`. In process that is the
742 /// consumer the transport picks for the next message; against a server, which picks its
743 /// consumer itself, it is the first of them. A topic reads the same under either spelling,
744 /// and a pattern matches the fully qualified names of the namespace its prefix names.
745 fn routes(&self, destination: &str, subscriptions: &[&str]) -> Vec<usize> {
746 let router = match &self.core.transport {
747 Transport::InProcess(bus) => Some(bus.router()),
748 Transport::Client(_) => None,
749 };
750 self.core.opened.routes(destination, subscriptions, router)
751 }
752}
753
754#[cfg(feature = "testing")]
755impl ConnectedPulsarBroker {
756 /// The in-process transport, which is all the harness injects into and reads back from.
757 fn bus(&self, what: &str) -> &Arc<Bus> {
758 match &self.core.transport {
759 Transport::InProcess(bus) => bus,
760 Transport::Client(_) => panic!(
761 "TestableBroker::{what} reached a broker connected with `connect`; the harness \
762 drives the transport `connect_in_process` produces"
763 ),
764 }
765 }
766}
767
768#[cfg(test)]
769mod tests {
770 #[cfg(feature = "testing")]
771 use ruststream::Publisher;
772 use ruststream::nonzero;
773
774 use super::*;
775
776 /// Every URL shape this broker accepts, reduced to the address a client dials.
777 #[test]
778 fn the_description_carries_the_address_alone() {
779 for (url, address) in [
780 ("pulsar://broker:6650", "broker:6650"),
781 ("pulsar+ssl://broker:6651", "broker:6651"),
782 ("pulsar://broker", "broker"),
783 ("pulsar://broker:6650/", "broker:6650"),
784 ("pulsar://admin:s3cret@broker:6650", "broker:6650"),
785 ("pulsar+ssl://admin:s3cret@broker", "broker"),
786 // A password may hold an `@`, so the userinfo ends at the last one.
787 ("pulsar://admin:p@ssw0rd@broker:6650", "broker:6650"),
788 ] {
789 assert_eq!(
790 PulsarBroker::new(url).describe_server().host.as_deref(),
791 Some(address),
792 "url {url}",
793 );
794 }
795 }
796
797 /// The client counts what follows the first try, so the bound a service writes in tries is
798 /// one more than the number the client is given. An off-by-one here is a service that gives
799 /// up a try early or waits one longer than it asked to.
800 #[test]
801 fn a_bound_in_tries_becomes_the_retries_that_follow_the_first() {
802 let bounded = OperationRetryOptions::from(OperationRetries::attempts(nonzero!(3u32)));
803 assert_eq!(bounded.max_retries, Some(2));
804
805 let once = OperationRetryOptions::from(OperationRetries::attempts(nonzero!(1u32)));
806 assert_eq!(
807 once.max_retries,
808 Some(0),
809 "one try must leave the first answer final",
810 );
811
812 let unbounded = OperationRetryOptions::from(OperationRetries::unbounded());
813 assert_eq!(unbounded.max_retries, None);
814 assert_eq!(unbounded.retry_delay, DEFAULT_RETRY_DELAY);
815 assert_eq!(unbounded.operation_timeout, DEFAULT_OPERATION_TIMEOUT);
816 }
817
818 /// A URL the client cannot connect with is refused in process as `connect` refuses it, so a
819 /// test cannot pass on a broker the service could not start.
820 #[cfg(feature = "testing")]
821 #[tokio::test]
822 async fn an_address_connect_refuses_is_refused_in_process() {
823 for url in ["not a url", "pulsar://"] {
824 let refused = PulsarBroker::new(url).connect_in_process().await;
825 assert!(
826 matches!(refused, Err(PulsarError::Connect(_))),
827 "{url:?} must be refused, got {refused:?}",
828 );
829 }
830 }
831
832 /// A publisher taken off the broker before the harness connected it shares the connection the
833 /// in-process transition fills, as it shares the one `connect` fills.
834 #[cfg(feature = "testing")]
835 #[tokio::test]
836 async fn an_early_publisher_publishes_in_process() {
837 let broker = PulsarBroker::new("pulsar://localhost:6650");
838 let early = broker.publisher();
839 let connected = broker
840 .connect_in_process()
841 .await
842 .expect("connects in process");
843
844 early
845 .publish(
846 OutgoingMessage::produced("orders", BytesMut::from(&b"{}"[..])),
847 None,
848 )
849 .await
850 .expect("the early publisher reaches the in-process transport");
851 assert_eq!(connected.published("orders").len(), 1);
852
853 connected.shutdown().await.expect("shutdown");
854 let late = early
855 .publish(
856 OutgoingMessage::produced("orders", BytesMut::from(&b"{}"[..])),
857 None,
858 )
859 .await;
860 assert!(matches!(late, Err(PulsarError::NotConnected)), "{late:?}");
861 }
862
863 /// A delivery held across the shutdown has no consumer to go back to in process, so handing it
864 /// back answers an error rather than promising a redelivery that never comes.
865 #[cfg(feature = "testing")]
866 #[tokio::test]
867 async fn a_delivery_held_across_shutdown_is_not_handed_back_in_process() {
868 use futures::StreamExt;
869 use ruststream::{IncomingMessage, Subscriber};
870
871 let connected = PulsarBroker::new("pulsar://localhost:6650")
872 .connect_in_process()
873 .await
874 .expect("connects in process");
875 let mut subscriber = connected
876 .subscribe_descriptor(PulsarSubscription::new("orders", "workers"))
877 .await
878 .expect("subscribes");
879 let publisher = connected.publisher();
880 for _ in 0..2 {
881 publisher
882 .publish(
883 OutgoingMessage::produced("orders", BytesMut::from(&b"{}"[..])),
884 None,
885 )
886 .await
887 .expect("publishes");
888 }
889 let (requeued, delayed) = {
890 let mut stream = subscriber.stream();
891 let mut next = async || {
892 stream
893 .next()
894 .await
895 .expect("the subscription is open")
896 .expect("a delivery")
897 };
898 (next().await, next().await)
899 };
900 connected.shutdown().await.expect("shutdown");
901
902 let requeue = requeued.nack(true).await;
903 assert!(requeue.is_err(), "{requeue:?}");
904 let delay = delayed.nack_after(Duration::from_secs(1)).await;
905 assert!(delay.is_err(), "{delay:?}");
906 }
907
908 /// What a consumer leaves unsettled goes back to its subscription once the consumer closes,
909 /// and waits there for the subscription's next consumer, as on a server.
910 #[cfg(feature = "testing")]
911 #[tokio::test]
912 async fn what_a_closed_consumer_left_unsettled_reaches_the_next_one() {
913 use futures::StreamExt;
914 use ruststream::{IncomingMessage, Subscriber};
915
916 let connected = PulsarBroker::new("pulsar://localhost:6650")
917 .connect_in_process()
918 .await
919 .expect("connects in process");
920 let descriptor = PulsarSubscription::new("orders", "workers");
921 let publisher = connected.publisher();
922 let mut first = connected
923 .subscribe_descriptor(descriptor.clone())
924 .await
925 .expect("subscribes");
926 for payload in [b"dropped".as_slice(), b"requeued", b"queued"] {
927 publisher
928 .publish(
929 OutgoingMessage::produced("orders", BytesMut::from(payload)),
930 None,
931 )
932 .await
933 .expect("publishes");
934 }
935 let requeued = {
936 let mut stream = first.stream();
937 let dropped = stream.next().await.expect("open").expect("a delivery");
938 drop(dropped);
939 stream.next().await.expect("open").expect("a delivery")
940 };
941 drop(first);
942 requeued
943 .nack(true)
944 .await
945 .expect("a closed consumer still hands a delivery back");
946
947 let mut next = connected
948 .subscribe_descriptor(descriptor)
949 .await
950 .expect("subscribes again");
951 let mut stream = next.stream();
952 let mut back = Vec::new();
953 for _ in 0..3 {
954 let delivery = stream.next().await.expect("open").expect("a delivery");
955 back.push(delivery.payload().to_vec());
956 delivery.ack().await.expect("ack");
957 }
958 back.sort();
959 assert_eq!(
960 back,
961 [
962 b"dropped".to_vec(),
963 b"queued".to_vec(),
964 b"requeued".to_vec()
965 ]
966 );
967 }
968
969 /// A payload up to the broker's limit is published and one byte more is refused, in process
970 /// as against a server, where the check runs before the client sends anything.
971 #[cfg(feature = "testing")]
972 #[tokio::test]
973 async fn a_payload_over_the_limit_is_refused() {
974 let connected = PulsarBroker::new("pulsar://localhost:6650")
975 .max_message_size(nonzero!(16usize))
976 .connect_in_process()
977 .await
978 .expect("connects in process");
979 let publisher = connected.publisher();
980 let publish = async |size: usize| {
981 publisher
982 .publish(
983 OutgoingMessage::produced("orders", BytesMut::from(vec![0; size].as_slice())),
984 None,
985 )
986 .await
987 };
988
989 publish(16)
990 .await
991 .expect("a payload at the limit is published");
992 let refused = publish(17).await;
993 assert!(
994 matches!(refused, Err(PulsarError::Publish { .. })),
995 "{refused:?}"
996 );
997 assert_eq!(connected.published("orders").len(), 1);
998 }
999
1000 /// The generated document is published and shared, so what a service put in its URL to
1001 /// authenticate must not be in it.
1002 #[test]
1003 fn credentials_never_reach_the_description() {
1004 let described = PulsarBroker::new("pulsar://admin:s3cret@broker:6650").describe_server();
1005 let host = described.host.expect("a URL with a host describes one");
1006 assert!(!host.contains('@'), "userinfo survived in {host:?}");
1007 assert!(!host.contains("admin"), "user name survived in {host:?}");
1008 assert!(!host.contains("s3cret"), "password survived in {host:?}");
1009 }
1010}