Skip to main content

ruststream_zeromq/
fanout.rs

1//! [`ZmqFanout`]: the PUB/SUB pattern - broadcast with prefix filtering by name.
2//!
3//! Honest scope, straight from the protocol: a subscriber that connects after a publisher has
4//! started misses what was sent before it arrived (the slow joiner), and a message published
5//! with no matching subscriber is dropped silently.
6
7use std::sync::Arc;
8
9use ruststream::{
10    Broker, ConnectedBroker, DefaultPublish, DescribeServer, OutgoingMessage, PairError,
11    PublishPolicy, Publisher, ServerSpec, Subscribe,
12};
13use tokio::sync::{Mutex, OnceCell, mpsc};
14use zeromq::prelude::*;
15use zeromq::{PubSocket, SubSocket};
16
17use crate::common::{DriverHandle, Lifecycle, SharedLifecycle, send_with_retry};
18use crate::endpoint::ZmqEndpoint;
19use crate::error::ZmqError;
20use crate::message::ZmqMessage;
21use crate::queue::ZmqSubscriber;
22use crate::wire;
23
24/// The PUB/SUB fan-out: each message reaches every subscriber whose name prefix matches.
25///
26/// # Examples
27///
28/// ```
29/// use ruststream_zeromq::{ZmqEndpoint, ZmqFanout};
30///
31/// let publisher_side = ZmqFanout::new(ZmqEndpoint::bind("tcp://0.0.0.0:5556"));
32/// let subscriber_side = ZmqFanout::new(ZmqEndpoint::connect("tcp://events:5556"));
33/// # let _ = (publisher_side, subscriber_side);
34/// ```
35#[derive(Debug, Clone)]
36#[must_use]
37pub struct ZmqFanout {
38    endpoint: ZmqEndpoint,
39    cell: Arc<OnceCell<SharedLifecycle>>,
40}
41
42impl ZmqFanout {
43    /// Records the endpoint. No I/O.
44    pub fn new(endpoint: ZmqEndpoint) -> Self {
45        Self {
46            endpoint,
47            cell: Arc::new(OnceCell::new()),
48        }
49    }
50
51    /// A publisher sharing this fan-out's state; buildable before `connect`.
52    #[must_use]
53    pub fn publisher(&self) -> ZmqFanoutPublisher {
54        ZmqFanoutPublisher {
55            cell: Arc::clone(&self.cell),
56            socket: Arc::new(Mutex::new(None)),
57        }
58    }
59}
60
61impl Broker for ZmqFanout {
62    type Error = ZmqError;
63    type Connected = ConnectedZmqFanout;
64
65    async fn connect(self) -> Result<Self::Connected, Self::Error> {
66        let lifecycle = self
67            .cell
68            .get_or_try_init(async || {
69                self.endpoint.validate()?;
70                Ok::<_, ZmqError>(Arc::new(Lifecycle::new(self.endpoint.clone())))
71            })
72            .await?
73            .clone();
74        Ok(ConnectedZmqFanout {
75            lifecycle,
76            cell: self.cell,
77        })
78    }
79}
80
81impl DescribeServer for ZmqFanout {
82    fn describe_server(&self) -> ServerSpec {
83        ServerSpec::new(self.endpoint.address(), "zeromq")
84    }
85}
86
87/// The connected form of [`ZmqFanout`].
88#[derive(Debug)]
89pub struct ConnectedZmqFanout {
90    lifecycle: SharedLifecycle,
91    cell: Arc<OnceCell<SharedLifecycle>>,
92}
93
94impl ConnectedZmqFanout {
95    /// The address a local subscription resolved by binding (useful with an ephemeral
96    /// `tcp://...:0` endpoint); `None` until a subscription has bound.
97    #[must_use]
98    pub fn bound_address(&self) -> Option<String> {
99        self.lifecycle.resolved.get().cloned()
100    }
101
102    /// A publisher from the connected form.
103    #[must_use]
104    pub fn publisher(&self) -> ZmqFanoutPublisher {
105        ZmqFanoutPublisher {
106            cell: Arc::clone(&self.cell),
107            socket: Arc::new(Mutex::new(None)),
108        }
109    }
110}
111
112impl ConnectedBroker for ConnectedZmqFanout {
113    type Error = ZmqError;
114    type Closed = ();
115
116    async fn shutdown(self) -> Result<(), Self::Error> {
117        self.lifecycle
118            .closed
119            .store(true, std::sync::atomic::Ordering::Release);
120        Ok(())
121    }
122}
123
124impl Subscribe for ConnectedZmqFanout {
125    type Subscriber = ZmqSubscriber;
126
127    async fn subscribe(&self, name: &str) -> Result<Self::Subscriber, Self::Error> {
128        self.lifecycle.ensure_open()?;
129        let mut socket = SubSocket::new();
130        self.lifecycle.attach_receiver(&mut socket).await?;
131        // The name frame doubles as the subscription prefix; filtering happens on the
132        // publisher side, per the protocol.
133        socket
134            .subscribe(name)
135            .await
136            .map_err(|e| ZmqError::Receive(e.to_string()))?;
137
138        let (tx, rx) = mpsc::unbounded_channel();
139        let task = tokio::spawn(async move {
140            loop {
141                match socket.recv().await {
142                    Ok(message) => {
143                        let item =
144                            wire::decode(message).map(|(name, headers, payload)| ZmqMessage {
145                                name,
146                                headers,
147                                payload,
148                            });
149                        if tx.send(item).is_err() {
150                            break;
151                        }
152                    }
153                    Err(err) => {
154                        if tx.send(Err(ZmqError::Receive(err.to_string()))).is_err() {
155                            break;
156                        }
157                    }
158                }
159            }
160        });
161        Ok(ZmqSubscriber::from_parts(
162            name.to_owned(),
163            rx,
164            DriverHandle { task },
165        ))
166    }
167}
168
169/// Publishes to the fan-out over a lazily attached PUB socket.
170///
171/// A message with no matching subscriber is dropped silently - that is the pattern's
172/// contract, not an error.
173#[derive(Clone)]
174pub struct ZmqFanoutPublisher {
175    cell: Arc<OnceCell<SharedLifecycle>>,
176    socket: Arc<Mutex<Option<PubSocket>>>,
177}
178
179impl std::fmt::Debug for ZmqFanoutPublisher {
180    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
181        f.debug_struct("ZmqFanoutPublisher").finish_non_exhaustive()
182    }
183}
184
185impl Publisher for ZmqFanoutPublisher {
186    type Error = ZmqError;
187
188    // The socket guard intentionally spans the lazy attach and the send: the socket takes
189    // &mut for every operation.
190    #[allow(clippy::significant_drop_tightening)]
191    async fn publish(&self, msg: OutgoingMessage<'_>) -> Result<(), Self::Error> {
192        let lifecycle = self.cell.get().ok_or(ZmqError::NotConnected)?;
193        lifecycle.ensure_open()?;
194        let mut guard = self.socket.lock().await;
195        if guard.is_none() {
196            let mut socket = PubSocket::new();
197            lifecycle.attach_sender(&mut socket).await?;
198            *guard = Some(socket);
199        }
200        let socket = guard.as_mut().expect("just attached");
201        // PUB never reports "no peers": an unmatched message is dropped by design, so the
202        // retry helper only smooths transport-level failures.
203        send_with_retry(
204            socket,
205            msg.name(),
206            wire::encode(msg.name(), msg.headers(), msg.payload()),
207        )
208        .await
209    }
210}
211
212/// The publish policy for [`ZmqFanoutPublisher`].
213///
214/// # Examples
215///
216/// ```
217/// use ruststream_zeromq::ZmqFanoutPublish;
218///
219/// let policy = ZmqFanoutPublish::default();
220/// # let _ = policy;
221/// ```
222#[derive(Debug, Clone, Copy, Default)]
223#[must_use]
224pub struct ZmqFanoutPublish;
225
226impl PublishPolicy<ConnectedZmqFanout> for ZmqFanoutPublish {
227    type Live = ZmqFanoutPublisher;
228
229    async fn pair(self, connected: &ConnectedZmqFanout) -> Result<Self::Live, PairError> {
230        Ok(connected.publisher())
231    }
232}
233
234impl DefaultPublish for ConnectedZmqFanout {
235    type Policy = ZmqFanoutPublish;
236}