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::metrics::prometheus_names::work_handler;
8use crate::metrics::work_handler_perf::{
9    WORK_HANDLER_NETWORK_TRANSIT_SECONDS, WORK_HANDLER_TIME_TO_FIRST_RESPONSE_SECONDS,
10};
11use crate::pipeline::{ManyIn, RequestStream};
12use crate::protocols::maybe_error::MaybeError;
13use futures::StreamExt;
14use prometheus::{Histogram, IntCounter, IntCounterVec, IntGauge};
15use serde::{Deserialize, Serialize};
16use std::sync::Arc;
17use std::time::Instant;
18use tracing::Instrument;
19use tracing::info_span;
20
21/// Metrics configuration for profiling work handlers
22#[derive(Clone, Debug)]
23pub struct WorkHandlerMetrics {
24    pub request_counter: IntCounter,
25    pub request_duration: Histogram,
26    pub inflight_requests: IntGauge,
27    pub request_bytes: IntCounter,
28    pub response_bytes: IntCounter,
29    pub error_counter: IntCounterVec,
30    pub cancellation_total: IntCounter,
31}
32
33impl WorkHandlerMetrics {
34    pub fn new(
35        request_counter: IntCounter,
36        request_duration: Histogram,
37        inflight_requests: IntGauge,
38        request_bytes: IntCounter,
39        response_bytes: IntCounter,
40        error_counter: IntCounterVec,
41        cancellation_total: IntCounter,
42    ) -> Self {
43        Self {
44            request_counter,
45            request_duration,
46            inflight_requests,
47            request_bytes,
48            response_bytes,
49            error_counter,
50            cancellation_total,
51        }
52    }
53
54    /// Create WorkHandlerMetrics from an endpoint using its built-in labeling
55    pub fn from_endpoint(
56        endpoint: &crate::component::Endpoint,
57        metrics_labels: Option<&[(&str, &str)]>,
58    ) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
59        let metrics_labels = metrics_labels.unwrap_or(&[]);
60        let metrics = endpoint.metrics();
61        let request_counter = metrics.create_intcounter(
62            work_handler::REQUESTS_TOTAL,
63            "Total number of requests processed by work handler",
64            metrics_labels,
65        )?;
66
67        // Custom buckets for inference workloads: retain sub-second resolution for
68        // fast operations, extend well beyond the default 10s ceiling to capture
69        // long-running generation requests that can last minutes.
70        let request_duration_buckets = vec![
71            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,
72            300.0, 600.0,
73        ];
74        let request_duration = metrics.create_histogram(
75            work_handler::REQUEST_DURATION_SECONDS,
76            "Time spent processing requests by work handler",
77            metrics_labels,
78            Some(request_duration_buckets),
79        )?;
80
81        let inflight_requests = metrics.create_intgauge(
82            work_handler::INFLIGHT_REQUESTS,
83            "Number of requests currently being processed by work handler",
84            metrics_labels,
85        )?;
86
87        let request_bytes = metrics.create_intcounter(
88            work_handler::REQUEST_BYTES_TOTAL,
89            "Total number of bytes received in requests by work handler",
90            metrics_labels,
91        )?;
92
93        let response_bytes = metrics.create_intcounter(
94            work_handler::RESPONSE_BYTES_TOTAL,
95            "Total number of bytes sent in responses by work handler",
96            metrics_labels,
97        )?;
98
99        let error_counter = metrics.create_intcountervec(
100            work_handler::ERRORS_TOTAL,
101            "Total number of errors in work handler processing",
102            &[work_handler::ERROR_TYPE_LABEL],
103            metrics_labels,
104        )?;
105
106        let cancellation_total = metrics.create_intcounter(
107            work_handler::CANCELLATION_TOTAL,
108            "Total number of requests cancelled by work handler",
109            metrics_labels,
110        )?;
111
112        Ok(Self::new(
113            request_counter,
114            request_duration,
115            inflight_requests,
116            request_bytes,
117            response_bytes,
118            error_counter,
119            cancellation_total,
120        ))
121    }
122}
123
124// RAII guard to ensure inflight gauge is decremented, request duration is observed,
125// and lifecycle logs are emitted on all code paths.
126struct RequestMetricsGuard {
127    inflight_requests: prometheus::IntGauge,
128    request_duration: prometheus::Histogram,
129    start_time: Instant,
130    request_id: Option<String>,
131}
132
133impl Drop for RequestMetricsGuard {
134    fn drop(&mut self) {
135        self.inflight_requests.dec();
136        self.request_duration
137            .observe(self.start_time.elapsed().as_secs_f64());
138        if let Some(request_id) = &self.request_id {
139            tracing::info!(request_id = %request_id, "request completed");
140        }
141    }
142}
143
144impl<Req: PipelineIO + Sync, Resp: PipelineIO> Ingress<Req, Resp> {
145    /// Pump every chunk from the engine's response stream out to the
146    /// upstream-side `StreamSender`, plus the terminal complete-final
147    /// frame. Captures the per-frame metrics, the publish-failure error
148    /// classification (client-side disconnect vs. real failure), and the
149    /// health-check notifier policy (notify only on non-error chunks and
150    /// at clean stream end).
151    async fn pump_response_stream<U>(
152        &self,
153        mut stream: ManyOut<U>,
154        publisher: &StreamSender,
155        payload_codec: RequestPlanePayloadCodec,
156    ) where
157        U: Data + Serialize + MaybeError + std::fmt::Debug,
158    {
159        let context = stream.context();
160
161        // TODO: Detect end-of-stream using Server-Sent Events (SSE)
162        let mut send_complete_final = true;
163        let mut saw_error_response = false;
164        while let Some(resp) = stream.next().await {
165            tracing::trace!("Sending response: {:?}", resp);
166            let is_error = resp.err().is_some();
167            if is_error {
168                saw_error_response = true;
169            }
170            let resp_wrapper = NetworkStreamWrapper {
171                data: Some(resp),
172                complete_final: false,
173            };
174            let resp_bytes = payload_codec
175                .encode(&resp_wrapper)
176                .expect("fatal error: invalid request-plane response object");
177            if let Some(m) = self.metrics() {
178                m.response_bytes.inc_by(resp_bytes.len() as u64);
179            }
180            if (publisher.send(resp_bytes.into()).await).is_err() {
181                send_complete_final = false;
182                if context.is_stopped() {
183                    // Say there are 2 threads accessing `context`, the sequence can be either:
184                    // 1. context.stop_generating (other) -> publisher.send failure (this)
185                    //    -> context.is_stopped (this)
186                    // 2. publisher.send failure (this) -> context.stop_generating (other)
187                    //    -> context.is_stopped (this)
188                    // Case 1 can happen when client closed the connection after receiving the
189                    // complete response from frontend. Hence, send failure can be expected in this
190                    // case.
191                    tracing::warn!("Failed to publish response for stream {}", context.id());
192                } else {
193                    // Otherwise, this is an error.
194                    tracing::error!("Failed to publish response for stream {}", context.id());
195                    context.stop_generating();
196                }
197                // Account errors in all cases, including cancellation. Therefore this metric can be
198                // inflated.
199                if let Some(m) = self.metrics() {
200                    m.error_counter
201                        .with_label_values(&[work_handler::error_types::PUBLISH_RESPONSE])
202                        .inc();
203                }
204                break;
205            } else if !is_error {
206                // Only notify on non-error chunks — error responses don't prove
207                // the engine is healthy and should not reset the canary timer.
208                if let Some(notifier) = self.endpoint_health_check_notifier.get() {
209                    notifier.notify_one();
210                }
211            }
212        }
213        if send_complete_final {
214            let resp_wrapper = NetworkStreamWrapper::<U> {
215                data: None,
216                complete_final: true,
217            };
218            let resp_bytes = payload_codec
219                .encode(&resp_wrapper)
220                .expect("fatal error: invalid request-plane response final object");
221            if let Some(m) = self.metrics() {
222                m.response_bytes.inc_by(resp_bytes.len() as u64);
223            }
224            if (publisher.send(resp_bytes.into()).await).is_err() {
225                tracing::error!(
226                    "Failed to publish complete final for stream {}",
227                    context.id()
228                );
229                if let Some(m) = self.metrics() {
230                    m.error_counter
231                        .with_label_values(&[work_handler::error_types::PUBLISH_FINAL])
232                        .inc();
233                }
234            }
235            // Only notify on stream completion if no error responses were seen
236            if let (false, Some(notifier)) = (
237                saw_error_response,
238                self.endpoint_health_check_notifier.get(),
239            ) {
240                notifier.notify_one();
241            }
242        }
243    }
244
245    /// Decode the wire envelope into its [`RequestControlMessage`] and the
246    /// optional data payload, shared by every [`IngressDispatch`] shape:
247    ///   - `HeaderAndData` → `(control, Some(data))` — the unary wire shape,
248    ///     where the request body travels in the data half.
249    ///   - `HeaderOnly` → `(control, None)` — the bidirectional wire shape,
250    ///     where request frames flow on the request-stream socket instead.
251    ///
252    /// The caller decides whether its path expects the data payload. The
253    /// deserialization and invalid-message error counters are incremented
254    /// here so every shape reports them consistently.
255    fn decode_control_message(
256        &self,
257        payload: Bytes,
258    ) -> Result<(RequestControlMessage, Option<Bytes>), PipelineError> {
259        let msg = TwoPartCodec::default()
260            .decode_message(payload)?
261            .into_message_type();
262
263        let (header, data) = match msg {
264            TwoPartMessageType::HeaderAndData(header, data) => (header, Some(data)),
265            TwoPartMessageType::HeaderOnly(header) => (header, None),
266            _ => {
267                if let Some(m) = self.metrics() {
268                    m.error_counter
269                        .with_label_values(&[work_handler::error_types::INVALID_MESSAGE])
270                        .inc();
271                }
272                return Err(PipelineError::Generic(String::from(
273                    "Unexpected message from work queue; expected a header-only or header-and-data TwoPartMessage",
274                )));
275            }
276        };
277
278        let control_msg: RequestControlMessage =
279            serde_json::from_slice(&header).map_err(|err| {
280                if let Some(m) = self.metrics() {
281                    m.error_counter
282                        .with_label_values(&[work_handler::error_types::DESERIALIZATION])
283                        .inc();
284                }
285                let json_str = String::from_utf8_lossy(&header);
286                PipelineError::DeserializationError(format!(
287                    "Failed deserializing to RequestControlMessage. err={err}, json_str={json_str}, header_len={}",
288                    header.len(),
289                ))
290            })?;
291
292        Ok((control_msg, data))
293    }
294}
295/// The output of [`IngressDispatch::parse_and_build_request`]: the typed
296/// request the engine consumes, plus the bits of the on-wire control
297/// message the shared handler needs after parsing (the response-stream
298/// connection info and the frontend send timestamp).
299struct ParsedRequest<Req> {
300    request: Req,
301    response_connection_info: ConnectionInfo,
302    frontend_send_ts_ns: Option<u64>,
303    payload_codec: RequestPlanePayloadCodec,
304}
305
306/// Per-shape strategy for turning a raw payload into a typed engine
307/// request. Captures the wire-shape divergence between the unary
308/// (`HeaderAndData`) and bidirectional (`HeaderOnly` + dial-in for the
309/// request stream) paths; everything else — metrics-guard, response stream
310/// open, `segment.generate`, prologue, pump — lives in
311/// [`Ingress::handle_payload_shared`] below.
312#[async_trait]
313trait IngressDispatch: Send + Sync {
314    type Request: PipelineIO;
315
316    async fn parse_and_build_request(
317        &self,
318        payload: Bytes,
319    ) -> Result<ParsedRequest<Self::Request>, PipelineError>;
320}
321
322#[async_trait]
323impl<T, U> IngressDispatch for Ingress<SingleIn<T>, ManyOut<U>>
324where
325    T: Data + for<'de> Deserialize<'de> + std::fmt::Debug,
326    U: Data + Serialize + MaybeError + std::fmt::Debug,
327{
328    type Request = SingleIn<T>;
329
330    async fn parse_and_build_request(
331        &self,
332        payload: Bytes,
333    ) -> Result<ParsedRequest<SingleIn<T>>, PipelineError> {
334        let (control_msg, data) = self.decode_control_message(payload)?;
335
336        // The unary path carries the request body in the data half; a
337        // header-only envelope means the sender used the bidirectional shape.
338        let data = data.ok_or_else(|| {
339            if let Some(m) = self.metrics() {
340                m.error_counter
341                    .with_label_values(&[work_handler::error_types::INVALID_MESSAGE])
342                    .inc();
343            }
344            PipelineError::Generic(String::from(
345                "unary engine received a header-only envelope; expected a request payload",
346            ))
347        })?;
348        let payload_codec = control_msg.payload_codec;
349        let request_t: T = payload_codec.decode(&data).map_err(|err| {
350            if let Some(m) = self.metrics() {
351                m.error_counter
352                    .with_label_values(&[work_handler::error_types::DESERIALIZATION])
353                    .inc();
354            }
355            PipelineError::DeserializationError(format!(
356                "Failed deserializing {} request payload: {}",
357                payload_codec.name(),
358                err
359            ))
360        })?;
361
362        tracing::trace!(
363            request_id = %control_msg.id,
364            metadata_entries = control_msg.metadata.len(),
365            "received control message"
366        );
367        tracing::trace!("received request: {:?}", request_t);
368
369        let request: context::Context<T> =
370            Context::with_id_and_metadata(request_t, control_msg.id, control_msg.metadata);
371
372        Ok(ParsedRequest {
373            request,
374            response_connection_info: control_msg.connection_info,
375            frontend_send_ts_ns: control_msg.frontend_send_ts_ns,
376            payload_codec,
377        })
378    }
379}
380
381#[async_trait]
382impl<T, U> IngressDispatch for Ingress<ManyIn<T>, ManyOut<U>>
383where
384    T: Data + for<'de> Deserialize<'de> + std::fmt::Debug,
385    U: Data + Serialize + MaybeError + std::fmt::Debug,
386{
387    type Request = ManyIn<T>;
388
389    async fn parse_and_build_request(
390        &self,
391        payload: Bytes,
392    ) -> Result<ParsedRequest<ManyIn<T>>, PipelineError> {
393        let (control_msg, data) = self.decode_control_message(payload)?;
394
395        // Bidirectional envelopes are header-only — all request frames
396        // (including the first) flow on the request-stream socket once it's
397        // dialed in. A data payload means the sender used the unary wire
398        // shape; reject it.
399        if data.is_some() {
400            if let Some(m) = self.metrics() {
401                m.error_counter
402                    .with_label_values(&[work_handler::error_types::INVALID_MESSAGE])
403                    .inc();
404            }
405            return Err(PipelineError::Generic(String::from(
406                "bidirectional engine received a non-header-only envelope",
407            )));
408        }
409
410        if !matches!(control_msg.request_type, RequestType::ManyIn) {
411            if let Some(m) = self.metrics() {
412                m.error_counter
413                    .with_label_values(&[work_handler::error_types::INVALID_MESSAGE])
414                    .inc();
415            }
416            return Err(PipelineError::Generic(String::from(
417                "bidirectional engine received a non-ManyIn request envelope",
418            )));
419        }
420
421        let req_stream_conn_info = control_msg
422            .request_stream_connection_info
423            .clone()
424            .ok_or_else(|| {
425                PipelineError::Generic(String::from(
426                    "bidirectional control message missing request_stream_connection_info",
427                ))
428            })?;
429
430        let request_context: context::Context<()> = context::Context::with_id_and_metadata(
431            (),
432            control_msg.id.clone(),
433            control_msg.metadata.clone(),
434        );
435        let payload_codec = control_msg.payload_codec;
436        let context_arc: Arc<dyn AsyncEngineContext> = request_context.context();
437
438        // Open the request stream (upstream → worker) up front. The shared
439        // handler opens the response stream uniformly after we return. If
440        // response-stream open subsequently fails, the forwarder task
441        // spawned below exits cleanly when `frame_tx.send` observes the
442        // dropped `frame_rx`.
443        let request_stream_recv = tcp::client::TcpClient::create_request_stream(
444            context_arc.clone(),
445            req_stream_conn_info,
446            None,
447        )
448        .await
449        .map_err(|e| {
450            if let Some(m) = self.metrics() {
451                m.error_counter
452                    .with_label_values(&[work_handler::error_types::RESPONSE_STREAM])
453                    .inc();
454            }
455            PipelineError::Generic(format!("Failed to create request stream: {e}"))
456        })?;
457
458        // Forwarder: deserialize raw bytes off the request socket into `T`
459        // and feed the engine's `ManyIn<T>` input. Every request frame
460        // (including the first) flows over this socket — the envelope is
461        // header-only.
462        let (frame_tx, frame_rx) = tokio::sync::mpsc::channel::<T>(8);
463        let forwarder_ctx = context_arc.clone();
464        tokio::spawn(async move {
465            let mut rx = request_stream_recv.rx;
466            while let Some(bytes) = rx.recv().await {
467                // Stop forwarding on either kill or soft-stop, matching the
468                // send-side `spawn_request_stream_forwarder`. Without the
469                // `stopped()` check, a `stop_generating()` would leave this
470                // task pumping frames into a channel the engine has abandoned.
471                if forwarder_ctx.is_killed() || forwarder_ctx.is_stopped() {
472                    break;
473                }
474                match payload_codec.decode::<T>(&bytes) {
475                    Ok(item) => {
476                        if frame_tx.send(item).await.is_err() {
477                            tracing::debug!(
478                                "engine consumer dropped; bidirectional input forwarder exiting"
479                            );
480                            break;
481                        }
482                    }
483                    Err(e) => {
484                        tracing::error!(
485                            error = %e,
486                            codec = payload_codec.name(),
487                            "failed to deserialize bidirectional request frame; killing context"
488                        );
489                        forwarder_ctx.kill();
490                        break;
491                    }
492                }
493            }
494        });
495
496        let input_stream: crate::engine::DataStream<T> =
497            Box::pin(tokio_stream::wrappers::ReceiverStream::new(frame_rx));
498        let request: ManyIn<T> = request_context.map(|_| RequestStream::new(input_stream));
499
500        Ok(ParsedRequest {
501            request,
502            response_connection_info: control_msg.connection_info,
503            frontend_send_ts_ns: control_msg.frontend_send_ts_ns,
504            payload_codec,
505        })
506    }
507}
508
509impl<Req: PipelineIO + Sync, U> Ingress<Req, ManyOut<U>>
510where
511    U: Data + Serialize + MaybeError + std::fmt::Debug,
512{
513    /// Shared body of `PushWorkHandler::handle_payload` for every
514    /// `Ingress<Req, ManyOut<U>>` shape that has an [`IngressDispatch`]
515    /// impl. Sets up the inflight metrics guard, calls
516    /// `parse_and_build_request` for the wire-shape-specific request
517    /// building, opens the response stream uniformly, dispatches via
518    /// the engine, sends the prologue, and pumps the response through
519    /// [`Self::pump_response_stream`].
520    async fn handle_payload_shared(
521        &self,
522        payload: Bytes,
523        request_id: Option<String>,
524    ) -> Result<(), PipelineError>
525    where
526        Self: IngressDispatch<Request = Req>,
527    {
528        let t2_wallclock_ns = std::time::SystemTime::now()
529            .duration_since(std::time::UNIX_EPOCH)
530            .unwrap_or_default()
531            .as_nanos() as u64;
532        let start_time = std::time::Instant::now();
533
534        // Increment inflight and ensure it's decremented on all exits via RAII guard
535        let _inflight_guard = self.metrics().map(|m| {
536            m.request_counter.inc();
537            m.inflight_requests.inc();
538            m.request_bytes.inc_by(payload.len() as u64);
539            if let Some(rid) = &request_id {
540                tracing::info!(request_id = %rid, "request received");
541            }
542            RequestMetricsGuard {
543                inflight_requests: m.inflight_requests.clone(),
544                request_duration: m.request_duration.clone(),
545                start_time,
546                request_id: request_id.clone(),
547            }
548        });
549
550        let ParsedRequest {
551            request,
552            response_connection_info,
553            frontend_send_ts_ns,
554            payload_codec,
555        } = self.parse_and_build_request(payload).await?;
556
557        // Compute network transit time (T2 - T1) using cross-process wall-clock timestamps
558        if let Some(t1_ns) = frontend_send_ts_ns {
559            let transit_ns = t2_wallclock_ns.saturating_sub(t1_ns);
560            WORK_HANDLER_NETWORK_TRANSIT_SECONDS.observe(transit_ns as f64 / 1_000_000_000.0);
561        }
562
563        // todo - eventually have a handler class which will returned an abstracted object, but for now,
564        // we only support tcp here, so we can just unwrap the connection info
565        tracing::trace!("creating tcp response stream");
566        let mut publisher = tcp::client::TcpClient::create_response_stream(
567            request.context(),
568            response_connection_info,
569            self.metrics().map(|m| m.cancellation_total.clone()),
570        )
571        .await
572        .map_err(|e| {
573            if let Some(m) = self.metrics() {
574                m.error_counter
575                    .with_label_values(&[work_handler::error_types::RESPONSE_STREAM])
576                    .inc();
577            }
578            PipelineError::Generic(format!("Failed to create response stream: {e}"))
579        })?;
580
581        tracing::trace!("calling generate");
582        let stream = self
583            .segment
584            .get()
585            .expect("segment not set")
586            .generate(request)
587            .await
588            .map_err(|e| {
589                if let Some(m) = self.metrics() {
590                    m.error_counter
591                        .with_label_values(&[work_handler::error_types::GENERATE])
592                        .inc();
593                }
594                PipelineError::GenerateError(e)
595            });
596
597        // the prolouge is sent to the client to indicate that the stream is ready to receive data
598        // or if the generate call failed, the error is sent to the client
599        let stream = match stream {
600            Ok(stream) => {
601                tracing::trace!("Successfully generated response stream; sending prologue");
602                let _result = publisher.send_prologue(None).await;
603                WORK_HANDLER_TIME_TO_FIRST_RESPONSE_SECONDS
604                    .observe(start_time.elapsed().as_secs_f64());
605                stream
606            }
607            Err(e) => {
608                let error_string = e.to_string();
609
610                #[cfg(debug_assertions)]
611                {
612                    tracing::debug!(
613                        "Failed to generate response stream (with debug backtrace): {:?}",
614                        e
615                    );
616                }
617                #[cfg(not(debug_assertions))]
618                {
619                    tracing::error!("Failed to generate response stream: {error_string}");
620                }
621
622                let _result = publisher.send_prologue(Some(error_string)).await;
623                Err(e)?
624            }
625        };
626
627        self.pump_response_stream(stream, &publisher, payload_codec)
628            .await;
629
630        // Ensure the metrics guard is not dropped until the end of the function.
631        // Drop fires "request completed" log via RAII.
632        drop(_inflight_guard);
633
634        Ok(())
635    }
636}
637
638#[async_trait]
639impl<T, U> PushWorkHandler for Ingress<SingleIn<T>, ManyOut<U>>
640where
641    T: Data + for<'de> Deserialize<'de> + std::fmt::Debug,
642    U: Data + Serialize + MaybeError + std::fmt::Debug,
643{
644    fn add_metrics(
645        &self,
646        endpoint: &crate::component::Endpoint,
647        metrics_labels: Option<&[(&str, &str)]>,
648    ) -> Result<()> {
649        // Call the inherent `Ingress::add_metrics`, not this trait method.
650        Ingress::add_metrics(self, endpoint, metrics_labels)
651    }
652
653    fn set_endpoint_health_check_notifier(&self, notifier: Arc<tokio::sync::Notify>) -> Result<()> {
654        self.endpoint_health_check_notifier
655            .set(notifier)
656            .map_err(|_| anyhow::anyhow!("Endpoint health check notifier already set"))?;
657        Ok(())
658    }
659
660    async fn handle_payload(
661        &self,
662        payload: Bytes,
663        request_id: Option<String>,
664    ) -> Result<(), PipelineError> {
665        self.handle_payload_shared(payload, request_id).await
666    }
667}
668
669#[async_trait]
670impl<T, U> PushWorkHandler for Ingress<ManyIn<T>, ManyOut<U>>
671where
672    T: Data + for<'de> Deserialize<'de> + std::fmt::Debug,
673    U: Data + Serialize + MaybeError + std::fmt::Debug,
674{
675    fn add_metrics(
676        &self,
677        endpoint: &crate::component::Endpoint,
678        metrics_labels: Option<&[(&str, &str)]>,
679    ) -> Result<()> {
680        // Call the inherent `Ingress::add_metrics`, not this trait method.
681        Ingress::add_metrics(self, endpoint, metrics_labels)
682    }
683
684    fn set_endpoint_health_check_notifier(&self, notifier: Arc<tokio::sync::Notify>) -> Result<()> {
685        self.endpoint_health_check_notifier
686            .set(notifier)
687            .map_err(|_| anyhow::anyhow!("Endpoint health check notifier already set"))?;
688        Ok(())
689    }
690
691    async fn handle_payload(
692        &self,
693        payload: Bytes,
694        request_id: Option<String>,
695    ) -> Result<(), PipelineError> {
696        self.handle_payload_shared(payload, request_id).await
697    }
698}