Skip to main content

ruststream/runtime/
publish_source.rs

1//! Cross-broker publisher tokens: a [`PublishPolicy`] bound to a concrete broker instance.
2//!
3//! A bare policy attached at an include site pairs with that scope's own broker. When the
4//! handler must publish to a *different* broker (consume Kafka, publish to Redis), or a sibling
5//! task needs a publisher after start, wrap the target broker in [`Bindable`] and mint [`Bound`]
6//! tokens from it before registration - tokens carry the instance identity a foreign scope
7//! cannot provide, and the registration order stops mattering (a bidirectional bridge binds
8//! both directions up front).
9
10use std::sync::Arc;
11
12use std::sync::Mutex;
13
14#[cfg(feature = "asyncapi")]
15use crate::asyncapi::Bindings;
16use crate::runtime::lifecycle::ConnectedSlot;
17#[cfg(feature = "testing")]
18use crate::testing::coordinator::pairs_against;
19use crate::{Broker, Connected, ConnectedBroker, PairError, PublishPolicy};
20
21/// A broker wrapped for token minting before registration.
22///
23/// `Bindable` owns the slot the runtime fills with the broker's connected form, so [`bind`]
24/// hands out [`Bound`] tokens *before* `with_broker` runs - registration order stops mattering,
25/// which is what a bidirectional bridge needs (each direction's scope takes the other broker's
26/// token). Pass the wrapper itself to
27/// [`with_broker`](crate::runtime::RustStream::with_broker); a token whose broker never
28/// registers fails fast at pairing with a clear error.
29///
30/// [`bind`]: Self::bind
31///
32/// # Examples
33///
34/// ```
35/// # #[cfg(all(feature = "macros", feature = "memory", feature = "json"))]
36/// # mod demo {
37/// use ruststream::memory::prelude::*;
38/// use serde::{Deserialize, Serialize};
39///
40/// #[derive(Deserialize)]
41/// struct Order {
42///     id: u64,
43/// }
44///
45/// /// An order relayed out of the east region, under a name the west region does not relay.
46/// #[derive(Serialize, Outgoing)]
47/// #[outgoing(name = "orders.from-east")]
48/// struct FromEast {
49///     id: u64,
50/// }
51///
52/// /// An order relayed out of the west region, under a name the east region does not relay.
53/// #[derive(Serialize, Outgoing)]
54/// #[outgoing(name = "orders.from-west")]
55/// struct FromWest {
56///     id: u64,
57/// }
58///
59/// #[subscriber("orders", reply)]
60/// async fn east_to_west(order: &Order) -> FromEast {
61///     FromEast { id: order.id }
62/// }
63///
64/// #[subscriber("orders", reply)]
65/// async fn west_to_east(order: &Order) -> FromWest {
66///     FromWest { id: order.id }
67/// }
68///
69/// fn app() -> RustStream {
70///     // Each region relays to the other: both tokens exist before either scope is built.
71///     let east = MemoryBroker::new().bindable();
72///     let west = MemoryBroker::new().bindable();
73///     let to_east = east.bind(Publish);
74///     let to_west = west.bind(Publish);
75///     RustStream::new(AppInfo::new("bridge", "0.1.0"))
76///         .with_broker_labeled("east", east, |b| {
77///             b.include(east_to_west).out_reply(to_west);
78///         })
79///         .with_broker_labeled("west", west, |b| {
80///             b.include(west_to_east).out_reply(to_east);
81///         })
82/// }
83/// # }
84/// # fn main() {}
85/// ```
86pub struct Bindable<B: Broker> {
87    pub(crate) broker: B,
88    pub(crate) slot: ConnectedSlot<B>,
89}
90
91impl<B: Broker + std::fmt::Debug> std::fmt::Debug for Bindable<B> {
92    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93        f.debug_struct("Bindable")
94            .field("broker", &self.broker)
95            .finish_non_exhaustive()
96    }
97}
98
99impl<B: Broker + 'static> Bindable<B> {
100    /// Wraps `broker` so publisher tokens can be minted before it registers.
101    #[must_use]
102    pub fn new(broker: B) -> Self {
103        Self {
104            broker,
105            slot: Arc::new(Mutex::new(None)),
106        }
107    }
108
109    /// Returns the wrapped broker, for its own constructors (publishers, subscribers).
110    #[must_use]
111    pub fn broker(&self) -> &B {
112        &self.broker
113    }
114
115    /// Mints a token for `source`: a publisher source carrying this broker's instance identity,
116    /// usable at any include site, in a sibling task via
117    /// [`RunningApp::publisher`](crate::runtime::RunningApp::publisher), or directly with
118    /// [`Bound::live`] once the app started.
119    #[must_use]
120    pub fn bind<S>(&self, source: S) -> Bound<B, S>
121    where
122        S: PublishPolicy<Connected<B>>,
123    {
124        Bound {
125            slot: Arc::clone(&self.slot),
126            source,
127        }
128    }
129}
130
131/// What the `with_broker` family accepts.
132///
133/// Either a bare [`Broker`] (registered with a fresh internal slot) or a [`Bindable`] carrying
134/// pre-minted tokens. Implemented by those two shapes only; you never implement or call it.
135pub trait BrokerRegistration {
136    /// The broker being registered.
137    type Broker: Broker + 'static;
138
139    #[doc(hidden)]
140    fn into_parts(self) -> (Self::Broker, ConnectedSlot<Self::Broker>);
141}
142
143impl<B: Broker + 'static> BrokerRegistration for B {
144    type Broker = B;
145
146    fn into_parts(self) -> (B, ConnectedSlot<B>) {
147        (self, Arc::new(Mutex::new(None)))
148    }
149}
150
151impl<B: Broker + 'static> BrokerRegistration for Bindable<B> {
152    type Broker = B;
153
154    fn into_parts(self) -> (B, ConnectedSlot<B>) {
155        (self.broker, self.slot)
156    }
157}
158
159/// A publisher source bound to a concrete broker instance, minted by [`Bindable::bind`].
160///
161/// The token shares the slot the runtime fills with that broker's connected form at startup, so
162/// pairing needs no lookup and cannot pick a wrong instance. It implements [`PublishPolicy`]
163/// against *any* connected broker by ignoring it and pairing against its own; that is what lets
164/// a Kafka-scope registration accept a token for the Redis broker.
165pub struct Bound<B2: Broker, S> {
166    pub(crate) slot: ConnectedSlot<B2>,
167    pub(crate) source: S,
168}
169
170impl<B2: Broker, S: std::fmt::Debug> std::fmt::Debug for Bound<B2, S> {
171    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
172        f.debug_struct("Bound")
173            .field("source", &self.source)
174            .finish_non_exhaustive()
175    }
176}
177
178impl<B2: Broker + 'static, S: Clone> Clone for Bound<B2, S> {
179    fn clone(&self) -> Self {
180        Self {
181            slot: Arc::clone(&self.slot),
182            source: self.source.clone(),
183        }
184    }
185}
186
187impl<B2, S> Bound<B2, S>
188where
189    B2: Broker + 'static,
190    S: PublishPolicy<Connected<B2>>,
191{
192    /// Pairs the token against its own broker, producing the live form.
193    ///
194    /// Available anywhere the application startup has already connected the broker: an
195    /// `after_startup` hook (the documented home of a first publish), or a sibling task after
196    /// [`start`](crate::runtime::RustStream::start) returned (the
197    /// [`RunningApp::publisher`](crate::runtime::RunningApp::publisher) sugar calls this).
198    ///
199    /// # Errors
200    ///
201    /// Returns [`PairError`] when the broker is not connected yet (pairing before startup
202    /// finished), or when the policy's own pairing fails.
203    ///
204    /// # Examples
205    ///
206    /// ```
207    /// # #[cfg(all(feature = "macros", feature = "memory", feature = "json"))]
208    /// # mod demo {
209    /// use ruststream::memory::prelude::*;
210    /// use ruststream::memory::MemoryError;
211    /// use ruststream::runtime::PublishError;
212    /// use ruststream::PairError;
213    /// use serde::Serialize;
214    ///
215    /// # #[derive(Debug, thiserror::Error)]
216    /// # enum StartupError {
217    /// #     #[error(transparent)]
218    /// #     Pair(#[from] PairError),
219    /// #     #[error(transparent)]
220    /// #     Publish(#[from] PublishError<MemoryError>),
221    /// # }
222    /// #[derive(Serialize, Outgoing)]
223    /// #[outgoing(name = "services.started")]
224    /// struct Started {
225    ///     service: &'static str,
226    /// }
227    ///
228    /// fn app() -> impl App {
229    ///     let broker = MemoryBroker::new().bindable();
230    ///     let announce = broker.bind(Publish);
231    ///     RustStream::new(AppInfo::new("orders", "0.1.0"))
232    ///         .with_broker(broker, |_b| {})
233    ///         .after_startup(async move |_state| {
234    ///             let publisher = announce.live().await?;
235    ///             publisher.message(&Started { service: "orders" }).publish().await?;
236    ///             Ok::<_, StartupError>(())
237    ///         })
238    /// }
239    /// # }
240    /// # fn main() {}
241    /// ```
242    pub async fn live(self) -> Result<S::Live, PairError> {
243        pair_bound::<B2, S>(&self.slot, self.source).await
244    }
245}
246
247/// Pairs `source` against the broker in `slot`. The runtime's own pairing entry for tokens.
248pub(crate) async fn pair_bound<B2, S>(
249    slot: &ConnectedSlot<B2>,
250    source: S,
251) -> Result<S::Live, PairError>
252where
253    B2: Broker + 'static,
254    S: PublishPolicy<Connected<B2>>,
255{
256    let connected = slot
257        .lock()
258        .expect("connected slot mutex poisoned")
259        .clone()
260        .ok_or_else(|| {
261            PairError::from_boxed(Box::from(
262                "the token's broker is not connected: pairing happens after startup connects \
263                 every registered broker",
264            ))
265        })?;
266    // The token's publisher publishes to its own broker, whatever scope pairs it.
267    #[cfg(feature = "testing")]
268    pairs_against(connected.as_ref());
269    source.pair(connected.as_ref()).await
270}
271
272// The token is a policy for ANY connected broker: it ignores the scope's broker and pairs
273// against its own.
274impl<C, B2, S> PublishPolicy<C> for Bound<B2, S>
275where
276    C: ConnectedBroker,
277    B2: Broker + 'static,
278    S: PublishPolicy<Connected<B2>> + Send,
279{
280    type Live = S::Live;
281
282    async fn pair(self, _connected: &C) -> Result<Self::Live, PairError> {
283        pair_bound::<B2, S>(&self.slot, self.source).await
284    }
285
286    // The token's channel is the other broker's, so what describes it is the policy bound to
287    // that broker rather than the scope the token was included in. The channel's `servers` list
288    // is the one thing that does not follow: it names the scope's server, because the
289    // registration's label is what the runtime knows.
290    #[cfg(feature = "asyncapi")]
291    fn channel_bindings(&self, channel: &str) -> Bindings {
292        <S as PublishPolicy<Connected<B2>>>::channel_bindings(&self.source, channel)
293    }
294
295    #[cfg(feature = "asyncapi")]
296    fn operation_bindings(&self, channel: &str) -> Bindings {
297        <S as PublishPolicy<Connected<B2>>>::operation_bindings(&self.source, channel)
298    }
299
300    #[cfg(feature = "asyncapi")]
301    fn message_bindings(&self, channel: &str) -> Bindings {
302        <S as PublishPolicy<Connected<B2>>>::message_bindings(&self.source, channel)
303    }
304
305    #[cfg(feature = "asyncapi")]
306    fn reply_address_location(&self) -> Option<&'static str> {
307        <S as PublishPolicy<Connected<B2>>>::reply_address_location(&self.source)
308    }
309}
310
311#[cfg(all(test, feature = "memory", feature = "json"))]
312mod tests {
313    use futures::StreamExt;
314
315    use crate::memory::{MemoryBroker, MemoryPublish};
316    use crate::runtime::{AppInfo, RustStream};
317    use crate::{Broker, IncomingMessage, OutgoingMessage, Publisher, Subscriber};
318
319    /// Cloning a token duplicates the binding, not the broker: both clones pair against the one
320    /// instance the wrapper registered.
321    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
322    async fn a_cloned_token_pairs_against_the_same_broker() {
323        let bindable = MemoryBroker::new().bindable();
324        let mut inbox = bindable.broker().subscribe("bound.out");
325        let token = bindable.bind(MemoryPublish);
326        let spare = token.clone();
327
328        assert!(
329            format!("{bindable:?}").contains("MemoryBroker"),
330            "the wrapper names the broker it holds",
331        );
332        assert!(
333            format!("{token:?}").contains("MemoryPublish"),
334            "the token names the policy it carries",
335        );
336
337        let app =
338            RustStream::new(AppInfo::new("bound", "0.1.0")).with_broker(bindable, |_scope| {});
339        let running = app.start().await.expect("startup failed");
340
341        for (token, payload) in [(token, b"one"), (spare, b"two")] {
342            let publisher = token.live().await.expect("pairing after startup failed");
343            publisher
344                .publish(OutgoingMessage::new("bound.out", payload), None)
345                .await
346                .expect("publish through the paired token failed");
347        }
348
349        let mut stream = std::pin::pin!(inbox.stream());
350        let mut seen = Vec::new();
351        for _ in 0..2 {
352            let msg = stream
353                .next()
354                .await
355                .expect("delivery missing")
356                .expect("memory subscriber never errors");
357            seen.push(msg.payload().to_vec());
358            msg.ack().await.expect("ack failed");
359        }
360        assert_eq!(
361            seen,
362            vec![b"one".to_vec(), b"two".to_vec()],
363            "both clones reach the broker the wrapper registered",
364        );
365
366        running.shutdown().await.expect("shutdown failed");
367    }
368}