dynamo_runtime/pipeline/network/tcp.rs
1// SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! TCP Transport Module
5//!
6//! TODO: this design-and-implementation overview should eventually move into
7//! the architecture docs (`docs/design-docs/architecture.md`); kept here for
8//! now until there's a home for transport-level design notes.
9//!
10//! Brief overview of the request-response transport:
11//!
12//! The request plane (TCP, NATS, etc.) carries a two-part message whose header is a
13//! `RequestControlMessage` — embedding the [`ConnectionInfo`] that tells the worker where to call home
14//! — and whose data half is the serialized request body if request streaming is not needed.
15//! All subsequent streaming bytes (responses, and request-stream) flow over the TCP socket established afterwards.
16//!
17//! For simplicity, if request streaming is needed, the `RequestControlMessage` should not contain
18//! the request body. Instead, all requests of the stream should be sent over the TCP socket.
19//!
20//! The TCP transport is the implementation that produces and consumes [`ConnectionInfo`] and
21//! carries the streaming response (and, optionally, request-stream) bytes between two peers
22//! on separate sockets from the initial request.
23//!
24//! # Roles
25//!
26//! The TCP transport has two sides:
27//!
28//! - Request sender: The upstream that **initiates the transfer** runs [`server::TcpStreamServer`],
29//! registers what it expects to receive, and listens. It publishes its address + a per-stream
30//! subject UUID via [`TcpStreamConnectionInfo`], which is serialized into a [`ConnectionInfo`].
31//! - Request receiver: The downstream that **acknowledges the transfer** runs [`client::TcpClient`],
32//! reads the connection info out of the request, dials the listener, and identifies itself with
33//! a `CallHomeHandshake` to the request sender.
34//!
35//! Although TCP is bidirectional, we keep separate sockets for the request stream and the response
36//! stream to match Dynamo's design principles. To establish both, the request receiver must receive
37//! two [`ConnectionInfo`] objects and run two handshakes — each [`StreamType`] is its own TCP
38//! connection with its own subject UUID.
39//!
40//! # Server-Client Interaction
41//!
42//! See the test cases below for detailed examples. Note that the response stream expects the client
43//! to send a [`ResponseStreamPrologue`] in order to properly establish the stream.
44//!
45//! # Stream Types
46//!
47//! [`StreamType::Response`] — worker pushes engine output back to the upstream. Server side is
48//! `process_response_stream` (delivers a [`StreamReceiver`] to the awaiting registrant once the
49//! client has sent its [`ResponseStreamPrologue`]). Client side is
50//! [`client::TcpClient::create_response_stream`] (returns a [`StreamSender`]; spawns reader/writer
51//! tasks plus a connection monitor that waits for the server's FIN).
52//!
53//! [`StreamType::Request`] — upstream pushes the request body (or a stream of follow-up frames)
54//! into a downstream worker. Server side is `process_request_stream` (delivers a [`StreamSender`]
55//! immediately; there is no prologue today). Client side is
56//! [`client::TcpClient::create_request_stream`] (returns a [`StreamReceiver`]; spawns a single
57//! task that handles both directions on the socket).
58//!
59//! # Registration and Lifecycle
60//!
61//! [`ResponseService::register`] takes [`StreamOptions`] with `enable_request_stream` /
62//! `enable_response_stream` flags and returns [`PendingConnections`] holding zero, one, or two
63//! [`RegisteredStream`]s. Each [`RegisteredStream`] carries a [`ConnectionInfo`] and a oneshot
64//! that resolves to the [`StreamSender`] / [`StreamReceiver`] once the downstream dials in and the
65//! handshake completes. Once registered, the pending entry remains until the downstream successfully
66//! establishes the stream. Two mechanisms ensure the pending entry is removed when the downstream
67//! cannot be reached:
68//!
69//! 1. The returned [`RegisteredStream`] is RAII — dropping it without `into_parts()` removes the
70//! pending entry from the server's subject tables. This is typically used by the request sender
71//! up until the `RequestControlMessage` is sent and the stream is established.
72//! 2. The server tracks `subject UUID → oneshot` in `tx_subjects` / `rx_subjects`.
73//! [`server::TcpStreamServer::associate_instance`] links one or both
74//! subjects to a discovery instance so [`server::TcpStreamServer::cancel_instance_streams`] can
75//! drop both halves' oneshots together when a worker disappears. Tombstones (`TOMBSTONE_TTL`)
76//! are the safety net that closes the cancel-vs-register race.
77//!
78//! # CallHome Handshake
79//!
80//! The first message a [`client::TcpClient`] sends on a freshly-opened socket is a
81//! `CallHomeHandshake` header-only frame carrying `{ subject, stream_type }`. The server pops
82//! the matching entry out of `tx_subjects` (for [`StreamType::Request`]) or `rx_subjects` (for
83//! [`StreamType::Response`]) and resolves the registrant's oneshot. After that the socket carries
84//! framed [`TwoPartCodec`] messages: data frames in the natural direction, control frames
85//! (and, for response streams, the prologue) interleaved.
86//!
87//! # Control / Shutdown Protocol
88//!
89//! [`ControlMessage`] frames are header-only frames interleaved with data on either socket:
90//!
91//! - [`ControlMessage::Sentinel`] — per-direction clean end-of-stream; the producing side emits
92//! it before closing the socket. Used on both the request and response sockets.
93//! - [`ControlMessage::Stop`] — sender asks the receiver to cancel; the receiving side calls
94//! `context.stop()`.
95//! - [`ControlMessage::Kill`] — hard cancel; `context.kill()` and break out.
96//!
97//! `Stop` and `Kill` only flow from upstream to downstream (i.e. frontend → worker). This is an
98//! expected asymmetry: the upstream can cancel the downstream operation for various reasons,
99//! but the downstream cannot cancel the upstream operation. A downstream that cannot consume
100//! the stream simply drops its socket, which the upstream surfaces as a write error and
101//! interprets as a hint for recovery or failure propagation.
102//!
103//! The cancellation direction is fixed, but the two streams carry **data** in opposite
104//! directions, so the practical handling differs per stream:
105//!
106//! ## Response stream (downstream → upstream) — bidirectional
107//!
108//! - Upstream writes: `Stop` / `Kill` (any time, to cancel).
109//! - Downstream writes: data frames, then `Sentinel` on clean close (skipped on kill/stop).
110//!
111//! ## Request stream (upstream → downstream) — unidirectional after the handshake
112//!
113//! - Upstream writes: data frames, then exactly one closing frame — `Sentinel` (clean drain)
114//! / `Stop` (`context.stopped()`) / `Kill` (`context.killed()`).
115//! - Downstream writes: nothing. Its TCP write half is closed right after the CallHome handshake.
116
117pub mod client;
118pub mod server;
119
120pub mod test_utils;
121
122use super::ControlMessage;
123use serde::{Deserialize, Serialize};
124
125#[allow(unused_imports)]
126use super::{
127 ConnectionInfo, PendingConnections, RegisteredStream, ResponseService, ResponseStreamPrologue,
128 StreamOptions, StreamReceiver, StreamSender, StreamType, codec::TwoPartCodec,
129};
130
131const TCP_TRANSPORT: &str = "tcp_server";
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct TcpStreamConnectionInfo {
135 pub address: String,
136 pub subject: String,
137 pub context: String,
138 pub stream_type: StreamType,
139}
140
141impl From<TcpStreamConnectionInfo> for ConnectionInfo {
142 fn from(info: TcpStreamConnectionInfo) -> Self {
143 // Need to consider the below. If failure should be fatal, keep the below with .expect()
144 // But if there is a default value, we can use:
145 // unwrap_or_else(|e| {
146 // eprintln!("Failed to serialize TcpStreamConnectionInfo: {:?}", e);
147 // "{}".to_string() // Provide a fallback empty JSON string or default value
148 ConnectionInfo {
149 transport: TCP_TRANSPORT.to_string(),
150 info: serde_json::to_string(&info)
151 .expect("Failed to serialize TcpStreamConnectionInfo"),
152 }
153 }
154}
155
156impl TryFrom<ConnectionInfo> for TcpStreamConnectionInfo {
157 type Error = anyhow::Error;
158
159 fn try_from(info: ConnectionInfo) -> Result<Self, Self::Error> {
160 if info.transport != TCP_TRANSPORT {
161 return Err(anyhow::anyhow!(
162 "Invalid transport; TcpClient requires the transport to be `tcp_server`; however {} was passed",
163 info.transport
164 ));
165 }
166
167 serde_json::from_str(&info.info)
168 .map_err(|e| anyhow::anyhow!("Failed parse ConnectionInfo: {:?}", e))
169 }
170}
171
172/// First message sent over a CallHome stream which will map the newly created socket to a specific
173/// response data stream which was registered with the same subject.
174///
175/// This is a transport specific message as part of forming/completing a CallHome TcpStream.
176#[derive(Debug, Clone, Serialize, Deserialize)]
177struct CallHomeHandshake {
178 subject: String,
179 stream_type: StreamType,
180}
181
182#[cfg(test)]
183mod tests {
184 use crate::engine::AsyncEngineContextProvider;
185
186 use super::*;
187 use crate::pipeline::Context;
188
189 #[derive(Debug, Clone, Serialize, Deserialize)]
190 struct TestMessage {
191 foo: String,
192 }
193
194 /// Round-trip a request-stream connection: register on the server with
195 /// `enable_request_stream(true)`, dial in via `TcpClient::create_request_stream`,
196 /// send a frame from the upstream `StreamSender`, and assert it arrives on the
197 /// downstream `StreamReceiver` returned by the client.
198 #[tokio::test]
199 async fn test_tcp_stream_request_stream_client_server() {
200 // [server] start the server and register the request stream
201 let options = server::ServerOptions::default();
202 let server = server::TcpStreamServer::new(options).await.unwrap();
203
204 let context_upstream = Context::new(());
205
206 let options = StreamOptions::builder()
207 .context(context_upstream.context())
208 .enable_request_stream(true)
209 .enable_response_stream(false)
210 .build()
211 .unwrap();
212
213 let pending_connection = server.register(options).await;
214
215 let connection_info = pending_connection
216 .send_stream
217 .as_ref()
218 .unwrap()
219 .connection_info
220 .clone();
221
222 // [client] Assume to receive the connection info from the server via the request plane,
223 // create the request stream and dial in to the server
224 let context_downstream = Context::with_id_and_metadata(
225 (),
226 context_upstream.id().to_string(),
227 Default::default(),
228 );
229
230 let mut recv_stream = client::TcpClient::create_request_stream(
231 context_downstream.context(),
232 connection_info,
233 None,
234 )
235 .await
236 .unwrap();
237
238 // [server] After client dials in, the server can pick up its `StreamSender` half.
239 let (_conn_info, stream_provider) = pending_connection.send_stream.unwrap().into_parts();
240 let send_stream = stream_provider.await.unwrap().unwrap();
241
242 let msg = TestMessage {
243 foo: "request-frame".to_string(),
244 };
245 let payload = serde_json::to_vec(&msg).unwrap();
246
247 send_stream.send(payload.into()).await.unwrap();
248
249 // [client] The client can now receive the response from the server
250 let data = recv_stream.rx.recv().await.unwrap();
251 let recv_msg = serde_json::from_slice::<TestMessage>(&data).unwrap();
252 assert_eq!(msg.foo, recv_msg.foo);
253
254 // Dropping the upstream `StreamSender` should cleanly close the request
255 // stream — the downstream receiver should observe `None`.
256 drop(send_stream);
257 assert!(recv_stream.rx.recv().await.is_none());
258 }
259
260 #[tokio::test]
261 async fn test_tcp_stream_client_server() {
262 // [server] start the server and register the response stream
263 let options = server::ServerOptions::builder().port(9124).build().unwrap();
264 let server = server::TcpStreamServer::new(options).await.unwrap();
265
266 let context_rank0 = Context::new(());
267
268 let options = StreamOptions::builder()
269 .context(context_rank0.context())
270 .enable_request_stream(false)
271 .enable_response_stream(true)
272 .build()
273 .unwrap();
274
275 let pending_connection = server.register(options).await;
276
277 let connection_info = pending_connection
278 .recv_stream
279 .as_ref()
280 .unwrap()
281 .connection_info
282 .clone();
283
284 // [client] set up the other rank and create the response stream
285 let context_rank1 =
286 Context::with_id_and_metadata((), context_rank0.id().to_string(), Default::default());
287
288 let mut send_stream = client::TcpClient::create_response_stream(
289 context_rank1.context(),
290 connection_info,
291 None,
292 )
293 .await
294 .unwrap();
295
296 // the client can now setup it's end of the stream and if it errors, it can send a message
297 // to the server to stop the stream
298 //
299 // this step must be done before the next step on the server can complete, i.e.
300 // the server's stream is now blocked on receiving the prologue message
301 //
302 // let's improve this and use an enum like Ok/Err; currently, None means good-to-go, and
303 // Some(String) means an error happened on this downstream node and we need to alert the
304 // upstream node that an error occurred
305 send_stream.send_prologue(None).await.unwrap();
306
307 // [server] After client sends the prologue, the server can pick up its `StreamReceiver` half.
308 let (_conn_info, stream_provider) = pending_connection.recv_stream.unwrap().into_parts();
309 let recv_stream = stream_provider.await.unwrap();
310
311 // [client] The client can now send the response message to the server
312 let msg = TestMessage {
313 foo: "bar".to_string(),
314 };
315
316 let payload = serde_json::to_vec(&msg).unwrap();
317
318 send_stream.send(payload.into()).await.unwrap();
319
320 // [server] The server can now receive the response message from the client
321
322 let data = recv_stream.unwrap().rx.recv().await.unwrap();
323
324 let recv_msg = serde_json::from_slice::<TestMessage>(&data).unwrap();
325
326 assert_eq!(msg.foo, recv_msg.foo);
327
328 drop(send_stream);
329
330 // let data = recv_stream.rx.recv().await;
331
332 // assert!(data.is_none());
333 }
334}