Skip to main content

dynamo_runtime/pipeline/
network.rs

1// SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Network layer for distributed communication
5//!
6//! Provides request distribution across multiple transport protocols:
7//! - HTTP/2 for standard deployments
8//! - TCP with length-prefixed protocol for high-performance scenarios
9//! - NATS for legacy/messaging-based deployments
10
11pub mod codec;
12pub mod egress;
13pub mod ingress;
14pub mod manager;
15pub mod tcp;
16
17use crate::SystemHealth;
18use crate::error::DynamoError;
19use std::sync::{Arc, OnceLock};
20
21use anyhow::Result;
22use async_trait::async_trait;
23use bytes::Bytes;
24use codec::{TwoPartCodec, TwoPartMessage, TwoPartMessageType};
25use derive_builder::Builder;
26use futures::StreamExt;
27// io::Cursor, TryStreamExt
28use super::{AsyncEngine, AsyncEngineContext, AsyncEngineContextProvider, ResponseStream};
29use serde::{Deserialize, Serialize, de::DeserializeOwned};
30
31use super::{
32    AsyncTransportEngine, Context, Data, Error, ManyIn, ManyOut, PipelineError, PipelineIO,
33    SegmentSource, ServiceBackend, ServiceEngine, SingleIn, Source, context,
34};
35use crate::metrics::MetricsHierarchy;
36use crate::metrics::prometheus_names::work_handler;
37use crate::protocols::maybe_error::MaybeError;
38use ingress::push_handler::WorkHandlerMetrics;
39use prometheus::{CounterVec, Histogram, IntCounter, IntCounterVec, IntGauge};
40
41/// Shared default maximum TCP message size across request-plane components.
42pub(crate) const DEFAULT_TCP_MAX_MESSAGE_SIZE: usize = 32 * 1024 * 1024;
43
44static TCP_MAX_MESSAGE_SIZE: OnceLock<usize> = OnceLock::new();
45static REQUEST_PLANE_PAYLOAD_CODEC: OnceLock<RequestPlanePayloadCodec> = OnceLock::new();
46
47/// Read the configured TCP max message size once and share it across client,
48/// server, and zero-copy decoder code paths.
49pub(crate) fn get_tcp_max_message_size() -> usize {
50    *TCP_MAX_MESSAGE_SIZE.get_or_init(|| {
51        std::env::var("DYN_TCP_MAX_MESSAGE_SIZE")
52            .ok()
53            .and_then(|s| s.parse::<usize>().ok())
54            .unwrap_or(DEFAULT_TCP_MAX_MESSAGE_SIZE)
55    })
56}
57
58#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
59#[serde(rename_all = "snake_case")]
60pub enum RequestPlanePayloadCodec {
61    /// The serde default deliberately remains JSON for wire compatibility with
62    /// control messages produced before the payload codec field existed.
63    #[default]
64    Json,
65    Msgpack,
66}
67
68impl RequestPlanePayloadCodec {
69    pub fn configured() -> Self {
70        *REQUEST_PLANE_PAYLOAD_CODEC.get_or_init(Self::from_env)
71    }
72
73    fn from_env() -> Self {
74        let value =
75            std::env::var(crate::config::environment_names::request_plane::DYN_REQUEST_PLANE_CODEC)
76                .ok();
77        Self::from_config_value(value.as_deref())
78    }
79
80    fn from_config_value(value: Option<&str>) -> Self {
81        match value {
82            None | Some("") | Some("msgpack") => Self::Msgpack,
83            Some("json") => Self::Json,
84            Some(other) => {
85                tracing::warn!(
86                    env_var =
87                        crate::config::environment_names::request_plane::DYN_REQUEST_PLANE_CODEC,
88                    value = other,
89                    "invalid request plane payload codec, defaulting to msgpack"
90                );
91                Self::Msgpack
92            }
93        }
94    }
95
96    pub fn name(&self) -> &'static str {
97        match self {
98            Self::Json => "json",
99            Self::Msgpack => "msgpack",
100        }
101    }
102
103    pub fn encode<T: Serialize>(&self, value: &T) -> Result<Vec<u8>> {
104        match self {
105            Self::Json => Ok(serde_json::to_vec(value)?),
106            Self::Msgpack => Ok(rmp_serde::to_vec_named(value)?),
107        }
108    }
109
110    /// Encode into a caller-owned writer, so a hot path can reuse one buffer
111    /// across frames instead of allocating per frame.
112    ///
113    /// Emits the same bytes as [`Self::encode`], which
114    /// `encode_into_matches_encode_byte_for_byte` pins for both codecs.
115    pub fn encode_into<T, W>(&self, value: &T, writer: &mut W) -> Result<()>
116    where
117        T: Serialize + ?Sized,
118        W: std::io::Write,
119    {
120        match self {
121            Self::Json => Ok(serde_json::to_writer(writer, value)?),
122            Self::Msgpack => Ok(rmp_serde::encode::write_named(writer, value)?),
123        }
124    }
125
126    pub fn decode<T: DeserializeOwned>(&self, bytes: &[u8]) -> Result<T> {
127        match self {
128            Self::Json => Ok(serde_json::from_slice(bytes)?),
129            Self::Msgpack => Ok(rmp_serde::from_slice(bytes)?),
130        }
131    }
132}
133
134pub trait Codable: PipelineIO + Serialize + for<'de> Deserialize<'de> {}
135impl<T: PipelineIO + Serialize + for<'de> Deserialize<'de>> Codable for T {}
136
137/// `WorkQueueConsumer` is a generic interface for a work queue that can be used to send and receive
138#[async_trait]
139pub trait WorkQueueConsumer {
140    async fn dequeue(&self) -> Result<Bytes, String>;
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
144#[serde(rename_all = "snake_case")]
145pub enum StreamType {
146    Request,
147    Response,
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize)]
151#[serde(rename_all = "snake_case")]
152pub(crate) enum RequestType {
153    SingleIn,
154    ManyIn,
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize)]
158#[serde(rename_all = "snake_case")]
159pub(crate) enum ResponseType {
160    SingleOut,
161    ManyOut,
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize)]
165pub(crate) struct RequestControlMessage {
166    pub(crate) id: String,
167    pub(crate) request_type: RequestType,
168    pub(crate) response_type: ResponseType,
169    #[serde(default)]
170    pub(crate) payload_codec: RequestPlanePayloadCodec,
171    pub(crate) connection_info: ConnectionInfo,
172    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
173    pub(crate) metadata: std::collections::BTreeMap<String, String>,
174    /// Wall-clock send timestamp (nanos since UNIX epoch) for transport latency breakdown.
175    /// Uses `SystemTime` so accuracy depends on NTP sync between frontend and backend hosts.
176    /// Reliable for single-machine profiling; treat cross-host values as approximate.
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub(crate) frontend_send_ts_ns: Option<u64>,
179    /// For bidirectional dispatch (`request_type == ManyIn`): connection info the
180    /// worker dials back to in order to receive subsequent request frames. `None`
181    /// for the unary path, which is the wire-compatible default.
182    #[serde(default, skip_serializing_if = "Option::is_none")]
183    pub(crate) request_stream_connection_info: Option<ConnectionInfo>,
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
187#[serde(rename_all = "snake_case")]
188pub enum ControlMessage {
189    Stop,
190    Kill,
191    Sentinel,
192}
193
194/// This is the first message in a `ResponseStream`. This is not a message that gets process
195/// by the general pipeline, but is a control message that is awaited before the
196/// [`AsyncEngine::generate`] method is allowed to return.
197///
198/// If an error is present, the [`AsyncEngine::generate`] method will return the error instead
199/// of returning the `ResponseStream`.
200#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
201pub struct ResponseStreamPrologue {
202    error: Option<String>,
203
204    /// The same failure as `error`, but keeping the worker's
205    /// [`crate::error::ErrorType`] instead of only its display text, so the
206    /// frontend can classify a
207    /// pre-stream failure (for example, a backend refusing a request it can
208    /// never serve) rather than guessing from a message.
209    ///
210    /// `Option` plus `#[serde(default)]` is a compatibility requirement, not a
211    /// convenience: worker and frontend are deployed independently, so during a
212    /// rolling upgrade an old worker sends a prologue without this field and a
213    /// new worker sends one an old frontend does not know. A required field
214    /// would break the handshake in both directions; an absent or unrecognized
215    /// field decodes to `None` and the frontend falls back to the untyped behavior.
216    #[serde(
217        default,
218        deserialize_with = "deserialize_typed_error",
219        skip_serializing_if = "Option::is_none"
220    )]
221    typed_error: Option<DynamoError>,
222}
223
224fn deserialize_typed_error<'de, D>(deserializer: D) -> Result<Option<DynamoError>, D::Error>
225where
226    D: serde::Deserializer<'de>,
227{
228    let typed_error = Option::<serde_json::Value>::deserialize(deserializer)?;
229    Ok(typed_error.and_then(|typed_error| serde_json::from_value(typed_error).ok()))
230}
231
232/// A pre-stream failure as it reaches the requesting side of the transport.
233///
234/// `message` is the display text the prologue has always carried.
235/// `typed_error` is the worker's own [`DynamoError`] when the worker was new
236/// enough to send one.
237#[derive(Debug, Clone, PartialEq, Eq)]
238pub struct StreamPrologueError {
239    pub message: String,
240    pub typed_error: Option<DynamoError>,
241}
242
243impl StreamPrologueError {
244    /// A failure detected by the transport itself, with no worker error behind it.
245    pub fn from_message(message: impl Into<String>) -> Self {
246        Self {
247            message: message.into(),
248            typed_error: None,
249        }
250    }
251
252    /// A worker failure, keeping the display text the prologue has always
253    /// carried alongside the worker's typed error.
254    pub fn new(message: impl Into<String>, typed_error: DynamoError) -> Self {
255        Self {
256            message: message.into(),
257            typed_error: Some(typed_error),
258        }
259    }
260}
261
262impl std::fmt::Display for StreamPrologueError {
263    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
264        f.write_str(&self.message)
265    }
266}
267
268/// Derefs to the message so code written against the previous `String` error
269/// keeps working: `err.contains(..)`, `err.len()`, `&err[..]` all still resolve.
270/// Only an explicit `String` type annotation or signature needs updating.
271impl std::ops::Deref for StreamPrologueError {
272    type Target = str;
273
274    fn deref(&self) -> &Self::Target {
275        &self.message
276    }
277}
278
279impl AsRef<str> for StreamPrologueError {
280    fn as_ref(&self) -> &str {
281        &self.message
282    }
283}
284
285impl From<String> for StreamPrologueError {
286    fn from(message: String) -> Self {
287        Self::from_message(message)
288    }
289}
290
291impl From<&str> for StreamPrologueError {
292    fn from(message: &str) -> Self {
293        Self::from_message(message)
294    }
295}
296
297/// Resolves once the worker's response stream is established, or with a
298/// [`StreamPrologueError`] carrying both the failure's display text and, when
299/// the worker sent one, its [`crate::error::ErrorType`], so the requesting side
300/// can classify a pre-stream failure without parsing the message.
301pub type StreamProvider<T> = tokio::sync::oneshot::Receiver<Result<T, StreamPrologueError>>;
302
303/// Owning `Drop` here (rather than on `RegisteredStream`) lets `into_parts()`
304/// move the public fields out by plain destructure.
305struct Cleanup(Option<Box<dyn FnOnce() + Send + 'static>>);
306
307impl Drop for Cleanup {
308    fn drop(&mut self) {
309        if let Some(f) = self.0.take() {
310            f();
311        }
312    }
313}
314
315/// Awaitable handle for a stream sender or receiver. Drop without calling
316/// `into_parts()` runs the optional cleanup closure, removing the
317/// registration from the stream server's maps.
318pub struct RegisteredStream<T> {
319    pub connection_info: ConnectionInfo,
320    pub stream_provider: StreamProvider<T>,
321    cleanup: Cleanup,
322}
323
324impl<T> std::fmt::Debug for RegisteredStream<T> {
325    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
326        f.debug_struct("RegisteredStream")
327            .field("connection_info", &self.connection_info)
328            .finish_non_exhaustive()
329    }
330}
331
332impl<T> RegisteredStream<T> {
333    pub(crate) fn new(connection_info: ConnectionInfo, stream_provider: StreamProvider<T>) -> Self {
334        Self {
335            connection_info,
336            stream_provider,
337            cleanup: Cleanup(None),
338        }
339    }
340
341    pub(crate) fn with_cleanup<F>(mut self, cleanup: F) -> Self
342    where
343        F: FnOnce() + Send + 'static,
344    {
345        self.cleanup.0 = Some(Box::new(cleanup));
346        self
347    }
348
349    /// Consume the registration, disarming the RAII cleanup. Caller takes
350    /// responsibility for cleanup if the stream provider is never awaited.
351    pub fn into_parts(self) -> (ConnectionInfo, StreamProvider<T>) {
352        let Self {
353            connection_info,
354            stream_provider,
355            mut cleanup,
356        } = self;
357        cleanup.0.take();
358        (connection_info, stream_provider)
359    }
360}
361
362/// After registering a stream, the [`PendingConnections`] object is returned to the caller. This
363/// object can be used to await the connection to be established.
364pub struct PendingConnections {
365    pub send_stream: Option<RegisteredStream<StreamSender>>,
366    pub recv_stream: Option<RegisteredStream<StreamReceiver>>,
367}
368
369impl PendingConnections {
370    pub fn into_parts(
371        self,
372    ) -> (
373        Option<RegisteredStream<StreamSender>>,
374        Option<RegisteredStream<StreamReceiver>>,
375    ) {
376        (self.send_stream, self.recv_stream)
377    }
378}
379
380/// A [`ResponseService`] implements a services in which a context a specific subject with will
381/// be associated with a stream of responses.
382#[async_trait::async_trait]
383pub trait ResponseService {
384    async fn register(&self, options: StreamOptions) -> PendingConnections;
385}
386
387#[cfg(test)]
388mod registered_stream_tests {
389    use super::*;
390    use std::sync::atomic::{AtomicBool, Ordering};
391
392    fn dummy_conn_info() -> ConnectionInfo {
393        ConnectionInfo {
394            transport: "test".to_string(),
395            info: "{}".to_string(),
396        }
397    }
398
399    /// Drop without `into_parts()` must run the cleanup closure.
400    #[test]
401    fn drop_runs_cleanup() {
402        let flag = Arc::new(AtomicBool::new(false));
403        let flag_clone = flag.clone();
404
405        let (_tx, rx) = tokio::sync::oneshot::channel::<Result<(), StreamPrologueError>>();
406        let stream = RegisteredStream::new(dummy_conn_info(), rx).with_cleanup(move || {
407            flag_clone.store(true, Ordering::SeqCst);
408        });
409
410        drop(stream);
411        assert!(
412            flag.load(Ordering::SeqCst),
413            "cleanup must fire when RegisteredStream is dropped"
414        );
415    }
416
417    /// `into_parts()` must disarm the cleanup. After the call, dropping the
418    /// returned halves must NOT trigger the closure -- the caller has taken
419    /// ownership of cleanup responsibility.
420    #[test]
421    fn into_parts_disarms_cleanup() {
422        let flag = Arc::new(AtomicBool::new(false));
423        let flag_clone = flag.clone();
424
425        let (_tx, rx) = tokio::sync::oneshot::channel::<Result<(), StreamPrologueError>>();
426        let stream = RegisteredStream::new(dummy_conn_info(), rx).with_cleanup(move || {
427            flag_clone.store(true, Ordering::SeqCst);
428        });
429
430        let (conn, provider) = stream.into_parts();
431        drop(conn);
432        drop(provider);
433
434        assert!(
435            !flag.load(Ordering::SeqCst),
436            "into_parts() must disarm the cleanup closure"
437        );
438    }
439
440    /// `RegisteredStream` with no cleanup configured must drop cleanly.
441    #[test]
442    fn drop_without_cleanup_is_a_noop() {
443        let (_tx, rx) = tokio::sync::oneshot::channel::<Result<(), StreamPrologueError>>();
444        let stream: RegisteredStream<()> = RegisteredStream::new(dummy_conn_info(), rx);
445        drop(stream); // must not panic; nothing observable to assert beyond that
446    }
447}
448
449// #[derive(Debug, Clone, Serialize, Deserialize)]
450// struct Handshake {
451//     request_id: String,
452//     worker_id: Option<String>,
453//     error: Option<String>,
454// }
455
456// impl Handshake {
457//     pub fn validate(&self) -> Result<(), String> {
458//         if let Some(e) = &self.error {
459//             return Err(e.clone());
460//         }
461//         Ok(())
462//     }
463// }
464
465// this probably needs to be come a ResponseStreamSender
466// since the prologue in this scenario sender telling the receiver
467// that all is good and it's ready to send
468//
469// in the RequestStreamSender, the prologue would be coming from the
470// receiver, so the sender would have to await the prologue which if
471// was not an error, would indicate the RequestStreamReceiver is read
472// to receive data.
473pub struct StreamSender {
474    tx: tokio::sync::mpsc::Sender<TwoPartMessage>,
475    prologue: Option<ResponseStreamPrologue>,
476}
477
478impl StreamSender {
479    pub async fn send(&self, data: Bytes) -> Result<()> {
480        Ok(self.tx.send(TwoPartMessage::from_data(data)).await?)
481    }
482
483    pub async fn send_control(&self, control: ControlMessage) -> Result<()> {
484        let bytes = serde_json::to_vec(&control)?;
485        Ok(self
486            .tx
487            .send(TwoPartMessage::from_header(bytes.into()))
488            .await?)
489    }
490
491    /// Send the prologue, reporting an untyped failure.
492    ///
493    /// A caller holding the worker's typed error uses
494    /// [`Self::send_prologue_typed`] instead, which keeps that type on the wire.
495    pub async fn send_prologue(&mut self, error: Option<String>) -> Result<(), String> {
496        self.send_prologue_typed(error.map(StreamPrologueError::from_message))
497            .await
498    }
499
500    pub async fn send_prologue_typed(
501        &mut self,
502        error: Option<StreamPrologueError>,
503    ) -> Result<(), String> {
504        // leaving the original logic in place for now
505        if let Some(_prologue) = self.prologue.take() {
506            let (error, typed_error) = match error {
507                Some(StreamPrologueError {
508                    message,
509                    typed_error,
510                }) => (Some(message), typed_error),
511                None => (None, None),
512            };
513            let prologue = ResponseStreamPrologue { error, typed_error };
514            let header_bytes: Bytes = match serde_json::to_vec(&prologue) {
515                Ok(b) => b.into(),
516                Err(err) => {
517                    tracing::error!(%err, "send_prologue: ResponseStreamPrologue did not serialize to a JSON array");
518                    return Err("Invalid prologue".to_string());
519                }
520            };
521            self.tx
522                .send(TwoPartMessage::from_header(header_bytes))
523                .await
524                .map_err(|e| e.to_string())?;
525        } else {
526            panic!("Prologue already sent; or not set; logic error");
527        }
528        Ok(())
529    }
530}
531
532pub struct StreamReceiver {
533    rx: tokio::sync::mpsc::Receiver<Bytes>,
534}
535
536/// Connection Info is encoded as JSON and then again serialized has part of the Transport
537/// Layer. The double serialization is not performance critical as it is only done once per
538/// connection. The primary reason storing the ConnecitonInfo has a JSON string is for type
539/// erasure. The Transport Layer will check the [`ConnectionInfo::transport`] type and then
540/// route it to the appropriate instance of the Transport, which will then deserialize the
541/// [`ConnectionInfo::info`] field to its internal connection info object.
542///
543/// Optionally, this object could become strongly typed for which all possible combinations
544/// of transport and connection info would need to be enumerated.
545#[derive(Debug, Clone, Serialize, Deserialize)]
546pub struct ConnectionInfo {
547    pub transport: String,
548    pub info: String,
549}
550
551/// Default number of frames buffered between the data-plane socket task and the
552/// engine consumer/producer for a single stream. Preserves the historically
553/// hard-coded mpsc channel capacity used by the TCP transport.
554pub const DEFAULT_SEND_BUFFER_COUNT: usize = 64;
555
556/// When registering a new TransportStream on the server, the caller specifies if the
557/// stream is a sender, receiver or both.
558///
559/// Senders and Receivers are with share a Context, but result in separate tcp socket
560/// connections to the server. Internally, we may use bcast channels to coordinate the
561/// internal control messages between the sender and receiver socket connections.
562#[derive(Clone, Builder)]
563pub struct StreamOptions {
564    /// Context
565    pub context: Arc<dyn AsyncEngineContext>,
566
567    /// Register with the server that this connection will have a server-side Sender
568    /// that can be picked up by the Request/Forward pipeline. The downstream side
569    /// dials in via [`crate::pipeline::network::tcp::client::TcpClient::create_request_stream`]
570    /// to receive the frames the server pushes.
571    pub enable_request_stream: bool,
572
573    /// Register with the server that this connection will have a server-side Receiver
574    /// that can be picked up by the Response/Reverse pipeline
575    pub enable_response_stream: bool,
576
577    /// The number of frames buffered between the data-plane socket task and the
578    /// engine consumer/producer before backpressure kicks in. Drives the mpsc
579    /// channel capacity for the per-stream buffer in the TCP transport.
580    #[builder(default = "DEFAULT_SEND_BUFFER_COUNT")]
581    pub send_buffer_count: usize,
582
583    /// The number of messages to buffer before blocking
584    #[builder(default = "8")]
585    pub recv_buffer_count: usize,
586}
587
588impl StreamOptions {
589    pub fn builder() -> StreamOptionsBuilder {
590        StreamOptionsBuilder::default()
591    }
592}
593
594pub struct Egress<Req: PipelineIO, Resp: PipelineIO> {
595    transport_engine: Arc<dyn AsyncTransportEngine<Req, Resp>>,
596}
597
598#[cfg(test)]
599mod tests {
600    use super::{
601        DEFAULT_SEND_BUFFER_COUNT, NetworkStreamWrapper, RequestControlMessage,
602        RequestPlanePayloadCodec, RequestType, ResponseStreamPrologue, ResponseType, StreamOptions,
603        StreamPrologueError,
604    };
605    use crate::engine::AsyncEngineContextProvider;
606    use crate::error::{BackendError, DynamoError, ErrorType};
607    use crate::pipeline::Context;
608    use serde::{Deserialize, Serialize};
609
610    #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
611    struct TestPayload {
612        id: u64,
613        text: String,
614        tokens: Vec<u32>,
615    }
616
617    #[test]
618    fn prologue_without_typed_error_field_still_decodes() {
619        let legacy = br#"{"error":"Generate Error: something went wrong"}"#;
620        let prologue: ResponseStreamPrologue =
621            serde_json::from_slice(legacy).expect("a prologue without the typed field must decode");
622
623        assert_eq!(
624            prologue.error.as_deref(),
625            Some("Generate Error: something went wrong")
626        );
627        assert!(
628            prologue.typed_error.is_none(),
629            "an absent typed error must decode to None, not fail"
630        );
631    }
632
633    /// Pins the `String` compat shim on the widened `StreamProvider` error:
634    /// `str` methods and `String` conversion must keep resolving without a
635    /// field access, or the source break gets wider than intended.
636    #[test]
637    fn stream_prologue_error_substitutes_for_the_previous_string() {
638        let err = StreamPrologueError::from_message("malformed prologue: bad header");
639
640        assert!(err.contains("malformed prologue"));
641        assert!(err.starts_with("malformed"));
642        assert_eq!(err.len(), "malformed prologue: bad header".len());
643        assert_eq!(&err[..9], "malformed");
644        assert_eq!(err.as_ref() as &str, "malformed prologue: bad header");
645
646        let converted: StreamPrologueError = "from a &str".into();
647        assert_eq!(converted.message, "from a &str");
648        assert!(converted.typed_error.is_none());
649
650        let converted: StreamPrologueError = String::from("from a String").into();
651        assert_eq!(converted.message, "from a String");
652    }
653
654    #[test]
655    fn prologue_round_trips_the_typed_error() {
656        let prologue = ResponseStreamPrologue {
657            error: Some("Generate Error: unsupported input".to_string()),
658            typed_error: Some(
659                DynamoError::builder()
660                    .error_type(ErrorType::Backend(BackendError::InvalidArgument))
661                    .message("unsupported input")
662                    .build(),
663            ),
664        };
665
666        let encoded = serde_json::to_vec(&prologue).expect("prologue should serialize");
667        let decoded: ResponseStreamPrologue =
668            serde_json::from_slice(&encoded).expect("prologue should deserialize");
669
670        assert_eq!(decoded, prologue);
671        assert_eq!(
672            decoded.typed_error.map(|e| e.error_type()),
673            Some(ErrorType::Backend(BackendError::InvalidArgument))
674        );
675    }
676
677    #[test]
678    fn encode_into_matches_encode_byte_for_byte() {
679        let payload = NetworkStreamWrapper {
680            data: Some(TestPayload {
681                id: 7,
682                text: "hello".to_string(),
683                tokens: vec![1, 200, 70_000],
684            }),
685            complete_final: false,
686        };
687
688        for codec in [
689            RequestPlanePayloadCodec::Json,
690            RequestPlanePayloadCodec::Msgpack,
691        ] {
692            let expected = codec.encode(&payload).expect("encode");
693            let mut actual = Vec::new();
694            codec
695                .encode_into(&payload, &mut actual)
696                .expect("encode_into");
697            assert_eq!(expected, actual, "codec={}", codec.name());
698        }
699    }
700
701    #[test]
702    fn stream_options_send_buffer_count_defaults_to_64() {
703        let context = Context::new(());
704        let options = StreamOptions::builder()
705            .context(context.context())
706            .enable_request_stream(true)
707            .enable_response_stream(true)
708            .build()
709            .expect("stream options should build");
710
711        assert_eq!(DEFAULT_SEND_BUFFER_COUNT, 64);
712        assert_eq!(options.send_buffer_count, DEFAULT_SEND_BUFFER_COUNT);
713    }
714
715    #[test]
716    fn stream_options_send_buffer_count_overrides_default() {
717        let context = Context::new(());
718        let options = StreamOptions::builder()
719            .context(context.context())
720            .enable_request_stream(true)
721            .enable_response_stream(true)
722            .send_buffer_count(128)
723            .build()
724            .expect("stream options should build");
725
726        assert_eq!(options.send_buffer_count, 128);
727    }
728
729    #[test]
730    fn legacy_frontend_control_message_defaults_payload_codec_to_json() {
731        let json = r#"{
732            "id": "request-123",
733            "request_type": "single_in",
734            "response_type": "many_out",
735            "connection_info": {
736                "transport": "tcp",
737                "info": "{}"
738            }
739        }"#;
740
741        let message: RequestControlMessage =
742            serde_json::from_str(json).expect("control message should deserialize");
743
744        assert_eq!(message.id, "request-123");
745        assert!(matches!(message.request_type, RequestType::SingleIn));
746        assert!(matches!(message.response_type, ResponseType::ManyOut));
747        assert_eq!(message.payload_codec, RequestPlanePayloadCodec::Json);
748        assert_eq!(message.connection_info.transport, "tcp");
749        assert_eq!(message.connection_info.info, "{}");
750        assert!(message.metadata.is_empty());
751        assert!(message.frontend_send_ts_ns.is_none());
752
753        let payload = br#"{"id":7,"text":"legacy","tokens":[1,2]}"#;
754        let decoded: TestPayload = message
755            .payload_codec
756            .decode(payload)
757            .expect("worker should decode the legacy frontend's JSON payload");
758        assert_eq!(
759            decoded,
760            TestPayload {
761                id: 7,
762                text: "legacy".to_string(),
763                tokens: vec![1, 2],
764            }
765        );
766    }
767
768    #[test]
769    fn request_control_message_decodes_msgpack_payload_codec() {
770        let json = r#"{
771            "id": "request-123",
772            "request_type": "single_in",
773            "response_type": "many_out",
774            "payload_codec": "msgpack",
775            "connection_info": {
776                "transport": "tcp",
777                "info": "{}"
778            }
779        }"#;
780
781        let message: RequestControlMessage =
782            serde_json::from_str(json).expect("control message should deserialize");
783
784        assert_eq!(message.payload_codec, RequestPlanePayloadCodec::Msgpack);
785    }
786
787    #[test]
788    fn request_plane_payload_codec_configuration_defaults_to_msgpack() {
789        assert_eq!(
790            RequestPlanePayloadCodec::from_config_value(None),
791            RequestPlanePayloadCodec::Msgpack
792        );
793        assert_eq!(
794            RequestPlanePayloadCodec::from_config_value(Some("")),
795            RequestPlanePayloadCodec::Msgpack
796        );
797        assert_eq!(
798            RequestPlanePayloadCodec::from_config_value(Some("invalid")),
799            RequestPlanePayloadCodec::Msgpack
800        );
801    }
802
803    #[test]
804    fn request_plane_payload_codec_configuration_honors_explicit_overrides() {
805        assert_eq!(
806            RequestPlanePayloadCodec::from_config_value(Some("json")),
807            RequestPlanePayloadCodec::Json
808        );
809        assert_eq!(
810            RequestPlanePayloadCodec::from_config_value(Some("msgpack")),
811            RequestPlanePayloadCodec::Msgpack
812        );
813    }
814
815    #[test]
816    fn request_plane_payload_codec_round_trips_response_wrapper_json_and_msgpack() {
817        let wrapper = NetworkStreamWrapper {
818            data: Some(TestPayload {
819                id: 42,
820                text: "line\nquote\"slash\\unicode δΈ­".to_string(),
821                tokens: vec![1, 2, 3, 65535],
822            }),
823            complete_final: false,
824        };
825
826        for codec in [
827            RequestPlanePayloadCodec::Json,
828            RequestPlanePayloadCodec::Msgpack,
829        ] {
830            let encoded = codec.encode(&wrapper).expect("wrapper should encode");
831            let decoded: NetworkStreamWrapper<TestPayload> =
832                codec.decode(&encoded).expect("wrapper should decode");
833            assert_eq!(decoded, wrapper);
834        }
835    }
836}
837
838#[async_trait]
839impl<T: Data, U: Data> AsyncEngine<SingleIn<T>, ManyOut<U>, Error>
840    for Egress<SingleIn<T>, ManyOut<U>>
841where
842    T: Data + Serialize,
843    U: for<'de> Deserialize<'de> + Data,
844{
845    async fn generate(&self, request: SingleIn<T>) -> Result<ManyOut<U>, Error> {
846        self.transport_engine.generate(request).await
847    }
848}
849
850/// Result of encoding one response item for the request plane.
851pub struct EncodedResponseFrame {
852    pub bytes: Bytes,
853    pub is_error: bool,
854    /// Stop consuming the engine stream after publishing this frame. The
855    /// normal complete-final frame is still sent.
856    pub stop_stream: bool,
857}
858
859/// Converts request-plane bytes into the item consumed by an ingress engine.
860pub trait IngressRequestDecoder<T>: Send + Sync + 'static
861where
862    T: Data,
863{
864    fn decode_request(
865        &self,
866        payload_codec: RequestPlanePayloadCodec,
867        bytes: Bytes,
868    ) -> impl std::future::Future<Output = std::result::Result<T, PipelineError>> + Send;
869}
870
871/// Converts an ingress engine response into its complete on-wire frame.
872pub trait IngressResponseEncoder<U>: Send + Sync + 'static
873where
874    U: Data,
875{
876    fn encode_response(
877        &self,
878        payload_codec: RequestPlanePayloadCodec,
879        response: Option<U>,
880        complete_final: bool,
881    ) -> impl std::future::Future<Output = std::result::Result<EncodedResponseFrame, PipelineError>> + Send;
882}
883
884/// Complete request/response payload adapter for an ingress engine.
885pub trait IngressPayloadAdapter<T, U>:
886    IngressRequestDecoder<T> + IngressResponseEncoder<U>
887where
888    T: Data,
889    U: Data,
890{
891}
892
893impl<T, U, Adapter> IngressPayloadAdapter<T, U> for Adapter
894where
895    T: Data,
896    U: Data,
897    Adapter: IngressRequestDecoder<T> + IngressResponseEncoder<U>,
898{
899}
900
901/// Default adapter for ordinary Rust request and response types.
902#[derive(Debug, Default)]
903pub struct SerdeIngressPayloadAdapter;
904
905impl<T> IngressRequestDecoder<T> for SerdeIngressPayloadAdapter
906where
907    T: Data + DeserializeOwned,
908{
909    #[inline]
910    fn decode_request(
911        &self,
912        payload_codec: RequestPlanePayloadCodec,
913        bytes: Bytes,
914    ) -> impl std::future::Future<Output = std::result::Result<T, PipelineError>> + Send {
915        let decoded = payload_codec.decode(&bytes).map_err(|err| {
916            PipelineError::DeserializationError(format!(
917                "Failed deserializing {} request payload: {}",
918                payload_codec.name(),
919                err
920            ))
921        });
922        std::future::ready(decoded)
923    }
924}
925
926impl<U> IngressResponseEncoder<U> for SerdeIngressPayloadAdapter
927where
928    U: Data + Serialize + MaybeError,
929{
930    #[inline]
931    fn encode_response(
932        &self,
933        payload_codec: RequestPlanePayloadCodec,
934        response: Option<U>,
935        complete_final: bool,
936    ) -> impl std::future::Future<Output = std::result::Result<EncodedResponseFrame, PipelineError>> + Send
937    {
938        let is_error = response
939            .as_ref()
940            .is_some_and(|response| response.err().is_some());
941        let wrapper = NetworkStreamWrapper {
942            data: response,
943            complete_final,
944        };
945        let encoded = payload_codec.encode(&wrapper).map_err(|err| {
946            PipelineError::SerializationError(format!(
947                "Failed serializing {} request-plane response: {}",
948                payload_codec.name(),
949                err
950            ))
951        });
952        std::future::ready(encoded.map(|bytes| EncodedResponseFrame {
953            bytes: bytes.into(),
954            is_error,
955            stop_stream: false,
956        }))
957    }
958}
959
960pub struct Ingress<Req: PipelineIO, Resp: PipelineIO, Adapter = SerdeIngressPayloadAdapter> {
961    segment: OnceLock<Arc<SegmentSource<Req, Resp>>>,
962    metrics: OnceLock<Arc<WorkHandlerMetrics>>,
963    /// Endpoint-specific notifier for health check timer resets
964    endpoint_health_check_notifier: OnceLock<Arc<tokio::sync::Notify>>,
965    payload_adapter: Arc<Adapter>,
966}
967
968impl<Req: PipelineIO + Sync, Resp: PipelineIO> Ingress<Req, Resp> {
969    pub fn new() -> Arc<Self> {
970        Ingress::new_with_adapter(SerdeIngressPayloadAdapter)
971    }
972
973    pub fn link(segment: Arc<SegmentSource<Req, Resp>>) -> Result<Arc<Self>> {
974        let ingress = Ingress::new();
975        ingress.attach(segment)?;
976        Ok(ingress)
977    }
978
979    pub fn for_pipeline(segment: Arc<SegmentSource<Req, Resp>>) -> Result<Arc<Self>> {
980        let ingress = Ingress::new();
981        ingress.attach(segment)?;
982        Ok(ingress)
983    }
984
985    pub fn for_engine(engine: ServiceEngine<Req, Resp>) -> Result<Arc<Self>> {
986        Self::for_engine_with_adapter(engine, SerdeIngressPayloadAdapter)
987    }
988}
989
990impl<Req, Resp, Adapter> Ingress<Req, Resp, Adapter>
991where
992    Req: PipelineIO + Sync,
993    Resp: PipelineIO,
994    Adapter: Send + Sync + 'static,
995{
996    pub fn new_with_adapter(payload_adapter: Adapter) -> Arc<Self> {
997        Arc::new(Self {
998            segment: OnceLock::new(),
999            metrics: OnceLock::new(),
1000            endpoint_health_check_notifier: OnceLock::new(),
1001            payload_adapter: Arc::new(payload_adapter),
1002        })
1003    }
1004
1005    pub fn attach(&self, segment: Arc<SegmentSource<Req, Resp>>) -> Result<()> {
1006        self.segment
1007            .set(segment)
1008            .map_err(|_| anyhow::anyhow!("Segment already set"))
1009    }
1010
1011    pub fn add_metrics(
1012        &self,
1013        endpoint: &crate::component::Endpoint,
1014        metrics_labels: Option<&[(&str, &str)]>,
1015    ) -> Result<()> {
1016        let metrics = WorkHandlerMetrics::from_endpoint(endpoint, metrics_labels)
1017            .map_err(|e| anyhow::anyhow!("Failed to create work handler metrics: {}", e))?;
1018
1019        // Register global transport breakdown metrics (idempotent)
1020        crate::metrics::work_handler_perf::ensure_work_handler_perf_metrics_registered(
1021            endpoint.get_metrics_registry(),
1022        );
1023
1024        // Register worker-pool saturation metrics (idempotent). These are
1025        // process-global and shared across all endpoints attached to the
1026        // same shared TCP server.
1027        crate::metrics::work_handler_pool::ensure_work_handler_pool_metrics_registered(
1028            endpoint.get_metrics_registry(),
1029        );
1030
1031        self.metrics
1032            .set(Arc::new(metrics))
1033            .map_err(|_| anyhow::anyhow!("Metrics already set"))
1034    }
1035
1036    pub fn for_engine_with_adapter(
1037        engine: ServiceEngine<Req, Resp>,
1038        payload_adapter: Adapter,
1039    ) -> Result<Arc<Self>> {
1040        let frontend = SegmentSource::<Req, Resp>::new();
1041        let backend = ServiceBackend::from_engine(engine);
1042
1043        // create the pipeline
1044        let pipeline = frontend.link(backend)?.link_terminal(frontend)?;
1045
1046        let ingress = Ingress::new_with_adapter(payload_adapter);
1047        ingress.attach(pipeline)?;
1048
1049        Ok(ingress)
1050    }
1051
1052    /// Helper method to access metrics if available
1053    fn metrics(&self) -> Option<&Arc<WorkHandlerMetrics>> {
1054        self.metrics.get()
1055    }
1056}
1057
1058#[async_trait]
1059pub trait PushWorkHandler: Send + Sync {
1060    async fn handle_payload(
1061        &self,
1062        payload: Bytes,
1063        request_id: Option<String>,
1064    ) -> Result<(), PipelineError>;
1065
1066    /// Add metrics to the handler
1067    fn add_metrics(
1068        &self,
1069        endpoint: &crate::component::Endpoint,
1070        metrics_labels: Option<&[(&str, &str)]>,
1071    ) -> Result<()>;
1072
1073    /// Set the endpoint-specific notifier for health check timer resets
1074    fn set_endpoint_health_check_notifier(
1075        &self,
1076        _notifier: Arc<tokio::sync::Notify>,
1077    ) -> Result<()> {
1078        // Default implementation for backwards compatibility
1079        Ok(())
1080    }
1081}
1082
1083/*
1084/// `NetworkStreamWrapper` is a simple wrapper used to detect proper stream termination
1085/// in network communication between ingress and egress components.
1086///
1087/// **Purpose**: This wrapper solves the problem of detecting whether a stream ended
1088/// gracefully or was cut off prematurely (e.g., due to network issues).
1089///
1090/// **Design Rationale**:
1091/// - Cannot use `Annotated` directly because the generic type `U` varies:
1092///   - Sometimes `U = Annotated<...>`
1093///   - Sometimes `U = LLMEngineOutput<...>`
1094/// - Using `Annotated` would require double-wrapping like `Annotated<Annotated<...>>`
1095/// - A simple wrapper is cleaner and more straightforward
1096///
1097/// **Stream Flow**:
1098/// ```
1099/// At AsyncEngine:
1100///   response 1 -> response 2 -> response 3 -> <end>
1101///
1102/// Between ingress/egress:
1103///   response 1 <end=false> -> response 2 <end=false> -> response 3 <end=false> -> (null) <end=true>
1104///
1105/// At client:
1106///   response 1 -> response 2 -> response 3 -> <end>
1107/// ```
1108///
1109/// **Error Handling**:
1110/// If the stream is cut off before proper termination, the egress is responsible for
1111/// injecting an error response to communicate the incomplete stream to the client:
1112/// ```
1113/// At AsyncEngine:
1114///   response 1 -> ... <without end flag>
1115///
1116/// At egress:
1117///   response 1 <end=false> -> <stream ended without end flag -> convert to error>
1118///
1119/// At client:
1120///   response 1 -> error response
1121/// ```
1122///
1123/// The detection must be done at egress level because premature stream termination
1124/// can be due to network issues that only the egress component can detect.
1125*/
1126/// TODO: Detect end-of-stream using Server-Sent Events (SSE). This will be removed.
1127#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
1128pub struct NetworkStreamWrapper<U> {
1129    #[serde(skip_serializing_if = "Option::is_none")]
1130    pub data: Option<U>,
1131    pub complete_final: bool,
1132}