ruststream/runtime/app/service.rs
1//! The [`RustStream`] builder: construction, configuration and handler registration.
2
3use std::{
4 borrow::Cow, collections::BTreeMap, error::Error as StdError, fmt, future::Future,
5 marker::PhantomData, sync::Arc, time::Duration,
6};
7
8use crate::codec::Codec;
9use crate::runtime::publish_source::BrokerRegistration;
10use crate::{Broker, DescribeServer, ServerSpec};
11
12use tokio_util::task::TaskTracker;
13
14use crate::runtime::lifecycle::{BoxError, BrokerCell, BrokerLifecycle, ConnectedSlot};
15use crate::runtime::metadata::HandlerMetadata;
16use crate::runtime::middleware::{Identity, Stack};
17#[cfg(feature = "poll-diagnostics")]
18use crate::runtime::poll_diagnostics::PollDiagnostics;
19use crate::runtime::publish::{PublishIdentity, PublishLayer, PublishStack};
20use crate::runtime::redelivery::ScopeDelivery;
21use crate::runtime::router::RouterSink;
22#[cfg(feature = "testing")]
23use crate::testing::coordinator::TestHooks;
24
25use super::scope::BrokerScope;
26use super::{AppInfo, LifecycleHook, LifecyclePhase, Starter, StateInit};
27
28/// The top-level application object.
29///
30/// `RustStream` binds one or more brokers, the handlers attached to each, and the service
31/// lifecycle into a single runnable unit. Handlers are registered through [`with_broker`], which
32/// hands a scope bound to that broker; nothing connects or subscribes until [`run`]. Brokers are
33/// held type-erased (only their lifecycle), so a single service can mix broker types.
34///
35/// The type parameter `Layers` is the global middleware stack applied to every per-message
36/// handler registered on a broker scope, whether directly or via
37/// [`include_router`](BrokerScope::include_router) (batch handlers are the exception: a
38/// per-message layer cannot wrap a whole-batch handler); it defaults to the no-op [`Identity`]
39/// and grows as [`layer`] is called. Add all layers before [`with_broker`], since a layer only
40/// applies to handlers registered after it.
41///
42/// The type parameter `Phase` tracks the builder phase at compile time: [`new`](Self::new)
43/// starts in [`Setup`], where the state ([`on_startup`]), the middleware stack ([`layer`]) and
44/// the publish pipeline ([`publish_layer`](Self::publish_layer)) are still configurable; the
45/// first [`with_broker`] moves it to [`Wired`], where those methods no longer exist - so a
46/// configuration call that would silently not apply to already-registered handlers does not
47/// compile. `Phase` defaults to [`Wired`], the built service; `Setup` lives inside a builder
48/// chain and is rarely written out.
49///
50/// [`with_broker`]: Self::with_broker
51/// [`layer`]: Self::layer
52/// [`on_startup`]: Self::on_startup
53/// [`run`]: Self::run
54///
55/// # Examples
56///
57/// ```
58/// # #[cfg(all(feature = "macros", feature = "memory", feature = "json"))]
59/// # mod demo {
60/// use ruststream::memory::prelude::*;
61/// use ruststream::runtime::layers::TracingLayer;
62/// use serde::Deserialize;
63///
64/// #[derive(Deserialize)]
65/// struct Order {
66/// id: u64,
67/// }
68///
69/// #[subscriber("orders")]
70/// async fn accept(order: &Order) -> HandlerOutcome {
71/// tracing::info!(order.id, "accepted");
72/// HandlerOutcome::ack()
73/// }
74///
75/// /// What `#[ruststream::app]` runs: a span around every delivery, one broker, one handler.
76/// pub fn app() -> impl App {
77/// RustStream::new(AppInfo::new("orders", "0.1.0"))
78/// .layer(TracingLayer::default())
79/// .with_broker(MemoryBroker::new(), |b| {
80/// b.include(accept);
81/// })
82/// }
83/// # }
84/// # fn main() {}
85/// ```
86pub struct RustStream<Layers = Identity, State = (), Pipeline = PublishIdentity, Phase = Wired> {
87 pub(super) info: AppInfo,
88 pub(super) brokers: Vec<RegisteredBroker>,
89 pub(super) starters: Vec<Starter<State>>,
90 pub(super) handlers: Vec<HandlerMetadata>,
91 pub(super) servers: BTreeMap<String, ServerSpec>,
92 pub(super) publish_pipeline: Pipeline,
93 pub(super) state_init: StateInit<State>,
94 pub(super) after_startup: Vec<LifecycleHook<State>>,
95 pub(super) on_shutdown: Vec<LifecycleHook<State>>,
96 pub(super) after_shutdown: Vec<LifecycleHook<State>>,
97 pub(super) shutdown_timeout: Option<Duration>,
98 /// Tracks post-settle `and_after` continuations spawned during dispatch, so a graceful
99 /// shutdown drains them after the dispatch loops stop. Shared (cloned) into every
100 /// [`Delivery`].
101 pub(super) continuations: TaskTracker,
102 /// Shared recording-and-quiescence hooks for the [`TestApp`](crate::testing::TestApp) harness,
103 /// cloned into every scope's [`Delivery`]. Empty until a harness installs a coordinator, so a
104 /// non-harness run with the `testing` feature enabled stays inert.
105 #[cfg(feature = "testing")]
106 pub(super) test_hooks: Arc<TestHooks>,
107 /// Where every subscription's poll-time samples go, and what they are held to.
108 #[cfg(feature = "poll-diagnostics")]
109 pub(super) poll_diagnostics: PollDiagnostics,
110 pub(super) global: Layers,
111 // `fn() -> Phase` keeps the marker out of auto-trait and variance considerations, matching
112 // the router builder's broker marker.
113 pub(super) phase: PhantomData<fn() -> Phase>,
114}
115
116/// [`RustStream`] phase marker: the builder is still being configured - the state type, the
117/// middleware stack, and the publish pipeline may change, and no broker is registered yet.
118#[derive(Debug, Clone, Copy, Default)]
119pub struct Setup;
120
121/// [`RustStream`] phase marker: at least one broker (with its handlers) is registered, so the
122/// state type, the middleware stack, and the publish pipeline are fixed.
123#[derive(Debug, Clone, Copy, Default)]
124pub struct Wired;
125
126/// A broker held by the app for lifecycle management, paired with its optional label.
127///
128/// The label is the broker's stable runtime identity and its `AsyncAPI` server name; it is `Some`
129/// for a broker registered through [`with_broker_labeled`](RustStream::with_broker_labeled) (or its
130/// codec variant) and `None` otherwise.
131pub(crate) struct RegisteredBroker {
132 pub(crate) lifecycle: Box<dyn BrokerLifecycle>,
133 pub(crate) label: Option<String>,
134}
135
136/// The internals the [`TestApp`](crate::testing::TestApp) harness needs to drive an app without
137/// connecting: the brokers (to recover and instrument), the deferred starters, the lifecycle hooks,
138/// and the shared test hooks slot. Produced by [`RustStream::into_test_parts`].
139///
140/// Public in name only, and never nameable outside the crate: the sealed supertrait of
141/// [`App`](crate::runtime::App) hands it to the harness for an app a builder returned as
142/// `impl App`, and a method of that trait cannot return a crate-private type.
143#[cfg(feature = "testing")]
144#[doc(hidden)]
145#[allow(unreachable_pub, missing_debug_implementations)]
146pub struct TestParts<State> {
147 pub(crate) brokers: Vec<RegisteredBroker>,
148 pub(crate) starters: Vec<Starter<State>>,
149 pub(crate) state_init: StateInit<State>,
150 pub(crate) after_startup: Vec<LifecycleHook<State>>,
151 pub(crate) shutdown_timeout: Option<Duration>,
152 pub(crate) continuations: TaskTracker,
153 pub(crate) test_hooks: Arc<TestHooks>,
154}
155
156impl<Layers, State, Pipeline, Phase> fmt::Debug for RustStream<Layers, State, Pipeline, Phase> {
157 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
158 f.debug_struct("RustStream")
159 .field("info", &self.info)
160 .field("brokers", &self.brokers.len())
161 .field("handlers", &self.handlers.len())
162 .finish_non_exhaustive()
163 }
164}
165
166impl RustStream<Identity, (), PublishIdentity, Setup> {
167 /// Creates an empty service with the given metadata, no global middleware, and the unit
168 /// application state `()`. Produce a typed state with [`on_startup`](Self::on_startup).
169 #[must_use]
170 pub fn new(info: AppInfo) -> Self {
171 Self {
172 info,
173 brokers: Vec::new(),
174 starters: Vec::new(),
175 handlers: Vec::new(),
176 servers: BTreeMap::new(),
177 publish_pipeline: PublishIdentity,
178 state_init: Box::new(|| Box::pin(async { Ok(()) })),
179 after_startup: Vec::new(),
180 on_shutdown: Vec::new(),
181 after_shutdown: Vec::new(),
182 shutdown_timeout: None,
183 continuations: TaskTracker::new(),
184 #[cfg(feature = "testing")]
185 test_hooks: Arc::new(TestHooks::detached()),
186 #[cfg(feature = "poll-diagnostics")]
187 poll_diagnostics: PollDiagnostics::new(),
188 global: Identity,
189 phase: PhantomData,
190 }
191 }
192}
193
194impl<Layers, State, Pipeline> RustStream<Layers, State, Pipeline, Setup> {
195 /// Adds a global middleware layer, applied to every handler registered later.
196 ///
197 /// The first layer added runs outermost. Only available before the first
198 /// [`with_broker`](Self::with_broker): a layer added later could not wrap the handlers
199 /// already registered, so that ordering does not compile.
200 #[must_use]
201 pub fn layer<N>(self, layer: N) -> RustStream<Stack<N, Layers>, State, Pipeline, Setup> {
202 RustStream {
203 info: self.info,
204 brokers: self.brokers,
205 starters: self.starters,
206 handlers: self.handlers,
207 servers: self.servers,
208 publish_pipeline: self.publish_pipeline,
209 state_init: self.state_init,
210 after_startup: self.after_startup,
211 on_shutdown: self.on_shutdown,
212 after_shutdown: self.after_shutdown,
213 shutdown_timeout: self.shutdown_timeout,
214 continuations: self.continuations,
215 #[cfg(feature = "testing")]
216 test_hooks: self.test_hooks,
217 #[cfg(feature = "poll-diagnostics")]
218 poll_diagnostics: self.poll_diagnostics,
219 global: Stack::new(layer, self.global),
220 phase: PhantomData,
221 }
222 }
223
224 /// Produces the typed application state at startup, transitioning the app's state type from the
225 /// previous `State` to `State2`.
226 ///
227 /// The hook runs once before brokers connect; its future can `await` (open a database pool,
228 /// connect a client), and the produced `State2` is shared with every handler (read via
229 /// [`Context::state`](crate::runtime::Context::state)) and the read-only lifecycle hooks. A
230 /// failing hook aborts startup. The initial state is `()` (from [`new`](Self::new)), so the
231 /// first call's hook receives `()`.
232 ///
233 /// Only available before the first [`with_broker`](Self::with_broker): the call fixes the
234 /// app's state type, and handlers registered against a different state type could not be
235 /// carried across - so that ordering does not compile.
236 ///
237 /// # Panics
238 ///
239 /// Panics if a lifecycle hook ([`after_startup`](Self::after_startup),
240 /// [`on_shutdown`](Self::on_shutdown), [`after_shutdown`](Self::after_shutdown)) was
241 /// registered first: hooks close over the state type and cannot be carried across the state
242 /// change. Register hooks after `on_startup`.
243 #[must_use]
244 pub fn on_startup<F, Fut, State2, E>(
245 self,
246 hook: F,
247 ) -> RustStream<Layers, State2, Pipeline, Setup>
248 where
249 F: FnOnce(State) -> Fut + Send + 'static,
250 Fut: Future<Output = Result<State2, E>> + Send,
251 State: Send + 'static,
252 State2: Send + Sync + 'static,
253 E: StdError + Send + Sync + 'static,
254 {
255 assert!(
256 self.after_startup.is_empty()
257 && self.on_shutdown.is_empty()
258 && self.after_shutdown.is_empty(),
259 "on_startup must be called before lifecycle hooks are registered: hooks close over \
260 the state type and cannot be carried across the state change"
261 );
262 let prev = self.state_init;
263 RustStream {
264 info: self.info,
265 brokers: self.brokers,
266 // Provably empty: with_broker (the only way to add starters) leaves Setup.
267 starters: Vec::new(),
268 handlers: self.handlers,
269 servers: self.servers,
270 publish_pipeline: self.publish_pipeline,
271 state_init: Box::new(move || {
272 Box::pin(async move {
273 let prev_state = prev().await?;
274 hook(prev_state).await.map_err(|e| Box::new(e) as BoxError)
275 })
276 }),
277 after_startup: Vec::new(),
278 on_shutdown: Vec::new(),
279 after_shutdown: Vec::new(),
280 shutdown_timeout: self.shutdown_timeout,
281 continuations: self.continuations,
282 #[cfg(feature = "testing")]
283 test_hooks: self.test_hooks,
284 #[cfg(feature = "poll-diagnostics")]
285 poll_diagnostics: self.poll_diagnostics,
286 global: self.global,
287 phase: PhantomData,
288 }
289 }
290
291 /// Adds an outgoing publish middleware, run on every publish a handler makes before it
292 /// reaches the broker (a Confluent / Avro envelope, publish metrics, dead-letter): a
293 /// `reply(..)` form's reply and every message that leaves through an injected
294 /// [`Out`](crate::runtime::Out) slot alike, each under the transforms its own mount site
295 /// named. It composes into the
296 /// pipeline type parameter, so the *last* one added wraps the rest and runs outermost (unlike the
297 /// consume-side [`layer`](Self::layer), where the first added is outermost); the middleware must
298 /// be [`Clone`] (the pipeline is cloned into each publishing handler and each slot). Only
299 /// available before
300 /// the first [`with_broker`](Self::with_broker): a middleware added later could not wrap the
301 /// publishers already handed out, so that ordering does not compile.
302 ///
303 /// A handler mounted through [`include_router`](BrokerScope::include_router) is the one
304 /// exception on the slot side: a router's routes are typed before the app that mounts them,
305 /// so its slots carry their own `.out(marker, policy).transform(..)` steps and not this
306 /// chain (its replies still travel it).
307 #[must_use]
308 pub fn publish_layer<M>(
309 self,
310 middleware: M,
311 ) -> RustStream<Layers, State, PublishStack<M, Pipeline>, Setup>
312 where
313 M: PublishLayer + Clone + 'static,
314 {
315 // Prepend `middleware` as the new outermost wrapper: the publish pipeline stays a statically
316 // composed type (no `dyn` dispatch), and the last one added runs outermost.
317 RustStream {
318 info: self.info,
319 brokers: self.brokers,
320 starters: self.starters,
321 handlers: self.handlers,
322 servers: self.servers,
323 publish_pipeline: PublishStack::new(middleware, self.publish_pipeline),
324 state_init: self.state_init,
325 after_startup: self.after_startup,
326 on_shutdown: self.on_shutdown,
327 after_shutdown: self.after_shutdown,
328 shutdown_timeout: self.shutdown_timeout,
329 continuations: self.continuations,
330 #[cfg(feature = "testing")]
331 test_hooks: self.test_hooks,
332 #[cfg(feature = "poll-diagnostics")]
333 poll_diagnostics: self.poll_diagnostics,
334 global: self.global,
335 phase: PhantomData,
336 }
337 }
338
339 /// Sets what the poll-time diagnostics hold every subscription's handler to, and where their
340 /// reports go (`poll-diagnostics` feature).
341 ///
342 /// With the feature on, every subscription is sampled whether or not this is called; the call
343 /// changes the threshold or the sampling interval, and the handle kept outside reads the
344 /// reports or hands them to an exporter. Only available before the first
345 /// [`with_broker`](Self::with_broker), which opens subscriptions under the settings in place
346 /// then, so that ordering does not compile.
347 ///
348 /// # Examples
349 ///
350 /// ```
351 /// # #[cfg(all(feature = "macros", feature = "memory", feature = "json"))]
352 /// # mod demo {
353 /// use std::time::Duration;
354 ///
355 /// use ruststream::memory::prelude::*;
356 /// use ruststream::runtime::PollDiagnostics;
357 /// use serde::Deserialize;
358 ///
359 /// #[derive(Deserialize)]
360 /// struct Order {
361 /// id: u64,
362 /// }
363 ///
364 /// #[subscriber("orders")]
365 /// async fn accept(order: &Order) -> HandlerOutcome {
366 /// tracing::info!(order.id, "accepted");
367 /// HandlerOutcome::ack()
368 /// }
369 ///
370 /// /// The caller keeps a clone of `diagnostics` to read the reports while the app runs.
371 /// pub fn app(diagnostics: &PollDiagnostics) -> impl App {
372 /// RustStream::new(AppInfo::new("orders", "0.1.0"))
373 /// .poll_diagnostics(diagnostics.clone().threshold(Duration::from_micros(500)))
374 /// .with_broker(MemoryBroker::new(), |b| {
375 /// b.include(accept);
376 /// })
377 /// }
378 /// # }
379 /// # fn main() {}
380 /// ```
381 #[cfg(feature = "poll-diagnostics")]
382 #[must_use]
383 pub fn poll_diagnostics(mut self, diagnostics: PollDiagnostics) -> Self {
384 self.poll_diagnostics = diagnostics;
385 self
386 }
387}
388
389impl<Layers, State, Pipeline, Phase> RustStream<Layers, State, Pipeline, Phase> {
390 /// Rebuilds the app under a different phase marker; the fields move as they are.
391 fn into_phase<Q>(self) -> RustStream<Layers, State, Pipeline, Q> {
392 RustStream {
393 info: self.info,
394 brokers: self.brokers,
395 starters: self.starters,
396 handlers: self.handlers,
397 servers: self.servers,
398 publish_pipeline: self.publish_pipeline,
399 state_init: self.state_init,
400 after_startup: self.after_startup,
401 on_shutdown: self.on_shutdown,
402 after_shutdown: self.after_shutdown,
403 shutdown_timeout: self.shutdown_timeout,
404 continuations: self.continuations,
405 #[cfg(feature = "testing")]
406 test_hooks: self.test_hooks,
407 #[cfg(feature = "poll-diagnostics")]
408 poll_diagnostics: self.poll_diagnostics,
409 global: self.global,
410 phase: PhantomData,
411 }
412 }
413
414 /// Adds a hook run after brokers connect and handlers are spawned (for example, to publish an
415 /// initial message or signal readiness). A failing hook aborts startup.
416 #[must_use]
417 pub fn after_startup<F, Fut, E>(self, hook: F) -> Self
418 where
419 State: Send + Sync + 'static,
420 F: FnOnce(Arc<State>) -> Fut + Send + 'static,
421 Fut: Future<Output = Result<(), E>> + Send,
422 E: StdError + Send + Sync + 'static,
423 {
424 self.push_lifecycle_hook(LifecyclePhase::AfterStartup, hook)
425 }
426
427 /// Adds a hook run when shutdown begins, while brokers are still connected. Errors are logged.
428 #[must_use]
429 pub fn on_shutdown<F, Fut, E>(self, hook: F) -> Self
430 where
431 State: Send + Sync + 'static,
432 F: FnOnce(Arc<State>) -> Fut + Send + 'static,
433 Fut: Future<Output = Result<(), E>> + Send,
434 E: StdError + Send + Sync + 'static,
435 {
436 self.push_lifecycle_hook(LifecyclePhase::OnShutdown, hook)
437 }
438
439 /// Adds a hook run after brokers have shut down (for final async resource teardown). Errors are
440 /// logged.
441 #[must_use]
442 pub fn after_shutdown<F, Fut, E>(self, hook: F) -> Self
443 where
444 State: Send + Sync + 'static,
445 F: FnOnce(Arc<State>) -> Fut + Send + 'static,
446 Fut: Future<Output = Result<(), E>> + Send,
447 E: StdError + Send + Sync + 'static,
448 {
449 self.push_lifecycle_hook(LifecyclePhase::AfterShutdown, hook)
450 }
451
452 fn push_lifecycle_hook<F, Fut, E>(mut self, phase: LifecyclePhase, hook: F) -> Self
453 where
454 State: Send + Sync + 'static,
455 F: FnOnce(Arc<State>) -> Fut + Send + 'static,
456 Fut: Future<Output = Result<(), E>> + Send,
457 E: StdError + Send + Sync + 'static,
458 {
459 let boxed: LifecycleHook<State> = Box::new(move |state| {
460 Box::pin(async move { hook(state).await.map_err(|e| Box::new(e) as BoxError) })
461 });
462 match phase {
463 LifecyclePhase::AfterStartup => self.after_startup.push(boxed),
464 LifecyclePhase::OnShutdown => self.on_shutdown.push(boxed),
465 LifecyclePhase::AfterShutdown => self.after_shutdown.push(boxed),
466 }
467 self
468 }
469
470 /// Sets how long [`run`](Self::run) waits for in-flight handlers to finish after shutdown is
471 /// triggered. After the timeout, the remaining handler tasks are aborted. The same bound then
472 /// applies to draining post-settle `and_after` continuations; on timeout they are abandoned
473 /// (they are at-most-once side effects, so this loses follow-up work, never a settlement).
474 /// Defaults to waiting indefinitely.
475 #[must_use]
476 pub fn shutdown_timeout(mut self, timeout: Duration) -> Self {
477 self.shutdown_timeout = Some(timeout);
478 self
479 }
480
481 /// Registers a broker for lifecycle management only (connect / shutdown), without attaching
482 /// subscribers. Use for publish-only brokers.
483 #[must_use]
484 pub fn register_broker<R>(mut self, broker: R) -> Self
485 where
486 R: BrokerRegistration,
487 {
488 // No subscriptions read the slot, but a Bindable registration's tokens do.
489 let (broker, slot) = broker.into_parts();
490 self.brokers.push(RegisteredBroker {
491 lifecycle: Box::new(BrokerCell { broker, slot }),
492 label: None,
493 });
494 self
495 }
496
497 /// Records an `AsyncAPI` server (one per broker the service connects to).
498 ///
499 /// Build the [`ServerSpec`] directly, or get it from a broker that implements
500 /// [`DescribeServer`](crate::DescribeServer): `app.server("nats", broker.describe_server())`.
501 /// `build_spec` emits these in the document's `servers` section.
502 ///
503 /// For a self-describing broker, prefer
504 /// [`with_broker_labeled`](Self::with_broker_labeled), which derives this entry from the broker
505 /// under its label in one step. Use this method for brokers without a network address (the
506 /// in-memory broker), or to override a labeled broker's own spec.
507 #[must_use]
508 pub fn server(mut self, name: impl Into<String>, spec: ServerSpec) -> Self {
509 self.servers.insert(name.into(), spec);
510 self
511 }
512
513 /// Registers a broker and the handlers attached to it.
514 ///
515 /// `build` receives a [`BrokerScope`] typed to this broker; use it to attach handlers. The
516 /// broker is then held for lifecycle management. Call this once per broker.
517 ///
518 /// The scope carries no codec of its own, so macro handlers mounted with `b.include(handle)`
519 /// decode with the [`DefaultCodec`](crate::codec::DefaultCodec). To decode with another codec
520 /// scope-wide, use [`with_broker_codec`](Self::with_broker_codec).
521 ///
522 /// The first call moves the builder to the [`Wired`] phase: the state, the middleware stack,
523 /// and the publish pipeline are fixed from here on, so a configuration call that could not
524 /// apply to this broker's handlers does not compile.
525 #[must_use]
526 pub fn with_broker<R, F>(
527 self,
528 broker: R,
529 build: F,
530 ) -> RustStream<Layers, State, Pipeline, Wired>
531 where
532 R: BrokerRegistration,
533 Layers: Clone,
534 Pipeline: Clone,
535 State: Send + Sync + 'static,
536 F: FnOnce(&mut BrokerScope<R::Broker, Layers, (), State, Pipeline>),
537 {
538 let mut this = self.into_phase::<Wired>();
539 let (broker, slot) = broker.into_parts();
540 let mut scope = this.new_scope(broker, slot, ());
541 build(&mut scope);
542 this.collect_scope(scope, None);
543 this
544 }
545
546 /// Registers a broker with a scope-wide `codec`, replacing the
547 /// [`DefaultCodec`](crate::codec::DefaultCodec) a codec-less scope decodes with.
548 ///
549 /// `build` receives a [`BrokerScope`] whose [`include`](BrokerScope::include) family reads
550 /// the same as on a codec-less scope (`b.include(handle)`) but decodes with `codec`.
551 #[must_use]
552 pub fn with_broker_codec<R, C, F>(
553 self,
554 broker: R,
555 codec: C,
556 build: F,
557 ) -> RustStream<Layers, State, Pipeline, Wired>
558 where
559 R: BrokerRegistration,
560 C: Codec + Clone + 'static,
561 Layers: Clone,
562 Pipeline: Clone,
563 State: Send + Sync + 'static,
564 F: FnOnce(&mut BrokerScope<R::Broker, Layers, C, State, Pipeline>),
565 {
566 let mut this = self.into_phase::<Wired>();
567 let (broker, slot) = broker.into_parts();
568 let mut scope = this.new_scope(broker, slot, codec);
569 build(&mut scope);
570 this.collect_scope(scope, None);
571 this
572 }
573
574 /// Registers a self-describing broker under `label`, along with the handlers attached to it.
575 ///
576 /// The `label` is the broker's stable identity in the service and the name of its `AsyncAPI`
577 /// server: the broker's [`describe_server`](DescribeServer::describe_server) coordinates are
578 /// recorded in the `servers` map under `label`, so the document stays in sync with the brokers
579 /// actually mounted, with no separate [`server`](Self::server) call. An explicit
580 /// [`server(label, spec)`](Self::server) entry for the same label takes precedence.
581 ///
582 /// Like [`with_broker`](Self::with_broker), the scope decodes with the
583 /// [`DefaultCodec`](crate::codec::DefaultCodec); use
584 /// [`with_broker_labeled_codec`](Self::with_broker_labeled_codec) to name another.
585 ///
586 /// # Examples
587 ///
588 /// ```no_run
589 /// # #[cfg(feature = "memory")]
590 /// # async fn run() -> Result<(), ruststream::runtime::RustStreamError> {
591 /// use ruststream::runtime::{AppInfo, RustStream};
592 /// use ruststream::{Broker, DescribeServer, ServerSpec};
593 ///
594 /// # struct NatsBroker;
595 /// # struct ConnectedNats;
596 /// # impl NatsBroker { fn new(_: &str) -> Self { Self } }
597 /// # impl Broker for NatsBroker {
598 /// # type Error = std::io::Error;
599 /// # type Connected = ConnectedNats;
600 /// # async fn connect(self) -> Result<ConnectedNats, Self::Error> { Ok(ConnectedNats) }
601 /// # }
602 /// # impl ruststream::ConnectedBroker for ConnectedNats {
603 /// # type Error = std::io::Error;
604 /// # type Closed = ();
605 /// # async fn shutdown(self) -> Result<(), Self::Error> { Ok(()) }
606 /// # }
607 /// # impl DescribeServer for NatsBroker {
608 /// # fn describe_server(&self) -> ServerSpec { ServerSpec::new("nats:4222", "nats") }
609 /// # }
610 /// let app = RustStream::new(AppInfo::new("svc", "0.1.0"))
611 /// .with_broker_labeled("ingress", NatsBroker::new("nats://localhost"), |_b| {});
612 /// // The AsyncAPI `servers` map now carries "ingress" with the broker's host / protocol.
613 /// app.run().await
614 /// # }
615 /// ```
616 #[must_use]
617 pub fn with_broker_labeled<R, F>(
618 self,
619 label: impl Into<String>,
620 broker: R,
621 build: F,
622 ) -> RustStream<Layers, State, Pipeline, Wired>
623 where
624 R: BrokerRegistration,
625 R::Broker: DescribeServer,
626 Layers: Clone,
627 Pipeline: Clone,
628 State: Send + Sync + 'static,
629 F: FnOnce(&mut BrokerScope<R::Broker, Layers, (), State, Pipeline>),
630 {
631 let mut this = self.into_phase::<Wired>();
632 let (broker, slot) = broker.into_parts();
633 let label = this.record_server(label, &broker);
634 let mut scope = this.new_scope(broker, slot, ());
635 build(&mut scope);
636 this.collect_scope(scope, Some(label));
637 this
638 }
639
640 /// Registers a self-describing broker under `label` with a default `codec`.
641 ///
642 /// Combines [`with_broker_labeled`](Self::with_broker_labeled) (the label is the broker's
643 /// identity and `AsyncAPI` server name) with
644 /// [`with_broker_codec`](Self::with_broker_codec) (the scope's macro handlers decode with
645 /// `codec`).
646 #[must_use]
647 pub fn with_broker_labeled_codec<R, C, F>(
648 self,
649 label: impl Into<String>,
650 broker: R,
651 codec: C,
652 build: F,
653 ) -> RustStream<Layers, State, Pipeline, Wired>
654 where
655 R: BrokerRegistration,
656 R::Broker: DescribeServer,
657 C: Codec + Clone + 'static,
658 Layers: Clone,
659 Pipeline: Clone,
660 State: Send + Sync + 'static,
661 F: FnOnce(&mut BrokerScope<R::Broker, Layers, C, State, Pipeline>),
662 {
663 let mut this = self.into_phase::<Wired>();
664 let (broker, slot) = broker.into_parts();
665 let label = this.record_server(label, &broker);
666 let mut scope = this.new_scope(broker, slot, codec);
667 build(&mut scope);
668 this.collect_scope(scope, Some(label));
669 this
670 }
671
672 /// Records `broker`'s server coordinates under `label` (keeping an explicit
673 /// [`server`](Self::server) entry already set for the same label), returning the owned label.
674 fn record_server<B: DescribeServer>(&mut self, label: impl Into<String>, broker: &B) -> String {
675 let label = label.into();
676 self.servers
677 .entry(label.clone())
678 .or_insert_with(|| broker.describe_server());
679 label
680 }
681
682 /// Builds a fresh scope bound to `broker` carrying `codec` and the app's publishers / pipeline.
683 fn new_scope<B, C>(
684 &self,
685 broker: B,
686 slot: ConnectedSlot<B>,
687 codec: C,
688 ) -> BrokerScope<B, Layers, C, State, Pipeline>
689 where
690 B: Broker + 'static,
691 Layers: Clone,
692 Pipeline: Clone,
693 State: Send + Sync + 'static,
694 {
695 BrokerScope {
696 broker,
697 slot,
698 startup_hooks: Vec::new(),
699 sink: RouterSink::new(),
700 pipeline: self.publish_pipeline.clone(),
701 global: self.global.clone(),
702 codec,
703 }
704 }
705
706 /// Drains a built scope's registrations into the app and holds the broker for lifecycle,
707 /// recording `label` as the broker's stable runtime identity (`None` when unlabeled).
708 ///
709 /// The broker itself is boxed into a [`BrokerCell`] whose consuming `connect` publishes the
710 /// typed connected form into a shared slot; each starter reads the slot at startup, after
711 /// every broker connected and before any subscription opens.
712 fn collect_scope<B, C>(
713 &mut self,
714 scope: BrokerScope<B, Layers, C, State, Pipeline>,
715 label: Option<String>,
716 ) where
717 B: Broker + 'static,
718 State: Send + Sync + 'static,
719 {
720 let BrokerScope {
721 broker,
722 slot,
723 startup_hooks,
724 sink,
725 ..
726 } = scope;
727 self.after_startup.extend(startup_hooks);
728 let (starters, handlers) = sink.into_parts();
729 for (bound, meta) in starters.into_iter().zip(handlers) {
730 let slot = Arc::clone(&slot);
731 // A live harness waits only for what reaches a subscription the app mounts, and tells
732 // two subscriptions reporting one name apart by the identity noted here.
733 #[cfg(feature = "testing")]
734 let subscription = self.test_hooks.subscribed(self.brokers.len(), &meta.name);
735 // The scope id is the index this broker will occupy once pushed below; the harness
736 // uses it to scope recorded deliveries per broker.
737 let scope_delivery = Arc::new(ScopeDelivery::new(
738 self.continuations.clone(),
739 #[cfg(feature = "testing")]
740 self.test_hooks.clone(),
741 #[cfg(feature = "testing")]
742 self.brokers.len(),
743 #[cfg(feature = "testing")]
744 subscription,
745 #[cfg(feature = "poll-diagnostics")]
746 self.poll_diagnostics.clone(),
747 ));
748 self.starters.push(Box::new(move |state, shutdown| {
749 let connected = slot
750 .lock()
751 .expect("connected slot mutex poisoned")
752 .clone()
753 .expect("brokers connect before subscriptions open");
754 bound(connected, state, scope_delivery, shutdown)
755 }));
756 // The scope's label is the name of this broker's AsyncAPI server, so every channel
757 // the scope mounts can say which server it lives on.
758 self.handlers.push(HandlerMetadata {
759 server: label.clone().map(Cow::Owned),
760 ..meta
761 });
762 }
763 self.brokers.push(RegisteredBroker {
764 lifecycle: Box::new(BrokerCell { broker, slot }),
765 label,
766 });
767 }
768
769 /// Returns metadata for every registered handler, in registration order. Input to the
770 /// `AsyncAPI` generator.
771 #[must_use]
772 pub fn handlers(&self) -> &[HandlerMetadata] {
773 &self.handlers
774 }
775
776 /// Returns the service metadata.
777 #[must_use]
778 pub fn info(&self) -> &AppInfo {
779 &self.info
780 }
781
782 /// Returns the registered `AsyncAPI` servers, keyed by name. Input to the `AsyncAPI` generator.
783 #[must_use]
784 pub fn servers(&self) -> &BTreeMap<String, ServerSpec> {
785 &self.servers
786 }
787
788 /// Decomposes the app into the pieces the [`TestApp`](crate::testing::TestApp) harness drives:
789 /// the brokers, the deferred starters, the lifecycle hooks, and the shared test-hooks slot.
790 /// Handlers metadata and `AsyncAPI` servers are dropped (the harness does not need them).
791 #[cfg(feature = "testing")]
792 pub(crate) fn into_test_parts(self) -> TestParts<State> {
793 TestParts {
794 brokers: self.brokers,
795 starters: self.starters,
796 state_init: self.state_init,
797 after_startup: self.after_startup,
798 shutdown_timeout: self.shutdown_timeout,
799 continuations: self.continuations,
800 test_hooks: self.test_hooks,
801 }
802 }
803}