1use 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#[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 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 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
125struct 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 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 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 tracing::warn!("Failed to publish response for stream {}", context.id());
209 } else {
210 tracing::error!("Failed to publish response for stream {}", context.id());
212 context.stop_generating();
213 }
214 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 if let Some(notifier) = self.endpoint_health_check_notifier.get() {
226 notifier.notify_one();
227 }
228 }
229 if encoded.stop_stream {
230 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 if context.is_stopped() && !context.is_killed() {
270 tracing::debug!(
274 "Failed to publish complete final for stream {}; client already torn down",
275 context.id()
276 );
277 } else {
278 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 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 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}
352struct ParsedRequest<Req> {
357 request: Req,
358 response_connection_info: ConnectionInfo,
359 frontend_send_ts_ns: Option<u64>,
360 payload_codec: RequestPlanePayloadCodec,
361}
362
363#[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
768pub(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 #[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 #[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 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 #[derive(Clone, Copy, Debug)]
849 enum Teardown {
850 Stop,
853 Kill,
856 ConnectionReadError,
861 TransportOnly,
864 }
865
866 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 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 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 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 for _ in 0..content_frames {
922 rx.recv().await.expect("content frame");
923 }
924
925 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 #[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 #[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 #[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 #[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 #[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}