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}