Skip to main content

dynamo_runtime/pipeline/network/ingress/
push_handler.rs

1// SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4use super::*;
5
6use crate::engine::AsyncEngineContext;
7use crate::error::DynamoError;
8use crate::metrics::prometheus_names::work_handler;
9use crate::metrics::work_handler_perf::{
10    WORK_HANDLER_NETWORK_TRANSIT_SECONDS, WORK_HANDLER_TIME_TO_FIRST_RESPONSE_SECONDS,
11};
12use crate::pipeline::network::StreamPrologueError;
13use crate::pipeline::{ManyIn, RequestStream};
14use futures::StreamExt;
15use prometheus::{Histogram, IntCounter, IntCounterVec, IntGauge};
16use serde::Deserialize;
17use std::sync::Arc;
18use std::time::Instant;
19use tracing::Instrument;
20use tracing::info_span;
21
22/// Metrics configuration for profiling work handlers
23#[derive(Clone, Debug)]
24pub struct WorkHandlerMetrics {
25    pub request_counter: IntCounter,
26    pub request_duration: Histogram,
27    pub inflight_requests: IntGauge,
28    pub request_bytes: IntCounter,
29    pub response_bytes: IntCounter,
30    pub error_counter: IntCounterVec,
31    pub cancellation_total: IntCounter,
32}
33
34impl WorkHandlerMetrics {
35    pub fn new(
36        request_counter: IntCounter,
37        request_duration: Histogram,
38        inflight_requests: IntGauge,
39        request_bytes: IntCounter,
40        response_bytes: IntCounter,
41        error_counter: IntCounterVec,
42        cancellation_total: IntCounter,
43    ) -> Self {
44        Self {
45            request_counter,
46            request_duration,
47            inflight_requests,
48            request_bytes,
49            response_bytes,
50            error_counter,
51            cancellation_total,
52        }
53    }
54
55    /// Create WorkHandlerMetrics from an endpoint using its built-in labeling
56    pub fn from_endpoint(
57        endpoint: &crate::component::Endpoint,
58        metrics_labels: Option<&[(&str, &str)]>,
59    ) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
60        let metrics_labels = metrics_labels.unwrap_or(&[]);
61        let metrics = endpoint.metrics();
62        let request_counter = metrics.create_intcounter(
63            work_handler::REQUESTS_TOTAL,
64            "Total number of requests processed by work handler",
65            metrics_labels,
66        )?;
67
68        // Custom buckets for inference workloads: retain sub-second resolution for
69        // fast operations, extend well beyond the default 10s ceiling to capture
70        // long-running generation requests that can last minutes.
71        let request_duration_buckets = vec![
72            0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 20.0, 30.0, 60.0, 120.0,
73            300.0, 600.0,
74        ];
75        let request_duration = metrics.create_histogram(
76            work_handler::REQUEST_DURATION_SECONDS,
77            "Time spent processing requests by work handler",
78            metrics_labels,
79            Some(request_duration_buckets),
80        )?;
81
82        let inflight_requests = metrics.create_intgauge(
83            work_handler::INFLIGHT_REQUESTS,
84            "Number of requests currently being processed by work handler",
85            metrics_labels,
86        )?;
87
88        let request_bytes = metrics.create_intcounter(
89            work_handler::REQUEST_BYTES_TOTAL,
90            "Total number of bytes received in requests by work handler",
91            metrics_labels,
92        )?;
93
94        let response_bytes = metrics.create_intcounter(
95            work_handler::RESPONSE_BYTES_TOTAL,
96            "Total number of bytes sent in responses by work handler",
97            metrics_labels,
98        )?;
99
100        let error_counter = metrics.create_intcountervec(
101            work_handler::ERRORS_TOTAL,
102            "Total number of errors in work handler processing",
103            &[work_handler::ERROR_TYPE_LABEL],
104            metrics_labels,
105        )?;
106
107        let cancellation_total = metrics.create_intcounter(
108            work_handler::CANCELLATION_TOTAL,
109            "Total number of requests cancelled by work handler",
110            metrics_labels,
111        )?;
112
113        Ok(Self::new(
114            request_counter,
115            request_duration,
116            inflight_requests,
117            request_bytes,
118            response_bytes,
119            error_counter,
120            cancellation_total,
121        ))
122    }
123}
124
125// RAII guard to ensure inflight gauge is decremented, request duration is observed,
126// and lifecycle logs are emitted on all code paths.
127struct RequestMetricsGuard {
128    inflight_requests: prometheus::IntGauge,
129    request_duration: prometheus::Histogram,
130    start_time: Instant,
131    request_id: Option<String>,
132}
133
134impl Drop for RequestMetricsGuard {
135    fn drop(&mut self) {
136        self.inflight_requests.dec();
137        self.request_duration
138            .observe(self.start_time.elapsed().as_secs_f64());
139        if let Some(request_id) = &self.request_id {
140            tracing::info!(request_id = %request_id, "request completed");
141        }
142    }
143}
144
145impl<Req, Resp, Adapter> Ingress<Req, Resp, Adapter>
146where
147    Req: PipelineIO + Sync,
148    Resp: PipelineIO,
149    Adapter: Send + Sync + 'static,
150{
151    /// Pump every chunk from the engine's response stream out to the
152    /// upstream-side `StreamSender`, plus the terminal complete-final
153    /// frame. Captures the per-frame metrics, the publish-failure error
154    /// classification (client-side disconnect vs. real failure), and the
155    /// health-check notifier policy (notify only on non-error chunks and
156    /// at clean stream end).
157    async fn pump_response_stream<U>(
158        &self,
159        mut stream: ManyOut<U>,
160        publisher: &StreamSender,
161        payload_codec: RequestPlanePayloadCodec,
162    ) where
163        U: Data + std::fmt::Debug,
164        Adapter: IngressResponseEncoder<U>,
165    {
166        let context = stream.context();
167
168        // TODO: Detect end-of-stream using Server-Sent Events (SSE)
169        let mut send_complete_final = true;
170        let mut saw_error_response = false;
171        while let Some(resp) = stream.next().await {
172            tracing::trace!("Sending response: {:?}", resp);
173            let encoded = match self
174                .payload_adapter
175                .encode_response(payload_codec, Some(resp), false)
176                .await
177            {
178                Ok(encoded) => encoded,
179                Err(err) => {
180                    tracing::error!(%err, "failed to encode request-plane response");
181                    saw_error_response = true;
182                    send_complete_final = false;
183                    if let Some(m) = self.metrics() {
184                        m.error_counter
185                            .with_label_values(&[work_handler::error_types::SERIALIZATION])
186                            .inc();
187                    }
188                    break;
189                }
190            };
191            let is_error = encoded.is_error;
192            saw_error_response |= is_error;
193            let resp_bytes = encoded.bytes;
194            if let Some(m) = self.metrics() {
195                m.response_bytes.inc_by(resp_bytes.len() as u64);
196            }
197            if (publisher.send(resp_bytes).await).is_err() {
198                send_complete_final = false;
199                if context.is_stopped() {
200                    // Say there are 2 threads accessing `context`, the sequence can be either:
201                    // 1. context.stop_generating (other) -> publisher.send failure (this)
202                    //    -> context.is_stopped (this)
203                    // 2. publisher.send failure (this) -> context.stop_generating (other)
204                    //    -> context.is_stopped (this)
205                    // Case 1 can happen when client closed the connection after receiving the
206                    // complete response from frontend. Hence, send failure can be expected in this
207                    // case.
208                    tracing::warn!("Failed to publish response for stream {}", context.id());
209                } else {
210                    // Otherwise, this is an error.
211                    tracing::error!("Failed to publish response for stream {}", context.id());
212                    context.stop_generating();
213                }
214                // Account errors in all cases, including cancellation. Therefore this metric can be
215                // inflated.
216                if let Some(m) = self.metrics() {
217                    m.error_counter
218                        .with_label_values(&[work_handler::error_types::PUBLISH_RESPONSE])
219                        .inc();
220                }
221                break;
222            } else if !is_error {
223                // Only notify on non-error chunks — error responses don't prove
224                // the engine is healthy and should not reset the canary timer.
225                if let Some(notifier) = self.endpoint_health_check_notifier.get() {
226                    notifier.notify_one();
227                }
228            }
229            if encoded.stop_stream {
230                // Dropping the engine stream after the terminal frame is sent
231                // propagates cancellation to a producer that is still running.
232                // Stopping the context here can close the response transport
233                // before the queued error and clean terminal frames are read.
234                break;
235            }
236        }
237        if send_complete_final {
238            let encoded = match self
239                .payload_adapter
240                .encode_response(payload_codec, None, true)
241                .await
242            {
243                Ok(encoded) => encoded,
244                Err(err) => {
245                    tracing::error!(%err, "failed to encode request-plane final response");
246                    if let Some(m) = self.metrics() {
247                        m.error_counter
248                            .with_label_values(&[work_handler::error_types::PUBLISH_FINAL])
249                            .inc();
250                    }
251                    return;
252                }
253            };
254            let resp_bytes = encoded.bytes;
255            if let Some(m) = self.metrics() {
256                m.response_bytes.inc_by(resp_bytes.len() as u64);
257            }
258            if (publisher.send(resp_bytes).await).is_err() {
259                // `is_stopped()` is `state != Live`, so it is also true after
260                // `kill()` — which the response-stream reader does on a TCP read
261                // error. Excluding killed narrows this to `state == Stopped` so
262                // real connection failures stay counted. `&&` reads `is_killed()`
263                // last, so a Stopped -> Killed upgrade between the two reads
264                // falls to the error path.
265                //
266                // Reachable only with a peer-sent `Stop`: the local
267                // `stop_generating()` above clears `send_complete_final` and
268                // breaks first. That invariant is load-bearing.
269                if context.is_stopped() && !context.is_killed() {
270                    // The peer asked us to stop, so a failed marker write is
271                    // attributable to that teardown, not to a fault here. Unlike
272                    // the per-frame branch, this also skips the counter.
273                    tracing::debug!(
274                        "Failed to publish complete final for stream {}; client already torn down",
275                        context.id()
276                    );
277                } else {
278                    // Still attached, or killed (hard cancel, protocol violation,
279                    // connection error): the client sees a stream with no
280                    // end-of-stream marker, so this stays a counted error.
281                    tracing::error!(
282                        "Failed to publish complete final for stream {}",
283                        context.id()
284                    );
285                    if let Some(m) = self.metrics() {
286                        m.error_counter
287                            .with_label_values(&[work_handler::error_types::PUBLISH_FINAL])
288                            .inc();
289                    }
290                }
291            }
292            // Only notify on stream completion if no error responses were seen
293            if let (false, Some(notifier)) = (
294                saw_error_response,
295                self.endpoint_health_check_notifier.get(),
296            ) {
297                notifier.notify_one();
298            }
299        }
300    }
301
302    /// Decode the wire envelope into its [`RequestControlMessage`] and the
303    /// optional data payload, shared by every [`IngressDispatch`] shape:
304    ///   - `HeaderAndData` → `(control, Some(data))` — the unary wire shape,
305    ///     where the request body travels in the data half.
306    ///   - `HeaderOnly` → `(control, None)` — the bidirectional wire shape,
307    ///     where request frames flow on the request-stream socket instead.
308    ///
309    /// The caller decides whether its path expects the data payload. The
310    /// deserialization and invalid-message error counters are incremented
311    /// here so every shape reports them consistently.
312    fn decode_control_message(
313        &self,
314        payload: Bytes,
315    ) -> Result<(RequestControlMessage, Option<Bytes>), PipelineError> {
316        let msg = TwoPartCodec::default()
317            .decode_message(payload)?
318            .into_message_type();
319
320        let (header, data) = match msg {
321            TwoPartMessageType::HeaderAndData(header, data) => (header, Some(data)),
322            TwoPartMessageType::HeaderOnly(header) => (header, None),
323            _ => {
324                if let Some(m) = self.metrics() {
325                    m.error_counter
326                        .with_label_values(&[work_handler::error_types::INVALID_MESSAGE])
327                        .inc();
328                }
329                return Err(PipelineError::Generic(String::from(
330                    "Unexpected message from work queue; expected a header-only or header-and-data TwoPartMessage",
331                )));
332            }
333        };
334
335        let control_msg: RequestControlMessage =
336            serde_json::from_slice(&header).map_err(|err| {
337                if let Some(m) = self.metrics() {
338                    m.error_counter
339                        .with_label_values(&[work_handler::error_types::DESERIALIZATION])
340                        .inc();
341                }
342                let json_str = String::from_utf8_lossy(&header);
343                PipelineError::DeserializationError(format!(
344                    "Failed deserializing to RequestControlMessage. err={err}, json_str={json_str}, header_len={}",
345                    header.len(),
346                ))
347            })?;
348
349        Ok((control_msg, data))
350    }
351}
352/// The output of [`IngressDispatch::parse_and_build_request`]: the typed
353/// request the engine consumes, plus the bits of the on-wire control
354/// message the shared handler needs after parsing (the response-stream
355/// connection info and the frontend send timestamp).
356struct ParsedRequest<Req> {
357    request: Req,
358    response_connection_info: ConnectionInfo,
359    frontend_send_ts_ns: Option<u64>,
360    payload_codec: RequestPlanePayloadCodec,
361}
362
363/// Per-shape strategy for turning a raw payload into a typed engine
364/// request. Captures the wire-shape divergence between the unary
365/// (`HeaderAndData`) and bidirectional (`HeaderOnly` + dial-in for the
366/// request stream) paths; everything else — metrics-guard, response stream
367/// open, `segment.generate`, prologue, pump — lives in
368/// [`Ingress::handle_payload_shared`] below.
369#[async_trait]
370trait IngressDispatch: Send + Sync {
371    type Request: PipelineIO;
372
373    async fn parse_and_build_request(
374        &self,
375        payload: Bytes,
376    ) -> Result<ParsedRequest<Self::Request>, PipelineError>;
377}
378
379#[async_trait]
380impl<T, U, Adapter> IngressDispatch for Ingress<SingleIn<T>, ManyOut<U>, Adapter>
381where
382    T: Data + for<'de> Deserialize<'de> + std::fmt::Debug,
383    U: Data + std::fmt::Debug,
384    Adapter: IngressRequestDecoder<T> + Send + Sync + 'static,
385{
386    type Request = SingleIn<T>;
387
388    async fn parse_and_build_request(
389        &self,
390        payload: Bytes,
391    ) -> Result<ParsedRequest<SingleIn<T>>, PipelineError> {
392        let (control_msg, data) = self.decode_control_message(payload)?;
393
394        // The unary path carries the request body in the data half; a
395        // header-only envelope means the sender used the bidirectional shape.
396        let data = data.ok_or_else(|| {
397            if let Some(m) = self.metrics() {
398                m.error_counter
399                    .with_label_values(&[work_handler::error_types::INVALID_MESSAGE])
400                    .inc();
401            }
402            PipelineError::Generic(String::from(
403                "unary engine received a header-only envelope; expected a request payload",
404            ))
405        })?;
406        let payload_codec = control_msg.payload_codec;
407        let request_t: T = self
408            .payload_adapter
409            .decode_request(payload_codec, data)
410            .await
411            .inspect_err(|_| {
412                if let Some(m) = self.metrics() {
413                    m.error_counter
414                        .with_label_values(&[work_handler::error_types::DESERIALIZATION])
415                        .inc();
416                }
417            })?;
418
419        tracing::trace!(
420            request_id = %control_msg.id,
421            metadata_entries = control_msg.metadata.len(),
422            "received control message"
423        );
424        tracing::trace!("received request: {:?}", request_t);
425
426        let request: context::Context<T> =
427            Context::with_id_and_metadata(request_t, control_msg.id, control_msg.metadata);
428
429        Ok(ParsedRequest {
430            request,
431            response_connection_info: control_msg.connection_info,
432            frontend_send_ts_ns: control_msg.frontend_send_ts_ns,
433            payload_codec,
434        })
435    }
436}
437
438#[async_trait]
439impl<T, U, Adapter> IngressDispatch for Ingress<ManyIn<T>, ManyOut<U>, Adapter>
440where
441    T: Data + for<'de> Deserialize<'de> + std::fmt::Debug,
442    U: Data + std::fmt::Debug,
443    Adapter: IngressRequestDecoder<T> + Send + Sync + 'static,
444{
445    type Request = ManyIn<T>;
446
447    async fn parse_and_build_request(
448        &self,
449        payload: Bytes,
450    ) -> Result<ParsedRequest<ManyIn<T>>, PipelineError> {
451        let (control_msg, data) = self.decode_control_message(payload)?;
452
453        // Bidirectional envelopes are header-only — all request frames
454        // (including the first) flow on the request-stream socket once it's
455        // dialed in. A data payload means the sender used the unary wire
456        // shape; reject it.
457        if data.is_some() {
458            if let Some(m) = self.metrics() {
459                m.error_counter
460                    .with_label_values(&[work_handler::error_types::INVALID_MESSAGE])
461                    .inc();
462            }
463            return Err(PipelineError::Generic(String::from(
464                "bidirectional engine received a non-header-only envelope",
465            )));
466        }
467
468        if !matches!(control_msg.request_type, RequestType::ManyIn) {
469            if let Some(m) = self.metrics() {
470                m.error_counter
471                    .with_label_values(&[work_handler::error_types::INVALID_MESSAGE])
472                    .inc();
473            }
474            return Err(PipelineError::Generic(String::from(
475                "bidirectional engine received a non-ManyIn request envelope",
476            )));
477        }
478
479        let req_stream_conn_info = control_msg
480            .request_stream_connection_info
481            .clone()
482            .ok_or_else(|| {
483                PipelineError::Generic(String::from(
484                    "bidirectional control message missing request_stream_connection_info",
485                ))
486            })?;
487
488        let request_context: context::Context<()> = context::Context::with_id_and_metadata(
489            (),
490            control_msg.id.clone(),
491            control_msg.metadata.clone(),
492        );
493        let payload_codec = control_msg.payload_codec;
494        let context_arc: Arc<dyn AsyncEngineContext> = request_context.context();
495
496        // Open the request stream (upstream → worker) up front. The shared
497        // handler opens the response stream uniformly after we return. If
498        // response-stream open subsequently fails, the forwarder task
499        // spawned below exits cleanly when `frame_tx.send` observes the
500        // dropped `frame_rx`.
501        let request_stream_recv = tcp::client::TcpClient::create_request_stream(
502            context_arc.clone(),
503            req_stream_conn_info,
504            None,
505        )
506        .await
507        .map_err(|e| {
508            if let Some(m) = self.metrics() {
509                m.error_counter
510                    .with_label_values(&[work_handler::error_types::RESPONSE_STREAM])
511                    .inc();
512            }
513            PipelineError::Generic(format!("Failed to create request stream: {e}"))
514        })?;
515
516        // Forwarder: deserialize raw bytes off the request socket into `T`
517        // and feed the engine's `ManyIn<T>` input. Every request frame
518        // (including the first) flows over this socket — the envelope is
519        // header-only.
520        let (frame_tx, frame_rx) = tokio::sync::mpsc::channel::<T>(8);
521        let forwarder_ctx = context_arc.clone();
522        let payload_adapter = self.payload_adapter.clone();
523        tokio::spawn(async move {
524            let mut rx = request_stream_recv.rx;
525            while let Some(bytes) = rx.recv().await {
526                // Stop forwarding on either kill or soft-stop, matching the
527                // send-side `spawn_request_stream_forwarder`. Without the
528                // `stopped()` check, a `stop_generating()` would leave this
529                // task pumping frames into a channel the engine has abandoned.
530                if forwarder_ctx.is_killed() || forwarder_ctx.is_stopped() {
531                    break;
532                }
533                match payload_adapter.decode_request(payload_codec, bytes).await {
534                    Ok(item) => {
535                        if frame_tx.send(item).await.is_err() {
536                            tracing::debug!(
537                                "engine consumer dropped; bidirectional input forwarder exiting"
538                            );
539                            break;
540                        }
541                    }
542                    Err(e) => {
543                        tracing::error!(
544                            error = %e,
545                            codec = payload_codec.name(),
546                            "failed to deserialize bidirectional request frame; killing context"
547                        );
548                        forwarder_ctx.kill();
549                        break;
550                    }
551                }
552            }
553        });
554
555        let input_stream: crate::engine::DataStream<T> =
556            Box::pin(tokio_stream::wrappers::ReceiverStream::new(frame_rx));
557        let request: ManyIn<T> = request_context.map(|_| RequestStream::new(input_stream));
558
559        Ok(ParsedRequest {
560            request,
561            response_connection_info: control_msg.connection_info,
562            frontend_send_ts_ns: control_msg.frontend_send_ts_ns,
563            payload_codec,
564        })
565    }
566}
567
568impl<Req, U, Adapter> Ingress<Req, ManyOut<U>, Adapter>
569where
570    Req: PipelineIO + Sync,
571    U: Data + std::fmt::Debug,
572    Adapter: IngressResponseEncoder<U> + Send + Sync + 'static,
573{
574    /// Shared body of `PushWorkHandler::handle_payload` for every
575    /// `Ingress<Req, ManyOut<U>>` shape that has an [`IngressDispatch`]
576    /// impl. Sets up the inflight metrics guard, calls
577    /// `parse_and_build_request` for the wire-shape-specific request
578    /// building, opens the response stream uniformly, dispatches via
579    /// the engine, sends the prologue, and pumps the response through
580    /// [`Self::pump_response_stream`].
581    async fn handle_payload_shared(
582        &self,
583        payload: Bytes,
584        request_id: Option<String>,
585    ) -> Result<(), PipelineError>
586    where
587        Self: IngressDispatch<Request = Req>,
588    {
589        let t2_wallclock_ns = std::time::SystemTime::now()
590            .duration_since(std::time::UNIX_EPOCH)
591            .unwrap_or_default()
592            .as_nanos() as u64;
593        let start_time = std::time::Instant::now();
594
595        // Increment inflight and ensure it's decremented on all exits via RAII guard
596        let _inflight_guard = self.metrics().map(|m| {
597            m.request_counter.inc();
598            m.inflight_requests.inc();
599            m.request_bytes.inc_by(payload.len() as u64);
600            if let Some(rid) = &request_id {
601                tracing::info!(request_id = %rid, "request received");
602            }
603            RequestMetricsGuard {
604                inflight_requests: m.inflight_requests.clone(),
605                request_duration: m.request_duration.clone(),
606                start_time,
607                request_id: request_id.clone(),
608            }
609        });
610
611        let ParsedRequest {
612            request,
613            response_connection_info,
614            frontend_send_ts_ns,
615            payload_codec,
616        } = self.parse_and_build_request(payload).await?;
617
618        // Compute network transit time (T2 - T1) using cross-process wall-clock timestamps
619        if let Some(t1_ns) = frontend_send_ts_ns {
620            let transit_ns = t2_wallclock_ns.saturating_sub(t1_ns);
621            WORK_HANDLER_NETWORK_TRANSIT_SECONDS.observe(transit_ns as f64 / 1_000_000_000.0);
622        }
623
624        // todo - eventually have a handler class which will returned an abstracted object, but for now,
625        // we only support tcp here, so we can just unwrap the connection info
626        tracing::trace!("creating tcp response stream");
627        let mut publisher = tcp::client::TcpClient::create_response_stream(
628            request.context(),
629            response_connection_info,
630            self.metrics().map(|m| m.cancellation_total.clone()),
631        )
632        .await
633        .map_err(|e| {
634            if let Some(m) = self.metrics() {
635                m.error_counter
636                    .with_label_values(&[work_handler::error_types::RESPONSE_STREAM])
637                    .inc();
638            }
639            PipelineError::Generic(format!("Failed to create response stream: {e}"))
640        })?;
641
642        tracing::trace!("calling generate");
643        let stream = self
644            .segment
645            .get()
646            .expect("segment not set")
647            .generate(request)
648            .await
649            .map_err(|e| {
650                if let Some(m) = self.metrics() {
651                    m.error_counter
652                        .with_label_values(&[work_handler::error_types::GENERATE])
653                        .inc();
654                }
655                PipelineError::GenerateError(e)
656            });
657
658        // the prolouge is sent to the client to indicate that the stream is ready to receive data
659        // or if the generate call failed, the error is sent to the client
660        let stream = match stream {
661            Ok(stream) => {
662                tracing::trace!("Successfully generated response stream; sending prologue");
663                let _result = publisher.send_prologue(None).await;
664                WORK_HANDLER_TIME_TO_FIRST_RESPONSE_SECONDS
665                    .observe(start_time.elapsed().as_secs_f64());
666                stream
667            }
668            Err(e) => {
669                let error_string = e.to_string();
670
671                #[cfg(debug_assertions)]
672                {
673                    tracing::debug!(
674                        "Failed to generate response stream (with debug backtrace): {:?}",
675                        e
676                    );
677                }
678                #[cfg(not(debug_assertions))]
679                {
680                    tracing::error!("Failed to generate response stream: {error_string}");
681                }
682
683                // Send the worker's error type with the display text, so a
684                // frontend can tell a request the backend cannot serve from
685                // a transport failure.
686                let prologue_error =
687                    StreamPrologueError::new(error_string, typed_error_from_pipeline_error(&e));
688                let _result = publisher.send_prologue_typed(Some(prologue_error)).await;
689                Err(e)?
690            }
691        };
692
693        self.pump_response_stream(stream, &publisher, payload_codec)
694            .await;
695
696        // Ensure the metrics guard is not dropped until the end of the function.
697        // Drop fires "request completed" log via RAII.
698        drop(_inflight_guard);
699
700        Ok(())
701    }
702}
703
704#[async_trait]
705impl<T, U, Adapter> PushWorkHandler for Ingress<SingleIn<T>, ManyOut<U>, Adapter>
706where
707    T: Data + for<'de> Deserialize<'de> + std::fmt::Debug,
708    U: Data + std::fmt::Debug,
709    Adapter: IngressPayloadAdapter<T, U> + Send + Sync + 'static,
710{
711    fn add_metrics(
712        &self,
713        endpoint: &crate::component::Endpoint,
714        metrics_labels: Option<&[(&str, &str)]>,
715    ) -> Result<()> {
716        // Call the inherent `Ingress::add_metrics`, not this trait method.
717        Ingress::add_metrics(self, endpoint, metrics_labels)
718    }
719
720    fn set_endpoint_health_check_notifier(&self, notifier: Arc<tokio::sync::Notify>) -> Result<()> {
721        self.endpoint_health_check_notifier
722            .set(notifier)
723            .map_err(|_| anyhow::anyhow!("Endpoint health check notifier already set"))?;
724        Ok(())
725    }
726
727    async fn handle_payload(
728        &self,
729        payload: Bytes,
730        request_id: Option<String>,
731    ) -> Result<(), PipelineError> {
732        self.handle_payload_shared(payload, request_id).await
733    }
734}
735
736#[async_trait]
737impl<T, U, Adapter> PushWorkHandler for Ingress<ManyIn<T>, ManyOut<U>, Adapter>
738where
739    T: Data + for<'de> Deserialize<'de> + std::fmt::Debug,
740    U: Data + std::fmt::Debug,
741    Adapter: IngressPayloadAdapter<T, U> + Send + Sync + 'static,
742{
743    fn add_metrics(
744        &self,
745        endpoint: &crate::component::Endpoint,
746        metrics_labels: Option<&[(&str, &str)]>,
747    ) -> Result<()> {
748        // Call the inherent `Ingress::add_metrics`, not this trait method.
749        Ingress::add_metrics(self, endpoint, metrics_labels)
750    }
751
752    fn set_endpoint_health_check_notifier(&self, notifier: Arc<tokio::sync::Notify>) -> Result<()> {
753        self.endpoint_health_check_notifier
754            .set(notifier)
755            .map_err(|_| anyhow::anyhow!("Endpoint health check notifier already set"))?;
756        Ok(())
757    }
758
759    async fn handle_payload(
760        &self,
761        payload: Bytes,
762        request_id: Option<String>,
763    ) -> Result<(), PipelineError> {
764        self.handle_payload_shared(payload, request_id).await
765    }
766}
767
768/// Recover the worker's typed error from a pipeline failure, for the prologue.
769///
770/// `GenerateError` must unwrap its `anyhow::Error` payload first. `anyhow::Error`
771/// does not implement `std::error::Error`, so that variant exposes no `source()`
772/// and converting the enclosing `PipelineError` yields a bare
773/// `ErrorType::Unknown`, losing the worker error type. Any other variant carries
774/// no worker error and converts to `ErrorType::Unknown`.
775pub(crate) fn typed_error_from_pipeline_error(e: &PipelineError) -> DynamoError {
776    let source: &(dyn std::error::Error + 'static) = match e {
777        PipelineError::GenerateError(inner) => inner.as_ref(),
778        other => other,
779    };
780    DynamoError::from(source)
781}
782
783#[cfg(test)]
784mod tests {
785    use super::*;
786    use crate::pipeline::network::{Ingress, RequestPlanePayloadCodec, StreamSender};
787    use crate::pipeline::{Context, ManyOut, ResponseStream, SingleIn};
788    use crate::protocols::annotated::Annotated;
789    use futures::stream;
790    use prometheus::{Histogram, HistogramOpts, IntCounter, IntCounterVec, IntGauge, Opts};
791
792    use crate::error::{BackendError, ErrorType};
793
794    type TestRequest = serde_json::Value;
795    type TestResponse = Annotated<serde_json::Value>;
796    type TestIngress = Ingress<SingleIn<TestRequest>, ManyOut<TestResponse>>;
797
798    /// The positive half of the recovery hop: a worker's typed refusal, boxed
799    /// into the `anyhow::Error` payload of `PipelineError::GenerateError`,
800    /// comes back out with its type intact.
801    #[test]
802    fn generate_error_payload_keeps_the_workers_error_type() {
803        let e = PipelineError::GenerateError(anyhow::Error::new(
804            DynamoError::builder()
805                .error_type(ErrorType::Backend(BackendError::InvalidArgument))
806                .message("multimodal input is not supported by this backend")
807                .build(),
808        ));
809
810        assert_eq!(
811            typed_error_from_pipeline_error(&e).error_type(),
812            ErrorType::Backend(BackendError::InvalidArgument),
813            "the worker's type must survive the anyhow payload"
814        );
815    }
816
817    /// The negative half: a failure that is not a worker's `generate()` error
818    /// has no type to recover, and must not acquire one.
819    #[test]
820    fn non_generate_pipeline_error_stays_untyped() {
821        let e = PipelineError::DeserializationError("bad request payload".to_string());
822
823        assert_eq!(
824            typed_error_from_pipeline_error(&e).error_type(),
825            ErrorType::Unknown,
826            "a transport-side failure must not be reported as a worker error"
827        );
828    }
829
830    /// Standalone metrics, not bound to an `Endpoint`, so the test needs no DRT.
831    fn test_metrics() -> WorkHandlerMetrics {
832        WorkHandlerMetrics::new(
833            IntCounter::with_opts(Opts::new("requests_total", "t")).unwrap(),
834            Histogram::with_opts(HistogramOpts::new("request_duration_seconds", "t")).unwrap(),
835            IntGauge::with_opts(Opts::new("inflight_requests", "t")).unwrap(),
836            IntCounter::with_opts(Opts::new("request_bytes_total", "t")).unwrap(),
837            IntCounter::with_opts(Opts::new("response_bytes_total", "t")).unwrap(),
838            IntCounterVec::new(
839                Opts::new(work_handler::ERRORS_TOTAL, "t"),
840                &[work_handler::ERROR_TYPE_LABEL],
841            )
842            .unwrap(),
843            IntCounter::with_opts(Opts::new("cancellation_total", "t")).unwrap(),
844        )
845    }
846
847    /// Which half of the teardown race a given run exercises.
848    #[derive(Clone, Copy, Debug)]
849    enum Teardown {
850        /// Frontend sent `ControlMessage::Stop`; the control reader called
851        /// `context.stop()` (see `tcp/client.rs`).
852        Stop,
853        /// Frontend sent `ControlMessage::Kill`; the control reader called
854        /// `context.kill()`.
855        Kill,
856        /// The response-stream reader hit a TCP read error and called
857        /// `context.kill()` (`tcp/client.rs`, "tcp stream read error").
858        /// Indistinguishable from `Kill` at the context level, which is
859        /// exactly why `is_stopped()` alone is too coarse a guard.
860        ConnectionReadError,
861        /// The transport died with the client still attached and the context
862        /// live — a genuine failure that must stay classified as an error.
863        TransportOnly,
864    }
865
866    /// Drive `pump_response_stream` through the client-teardown ordering:
867    /// the engine emits `content_frames` chunks, then the upstream reader goes
868    /// away (mirroring `handle_writer` exiting on `context.stopped()`) while the
869    /// trailing `complete_final` frame is still unsent.
870    ///
871    /// Returns (publish_final count, publish_response count).
872    async fn run_teardown_race(content_frames: usize, teardown: Teardown) -> (u64, u64) {
873        let ingress = TestIngress::new();
874        let metrics = Arc::new(test_metrics());
875        ingress
876            .metrics
877            .set(metrics.clone())
878            .expect("metrics already set");
879
880        // Capacity covers every content frame, so a send only fails once the
881        // receiver is gone — never merely because the channel is full.
882        let (tx, mut rx) = tokio::sync::mpsc::channel(content_frames + 8);
883        let publisher = StreamSender { tx, prologue: None };
884
885        let ctx = Context::new(serde_json::json!({}));
886        let engine_ctx = ctx.context();
887
888        // The stream yields its content, then parks until the test has torn the
889        // receiver down. Ending after the gate (rather than on a timer) is what
890        // makes the race deterministic.
891        let (gate_tx, gate_rx) = tokio::sync::oneshot::channel::<()>();
892        let content: Vec<TestResponse> = (0..content_frames)
893            .map(|i| Annotated::from_data(serde_json::json!({ "token": i })))
894            .collect();
895        let tail = stream::unfold(Some(gate_rx), |state| async move {
896            // Awaiting then yielding `None` ends the stream, so
897            // `send_complete_final` stays true and the final frame is attempted.
898            let gate = state?;
899            let _ = gate.await;
900            None
901        });
902        let response_stream: ManyOut<TestResponse> = ResponseStream::new(
903            Box::pin(stream::iter(content).chain(tail)),
904            engine_ctx.clone(),
905        );
906
907        let pump = tokio::spawn({
908            let ingress = ingress.clone();
909            async move {
910                ingress
911                    .pump_response_stream(
912                        response_stream,
913                        &publisher,
914                        RequestPlanePayloadCodec::Json,
915                    )
916                    .await;
917            }
918        });
919
920        // Drain the content frames so the pump is past the per-frame branch.
921        for _ in 0..content_frames {
922            rx.recv().await.expect("content frame");
923        }
924
925        // Now reproduce the teardown: the frontend has everything it needs and
926        // drops the request, which kills the worker's writer task.
927        match teardown {
928            Teardown::Stop => engine_ctx.stop(),
929            Teardown::Kill | Teardown::ConnectionReadError => engine_ctx.kill(),
930            Teardown::TransportOnly => {}
931        }
932        drop(rx);
933        let _ = gate_tx.send(());
934
935        pump.await.expect("pump task panicked");
936
937        let errors = &metrics.error_counter;
938        (
939            errors
940                .with_label_values(&[work_handler::error_types::PUBLISH_FINAL])
941                .get(),
942            errors
943                .with_label_values(&[work_handler::error_types::PUBLISH_RESPONSE])
944                .get(),
945        )
946    }
947
948    /// Losing the `complete_final` send to a client that has already
949    /// torn down is not a worker error. The per-frame branch already makes this
950    /// distinction; the final-marker branch must make it too.
951    #[tokio::test]
952    async fn test_publish_final_race_with_stopped_context_is_not_an_error() {
953        let (publish_final, publish_response) = run_teardown_race(3, Teardown::Stop).await;
954        assert_eq!(
955            publish_final, 0,
956            "complete_final lost to a stopped context must not count as an error"
957        );
958        assert_eq!(
959            publish_response, 0,
960            "content frames were all delivered before teardown"
961        );
962    }
963
964    /// A killed context must stay a counted error. `is_stopped()` is
965    /// `state != Live`, so it is true after `kill()` as well — but the
966    /// response-stream reader kills the context on a TCP read error, so
967    /// suppressing on `is_stopped()` alone would hide real connection
968    /// failures from the very counter meant to surface them.
969    #[tokio::test]
970    async fn test_publish_final_with_killed_context_is_still_an_error() {
971        let (publish_final, _) = run_teardown_race(3, Teardown::Kill).await;
972        assert_eq!(
973            publish_final, 1,
974            "a killed context is not a graceful teardown and must still be counted"
975        );
976    }
977
978    /// The concrete regression: `tcp/client.rs` calls `context.kill()` on a TCP
979    /// read error ("tcp stream read error, closing connection"). That path must
980    /// remain visible in `dynamo_component_errors_total`.
981    #[tokio::test]
982    async fn test_publish_final_after_connection_read_error_is_still_an_error() {
983        let (publish_final, _) = run_teardown_race(3, Teardown::ConnectionReadError).await;
984        assert_eq!(
985            publish_final, 1,
986            "a dropped connection must not be silently reclassified as a benign teardown"
987        );
988    }
989
990    /// Guards against suppressing too much: with the context still live, a failed
991    /// `complete_final` is a real transport failure and must still be counted.
992    #[tokio::test]
993    async fn test_publish_final_failure_without_stop_is_still_an_error() {
994        let (publish_final, _) = run_teardown_race(3, Teardown::TransportOnly).await;
995        assert_eq!(
996            publish_final, 1,
997            "a genuine complete_final failure must still be counted"
998        );
999    }
1000
1001    /// The marker itself is the transport-level end-of-stream signal that
1002    /// non-chat consumers (KV-router worker index queries, disaggregated
1003    /// prefill→decode) rely on to tell a clean end from a truncated one. The
1004    /// classification change must not disturb the clean path: every content
1005    /// frame plus a `complete_final: true` frame still goes out, and nothing is
1006    /// counted as an error.
1007    #[tokio::test]
1008    async fn test_complete_final_marker_still_sent_on_clean_stream() {
1009        let ingress = TestIngress::new();
1010        let metrics = Arc::new(test_metrics());
1011        ingress.metrics.set(metrics.clone()).unwrap();
1012
1013        let content_frames = 3;
1014        let (tx, mut rx) = tokio::sync::mpsc::channel(content_frames + 8);
1015        let publisher = StreamSender { tx, prologue: None };
1016
1017        let ctx = Context::new(serde_json::json!({}));
1018        let content: Vec<TestResponse> = (0..content_frames)
1019            .map(|i| Annotated::from_data(serde_json::json!({ "token": i })))
1020            .collect();
1021        let response_stream: ManyOut<TestResponse> =
1022            ResponseStream::new(Box::pin(stream::iter(content)), ctx.context());
1023
1024        ingress
1025            .pump_response_stream(response_stream, &publisher, RequestPlanePayloadCodec::Json)
1026            .await;
1027        drop(publisher);
1028
1029        let mut frames = Vec::new();
1030        while let Some(msg) = rx.recv().await {
1031            let (_header, data) = msg.into_parts();
1032            frames.push(serde_json::from_slice::<serde_json::Value>(&data).unwrap());
1033        }
1034
1035        assert_eq!(
1036            frames.len(),
1037            content_frames + 1,
1038            "expected every content frame plus the trailing marker"
1039        );
1040        for (i, frame) in frames.iter().take(content_frames).enumerate() {
1041            assert_eq!(frame["complete_final"], false, "content frame {i}");
1042        }
1043        assert_eq!(
1044            frames[content_frames]["complete_final"], true,
1045            "trailing frame must carry the end-of-stream marker"
1046        );
1047
1048        let errors = &metrics.error_counter;
1049        assert_eq!(
1050            errors
1051                .with_label_values(&[work_handler::error_types::PUBLISH_FINAL])
1052                .get(),
1053            0
1054        );
1055        assert_eq!(
1056            errors
1057                .with_label_values(&[work_handler::error_types::PUBLISH_RESPONSE])
1058                .get(),
1059            0
1060        );
1061    }
1062}