Skip to main content

ruststream_zeromq/
rpc.rs

1//! [`ZmqRpc`]: the DEALER/ROUTER pattern - request and reply.
2//!
3//! The responder side subscribes (a ROUTER socket): each request arrives with a `reply-to`
4//! header addressing the requesting peer, and the plain publisher routes replies back through
5//! the same ROUTER. The requester side uses the [`RequestReply`] capability: one DEALER per
6//! request, correlated by the `correlation-id` header.
7
8use std::sync::Arc;
9use std::time::Duration;
10
11use bytes::Bytes;
12use ruststream::{
13    Broker, ConnectedBroker, DefaultPublish, DescribeServer, OutgoingMessage, PairError,
14    PublishPolicy, Publisher, RequestReply, ServerSpec, Subscribe,
15};
16use tokio::sync::{Mutex, OnceCell, mpsc};
17use zeromq::prelude::*;
18use zeromq::util::PeerIdentity;
19use zeromq::{DealerSocket, RouterSendHalf, RouterSocket, SocketOptions};
20
21use crate::common::{DriverHandle, Lifecycle, SharedLifecycle, send_with_retry};
22use crate::endpoint::ZmqEndpoint;
23use crate::error::ZmqError;
24use crate::message::ZmqMessage;
25use crate::queue::ZmqSubscriber;
26use crate::wire;
27
28/// The prefix of reply destinations minted by the responder subscription.
29const REPLY_PREFIX: &str = "zmq-reply:";
30
31fn hex_encode(bytes: &[u8]) -> String {
32    use std::fmt::Write as _;
33    let mut out = String::with_capacity(bytes.len() * 2);
34    for b in bytes {
35        let _ = write!(out, "{b:02x}");
36    }
37    out
38}
39
40fn hex_decode(text: &str) -> Option<Vec<u8>> {
41    if text.len() % 2 != 0 {
42        return None;
43    }
44    (0..text.len())
45        .step_by(2)
46        .map(|i| u8::from_str_radix(&text[i..i + 2], 16).ok())
47        .collect()
48}
49
50/// The DEALER/ROUTER request-reply pattern.
51///
52/// # Examples
53///
54/// ```
55/// use ruststream_zeromq::{ZmqEndpoint, ZmqRpc};
56///
57/// let responder = ZmqRpc::new(ZmqEndpoint::bind("tcp://0.0.0.0:5557"));
58/// let requester = ZmqRpc::new(ZmqEndpoint::connect("tcp://ml:5557"));
59/// # let _ = (responder, requester);
60/// ```
61#[derive(Debug, Clone)]
62#[must_use]
63pub struct ZmqRpc {
64    endpoint: ZmqEndpoint,
65    cell: Arc<OnceCell<RpcShared>>,
66}
67
68#[derive(Clone)]
69pub(crate) struct RpcShared {
70    lifecycle: SharedLifecycle,
71    /// The responder's ROUTER send half; set when a subscription attaches.
72    router_tx: Arc<OnceCell<Arc<Mutex<RouterSendHalf>>>>,
73}
74
75impl std::fmt::Debug for RpcShared {
76    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77        f.debug_struct("RpcShared").finish_non_exhaustive()
78    }
79}
80
81impl ZmqRpc {
82    /// Records the endpoint. No I/O.
83    pub fn new(endpoint: ZmqEndpoint) -> Self {
84        Self {
85            endpoint,
86            cell: Arc::new(OnceCell::new()),
87        }
88    }
89
90    /// A publisher sharing this pattern's state; buildable before `connect`.
91    #[must_use]
92    pub fn publisher(&self) -> ZmqRpcPublisher {
93        ZmqRpcPublisher {
94            cell: Arc::clone(&self.cell),
95        }
96    }
97}
98
99impl Broker for ZmqRpc {
100    type Error = ZmqError;
101    type Connected = ConnectedZmqRpc;
102
103    async fn connect(self) -> Result<Self::Connected, Self::Error> {
104        let shared = self
105            .cell
106            .get_or_try_init(async || {
107                self.endpoint.validate()?;
108                Ok::<_, ZmqError>(RpcShared {
109                    lifecycle: Arc::new(Lifecycle::new(self.endpoint.clone())),
110                    router_tx: Arc::new(OnceCell::new()),
111                })
112            })
113            .await?
114            .clone();
115        Ok(ConnectedZmqRpc {
116            shared,
117            cell: self.cell,
118        })
119    }
120}
121
122impl DescribeServer for ZmqRpc {
123    fn describe_server(&self) -> ServerSpec {
124        ServerSpec::new(self.endpoint.address(), "zeromq")
125    }
126}
127
128/// The connected form of [`ZmqRpc`].
129#[derive(Debug)]
130pub struct ConnectedZmqRpc {
131    shared: RpcShared,
132    cell: Arc<OnceCell<RpcShared>>,
133}
134
135impl ConnectedZmqRpc {
136    /// The address the responder resolved by binding (useful with an ephemeral
137    /// `tcp://...:0` endpoint); `None` until a subscription has bound.
138    #[must_use]
139    pub fn bound_address(&self) -> Option<String> {
140        self.shared.lifecycle.resolved.get().cloned()
141    }
142
143    /// A publisher from the connected form.
144    #[must_use]
145    pub fn publisher(&self) -> ZmqRpcPublisher {
146        ZmqRpcPublisher {
147            cell: Arc::clone(&self.cell),
148        }
149    }
150}
151
152impl ConnectedBroker for ConnectedZmqRpc {
153    type Error = ZmqError;
154    type Closed = ();
155
156    async fn shutdown(self) -> Result<(), Self::Error> {
157        self.shared
158            .lifecycle
159            .closed
160            .store(true, std::sync::atomic::Ordering::Release);
161        Ok(())
162    }
163}
164
165impl Subscribe for ConnectedZmqRpc {
166    type Subscriber = ZmqSubscriber;
167
168    async fn subscribe(&self, name: &str) -> Result<Self::Subscriber, Self::Error> {
169        self.shared.lifecycle.ensure_open()?;
170        let mut socket = RouterSocket::new();
171        self.shared.lifecycle.attach_receiver(&mut socket).await?;
172        let (send_half, mut recv_half) = socket.split();
173        // One responder ROUTER per pattern instance: replies route through it.
174        let _ = self.shared.router_tx.set(Arc::new(Mutex::new(send_half)));
175
176        let (tx, rx) = mpsc::unbounded_channel();
177        let task = tokio::spawn(async move {
178            loop {
179                match recv_half.recv().await {
180                    Ok(message) => {
181                        let mut frames = message.into_vecdeque();
182                        let Some(identity) = frames.pop_front() else {
183                            continue;
184                        };
185                        let rest: Result<zeromq::ZmqMessage, _> = frames.try_into();
186                        let Ok(rest) = rest else {
187                            let _ = tx.send(Err(ZmqError::Wire(
188                                "a request needs name and payload frames".into(),
189                            )));
190                            continue;
191                        };
192                        let item = wire::decode(rest).map(|(name, mut headers, payload)| {
193                            headers.insert(
194                                "reply-to",
195                                format!("{REPLY_PREFIX}{}", hex_encode(&identity)),
196                            );
197                            ZmqMessage {
198                                name,
199                                headers,
200                                payload,
201                            }
202                        });
203                        if tx.send(item).is_err() {
204                            break;
205                        }
206                    }
207                    Err(err) => {
208                        if tx.send(Err(ZmqError::Receive(err.to_string()))).is_err() {
209                            break;
210                        }
211                    }
212                }
213            }
214        });
215        Ok(ZmqSubscriber::from_parts(
216            name.to_owned(),
217            rx,
218            DriverHandle { task },
219        ))
220    }
221}
222
223/// Publishes replies back through the responder's ROUTER, and issues requests via
224/// [`RequestReply`].
225#[derive(Clone)]
226pub struct ZmqRpcPublisher {
227    cell: Arc<OnceCell<RpcShared>>,
228}
229
230impl std::fmt::Debug for ZmqRpcPublisher {
231    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
232        f.debug_struct("ZmqRpcPublisher").finish_non_exhaustive()
233    }
234}
235
236impl ZmqRpcPublisher {
237    fn shared(&self) -> Result<&RpcShared, ZmqError> {
238        let shared = self.cell.get().ok_or(ZmqError::NotConnected)?;
239        shared.lifecycle.ensure_open()?;
240        Ok(shared)
241    }
242}
243
244impl Publisher for ZmqRpcPublisher {
245    type Error = ZmqError;
246
247    async fn publish(&self, msg: OutgoingMessage<'_>) -> Result<(), Self::Error> {
248        let shared = self.shared()?;
249        let Some(identity_hex) = msg.name().strip_prefix(REPLY_PREFIX) else {
250            return Err(ZmqError::Send {
251                name: msg.name().to_owned(),
252                reason: format!(
253                    "the rpc publisher routes '{REPLY_PREFIX}...' replies; use request() for outbound requests"
254                ),
255            });
256        };
257        let identity = hex_decode(identity_hex).ok_or_else(|| ZmqError::Send {
258            name: msg.name().to_owned(),
259            reason: "malformed reply address".to_owned(),
260        })?;
261        let router = shared.router_tx.get().ok_or_else(|| ZmqError::Send {
262            name: msg.name().to_owned(),
263            reason: "no responder subscription is attached".to_owned(),
264        })?;
265
266        let mut message = wire::encode("reply", msg.headers(), msg.payload());
267        message.push_front(Bytes::from(identity));
268        let mut router = router.lock().await;
269        router.send(message).await.map_err(|e| ZmqError::Send {
270            name: msg.name().to_owned(),
271            reason: e.to_string(),
272        })
273    }
274}
275
276impl RequestReply for ZmqRpcPublisher {
277    type Reply = ZmqMessage;
278
279    async fn request(
280        &self,
281        msg: OutgoingMessage<'_>,
282        timeout: Duration,
283    ) -> Result<Self::Reply, Self::Error> {
284        let shared = self.shared()?;
285        let (address, _) = shared.lifecycle.sender_address()?;
286
287        // One DEALER per request: simple and correct; a shared correlated link is a later
288        // optimisation. The identity is random, so replies route to this request alone.
289        let mut options = SocketOptions::default();
290        options.peer_identity(PeerIdentity::new());
291        options.connect_timeout(timeout.min(Duration::from_secs(10)));
292        let mut dealer = DealerSocket::with_options(options);
293        dealer
294            .connect(&address)
295            .await
296            .map_err(|e| ZmqError::Endpoint {
297                endpoint: address.clone(),
298                source: crate::error::box_err(e),
299            })?;
300
301        // Respect a caller-supplied correlation id (an upper layer may match on it too).
302        let correlation = msg.headers().correlation_id().map_or_else(
303            || format!("req-{}-{}", std::process::id(), hex_encode(&rand_suffix())),
304            str::to_owned,
305        );
306        let mut headers = msg.headers().clone();
307        headers.insert("correlation-id", correlation.clone());
308        let request = wire::encode(msg.name(), &headers, msg.payload());
309        send_with_retry(&mut dealer, msg.name(), request).await?;
310
311        let exchange = async {
312            loop {
313                let reply = dealer
314                    .recv()
315                    .await
316                    .map_err(|e| ZmqError::Receive(e.to_string()))?;
317                let (name, reply_headers, payload) = wire::decode(reply)?;
318                if reply_headers.correlation_id() == Some(correlation.as_str()) {
319                    return Ok(ZmqMessage {
320                        name,
321                        headers: reply_headers,
322                        payload,
323                    });
324                }
325            }
326        };
327        tokio::time::timeout(timeout, exchange)
328            .await
329            .unwrap_or(Err(ZmqError::RequestTimeout))
330    }
331}
332
333/// A per-request unique suffix without a randomness dependency: the address of a fresh
334/// allocation mixed with a monotonic counter.
335fn rand_suffix() -> [u8; 8] {
336    use std::sync::atomic::{AtomicU64, Ordering};
337    static SEQ: AtomicU64 = AtomicU64::new(0);
338    let seq = SEQ.fetch_add(1, Ordering::Relaxed);
339    seq.to_be_bytes()
340}
341
342/// The publish policy for [`ZmqRpcPublisher`].
343///
344/// # Examples
345///
346/// ```
347/// use ruststream_zeromq::ZmqRpcPublish;
348///
349/// let policy = ZmqRpcPublish::default();
350/// # let _ = policy;
351/// ```
352#[derive(Debug, Clone, Copy, Default)]
353#[must_use]
354pub struct ZmqRpcPublish;
355
356impl PublishPolicy<ConnectedZmqRpc> for ZmqRpcPublish {
357    type Live = ZmqRpcPublisher;
358
359    async fn pair(self, connected: &ConnectedZmqRpc) -> Result<Self::Live, PairError> {
360        Ok(connected.publisher())
361    }
362}
363
364impl DefaultPublish for ConnectedZmqRpc {
365    type Policy = ZmqRpcPublish;
366}