Skip to main content

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::sync::Arc;
10use std::sync::atomic::{AtomicBool, Ordering};
11
12use pulsar::{Authentication, Pulsar, TokioExecutor};
13use ruststream::{Broker, ConnectedBroker, DefaultPublish, DescribeServer, ServerSpec, Subscribe};
14use tokio::sync::{Mutex, OnceCell};
15
16use crate::error::{PulsarError, box_err};
17use crate::publisher::{PulsarProducer, PulsarPublish, PulsarPublisher};
18use crate::subscriber::PulsarSubscriber;
19use crate::subscription::PulsarSubscription;
20
21/// The live client state shared by the connected form and every handle derived from it.
22///
23/// Why runtime checks exist here at all: the client handle is `Clone` and would happily
24/// reconnect after our typed shutdown, and publishers may be handed out before `connect` and
25/// outlive `shutdown` (aliasing) - so the closed state is an explicit flag a stale handle
26/// trips over instead of silently succeeding.
27pub(crate) struct Core {
28    pub(crate) client: Pulsar<TokioExecutor>,
29    pub(crate) closed: AtomicBool,
30    /// Per-topic producers, shared by every publisher handle so shutdown can close them.
31    pub(crate) producers: Mutex<HashMap<String, Arc<Mutex<PulsarProducer>>>>,
32}
33
34impl Core {
35    pub(crate) fn ensure_open(&self) -> Result<(), PulsarError> {
36        if self.closed.load(Ordering::Acquire) {
37            return Err(PulsarError::NotConnected);
38        }
39        Ok(())
40    }
41}
42
43impl std::fmt::Debug for Core {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        f.debug_struct("Core")
46            .field("closed", &self.closed.load(Ordering::Relaxed))
47            .finish_non_exhaustive()
48    }
49}
50
51pub(crate) type CoreCell = Arc<OnceCell<Arc<Core>>>;
52
53/// An Apache Pulsar broker for the `RustStream` messaging framework.
54///
55/// `new` is synchronous and records only configuration; the runtime dials once at startup via
56/// the consuming [`Broker::connect`]. That is what lets a service compose with the synchronous
57/// `#[ruststream::app]` builder.
58///
59/// # Examples
60///
61/// ```
62/// use ruststream_pulsar::PulsarBroker;
63///
64/// let broker = PulsarBroker::new("pulsar://localhost:6650");
65/// let secured = PulsarBroker::new("pulsar+ssl://broker:6651").token("jwt...");
66/// # let _ = (broker, secured);
67/// ```
68#[derive(Debug, Clone)]
69#[must_use]
70pub struct PulsarBroker {
71    url: String,
72    token: Option<String>,
73    // Shared with publishers handed out before connect; the consuming connect fills it.
74    cell: CoreCell,
75}
76
77impl PulsarBroker {
78    /// Records the service URL (`pulsar://` or `pulsar+ssl://`). No I/O.
79    pub fn new(url: impl Into<String>) -> Self {
80        Self {
81            url: url.into(),
82            token: None,
83            cell: Arc::new(OnceCell::new()),
84        }
85    }
86
87    /// Authenticates with a JWT token.
88    pub fn token(mut self, token: impl Into<String>) -> Self {
89        self.token = Some(token.into());
90        self
91    }
92
93    /// A publisher sharing this broker's connection cell; buildable before `connect`.
94    #[must_use]
95    pub fn publisher(&self) -> PulsarPublisher {
96        PulsarPublisher::new(Arc::clone(&self.cell))
97    }
98}
99
100impl Broker for PulsarBroker {
101    type Error = PulsarError;
102    type Connected = ConnectedPulsarBroker;
103
104    async fn connect(self) -> Result<Self::Connected, Self::Error> {
105        let core = self
106            .cell
107            .get_or_try_init(async || {
108                let mut builder = Pulsar::builder(self.url.clone(), TokioExecutor);
109                if let Some(token) = &self.token {
110                    builder = builder.with_auth(Authentication {
111                        name: "token".to_owned(),
112                        data: token.clone().into_bytes(),
113                    });
114                }
115                let client = builder
116                    .build()
117                    .await
118                    .map_err(|e| PulsarError::Connect(box_err(e)))?;
119                Ok::<_, PulsarError>(Arc::new(Core {
120                    client,
121                    closed: AtomicBool::new(false),
122                    producers: Mutex::new(HashMap::new()),
123                }))
124            })
125            .await?
126            .clone();
127        Ok(ConnectedPulsarBroker {
128            core,
129            cell: self.cell,
130        })
131    }
132}
133
134impl DescribeServer for PulsarBroker {
135    fn describe_server(&self) -> ServerSpec {
136        ServerSpec::new(
137            self.url
138                .trim_start_matches("pulsar+ssl://")
139                .trim_start_matches("pulsar://"),
140            "pulsar",
141        )
142    }
143}
144
145/// The typed witness that `connect` succeeded: holds the live client directly.
146#[derive(Debug)]
147pub struct ConnectedPulsarBroker {
148    pub(crate) core: Arc<Core>,
149    // Keeps the cell of publishers handed out before connect alive and filled.
150    cell: CoreCell,
151}
152
153impl ConnectedPulsarBroker {
154    /// A publisher from the connected form. It rides the same cell-backed publisher type as
155    /// the early path; by now `connect` has filled the cell, so it resolves immediately.
156    #[must_use]
157    pub fn publisher(&self) -> PulsarPublisher {
158        PulsarPublisher::new(Arc::clone(&self.cell))
159    }
160
161    /// Opens the subscription described by `descriptor`.
162    ///
163    /// # Errors
164    ///
165    /// Returns [`PulsarError`] when the descriptor is invalid, the consumer cannot be created,
166    /// or the broker is shut down.
167    pub async fn subscribe_descriptor(
168        &self,
169        descriptor: PulsarSubscription,
170    ) -> Result<PulsarSubscriber, PulsarError> {
171        descriptor.validate()?;
172        self.core.ensure_open()?;
173        PulsarSubscriber::open(&self.core, descriptor).await
174    }
175}
176
177impl ConnectedBroker for ConnectedPulsarBroker {
178    type Error = PulsarError;
179    type Closed = ();
180
181    async fn shutdown(self) -> Result<(), Self::Error> {
182        self.core.closed.store(true, Ordering::Release);
183        // The client has no close of its own; producers are the handles holding broker-side
184        // state worth a clean goodbye.
185        let producers: Vec<_> = {
186            let mut map = self.core.producers.lock().await;
187            map.drain().map(|(_, producer)| producer).collect()
188        };
189        for producer in producers {
190            let mut producer = producer.lock().await;
191            if let Err(err) = Box::pin(producer.close()).await {
192                tracing::debug!(error = %err, "pulsar producer close failed");
193            }
194        }
195        Ok(())
196    }
197}
198
199impl Subscribe for ConnectedPulsarBroker {
200    type Subscriber = PulsarSubscriber;
201
202    async fn subscribe(&self, name: &str) -> Result<Self::Subscriber, Self::Error> {
203        // By-name subscriptions share one durable subscription named after the service-wide
204        // convention "ruststream", matching competing-consumer expectations.
205        self.subscribe_descriptor(PulsarSubscription::new(name, "ruststream"))
206            .await
207    }
208}
209
210impl DefaultPublish for ConnectedPulsarBroker {
211    type Policy = PulsarPublish;
212}