Skip to main content

dynamo_runtime/transports/
zmq.rs

1// SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! ZMQ Transport
5//!
6//! This module provides a ZMQ transport for the [crate::DistributedRuntime].
7//!
8//! Currently, the [Server] consists of a [tmq::router::Router] and the [Client] leverages
9//! a [tmq::dealer::Dealer].
10//!
11//! The distributed service pattern we will use is based on the Harmony pattern described in
12//! [Chapter 8: A Framework for Distributed Computing](https://zguide.zeromq.org/docs/chapter8/#True-Peer-Connectivity-Harmony-Pattern).
13//!
14//! This is similar to the TCP implementation; however, the TCP implementation used a direct
15//! connection between the client and server per stream. The ZMQ transport will enable the
16//! equivalent of a connection pool per upstream service at the cost of needing an extra internal
17//! routing step per service endpoint.
18
19use anyhow::{Context, Result, anyhow};
20use bytes::Bytes;
21use derive_getters::Dissolve;
22use futures::{SinkExt, StreamExt};
23use serde::{Deserialize, Serialize};
24use std::{collections::HashMap, sync::Arc};
25use tmq::{AsZmqSocket, Context as TmqContext, dealer, router};
26use tokio::{
27    sync::{Mutex, mpsc},
28    task::JoinHandle,
29};
30use tokio_util::sync::CancellationToken;
31
32pub type MultipartMessage = Vec<Vec<u8>>;
33
34/// Returns whether `endpoint` needs `ZMQ_IPV6`.
35///
36/// Set the option before each bind or connect; libzmq applies it per connection.
37/// Rejects unbracketed IPv6 literals: without `ZMQ_IPV6`, libzmq resolves them
38/// as IPv4 and retries the connection forever without reporting an error.
39pub fn ipv6_option_for(endpoint: &str) -> Result<bool> {
40    let Some(address) = endpoint.strip_prefix("tcp://") else {
41        return Ok(false);
42    };
43    if address.starts_with('[') {
44        return Ok(true);
45    }
46    if let Some((host, _port)) = address.rsplit_once(':')
47        && host.parse::<std::net::Ipv6Addr>().is_ok()
48    {
49        anyhow::bail!(
50            "Invalid ZMQ endpoint '{endpoint}': IPv6 addresses must be bracketed, for example tcp://[{host}]:<port>"
51        );
52    }
53    Ok(false)
54}
55
56// Core message types
57#[derive(Debug, Clone, Serialize, Deserialize)]
58enum ControlMessage {
59    Cancel { request_id: String },
60    CancelAck { request_id: String },
61    Error { request_id: String, error: String },
62    Complete { request_id: String },
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
66enum MessageType {
67    Data(Vec<u8>),
68    Control(ControlMessage),
69}
70
71enum StreamAction {
72    SendEager(usize),
73    SendDelayed(usize),
74    Close,
75}
76
77// Router state management
78struct RouterState {
79    active_streams: HashMap<String, mpsc::Sender<Bytes>>,
80    control_channels: HashMap<String, mpsc::Sender<ControlMessage>>,
81}
82
83impl RouterState {
84    fn new() -> Self {
85        Self {
86            active_streams: HashMap::new(),
87            control_channels: HashMap::new(),
88        }
89    }
90
91    fn register_stream(
92        &mut self,
93        request_id: String,
94        data_tx: mpsc::Sender<Bytes>,
95        control_tx: mpsc::Sender<ControlMessage>,
96    ) {
97        self.active_streams.insert(request_id.clone(), data_tx);
98        self.control_channels.insert(request_id, control_tx);
99    }
100
101    fn remove_stream(&mut self, request_id: &str) {
102        self.active_streams.remove(request_id);
103        self.control_channels.remove(request_id);
104    }
105}
106
107// Server implementation
108#[derive(Clone, Dissolve)]
109pub struct Server {
110    state: Arc<Mutex<RouterState>>,
111    cancel_token: CancellationToken,
112    fd: i32,
113}
114
115impl Server {
116    /// Create a new [Server] which is a [tmq::router::Router] with the given [tmq::Context]
117    /// and address to bind the ZMQ router socket.
118    ///
119    /// If the event loop processing the router fails with an error, the signal is propagated through the [CancellationToken]
120    /// by issuing a [CancellationToken::cancel].
121    ///
122    /// The [Server] is how you interact with the running instance.
123    ///
124    /// The [ServerExecutionHandle] is the handle for background task executing the [Server].
125    pub async fn new(
126        context: &TmqContext,
127        address: &str,
128        cancel_token: CancellationToken,
129    ) -> Result<(Self, ServerExecutionHandle)> {
130        let router = router(context).bind(address)?;
131        let fd = router.get_socket().get_fd()?;
132        let state = Arc::new(Mutex::new(RouterState::new()));
133
134        // can cancel the router's event loop
135        let child = cancel_token.child_token();
136        let primary_task = tokio::spawn(Self::run(router, state.clone(), child.child_token()));
137
138        // this task captures the primary cancellation token, so if an error occurs, we can cancel the router's event loop
139        // but we also propagate the error to the caller's cancellation token
140        let watch_task = tokio::spawn(async move {
141            let result = primary_task.await.inspect_err(|e| {
142                tracing::error!("zmq server/router task failed: {e}");
143                cancel_token.cancel();
144            })?;
145            result.inspect_err(|e| {
146                tracing::error!("zmq server/router task failed: {e}");
147                cancel_token.cancel();
148            })
149        });
150
151        let handle = ServerExecutionHandle {
152            task: watch_task,
153            cancel_token: child.clone(),
154        };
155
156        Ok((
157            Self {
158                state,
159                cancel_token: child,
160                fd,
161            },
162            handle,
163        ))
164    }
165
166    // pub async fn register_stream(&)
167
168    async fn run(
169        router: tmq::router::Router,
170        state: Arc<Mutex<RouterState>>,
171        token: CancellationToken,
172    ) -> Result<()> {
173        let mut router = router;
174
175        // todo - move this into the Server impl to discover the os port being used
176        // let fd = router.as_raw_socket().get_fd()?;
177        // let sock = unsafe { socket2::Socket::from_raw_fd(fd) };
178        // let addr = sock.local_addr()?;
179        // let port = addr.as_socket().map(|s| s.port());
180
181        // if let Some(port) = port {
182        //     tracing::info!("Server listening on port {port}");
183        // }
184
185        loop {
186            let frames = tokio::select! {
187                biased;
188
189                frames = router.next() => {
190                    match frames {
191                        Some(Ok(frames)) => {
192                            frames
193                        },
194                        Some(Err(e)) => {
195                            tracing::warn!("Error receiving message: {e}");
196                            continue;
197                        }
198                        None => break,
199                    }
200                }
201
202                _ = token.cancelled() => {
203                    tracing::info!("Server shutting down");
204                    break;
205                }
206            };
207
208            // we should have at least 3 frames
209            // 0: identity
210            // 1: request_id
211            // 2: message type
212
213            // if the contract is broken, we should exit
214            if frames.len() != 3 {
215                anyhow::bail!(
216                    "Fatal Error -- Broken contract -- Expected 3 frames, got {}",
217                    frames.len()
218                );
219            }
220
221            let request_id = String::from_utf8_lossy(&frames[1]).to_string();
222            let message = frames[2].to_vec();
223            let message_size = message.len();
224
225            if let Some(tx) = state.lock().await.active_streams.get(&request_id) {
226                // first we try to send the data eagerly without blocking
227                let action = match tx.try_send(message.into()) {
228                    Ok(_) => {
229                        tracing::trace!(
230                            request_id,
231                            "response data sent eagerly to stream: {} bytes",
232                            message_size
233                        );
234                        StreamAction::SendEager(message_size)
235                    }
236                    Err(e) => match e {
237                        mpsc::error::TrySendError::Closed(_) => {
238                            tracing::info!(request_id, "response stream was closed");
239                            StreamAction::Close
240                        }
241                        mpsc::error::TrySendError::Full(data) => {
242                            tracing::warn!(
243                                request_id,
244                                "response stream is full; backpressure alert"
245                            );
246                            // todo - add timeout - we are blocking all other streams
247                            if (tx.send(data).await).is_err() {
248                                StreamAction::Close
249                            } else {
250                                StreamAction::SendDelayed(message_size)
251                            }
252                        }
253                    },
254                };
255
256                match action {
257                    StreamAction::SendEager(_size) => {
258                        // increment bytes_received
259                        // increment messages_received
260                        // increment eager_messages_received
261                    }
262                    StreamAction::SendDelayed(_size) => {
263                        // increment bytes_received
264                        // increment messages_received
265                        // increment delayed_messages_received
266                    }
267                    StreamAction::Close => {
268                        state.lock().await.active_streams.remove(&request_id);
269                    }
270                }
271            } else {
272                // increment bytes_dropped
273                // increment messages_dropped
274                tracing::trace!(request_id, "no active stream for request_id");
275            }
276        }
277
278        Ok(())
279    }
280}
281
282/// The [ServerExecutionHandle] is the handle for background task executing the [Server].
283///
284/// You can use this to check if the server is finished or cancelled.
285///
286/// You can also join on the task to wait for it to finish.
287pub struct ServerExecutionHandle {
288    task: JoinHandle<Result<()>>,
289    cancel_token: CancellationToken,
290}
291
292impl ServerExecutionHandle {
293    /// Check if the task awaiting on the [Server]s background event loop has finished.
294    pub fn is_finished(&self) -> bool {
295        self.task.is_finished()
296    }
297
298    /// Check if the server's event loop has been cancelled.
299    pub fn is_cancelled(&self) -> bool {
300        self.cancel_token.is_cancelled()
301    }
302
303    /// Cancel the server's event loop.
304    ///
305    /// This will signal the server to stop processing requests and exit.
306    ///
307    /// This will not wait for the server to finish, it will exit immediately.
308    ///
309    /// This will not propagate to the [CancellationToken] used to start the [Server]
310    /// unless an error happens during the shutdown process.
311    pub fn cancel(&self) {
312        self.cancel_token.cancel();
313    }
314
315    /// Join on the task awaiting on the [Server]s background event loop.
316    ///
317    /// This will return the result of the [Server]s background event loop.
318    pub async fn join(self) -> Result<()> {
319        self.task.await?
320    }
321}
322
323// Client implementation
324pub struct Client {
325    dealer: tmq::dealer::Dealer,
326}
327
328impl Client {
329    fn new(context: &TmqContext, address: &str) -> Result<Self> {
330        let dealer = dealer(context).connect(address)?;
331
332        Ok(Self { dealer })
333    }
334
335    fn dealer(&mut self) -> &mut tmq::dealer::Dealer {
336        &mut self.dealer
337    }
338
339    // async fn send_data(&self, data: Vec<u8>) -> Result<()> {
340    //     let msg_type = MessageType::Data(data);
341    //     let type_bytes = serde_json::to_vec(&msg_type)?;
342
343    //     self.dealer
344    //         .send_multipart(&[type_bytes, self.request_id.as_bytes().to_vec()])
345    //         .await
346    //         .map_err(|e| anyhow!("Failed to send data: {}", e))
347    // }
348
349    // async fn send_control(&self, msg: ControlMessage) -> Result<()> {
350    //     let msg_type = MessageType::Control(msg);
351    //     let type_bytes = serde_json::to_vec(&msg_type)?;
352
353    //     self.dealer
354    //         .send_multipart(&[type_bytes])
355    //         .await
356    //         .map_err(|e| anyhow!("Failed to send control message: {}", e))
357    // }
358
359    // async fn receive(&self) -> Result<MessageType> {
360    //     let frames = self
361    //         .dealer
362    //         .recv_multipart()
363    //         .await
364    //         .map_err(|e| anyhow!("Failed to receive message: {}", e))?;
365
366    //     if frames.is_empty() {
367    //         return Err(anyhow!("Received empty message"));
368    //     }
369
370    //     serde_json::from_slice(&frames[0])
371    //         .map_err(|e| anyhow!("Failed to deserialize message: {}", e))
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377    use tokio::time::timeout;
378
379    #[test]
380    fn ipv6_option_follows_endpoint_address_family() {
381        for (endpoint, expected) in [
382            ("tcp://[::1]:5555", true),
383            ("tcp://[::]:*", true),
384            ("tcp://[2001:db8::10]:5555", true),
385            ("tcp://127.0.0.1:5555", false),
386            ("tcp://0.0.0.0:*", false),
387            ("tcp://localhost:5555", false),
388            ("tcp://*:5555", false),
389            ("inproc://events", false),
390        ] {
391            assert_eq!(ipv6_option_for(endpoint).unwrap(), expected, "{endpoint}");
392        }
393
394        for endpoint in ["tcp://::1:5555", "tcp://2001:db8::10:5555"] {
395            let error = ipv6_option_for(endpoint).unwrap_err().to_string();
396            assert!(
397                error.contains("IPv6 addresses must be bracketed"),
398                "{endpoint}: {error}"
399            );
400        }
401    }
402
403    #[tokio::test]
404    async fn test_basic_communication() -> Result<()> {
405        let context = TmqContext::new();
406        let address = "tcp://127.0.0.1:1337";
407        let token = CancellationToken::new();
408
409        // Start server
410        let (server, handle) = Server::new(&context, address, token.clone()).await?;
411        let state = server.state.clone();
412
413        let id = "test-request".to_string();
414        let (tx, mut rx) = tokio::sync::mpsc::channel(512);
415        state.lock().await.active_streams.insert(id.clone(), tx);
416
417        // Create client
418        let mut client = Client::new(&context, address)?;
419
420        client
421            .dealer()
422            .send(tmq::Multipart::from(vec![
423                id.as_bytes().to_vec(),
424                id.as_bytes().to_vec(),
425            ]))
426            .await?;
427
428        let receive_result = rx.recv().await;
429
430        let received = receive_result.unwrap();
431
432        // convert to string
433        let received_str = String::from_utf8_lossy(&received).to_string();
434        assert_eq!(received_str, "test-request");
435
436        drop(client);
437
438        handle.cancel();
439        handle.join().await?;
440
441        println!("done");
442
443        Ok(())
444    }
445
446    // #[tokio::test]
447    // async fn test_multiple_streams() -> Result<()> {
448    //     // Similar to above but with multiple clients/streams
449    //     Ok(())
450    // }
451
452    // #[tokio::test]
453    // async fn test_error_handling() -> Result<()> {
454    //     // Test various error conditions
455    //     Ok(())
456    // }
457}