1use crate::error::FaucetError;
5use crate::observability::labels::Labels;
6use crate::observability::timer::DurationGuard;
7use crate::pipeline::StreamPage;
8use crate::traits::{Sink, Source};
9use crate::usage::{UsageMeter, estimate_page_bytes};
10use async_trait::async_trait;
11use futures::FutureExt;
12use futures_core::Stream;
13use metrics::{Label, SharedString, counter, gauge};
14use serde_json::Value;
15use std::collections::HashMap;
16use std::panic::AssertUnwindSafe;
17use std::pin::Pin;
18use std::sync::Arc;
19use std::sync::atomic::{AtomicUsize, Ordering};
20use tracing::{Instrument, info_span};
21
22fn metered_payload(
25 payload: crate::native::NativePayload,
26 labels: Vec<Label>,
27 metric: &'static str,
28 add: impl Fn(u64) + Send + Sync + 'static,
29) -> crate::native::NativePayload {
30 use crate::native::NativePayload;
31 use futures::StreamExt;
32 match payload {
33 NativePayload::Bytes(b) => {
34 counter!(metric, labels).increment(b.len() as u64);
35 add(b.len() as u64);
36 NativePayload::Bytes(b)
37 }
38 NativePayload::Stream(s) => NativePayload::Stream(Box::pin(s.inspect(move |chunk| {
39 if let Ok(c) = chunk {
40 counter!(metric, labels.clone()).increment(c.len() as u64);
41 add(c.len() as u64);
42 }
43 }))),
44 }
45}
46
47fn panic_message(panic: &(dyn std::any::Any + Send)) -> String {
48 panic
49 .downcast_ref::<&'static str>()
50 .map(|s| (*s).to_string())
51 .or_else(|| panic.downcast_ref::<String>().cloned())
52 .unwrap_or_else(|| "<non-string panic payload>".to_string())
53}
54
55type BoxedStream<'b, T> = Pin<Box<dyn Stream<Item = Result<T, FaucetError>> + Send + 'b>>;
56
57fn panic_isolated<'b, T: Send + 'b>(
61 open: impl FnOnce() -> BoxedStream<'b, T>,
62 labels: Vec<Label>,
63) -> BoxedStream<'b, T> {
64 use futures::StreamExt;
65 let fail = move |panic: Box<dyn std::any::Any + Send>| {
66 let mut l = labels.clone();
67 l.push(Label::new("kind", SharedString::const_str("Panic")));
68 counter!("faucet_source_errors_total", l).increment(1);
69 FaucetError::Custom(format!("panic in source: {}", panic_message(&*panic)).into())
70 };
71 let inner = match std::panic::catch_unwind(AssertUnwindSafe(open)) {
72 Ok(stream) => stream,
73 Err(panic) => {
74 let err = fail(panic);
75 return Box::pin(futures::stream::once(async move { Err(err) }));
76 }
77 };
78 Box::pin(futures::stream::unfold(Some(inner), move |state| {
79 let fail = fail.clone();
80 async move {
81 let mut stream = state?;
82 match AssertUnwindSafe(stream.next()).catch_unwind().await {
83 Ok(Some(item)) => Some((item, Some(stream))),
84 Ok(None) => None,
85 Err(panic) => Some((Err(fail(panic)), None)),
86 }
87 }
88 }))
89}
90
91fn guarded_connector_name(raw: &'static str) -> &'static str {
95 if raw.is_empty() { "unknown" } else { raw }
96}
97
98fn base_metric_labels(labels: &Labels, connector: &SharedString) -> Vec<Label> {
103 vec![
104 Label::new("pipeline", SharedString::from(labels.pipeline.to_string())),
105 Label::new("row", SharedString::from(labels.row.to_string())),
106 Label::new("connector", connector.clone()),
107 ]
108}
109
110pub struct InstrumentedSource<'a, S: Source + ?Sized> {
114 inner: &'a S,
115 labels: Labels,
116 connector: SharedString,
117 base_labels: Vec<Label>,
119 page_index: Arc<AtomicUsize>,
120 meter: Option<Arc<UsageMeter>>,
122}
123
124impl<'a, S: Source + ?Sized> InstrumentedSource<'a, S> {
125 pub fn new(inner: &'a S, labels: Labels) -> Self {
126 let raw = inner.connector_name();
127 debug_assert!(
128 !raw.is_empty(),
129 "connector_name() must return a non-empty string"
130 );
131 let connector: SharedString = SharedString::const_str(guarded_connector_name(raw));
132 let base_labels = base_metric_labels(&labels, &connector);
133 Self {
134 inner,
135 labels,
136 connector,
137 base_labels,
138 page_index: Arc::new(AtomicUsize::new(0)),
139 meter: None,
140 }
141 }
142
143 pub fn with_meter(mut self, meter: Arc<UsageMeter>) -> Self {
145 self.meter = Some(meter);
146 self
147 }
148
149 fn metric_labels(&self) -> Vec<Label> {
150 self.base_labels.clone()
151 }
152
153 #[allow(dead_code)]
157 fn error_labels(&self, kind: &'static str) -> Vec<Label> {
158 let mut l = self.metric_labels();
159 l.push(Label::new("kind", SharedString::const_str(kind)));
160 l
161 }
162}
163
164#[async_trait]
165impl<'a, S: Source + ?Sized> Source for InstrumentedSource<'a, S> {
166 fn connector_name(&self) -> &'static str {
167 guarded_connector_name(self.inner.connector_name())
171 }
172
173 crate::forward_source!(inner; except connector_name, stream_batches, stream_native, stream_pages);
177
178 #[cfg(feature = "arrow")]
179 fn stream_batches<'b>(
180 &'b self,
181 context: &'b HashMap<String, Value>,
182 batch_size: usize,
183 ) -> Pin<Box<dyn Stream<Item = Result<crate::columnar::ColumnarPage, FaucetError>> + Send + 'b>>
184 {
185 use futures::StreamExt;
186 let inner = panic_isolated(
187 || self.inner.stream_batches(context, batch_size),
188 self.metric_labels(),
189 );
190 let Some(meter) = self.meter.clone() else {
191 return inner;
192 };
193 let labels = self.metric_labels();
194 Box::pin(inner.inspect(move |page| {
195 if let Ok(page) = page {
196 let bytes = crate::columnar::batch_slice_bytes(&page.batch);
197 counter!("faucet_source_bytes_total", labels.clone()).increment(bytes);
198 meter.add_read(page.batch.num_rows() as u64, bytes);
199 }
200 }))
201 }
202
203 fn stream_native<'b>(
204 &'b self,
205 context: &'b HashMap<String, Value>,
206 format: crate::native::NativeFormat,
207 batch_size: usize,
208 ) -> Pin<Box<dyn Stream<Item = Result<crate::native::NativeBatch, FaucetError>> + Send + 'b>>
209 {
210 use futures::StreamExt;
211 let inner = panic_isolated(
212 || self.inner.stream_native(context, format, batch_size),
213 self.metric_labels(),
214 );
215 let Some(meter) = self.meter.clone() else {
216 return inner;
217 };
218 let labels = self.metric_labels();
219 Box::pin(inner.map(move |batch| {
220 batch.map(|mut b| {
221 let records = b.records.unwrap_or(0);
222 b.payload =
223 metered_payload(b.payload, labels.clone(), "faucet_source_bytes_total", {
224 let meter = Arc::clone(&meter);
225 move |n| meter.add_read(0, n)
226 });
227 meter.add_read(records, 0);
228 b
229 })
230 }))
231 }
232
233 fn stream_pages<'b>(
234 &'b self,
235 context: &'b HashMap<String, Value>,
236 batch_size: usize,
237 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'b>> {
238 let inner_stream = self.inner.stream_pages(context, batch_size);
239 let labels = self.labels.clone();
240 let connector = self.connector.clone();
241 let page_index = Arc::clone(&self.page_index);
242 let meter = self.meter.clone();
243 let metric_labels = self.metric_labels();
244 let pipeline = self.labels.pipeline.clone();
245 let row = self.labels.row.clone();
246
247 Box::pin(async_stream::try_stream! {
248 struct InFlightGuard(Vec<Label>);
251 impl Drop for InFlightGuard {
252 fn drop(&mut self) {
253 gauge!("faucet_source_in_flight", self.0.clone()).decrement(1.0);
254 }
255 }
256 gauge!("faucet_source_in_flight", metric_labels.clone()).increment(1.0);
257 let _in_flight = InFlightGuard(metric_labels.clone());
258
259 let mut inner = inner_stream;
260 loop {
261 let idx = page_index.fetch_add(1, Ordering::Relaxed);
262 let span = info_span!(
263 "faucet.source.page",
264 pipeline = %pipeline,
265 row = %row,
266 run_id = %labels.run_id,
267 connector = %connector,
268 page_index = idx,
269 );
270 let mut _timer = DurationGuard::new(
275 "faucet_source_page_duration_seconds",
276 metric_labels.clone(),
277 );
278
279 let next = AssertUnwindSafe(async {
280 use futures::StreamExt;
281 inner.next().await
282 })
283 .catch_unwind()
284 .instrument(span)
285 .await;
286
287 match next {
288 Ok(Some(Ok(page))) => {
289 counter!("faucet_source_pages_total", metric_labels.clone()).increment(1);
290 counter!("faucet_source_records_total", metric_labels.clone())
291 .increment(page.records.len() as u64);
292 if let Some(m) = &meter {
293 let bytes = estimate_page_bytes(&page.records);
294 counter!("faucet_source_bytes_total", metric_labels.clone())
295 .increment(bytes);
296 m.add_read(page.records.len() as u64, bytes);
297 }
298 _timer.record_now();
304 yield page;
305 }
306 Ok(Some(Err(e))) => {
307 let mut l = metric_labels.clone();
308 l.push(Label::new("kind", SharedString::const_str(error_kind(&e))));
309 counter!("faucet_source_errors_total", l).increment(1);
310 Err(e)?;
311 }
312 Ok(None) => {
313 _timer.disarm();
314 break;
315 }
316 Err(panic) => {
317 let mut l = metric_labels.clone();
318 l.push(Label::new("kind", SharedString::const_str("Panic")));
319 counter!("faucet_source_errors_total", l).increment(1);
320 let msg = panic.downcast_ref::<&'static str>().map(|s| (*s).to_string())
321 .or_else(|| panic.downcast_ref::<String>().cloned())
322 .unwrap_or_else(|| "<non-string panic payload>".to_string());
323 Err(FaucetError::Custom(format!("panic in source: {msg}").into()))?;
324 }
325 }
326 }
327 })
328 }
329}
330
331pub(crate) fn error_kind(e: &FaucetError) -> &'static str {
334 match e {
335 FaucetError::Http(_) => "Http",
336 FaucetError::HttpStatus { .. } => "HttpStatus",
337 FaucetError::Json(_) => "Json",
338 FaucetError::JsonPath(_) => "JsonPath",
339 FaucetError::Auth(_) => "Auth",
340 FaucetError::RateLimited { .. } => "RateLimited",
341 FaucetError::Url(_) => "Url",
342 FaucetError::Transform(_) => "Transform",
343 FaucetError::Config(_) => "Config",
344 FaucetError::Source(_) => "Source",
345 FaucetError::Sink(_) => "Sink",
346 FaucetError::QualityFailure { .. } => "QualityFailure",
347 FaucetError::SchemaDrift { .. } => "SchemaDrift",
348 FaucetError::ProfileDrift { .. } => "ProfileDrift",
349 FaucetError::PolicyViolation { .. } => "PolicyViolation",
350 FaucetError::BudgetExceeded { .. } => "BudgetExceeded",
351 FaucetError::RowsUnaccounted { .. } => "RowsUnaccounted",
352 FaucetError::ContractViolation { .. } => "ContractViolation",
353 FaucetError::State(_) => "State",
354 FaucetError::StateIncompatible { .. } => "StateIncompatible",
355 FaucetError::CircuitOpen { .. } => "CircuitOpen",
356 FaucetError::Timeout { .. } => "Timeout",
357 FaucetError::Cancelled { .. } => "Cancelled",
358 FaucetError::Custom(_) => "Custom",
359 }
360}
361
362pub struct InstrumentedSink<'a, S: Sink + ?Sized> {
365 inner: &'a S,
366 labels: Labels,
367 connector: SharedString,
368 base_labels: Vec<Label>,
370 meter: Option<Arc<UsageMeter>>,
372}
373
374impl<'a, S: Sink + ?Sized> InstrumentedSink<'a, S> {
375 pub fn new(inner: &'a S, labels: Labels) -> Self {
376 let raw = inner.connector_name();
377 debug_assert!(
378 !raw.is_empty(),
379 "connector_name() must return a non-empty string"
380 );
381 let connector: SharedString = SharedString::const_str(guarded_connector_name(raw));
382 let base_labels = base_metric_labels(&labels, &connector);
383 Self {
384 inner,
385 labels,
386 connector,
387 base_labels,
388 meter: None,
389 }
390 }
391
392 pub fn with_meter(mut self, meter: Arc<UsageMeter>) -> Self {
395 self.meter = Some(meter);
396 self
397 }
398
399 fn metric_labels(&self) -> Vec<Label> {
400 self.base_labels.clone()
401 }
402
403 fn error_labels(&self, kind: &'static str) -> Vec<Label> {
404 let mut l = self.metric_labels();
405 l.push(Label::new("kind", SharedString::const_str(kind)));
406 l
407 }
408
409 fn panicked(&self, what: &str, panic: Box<dyn std::any::Any + Send>) -> FaucetError {
411 counter!("faucet_sink_errors_total", self.error_labels("Panic")).increment(1);
412 FaucetError::Custom(format!("panic in {what}: {}", panic_message(&*panic)).into())
413 }
414
415 async fn write_instrumented(
417 &self,
418 records: &[Value],
419 reported: bool,
420 governed: Option<&crate::governance::GovernedPage<'_>>,
421 ) -> Result<crate::accounting::WriteReport, FaucetError> {
422 let span = info_span!(
423 "faucet.sink.write",
424 pipeline = %self.labels.pipeline,
425 row = %self.labels.row,
426 run_id = %self.labels.run_id,
427 connector = %self.connector,
428 records = records.len(),
429 );
430 let metric_labels = self.metric_labels();
431 gauge!("faucet_sink_in_flight", metric_labels.clone()).increment(1.0);
432
433 struct InFlightGuard(Vec<Label>);
436 impl Drop for InFlightGuard {
437 fn drop(&mut self) {
438 gauge!("faucet_sink_in_flight", self.0.clone()).decrement(1.0);
439 }
440 }
441 let _in_flight = InFlightGuard(metric_labels.clone());
442
443 let _timer =
444 DurationGuard::new("faucet_sink_write_duration_seconds", metric_labels.clone());
445
446 let write = async {
447 if let Some(page) = governed {
448 self.inner.write_governed(page).await
449 } else if reported {
450 self.inner.write_batch_reported(records).await
451 } else {
452 self.inner
453 .write_batch(records)
454 .await
455 .map(crate::accounting::WriteReport::accepted)
456 }
457 };
458 let result = AssertUnwindSafe(write)
459 .catch_unwind()
460 .instrument(span)
461 .await;
462
463 match result {
464 Ok(Ok(r)) => {
465 let n = r.accepted;
466 counter!("faucet_sink_writes_total", metric_labels.clone()).increment(1);
467 counter!("faucet_sink_records_total", metric_labels.clone()).increment(n as u64);
468 self.meter_written(records, n);
469 Ok(r)
470 }
471 Ok(Err(e)) => {
472 counter!(
473 "faucet_sink_errors_total",
474 self.error_labels(error_kind(&e))
475 )
476 .increment(1);
477 Err(e)
478 }
479 Err(panic) => {
480 counter!("faucet_sink_errors_total", self.error_labels("Panic")).increment(1);
481 let msg = panic
482 .downcast_ref::<&'static str>()
483 .map(|s| (*s).to_string())
484 .or_else(|| panic.downcast_ref::<String>().cloned())
485 .unwrap_or_else(|| "<non-string panic payload>".to_string());
486 Err(FaucetError::Custom(format!("panic in sink: {msg}").into()))
487 }
488 }
489 }
490
491 fn meter_written(&self, records: &[Value], accepted: usize) {
494 let Some(m) = &self.meter else {
495 return;
496 };
497 let bytes = if accepted >= records.len() {
498 estimate_page_bytes(records)
499 } else {
500 estimate_page_bytes(&records[..accepted])
501 };
502 counter!("faucet_sink_bytes_total", self.metric_labels()).increment(bytes);
503 m.add_written(accepted as u64, bytes);
504 }
505}
506
507#[async_trait]
508impl<'a, S: Sink + ?Sized> Sink for InstrumentedSink<'a, S> {
509 crate::forward_sink!(inner; except connector_name, write_batch_columnar,
513 load_native, write_batch, write_batch_reported, write_governed, write_batch_partial,
514 flush, write_batch_idempotent, write_batch_idempotent_reported);
515
516 fn connector_name(&self) -> &'static str {
517 guarded_connector_name(self.inner.connector_name())
521 }
522
523 #[cfg(feature = "arrow")]
524 async fn write_batch_columnar(
525 &self,
526 batch: &arrow::array::RecordBatch,
527 ) -> Result<usize, FaucetError> {
528 let n = AssertUnwindSafe(async { self.inner.write_batch_columnar(batch).await })
529 .catch_unwind()
530 .await
531 .unwrap_or_else(|panic| Err(self.panicked("sink", panic)))?;
532 if let Some(m) = &self.meter {
533 let rows = batch.num_rows().max(1);
534 let bytes = crate::columnar::batch_slice_bytes(batch)
535 .saturating_mul(n.min(rows) as u64)
536 / rows as u64;
537 counter!("faucet_sink_bytes_total", self.metric_labels()).increment(bytes);
538 m.add_written(n as u64, bytes);
539 }
540 Ok(n)
541 }
542
543 async fn load_native(
544 &self,
545 batch: crate::native::NativeBatch,
546 scope: &str,
547 ctx: crate::native::NativeLoadContext,
548 ) -> Result<usize, FaucetError> {
549 let Some(meter) = self.meter.clone() else {
550 return AssertUnwindSafe(async { self.inner.load_native(batch, scope, ctx).await })
551 .catch_unwind()
552 .await
553 .unwrap_or_else(|panic| Err(self.panicked("sink", panic)));
554 };
555 let bytes = Arc::new(std::sync::atomic::AtomicU64::new(0));
556 let mut batch = batch;
557 batch.payload = metered_payload(
558 batch.payload,
559 self.metric_labels(),
560 "faucet_sink_bytes_total",
561 {
562 let bytes = Arc::clone(&bytes);
563 move |n| {
564 bytes.fetch_add(n, Ordering::Relaxed);
565 }
566 },
567 );
568 let n = AssertUnwindSafe(async { self.inner.load_native(batch, scope, ctx).await })
569 .catch_unwind()
570 .await
571 .unwrap_or_else(|panic| Err(self.panicked("sink", panic)))?;
572 meter.add_written(n as u64, bytes.load(Ordering::Relaxed));
573 Ok(n)
574 }
575
576 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
577 self.write_instrumented(records, false, None)
578 .await
579 .map(|r| r.accepted)
580 }
581
582 async fn write_batch_reported(
583 &self,
584 records: &[Value],
585 ) -> Result<crate::accounting::WriteReport, FaucetError> {
586 self.write_instrumented(records, true, None).await
587 }
588
589 async fn write_governed(
590 &self,
591 page: &crate::governance::GovernedPage<'_>,
592 ) -> Result<crate::accounting::WriteReport, FaucetError> {
593 self.write_instrumented(page.records(), true, Some(page))
594 .await
595 }
596
597 async fn write_batch_partial(
598 &self,
599 records: &[Value],
600 ) -> Result<Vec<crate::traits::RowOutcome>, FaucetError> {
601 let span = info_span!(
602 "faucet.sink.write_partial",
603 pipeline = %self.labels.pipeline,
604 row = %self.labels.row,
605 run_id = %self.labels.run_id,
606 connector = %self.connector,
607 records = records.len(),
608 );
609 let metric_labels = self.metric_labels();
610 gauge!("faucet_sink_in_flight", metric_labels.clone()).increment(1.0);
611
612 struct InFlightGuard(Vec<Label>);
615 impl Drop for InFlightGuard {
616 fn drop(&mut self) {
617 gauge!("faucet_sink_in_flight", self.0.clone()).decrement(1.0);
618 }
619 }
620 let _in_flight = InFlightGuard(metric_labels.clone());
621
622 let _timer =
623 DurationGuard::new("faucet_sink_write_duration_seconds", metric_labels.clone());
624
625 let result = AssertUnwindSafe(self.inner.write_batch_partial(records))
626 .catch_unwind()
627 .instrument(span)
628 .await;
629
630 match result {
631 Ok(Ok(outcomes)) => {
632 let success_count = outcomes.iter().filter(|o| o.is_ok()).count();
633 counter!("faucet_sink_writes_total", metric_labels.clone()).increment(1);
634 counter!("faucet_sink_records_total", metric_labels.clone())
635 .increment(success_count as u64);
636 if let Some(m) = &self.meter {
637 let bytes: u64 = outcomes
638 .iter()
639 .zip(records.iter())
640 .filter(|(o, _)| o.is_ok())
641 .map(|(_, r)| crate::usage::estimate_json_bytes(r))
642 .sum();
643 counter!("faucet_sink_bytes_total", metric_labels.clone()).increment(bytes);
644 m.add_written(success_count as u64, bytes);
645 }
646 Ok(outcomes)
647 }
648 Ok(Err(e)) => {
649 counter!(
650 "faucet_sink_errors_total",
651 self.error_labels(error_kind(&e))
652 )
653 .increment(1);
654 Err(e)
655 }
656 Err(panic) => {
657 counter!("faucet_sink_errors_total", self.error_labels("Panic")).increment(1);
658 let msg = panic
659 .downcast_ref::<&'static str>()
660 .map(|s| (*s).to_string())
661 .or_else(|| panic.downcast_ref::<String>().cloned())
662 .unwrap_or_else(|| "<non-string panic payload>".to_string());
663 Err(FaucetError::Custom(format!("panic in sink: {msg}").into()))
664 }
665 }
666 }
667
668 async fn flush(&self) -> Result<(), FaucetError> {
669 let span = info_span!(
670 "faucet.sink.flush",
671 pipeline = %self.labels.pipeline,
672 row = %self.labels.row,
673 run_id = %self.labels.run_id,
674 connector = %self.connector,
675 );
676 let metric_labels = self.metric_labels();
677 let _timer =
678 DurationGuard::new("faucet_sink_flush_duration_seconds", metric_labels.clone());
679
680 let result = AssertUnwindSafe(self.inner.flush())
681 .catch_unwind()
682 .instrument(span)
683 .await;
684
685 match result {
686 Ok(Ok(())) => Ok(()),
687 Ok(Err(e)) => {
688 counter!(
689 "faucet_sink_errors_total",
690 self.error_labels(error_kind(&e))
691 )
692 .increment(1);
693 Err(e)
694 }
695 Err(panic) => {
696 counter!("faucet_sink_errors_total", self.error_labels("Panic")).increment(1);
697 let msg = panic
698 .downcast_ref::<&'static str>()
699 .map(|s| (*s).to_string())
700 .or_else(|| panic.downcast_ref::<String>().cloned())
701 .unwrap_or_else(|| "<non-string panic payload>".to_string());
702 Err(FaucetError::Custom(format!("panic in flush: {msg}").into()))
703 }
704 }
705 }
706
707 #[allow(deprecated)] async fn write_batch_idempotent(
709 &self,
710 records: &[Value],
711 scope: &str,
712 token: &str,
713 ) -> Result<usize, FaucetError> {
714 self.idempotent_instrumented(
715 records,
716 self.inner.write_batch_idempotent(records, scope, token),
717 |n| *n,
718 )
719 .await
720 }
721
722 #[allow(deprecated)] async fn write_batch_idempotent_reported(
724 &self,
725 records: &[Value],
726 scope: &str,
727 token: &str,
728 ) -> Result<crate::accounting::WriteReport, FaucetError> {
729 self.idempotent_instrumented(
730 records,
731 self.inner
732 .write_batch_idempotent_reported(records, scope, token),
733 |r| r.accepted,
734 )
735 .await
736 }
737}
738
739impl<'a, S: Sink + ?Sized> InstrumentedSink<'a, S> {
740 async fn idempotent_instrumented<T>(
743 &self,
744 records: &[Value],
745 write: impl std::future::Future<Output = Result<T, FaucetError>>,
746 accepted: impl Fn(&T) -> usize,
747 ) -> Result<T, FaucetError> {
748 let span = info_span!(
749 "faucet.sink.write_idempotent",
750 pipeline = %self.labels.pipeline,
751 row = %self.labels.row,
752 run_id = %self.labels.run_id,
753 connector = %self.connector,
754 records = records.len(),
755 );
756 let metric_labels = self.metric_labels();
757 gauge!("faucet_sink_in_flight", metric_labels.clone()).increment(1.0);
758 struct InFlightGuard(Vec<Label>);
759 impl Drop for InFlightGuard {
760 fn drop(&mut self) {
761 gauge!("faucet_sink_in_flight", self.0.clone()).decrement(1.0);
762 }
763 }
764 let _in_flight = InFlightGuard(metric_labels.clone());
765 let _timer =
766 DurationGuard::new("faucet_sink_write_duration_seconds", metric_labels.clone());
767
768 let result = AssertUnwindSafe(write)
769 .catch_unwind()
770 .instrument(span)
771 .await;
772 match result {
773 Ok(Ok(out)) => {
774 let n = accepted(&out);
775 counter!("faucet_sink_writes_total", metric_labels.clone()).increment(1);
776 counter!("faucet_sink_records_total", metric_labels).increment(n as u64);
777 self.meter_written(records, n);
778 Ok(out)
779 }
780 Ok(Err(e)) => {
781 counter!(
782 "faucet_sink_errors_total",
783 self.error_labels(error_kind(&e))
784 )
785 .increment(1);
786 Err(e)
787 }
788 Err(panic) => {
789 counter!("faucet_sink_errors_total", self.error_labels("Panic")).increment(1);
790 Err(FaucetError::Custom(
791 format!("panic in sink: {}", panic_message(&*panic)).into(),
792 ))
793 }
794 }
795 }
796}
797
798#[cfg(test)]
799pub(crate) mod source_tests {
800 use super::*;
801 use async_trait::async_trait;
802 use futures::StreamExt;
803 use metrics_util::debugging::{DebugValue, DebuggingRecorder, Snapshotter};
804 use serde_json::json;
805 use std::sync::{Mutex, OnceLock};
806
807 #[tokio::test]
809 async fn routed_double_fetches_nothing() {
810 use crate::Source as _;
811 assert!(
812 RoutedSource
813 .fetch_with_context(&Default::default())
814 .await
815 .unwrap()
816 .is_empty()
817 );
818 }
819
820 struct RoutedSource;
821
822 #[async_trait::async_trait]
823 impl crate::Source for RoutedSource {
824 fn set_cancel(&self, _cancel: tokio_util::sync::CancellationToken) -> bool {
825 true
826 }
827 async fn fetch_with_context(
828 &self,
829 _ctx: &std::collections::HashMap<String, serde_json::Value>,
830 ) -> Result<Vec<serde_json::Value>, crate::FaucetError> {
831 Ok(Vec::new())
832 }
833
834 fn record_table(&self, record: &serde_json::Value) -> Option<String> {
835 record.get("t")?.as_str().map(str::to_string)
836 }
837
838 fn position_le(&self, a: &serde_json::Value, b: &serde_json::Value) -> Option<bool> {
839 Some(a.as_u64()? <= b.as_u64()?)
840 }
841
842 fn position_min(&self, _positions: &[serde_json::Value]) -> Option<serde_json::Value> {
843 Some(serde_json::json!("inner-min"))
844 }
845
846 fn record_covered(
847 &self,
848 record: &serde_json::Value,
849 position: &serde_json::Value,
850 ) -> Option<bool> {
851 Some(record.get("p")?.as_u64()? < position.as_u64()?)
852 }
853 }
854
855 fn assert_forwards_multi_table_hooks(s: &dyn crate::Source) {
856 assert_eq!(
857 s.record_table(&serde_json::json!({"t": "public.a"}))
858 .as_deref(),
859 Some("public.a")
860 );
861 assert_eq!(
862 s.position_le(&serde_json::json!(1), &serde_json::json!(2)),
863 Some(true)
864 );
865 assert_eq!(
866 s.position_le(&serde_json::json!(3), &serde_json::json!(2)),
867 Some(false)
868 );
869 assert_eq!(
870 s.record_covered(&serde_json::json!({"p": 1}), &serde_json::json!(2)),
871 Some(true)
872 );
873 assert_eq!(
874 s.record_covered(&serde_json::json!({"p": 2}), &serde_json::json!(2)),
875 Some(false)
876 );
877 assert_eq!(
878 s.position_min(&[serde_json::json!(1), serde_json::json!(2)]),
879 Some(serde_json::json!("inner-min"))
880 );
881 }
882
883 #[test]
884 fn multi_table_hooks_are_forwarded_to_the_inner_source() {
885 let inner = RoutedSource;
886 let wrapped = InstrumentedSource::new(&inner, labels());
887 assert_forwards_multi_table_hooks(&wrapped);
888 wrapped.set_run_clock(chrono::Utc::now());
889 assert!(wrapped.set_cancel(tokio_util::sync::CancellationToken::new()));
890 }
891
892 pub(crate) static LOCK: Mutex<()> = Mutex::new(());
895 static SNAPSHOTTER: OnceLock<Snapshotter> = OnceLock::new();
896
897 pub(crate) fn snapshotter() -> &'static Snapshotter {
898 SNAPSHOTTER.get_or_init(|| {
899 let recorder = DebuggingRecorder::new();
900 let snap = recorder.snapshotter();
901 let _ = metrics::set_global_recorder(recorder);
909 snap
910 })
911 }
912
913 pub(in crate::observability) fn labels() -> Labels {
914 Labels::new("p", "r", "rid")
915 }
916
917 struct MockSource(Vec<Value>);
918 #[async_trait]
919 impl Source for MockSource {
920 async fn fetch_with_context(
921 &self,
922 _: &HashMap<String, Value>,
923 ) -> Result<Vec<Value>, FaucetError> {
924 Ok(self.0.clone())
925 }
926 fn connector_name(&self) -> &'static str {
927 "mock"
928 }
929 }
930
931 struct PanickingSource;
932 #[async_trait]
933 impl Source for PanickingSource {
934 async fn fetch_with_context(
935 &self,
936 _: &HashMap<String, Value>,
937 ) -> Result<Vec<Value>, FaucetError> {
938 panic!("kaboom")
939 }
940 fn connector_name(&self) -> &'static str {
941 "panic-test"
942 }
943 }
944
945 struct EmptyNameSource;
949 #[async_trait]
950 impl Source for EmptyNameSource {
951 async fn fetch_with_context(
952 &self,
953 _: &HashMap<String, Value>,
954 ) -> Result<Vec<Value>, FaucetError> {
955 Ok(vec![])
956 }
957 fn connector_name(&self) -> &'static str {
958 ""
959 }
960 }
961
962 #[test]
963 fn empty_inner_connector_name_falls_back_to_unknown() {
964 let inner = EmptyNameSource;
965 let wrapped = InstrumentedSource {
969 inner: &inner,
970 labels: labels(),
971 connector: SharedString::const_str("unknown"),
972 base_labels: Vec::new(),
973 page_index: Arc::new(AtomicUsize::new(0)),
974 meter: None,
975 };
976 assert_eq!(
977 Source::connector_name(&wrapped),
978 "unknown",
979 "instrumented source must not leak an empty connector name"
980 );
981 }
982
983 #[tokio::test]
984 #[allow(clippy::await_holding_lock)]
985 async fn records_records_counter_per_page() {
986 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
987 let snap = snapshotter();
988 let inner = MockSource((0..5).map(|i| json!({"i": i})).collect());
989 let wrapped = InstrumentedSource::new(&inner, labels());
990 let ctx = HashMap::new();
991 let mut s = wrapped.stream_pages(&ctx, 2);
992 while s.next().await.is_some() {}
993 let snapshot = snap.snapshot();
994 let records: u64 = snapshot
995 .into_vec()
996 .into_iter()
997 .filter_map(|(key, _u, _d, v)| {
998 if key.key().name() == "faucet_source_records_total"
999 && let DebugValue::Counter(c) = v
1000 {
1001 return Some(c);
1002 }
1003 None
1004 })
1005 .sum();
1006 assert!(
1007 records >= 5,
1008 "expected at least 5 records counted, got {records}"
1009 );
1010 }
1011
1012 struct PageCountSource(Vec<Value>);
1015 #[async_trait]
1016 impl Source for PageCountSource {
1017 async fn fetch_with_context(
1018 &self,
1019 _: &HashMap<String, Value>,
1020 ) -> Result<Vec<Value>, FaucetError> {
1021 Ok(self.0.clone())
1022 }
1023 fn connector_name(&self) -> &'static str {
1024 "page-count-probe"
1025 }
1026 }
1027
1028 #[tokio::test]
1029 #[allow(clippy::await_holding_lock)]
1030 async fn page_duration_records_one_sample_per_yielded_page() {
1031 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1034 let snap = snapshotter();
1035 let inner = PageCountSource((0..5).map(|i| json!({"i": i})).collect());
1036 let wrapped = InstrumentedSource::new(&inner, labels());
1037 let ctx = HashMap::new();
1038 let mut s = wrapped.stream_pages(&ctx, 2);
1039 let mut pages = 0usize;
1040 while s.next().await.is_some() {
1041 pages += 1;
1042 }
1043 assert_eq!(pages, 3, "expected 3 yielded pages");
1044
1045 let snapshot = snap.snapshot();
1046 let samples: usize = snapshot
1047 .into_vec()
1048 .into_iter()
1049 .filter_map(|(key, _u, _d, v)| {
1050 if key.key().name() == "faucet_source_page_duration_seconds"
1051 && key
1052 .key()
1053 .labels()
1054 .any(|l| l.key() == "connector" && l.value() == "page-count-probe")
1055 && let DebugValue::Histogram(h) = v
1056 {
1057 return Some(h.len());
1058 }
1059 None
1060 })
1061 .sum();
1062 assert_eq!(
1063 samples, pages,
1064 "page-duration histogram must have exactly one sample per yielded \
1065 page ({pages}), not page+1 (no spurious terminal sample)"
1066 );
1067 }
1068
1069 #[tokio::test]
1070 #[allow(clippy::await_holding_lock)]
1071 async fn maps_panic_to_custom_error_with_kind_panic() {
1072 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1073 let _snap = snapshotter();
1074 let inner = PanickingSource;
1075 let wrapped = InstrumentedSource::new(&inner, labels());
1076 let ctx = HashMap::new();
1077 let mut s = wrapped.stream_pages(&ctx, 10);
1078 let first = s
1079 .next()
1080 .await
1081 .expect("stream yields at least one item before terminating");
1082 assert!(matches!(first, Err(FaucetError::Custom(_))));
1083 }
1085
1086 #[test]
1089 fn error_kind_covers_all_variants() {
1090 use std::time::Duration;
1091 let cases: Vec<(FaucetError, &str)> = vec![
1096 (
1097 FaucetError::HttpStatus {
1098 status: 500,
1099 url: "u".into(),
1100 body: "b".into(),
1101 },
1102 "HttpStatus",
1103 ),
1104 (
1105 FaucetError::Json(serde_json::from_str::<Value>("nope").unwrap_err()),
1106 "Json",
1107 ),
1108 (FaucetError::JsonPath("bad".into()), "JsonPath"),
1109 (FaucetError::Auth("a".into()), "Auth"),
1110 (
1111 FaucetError::RateLimited(Duration::from_secs(1)),
1112 "RateLimited",
1113 ),
1114 (FaucetError::Url("bad url".into()), "Url"),
1115 (FaucetError::Transform("t".into()), "Transform"),
1116 (FaucetError::Config("c".into()), "Config"),
1117 (FaucetError::Source("s".into()), "Source"),
1118 (FaucetError::Sink("s".into()), "Sink"),
1119 (
1120 FaucetError::QualityFailure {
1121 check: "chk".into(),
1122 message: "m".into(),
1123 },
1124 "QualityFailure",
1125 ),
1126 (FaucetError::State("st".into()), "State"),
1127 (
1128 FaucetError::CircuitOpen {
1129 failures: 3,
1130 cooldown: Duration::from_secs(60),
1131 },
1132 "CircuitOpen",
1133 ),
1134 (
1135 FaucetError::Timeout {
1136 operation: "op".into(),
1137 after: Duration::from_secs(1),
1138 },
1139 "Timeout",
1140 ),
1141 (
1142 FaucetError::Custom(Box::new(std::io::Error::other("boom"))),
1143 "Custom",
1144 ),
1145 ];
1146 for (err, expected) in cases {
1147 assert_eq!(error_kind(&err), expected, "mismatch for {err:?}");
1148 }
1149 }
1150
1151 struct PassthroughSource {
1157 seen_bookmark: Mutex<Option<Value>>,
1158 }
1159 #[async_trait]
1160 impl Source for PassthroughSource {
1161 async fn fetch_with_context(
1162 &self,
1163 _: &HashMap<String, Value>,
1164 ) -> Result<Vec<Value>, FaucetError> {
1165 Ok(vec![json!({"fwc": 1})])
1166 }
1167 async fn fetch_with_context_incremental(
1168 &self,
1169 _: &HashMap<String, Value>,
1170 ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
1171 Ok((vec![json!({"inc": 1})], Some(json!("bm"))))
1172 }
1173 fn state_key(&self) -> Option<String> {
1174 Some("passthrough_key".into())
1175 }
1176 async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
1177 *self.seen_bookmark.lock().unwrap() = Some(bookmark);
1178 Ok(())
1179 }
1180 fn connector_name(&self) -> &'static str {
1181 "passthrough"
1182 }
1183 }
1184
1185 #[tokio::test]
1186 async fn source_passthroughs_delegate_to_inner() {
1187 let inner = PassthroughSource {
1188 seen_bookmark: Mutex::new(None),
1189 };
1190 let wrapped = InstrumentedSource::new(&inner, labels());
1191
1192 assert_eq!(wrapped.state_key(), Some("passthrough_key".to_string()));
1194
1195 let ctx = HashMap::new();
1197 assert_eq!(
1198 wrapped.fetch_with_context(&ctx).await.unwrap(),
1199 vec![json!({"fwc": 1})]
1200 );
1201
1202 let (recs, bm) = wrapped.fetch_with_context_incremental(&ctx).await.unwrap();
1204 assert_eq!(recs, vec![json!({"inc": 1})]);
1205 assert_eq!(bm, Some(json!("bm")));
1206
1207 wrapped.apply_start_bookmark(json!("resume")).await.unwrap();
1209 assert_eq!(
1210 *inner.seen_bookmark.lock().unwrap(),
1211 Some(json!("resume")),
1212 "apply_start_bookmark must reach the inner source"
1213 );
1214
1215 assert!(!wrapped.supports_exactly_once());
1217 assert!(!wrapped.consumes_destructively());
1218 assert_eq!(
1219 wrapped.replay_guarantee(),
1220 crate::idempotency::ReplayGuarantee::NonDeterministic
1221 );
1222 assert_eq!(wrapped.capture_resume_position().await.unwrap(), None);
1223 assert_eq!(wrapped.lag().await.unwrap(), None);
1224 }
1225
1226 struct ExactlyOnceSource;
1229 #[async_trait]
1230 impl Source for ExactlyOnceSource {
1231 async fn fetch_with_context(
1232 &self,
1233 _context: &HashMap<String, Value>,
1234 ) -> Result<Vec<Value>, FaucetError> {
1235 Ok(vec![])
1236 }
1237 fn supports_exactly_once(&self) -> bool {
1238 true
1239 }
1240 async fn capture_resume_position(&self) -> Result<Option<Value>, FaucetError> {
1241 Ok(Some(json!("pos")))
1242 }
1243 fn connector_name(&self) -> &'static str {
1244 "eo-source"
1245 }
1246 }
1247
1248 #[tokio::test]
1249 async fn source_capability_passthroughs_delegate_to_inner() {
1250 let inner = ExactlyOnceSource;
1251 let wrapped = InstrumentedSource::new(&inner, labels());
1252 assert!(wrapped.supports_exactly_once());
1253 assert_eq!(
1254 wrapped.replay_guarantee(),
1255 crate::idempotency::ReplayGuarantee::Deterministic,
1256 "typed capability derives through the wrapper"
1257 );
1258 assert_eq!(
1259 wrapped.capture_resume_position().await.unwrap(),
1260 Some(json!("pos"))
1261 );
1262 }
1263}
1264
1265#[allow(deprecated)] #[cfg(test)]
1267mod sink_tests {
1268 use super::source_tests::{LOCK, labels, snapshotter};
1269 use super::*;
1270 use async_trait::async_trait;
1271 use metrics_util::debugging::DebugValue;
1272 use serde_json::json;
1273
1274 struct MockSink(std::sync::Mutex<Vec<Value>>);
1275 #[async_trait]
1276 impl Sink for MockSink {
1277 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1278 self.0.lock().unwrap().extend(records.iter().cloned());
1279 Ok(records.len())
1280 }
1281 fn connector_name(&self) -> &'static str {
1282 "mock-sink"
1283 }
1284 }
1285
1286 struct FailingSink;
1287 #[async_trait]
1288 impl Sink for FailingSink {
1289 async fn write_batch(&self, _: &[Value]) -> Result<usize, FaucetError> {
1290 Err(FaucetError::Sink("nope".into()))
1291 }
1292 fn connector_name(&self) -> &'static str {
1293 "failing-sink"
1294 }
1295 }
1296
1297 struct EmptyNameSink;
1298 #[async_trait]
1299 impl Sink for EmptyNameSink {
1300 async fn write_batch(&self, _: &[Value]) -> Result<usize, FaucetError> {
1301 Ok(0)
1302 }
1303 fn connector_name(&self) -> &'static str {
1304 ""
1305 }
1306 }
1307
1308 #[test]
1309 fn empty_inner_connector_name_falls_back_to_unknown() {
1310 let inner = EmptyNameSink;
1311 let wrapped = InstrumentedSink {
1315 inner: &inner,
1316 labels: labels(),
1317 connector: SharedString::const_str("unknown"),
1318 base_labels: Vec::new(),
1319 meter: None,
1320 };
1321 assert_eq!(
1322 Sink::connector_name(&wrapped),
1323 "unknown",
1324 "instrumented sink must not leak an empty connector name"
1325 );
1326 }
1327
1328 struct CapableSink;
1336 #[async_trait]
1337 impl Sink for CapableSink {
1338 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1339 Ok(records.len())
1340 }
1341 fn connector_name(&self) -> &'static str {
1342 "capable-sink"
1343 }
1344 async fn current_schema(&self) -> Result<Option<Value>, FaucetError> {
1345 Ok(Some(
1346 json!({"type": "object", "properties": {"id": {"type": "integer"}}}),
1347 ))
1348 }
1349 fn supports_schema_evolution(&self) -> bool {
1350 true
1351 }
1352 fn supports_idempotent_writes(&self) -> bool {
1353 true
1354 }
1355 fn supported_write_modes(&self) -> &'static [crate::write_mode::WriteMode] {
1356 &[
1357 crate::write_mode::WriteMode::Append,
1358 crate::write_mode::WriteMode::Upsert,
1359 ]
1360 }
1361 async fn last_committed_token(&self, _scope: &str) -> Result<Option<String>, FaucetError> {
1362 Ok(Some("tok-1".into()))
1363 }
1364 fn dedups_by_key(&self) -> bool {
1365 true
1366 }
1367 }
1368
1369 #[tokio::test]
1370 async fn instrumented_sink_forwards_capability_methods_to_inner() {
1371 let inner = CapableSink;
1372 let wrapped = InstrumentedSink::new(&inner, labels());
1373
1374 assert_eq!(
1377 wrapped.current_schema().await.unwrap(),
1378 Some(json!({"type": "object", "properties": {"id": {"type": "integer"}}})),
1379 "current_schema must delegate to the inner sink"
1380 );
1381 assert!(
1382 wrapped.supports_schema_evolution(),
1383 "supports_schema_evolution must delegate"
1384 );
1385 assert!(
1387 wrapped.supports_idempotent_writes(),
1388 "supports_idempotent_writes must delegate (exactly-once)"
1389 );
1390 assert!(
1391 wrapped
1392 .supported_write_modes()
1393 .contains(&crate::write_mode::WriteMode::Upsert),
1394 "supported_write_modes must delegate"
1395 );
1396 assert_eq!(
1397 wrapped.last_committed_token("scope").await.unwrap(),
1398 Some("tok-1".to_string()),
1399 "last_committed_token must delegate"
1400 );
1401 assert_eq!(
1404 wrapped.sink_guarantee(),
1405 crate::idempotency::SinkGuarantee::AtomicWatermark,
1406 "sink_guarantee must delegate"
1407 );
1408 assert!(wrapped.dedups_by_key(), "dedups_by_key must delegate");
1409 assert_eq!(
1410 wrapped.batch_atomicity(),
1411 crate::dlq::BatchAtomicity::BestEffort
1412 );
1413 }
1414
1415 #[tokio::test]
1416 #[allow(clippy::await_holding_lock)]
1417 async fn records_writes_and_records_counters() {
1418 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1419 let snap = snapshotter();
1420 let inner = MockSink(std::sync::Mutex::new(Vec::new()));
1421 let wrapped = InstrumentedSink::new(&inner, labels());
1422 wrapped
1423 .write_batch(&[json!({"a": 1}), json!({"a": 2})])
1424 .await
1425 .unwrap();
1426 let snapshot = snap.snapshot();
1427 let writes: u64 = snapshot
1428 .into_vec()
1429 .into_iter()
1430 .filter_map(|(key, _u, _d, v)| {
1431 if key.key().name() == "faucet_sink_writes_total"
1432 && let DebugValue::Counter(c) = v
1433 {
1434 return Some(c);
1435 }
1436 None
1437 })
1438 .sum();
1439 assert!(writes >= 1, "expected at least one write counted");
1440 }
1441
1442 #[tokio::test]
1443 #[allow(clippy::await_holding_lock)]
1444 async fn error_increments_errors_total_with_kind() {
1445 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1446 let snap = snapshotter();
1447 let inner = FailingSink;
1448 let wrapped = InstrumentedSink::new(&inner, labels());
1449 let _ = wrapped.write_batch(&[json!({})]).await;
1450 let snapshot = snap.snapshot();
1451 let found = snapshot.into_vec().into_iter().any(|(key, _u, _d, v)| {
1452 key.key().name() == "faucet_sink_errors_total"
1453 && key
1454 .key()
1455 .labels()
1456 .any(|l| l.key() == "kind" && l.value() == "Sink")
1457 && matches!(v, DebugValue::Counter(c) if c >= 1)
1458 });
1459 assert!(found, "expected sink_errors_total with kind=Sink");
1460 }
1461
1462 #[tokio::test]
1463 #[allow(clippy::await_holding_lock)]
1464 async fn instrumented_sink_write_batch_partial_counts_successful_outcomes() {
1465 use crate::traits::RowOutcome;
1466 use metrics_util::debugging::DebugValue;
1467
1468 struct MixedSink;
1470 #[async_trait]
1471 impl Sink for MixedSink {
1472 async fn write_batch(&self, _r: &[Value]) -> Result<usize, FaucetError> {
1473 unreachable!()
1474 }
1475 async fn write_batch_partial(
1476 &self,
1477 _r: &[Value],
1478 ) -> Result<Vec<RowOutcome>, FaucetError> {
1479 Ok(vec![
1480 Ok(()),
1481 Err(FaucetError::Sink("bad row".into())),
1482 Ok(()),
1483 ])
1484 }
1485 fn connector_name(&self) -> &'static str {
1486 "mixed"
1487 }
1488 }
1489
1490 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1491 let snap = snapshotter();
1492
1493 let inner = MixedSink;
1494 let wrapped = InstrumentedSink::new(&inner, labels());
1495 let _ = wrapped
1496 .write_batch_partial(&[json!({}), json!({}), json!({})])
1497 .await
1498 .unwrap();
1499
1500 let snapshot = snap.snapshot();
1508 let records: u64 = snapshot
1509 .into_vec()
1510 .into_iter()
1511 .filter_map(|(k, _u, _d, v): (metrics_util::CompositeKey, _, _, _)| {
1512 if k.key().name() == "faucet_sink_records_total"
1513 && k.key()
1514 .labels()
1515 .any(|l| l.key() == "connector" && l.value() == "mixed")
1516 && let DebugValue::Counter(c) = v
1517 {
1518 Some(c)
1519 } else {
1520 None
1521 }
1522 })
1523 .sum();
1524 assert!(
1525 records >= 2,
1526 "expected faucet_sink_records_total{{connector=mixed}} >= 2, got {records}"
1527 );
1528 }
1529
1530 #[tokio::test]
1533 #[allow(clippy::await_holding_lock)]
1534 async fn flush_error_increments_errors_total_and_propagates() {
1535 struct FlushFailSink;
1538 #[async_trait]
1539 impl Sink for FlushFailSink {
1540 async fn write_batch(&self, r: &[Value]) -> Result<usize, FaucetError> {
1541 Ok(r.len())
1542 }
1543 async fn flush(&self) -> Result<(), FaucetError> {
1544 Err(FaucetError::Sink("flush boom".into()))
1545 }
1546 fn connector_name(&self) -> &'static str {
1547 "flush-fail-sink"
1548 }
1549 }
1550
1551 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1552 let snap = snapshotter();
1553 let inner = FlushFailSink;
1554 let wrapped = InstrumentedSink::new(&inner, labels());
1555 let err = wrapped.flush().await.unwrap_err();
1556 assert!(matches!(&err, FaucetError::Sink(m) if m.contains("flush boom")));
1557
1558 let snapshot = snap.snapshot();
1559 let found = snapshot.into_vec().into_iter().any(|(key, _u, _d, v)| {
1560 key.key().name() == "faucet_sink_errors_total"
1561 && key
1562 .key()
1563 .labels()
1564 .any(|l| l.key() == "connector" && l.value() == "flush-fail-sink")
1565 && key
1566 .key()
1567 .labels()
1568 .any(|l| l.key() == "kind" && l.value() == "Sink")
1569 && matches!(v, DebugValue::Counter(c) if c >= 1)
1570 });
1571 assert!(
1572 found,
1573 "expected sink_errors_total{{connector=flush-fail-sink,kind=Sink}}"
1574 );
1575 }
1576
1577 struct PanickingSink;
1580 #[async_trait]
1581 impl Sink for PanickingSink {
1582 async fn write_batch(&self, _: &[Value]) -> Result<usize, FaucetError> {
1583 panic!("write kaboom")
1584 }
1585 async fn write_batch_partial(
1586 &self,
1587 _: &[Value],
1588 ) -> Result<Vec<crate::traits::RowOutcome>, FaucetError> {
1589 panic!("partial kaboom")
1590 }
1591 async fn flush(&self) -> Result<(), FaucetError> {
1592 panic!("flush kaboom")
1593 }
1594 fn connector_name(&self) -> &'static str {
1595 "panic-sink"
1596 }
1597 }
1598
1599 #[tokio::test]
1600 #[allow(clippy::await_holding_lock)]
1601 async fn write_batch_panic_maps_to_custom_error() {
1602 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1603 let _snap = snapshotter();
1604 let inner = PanickingSink;
1605 let wrapped = InstrumentedSink::new(&inner, labels());
1606 let err = wrapped.write_batch(&[json!({})]).await.unwrap_err();
1607 match err {
1608 FaucetError::Custom(b) => {
1609 assert!(b.to_string().contains("panic in sink: write kaboom"))
1610 }
1611 other => panic!("expected Custom panic error, got {other:?}"),
1612 }
1613 }
1614
1615 #[tokio::test]
1616 #[allow(clippy::await_holding_lock)]
1617 async fn write_batch_partial_panic_maps_to_custom_error() {
1618 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1619 let _snap = snapshotter();
1620 let inner = PanickingSink;
1621 let wrapped = InstrumentedSink::new(&inner, labels());
1622 let err = wrapped.write_batch_partial(&[json!({})]).await.unwrap_err();
1623 match err {
1624 FaucetError::Custom(b) => {
1625 assert!(b.to_string().contains("panic in sink: partial kaboom"))
1626 }
1627 other => panic!("expected Custom panic error, got {other:?}"),
1628 }
1629 }
1630
1631 #[tokio::test]
1632 #[allow(clippy::await_holding_lock)]
1633 async fn flush_panic_maps_to_custom_error() {
1634 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1635 let _snap = snapshotter();
1636 let inner = PanickingSink;
1637 let wrapped = InstrumentedSink::new(&inner, labels());
1638 let err = wrapped.flush().await.unwrap_err();
1639 match err {
1640 FaucetError::Custom(b) => {
1641 assert!(b.to_string().contains("panic in flush: flush kaboom"))
1642 }
1643 other => panic!("expected Custom panic error, got {other:?}"),
1644 }
1645 }
1646 #[tokio::test]
1660 async fn instrumented_sink_forwards_identity_and_local_outputs() {
1661 use crate::local_outputs::{LocalOutput, LocalOutputLog};
1662
1663 struct FileSink {
1664 outputs: LocalOutputLog,
1665 }
1666
1667 #[async_trait]
1668 impl Sink for FileSink {
1669 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1670 Ok(records.len())
1671 }
1672 fn connector_name(&self) -> &'static str {
1673 "jsonl"
1674 }
1675 fn dataset_uri(&self) -> String {
1676 "file:///tmp/out.jsonl".to_string()
1677 }
1678 async fn local_outputs(&self) -> Vec<LocalOutput> {
1679 self.outputs.snapshot()
1680 }
1681 }
1682
1683 let outputs = LocalOutputLog::new();
1684 outputs.record_open("/tmp/out.jsonl", false);
1685 let inner = FileSink { outputs };
1686 let sink = InstrumentedSink::new(&inner, Labels::new("p", "r", "run-1"));
1687
1688 let reported = sink.local_outputs().await;
1689 assert_eq!(
1690 reported.len(),
1691 1,
1692 "InstrumentedSink must not hide the inner sink's files from the GC"
1693 );
1694 assert_eq!(reported[0].path, std::path::PathBuf::from("/tmp/out.jsonl"));
1695 assert!(
1696 !reported[0].pre_existing,
1697 "classification must survive verbatim"
1698 );
1699 assert_eq!(sink.dataset_uri(), "file:///tmp/out.jsonl");
1700 }
1701
1702 fn counters_for(row: &str) -> HashMap<String, u64> {
1703 let mut out = HashMap::new();
1704 for (key, _u, _d, v) in snapshotter().snapshot().into_vec() {
1705 let k = key.key();
1706 if k.labels().any(|l| l.key() == "row" && l.value() == row)
1707 && let DebugValue::Counter(c) = v
1708 {
1709 *out.entry(k.name().to_string()).or_default() += c;
1710 }
1711 }
1712 out
1713 }
1714
1715 #[tokio::test]
1716 async fn instrumented_sink_forwards_every_hook() {
1717 let probe = crate::forwarding::HookSink::default();
1718 let wrapped = InstrumentedSink::new(&probe, labels());
1719 crate::forwarding::assert_sink_forwards_every_hook(&wrapped, &probe, &[]).await;
1720 }
1721
1722 #[allow(clippy::await_holding_lock)]
1723 #[tokio::test]
1724 async fn idempotent_write_is_counted_metered_and_panic_guarded() {
1725 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1726 let _snap = snapshotter();
1727 let probe = crate::forwarding::HookSink::default();
1728 let meter = Arc::new(UsageMeter::new());
1729 let wrapped = InstrumentedSink::new(&probe, Labels::new("p", "eo-row", "rid"))
1730 .with_meter(Arc::clone(&meter));
1731 let n = wrapped
1732 .write_batch_idempotent(&[json!({"a": 1}), json!({"a": 2})], "s", "t")
1733 .await
1734 .unwrap();
1735 assert_eq!(n, 2);
1736 let c = counters_for("eo-row");
1737 assert_eq!(c["faucet_sink_writes_total"], 1);
1738 assert_eq!(c["faucet_sink_records_total"], 2);
1739 assert_eq!(meter.snapshot().records_written, 2);
1740
1741 struct EoPanics;
1742 #[async_trait]
1743 impl Sink for EoPanics {
1744 async fn write_batch(&self, _: &[Value]) -> Result<usize, FaucetError> {
1745 Ok(0)
1746 }
1747 async fn write_batch_idempotent(
1748 &self,
1749 _: &[Value],
1750 _: &str,
1751 _: &str,
1752 ) -> Result<usize, FaucetError> {
1753 panic!("eo kaboom")
1754 }
1755 }
1756 let wrapped = InstrumentedSink::new(&EoPanics, Labels::new("p", "eo-panic", "rid"));
1757 let err = wrapped
1758 .write_batch_idempotent(&[json!({})], "s", "t")
1759 .await
1760 .unwrap_err();
1761 assert!(
1762 err.to_string().contains("panic in sink: eo kaboom"),
1763 "{err}"
1764 );
1765 assert_eq!(counters_for("eo-panic")["faucet_sink_errors_total"], 1);
1766
1767 struct EoFails;
1768 #[async_trait]
1769 impl Sink for EoFails {
1770 async fn write_batch(&self, _: &[Value]) -> Result<usize, FaucetError> {
1771 Ok(0)
1772 }
1773 async fn write_batch_idempotent(
1774 &self,
1775 _: &[Value],
1776 _: &str,
1777 _: &str,
1778 ) -> Result<usize, FaucetError> {
1779 Err(FaucetError::Sink("nope".into()))
1780 }
1781 }
1782 let wrapped = InstrumentedSink::new(&EoFails, Labels::new("p", "eo-err", "rid"));
1783 assert!(wrapped.write_batch_idempotent(&[], "s", "t").await.is_err());
1784 assert_eq!(counters_for("eo-err")["faucet_sink_errors_total"], 1);
1785 }
1786
1787 #[tokio::test]
1788 async fn native_loads_are_metered_for_buffered_and_streamed_payloads() {
1789 use crate::native::{NativeBatch, NativeFormat, NativeLoadContext, NativePayload};
1790 let probe = crate::forwarding::HookSink::default();
1791 let meter = Arc::new(UsageMeter::new());
1792 let wrapped = InstrumentedSink::new(&probe, labels()).with_meter(Arc::clone(&meter));
1793 let ctx = || NativeLoadContext {
1794 write_mode: crate::write_mode::WriteMode::Append,
1795 first_batch: true,
1796 };
1797 let batch = |payload| NativeBatch {
1798 format: NativeFormat::NdJson,
1799 payload,
1800 csv: Default::default(),
1801 records: Some(2),
1802 bookmark: None,
1803 };
1804 wrapped
1805 .load_native(batch(NativePayload::Bytes(vec![0; 10])), "s", ctx())
1806 .await
1807 .unwrap();
1808 let stream: Pin<Box<dyn Stream<Item = Result<Vec<u8>, FaucetError>> + Send>> =
1809 Box::pin(futures::stream::iter(vec![Ok(vec![0; 3]), Ok(vec![0; 4])]));
1810 struct Drains;
1811 #[async_trait]
1812 impl Sink for Drains {
1813 async fn write_batch(&self, _: &[Value]) -> Result<usize, FaucetError> {
1814 Ok(0)
1815 }
1816 async fn load_native(
1817 &self,
1818 batch: NativeBatch,
1819 _: &str,
1820 _: NativeLoadContext,
1821 ) -> Result<usize, FaucetError> {
1822 use futures::StreamExt;
1823 if let NativePayload::Stream(mut s) = batch.payload {
1824 while s.next().await.is_some() {}
1825 }
1826 Ok(5)
1827 }
1828 }
1829 let wrapped2 = InstrumentedSink::new(&Drains, labels()).with_meter(Arc::clone(&meter));
1830 wrapped2
1831 .load_native(batch(NativePayload::Stream(stream)), "s", ctx())
1832 .await
1833 .unwrap();
1834 let snap = meter.snapshot();
1835 assert_eq!(snap.records_written, 7 + 5);
1836 assert_eq!(snap.bytes_written, 10 + 7);
1837 }
1838
1839 #[tokio::test]
1840 async fn native_source_batches_are_metered() {
1841 use crate::native::{NativeBatch, NativeFormat, NativePayload};
1842 struct Native;
1843 #[async_trait]
1844 impl Source for Native {
1845 async fn fetch_with_context(
1846 &self,
1847 _: &HashMap<String, Value>,
1848 ) -> Result<Vec<Value>, FaucetError> {
1849 Ok(vec![])
1850 }
1851 fn stream_native<'b>(
1852 &'b self,
1853 _: &'b HashMap<String, Value>,
1854 _: NativeFormat,
1855 _: usize,
1856 ) -> Pin<Box<dyn Stream<Item = Result<NativeBatch, FaucetError>> + Send + 'b>>
1857 {
1858 let b = NativeBatch {
1859 format: NativeFormat::NdJson,
1860 payload: NativePayload::Bytes(vec![1; 6]),
1861 csv: Default::default(),
1862 records: Some(3),
1863 bookmark: None,
1864 };
1865 Box::pin(futures::stream::iter(vec![Ok(b)]))
1866 }
1867 }
1868 use futures::StreamExt;
1869 let meter = Arc::new(UsageMeter::new());
1870 let wrapped = InstrumentedSource::new(&Native, labels()).with_meter(Arc::clone(&meter));
1871 let ctx = HashMap::new();
1872 let got: Vec<_> = wrapped
1873 .stream_native(&ctx, NativeFormat::NdJson, 10)
1874 .collect()
1875 .await;
1876 assert_eq!(got.len(), 1);
1877 let snap = meter.snapshot();
1878 assert_eq!((snap.records_read, snap.bytes_read), (3, 6));
1879 let unmetered = InstrumentedSource::new(&Native, labels());
1880 assert_eq!(
1881 unmetered
1882 .stream_native(&ctx, NativeFormat::NdJson, 10)
1883 .count()
1884 .await,
1885 1
1886 );
1887 }
1888
1889 #[cfg(feature = "arrow")]
1890 #[tokio::test]
1891 async fn columnar_batches_are_metered_on_both_sides() {
1892 use crate::columnar::ColumnarPage;
1893 use arrow::array::{Int64Array, RecordBatch};
1894 use arrow::datatypes::{DataType, Field, Schema};
1895 fn batch() -> RecordBatch {
1896 RecordBatch::try_new(
1897 Arc::new(Schema::new(vec![Field::new("id", DataType::Int64, false)])),
1898 vec![Arc::new(Int64Array::from(vec![1, 2, 3]))],
1899 )
1900 .unwrap()
1901 }
1902 struct Cols;
1903 #[async_trait]
1904 impl Source for Cols {
1905 async fn fetch_with_context(
1906 &self,
1907 _: &HashMap<String, Value>,
1908 ) -> Result<Vec<Value>, FaucetError> {
1909 Ok(vec![])
1910 }
1911 fn stream_batches<'b>(
1912 &'b self,
1913 _: &'b HashMap<String, Value>,
1914 _: usize,
1915 ) -> Pin<Box<dyn Stream<Item = Result<ColumnarPage, FaucetError>> + Send + 'b>>
1916 {
1917 Box::pin(futures::stream::iter(vec![Ok(ColumnarPage {
1918 batch: batch(),
1919 bookmark: None,
1920 })]))
1921 }
1922 }
1923 use futures::StreamExt;
1924 let meter = Arc::new(UsageMeter::new());
1925 let src = InstrumentedSource::new(&Cols, labels()).with_meter(Arc::clone(&meter));
1926 let ctx = HashMap::new();
1927 assert_eq!(src.stream_batches(&ctx, 10).count().await, 1);
1928 let unmetered = InstrumentedSource::new(&Cols, labels());
1929 assert_eq!(unmetered.stream_batches(&ctx, 10).count().await, 1);
1930
1931 let probe = crate::forwarding::HookSink::default();
1932 let sink = InstrumentedSink::new(&probe, labels()).with_meter(Arc::clone(&meter));
1933 assert_eq!(sink.write_batch_columnar(&batch()).await.unwrap(), 3);
1934 let snap = meter.snapshot();
1935 assert_eq!((snap.records_read, snap.records_written), (3, 3));
1936 assert!(snap.bytes_read > 0);
1937 assert_eq!(snap.bytes_read, snap.bytes_written);
1938 }
1939
1940 fn panic_errors_for(row: &str, metric: &str) -> u64 {
1943 snapshotter()
1944 .snapshot()
1945 .into_vec()
1946 .into_iter()
1947 .filter_map(|(key, _u, _d, v)| {
1948 let k = key.key();
1949 let hit = k.name() == metric
1950 && k.labels().any(|l| l.key() == "row" && l.value() == row)
1951 && k.labels()
1952 .any(|l| l.key() == "kind" && l.value() == "Panic");
1953 match (hit, v) {
1954 (true, DebugValue::Counter(c)) => Some(c),
1955 _ => None,
1956 }
1957 })
1958 .sum()
1959 }
1960
1961 fn assert_panic_error(result: Result<usize, FaucetError>, what: &str, msg: &str) {
1962 match result {
1963 Err(FaucetError::Custom(b)) => {
1964 assert_eq!(b.to_string(), format!("panic in {what}: {msg}"))
1965 }
1966 other => panic!("expected a panic error, got {other:?}"),
1967 }
1968 }
1969
1970 struct PanicsOnFastPaths;
1971 #[async_trait]
1972 impl Sink for PanicsOnFastPaths {
1973 async fn write_batch(&self, _: &[Value]) -> Result<usize, FaucetError> {
1974 Ok(0)
1975 }
1976 #[cfg(feature = "arrow")]
1977 async fn write_batch_columnar(
1978 &self,
1979 _batch: &arrow::array::RecordBatch,
1980 ) -> Result<usize, FaucetError> {
1981 panic!("columnar kaboom")
1982 }
1983 async fn load_native(
1984 &self,
1985 _batch: crate::native::NativeBatch,
1986 _scope: &str,
1987 _ctx: crate::native::NativeLoadContext,
1988 ) -> Result<usize, FaucetError> {
1989 panic!("native kaboom")
1990 }
1991 }
1992
1993 fn native_batch() -> crate::native::NativeBatch {
1994 crate::native::NativeBatch {
1995 format: crate::native::NativeFormat::NdJson,
1996 payload: crate::native::NativePayload::Bytes(b"{}\n".to_vec()),
1997 csv: crate::native::CsvDialect::default(),
1998 records: Some(1),
1999 bookmark: None,
2000 }
2001 }
2002
2003 fn load_ctx() -> crate::native::NativeLoadContext {
2004 crate::native::NativeLoadContext {
2005 write_mode: crate::write_mode::WriteMode::Append,
2006 first_batch: true,
2007 }
2008 }
2009
2010 #[allow(clippy::await_holding_lock)]
2011 #[tokio::test]
2012 async fn native_load_panic_is_a_typed_error_metered_or_not() {
2013 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
2014 let _snap = snapshotter();
2015 let plain =
2016 InstrumentedSink::new(&PanicsOnFastPaths, Labels::new("p", "native-panic", "rid"));
2017 let r = plain.load_native(native_batch(), "s", load_ctx()).await;
2018 assert_panic_error(r, "sink", "native kaboom");
2019 let metered =
2020 InstrumentedSink::new(&PanicsOnFastPaths, Labels::new("p", "native-panic", "rid"))
2021 .with_meter(Arc::new(UsageMeter::new()));
2022 let r = metered.load_native(native_batch(), "s", load_ctx()).await;
2023 assert_panic_error(r, "sink", "native kaboom");
2024 assert_eq!(
2025 panic_errors_for("native-panic", "faucet_sink_errors_total"),
2026 2
2027 );
2028 }
2029
2030 #[cfg(feature = "arrow")]
2031 fn one_row() -> arrow::array::RecordBatch {
2032 let schema = Arc::new(arrow::datatypes::Schema::new(vec![
2033 arrow::datatypes::Field::new("id", arrow::datatypes::DataType::Int64, false),
2034 ]));
2035 arrow::array::RecordBatch::try_new(
2036 schema,
2037 vec![Arc::new(arrow::array::Int64Array::from(vec![1]))],
2038 )
2039 .unwrap()
2040 }
2041
2042 #[cfg(feature = "arrow")]
2043 #[allow(clippy::await_holding_lock)]
2044 #[tokio::test]
2045 async fn columnar_write_panic_is_a_typed_error() {
2046 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
2047 let _snap = snapshotter();
2048 let wrapped =
2049 InstrumentedSink::new(&PanicsOnFastPaths, Labels::new("p", "col-panic", "rid"))
2050 .with_meter(Arc::new(UsageMeter::new()));
2051 let r = wrapped.write_batch_columnar(&one_row()).await;
2052 assert_panic_error(r, "sink", "columnar kaboom");
2053 assert_eq!(panic_errors_for("col-panic", "faucet_sink_errors_total"), 1);
2054 }
2055
2056 struct PanickyStreams;
2059 #[async_trait]
2060 impl Source for PanickyStreams {
2061 async fn fetch_with_context(
2062 &self,
2063 _: &HashMap<String, Value>,
2064 ) -> Result<Vec<Value>, FaucetError> {
2065 Ok(vec![])
2066 }
2067 #[cfg(feature = "arrow")]
2068 fn stream_batches<'a>(
2069 &'a self,
2070 _context: &'a HashMap<String, Value>,
2071 _batch_size: usize,
2072 ) -> Pin<
2073 Box<dyn Stream<Item = Result<crate::columnar::ColumnarPage, FaucetError>> + Send + 'a>,
2074 > {
2075 let mut polled = 0;
2076 Box::pin(futures::stream::poll_fn(move |_| {
2077 polled += 1;
2078 if polled == 1 {
2079 std::task::Poll::Ready(Some(Ok(crate::columnar::ColumnarPage {
2080 batch: one_row(),
2081 bookmark: None,
2082 })))
2083 } else {
2084 panic!("batches kaboom")
2085 }
2086 }))
2087 }
2088 fn stream_native<'a>(
2089 &'a self,
2090 _context: &'a HashMap<String, Value>,
2091 _format: crate::native::NativeFormat,
2092 _batch_size: usize,
2093 ) -> Pin<Box<dyn Stream<Item = Result<crate::native::NativeBatch, FaucetError>> + Send + 'a>>
2094 {
2095 panic!("open kaboom")
2096 }
2097 }
2098
2099 struct PanicsMidNative;
2101 #[async_trait]
2102 impl Source for PanicsMidNative {
2103 async fn fetch_with_context(
2104 &self,
2105 _: &HashMap<String, Value>,
2106 ) -> Result<Vec<Value>, FaucetError> {
2107 Ok(vec![])
2108 }
2109 fn stream_native<'a>(
2110 &'a self,
2111 _context: &'a HashMap<String, Value>,
2112 _format: crate::native::NativeFormat,
2113 _batch_size: usize,
2114 ) -> Pin<Box<dyn Stream<Item = Result<crate::native::NativeBatch, FaucetError>> + Send + 'a>>
2115 {
2116 let mut polled = 0;
2117 Box::pin(futures::stream::poll_fn(move |_| {
2118 polled += 1;
2119 if polled == 1 {
2120 std::task::Poll::Ready(Some(Ok(native_batch())))
2121 } else {
2122 panic!("mid kaboom")
2123 }
2124 }))
2125 }
2126 }
2127
2128 fn panic_text(item: Option<Result<impl std::fmt::Debug, FaucetError>>) -> String {
2129 match item {
2130 Some(Err(FaucetError::Custom(b))) => b.to_string(),
2131 other => panic!("expected a panic error item, got {other:?}"),
2132 }
2133 }
2134
2135 #[allow(clippy::await_holding_lock)]
2136 #[tokio::test]
2137 async fn native_stream_panics_become_one_error_item_that_ends_the_stream() {
2138 use futures::StreamExt;
2139 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
2140 let _snap = snapshotter();
2141 let ctx = HashMap::new();
2142 let fmt = crate::native::NativeFormat::NdJson;
2143
2144 let opening =
2145 InstrumentedSource::new(&PanickyStreams, Labels::new("p", "src-panic", "rid"));
2146 let mut s = opening.stream_native(&ctx, fmt, 10);
2147 assert_eq!(panic_text(s.next().await), "panic in source: open kaboom");
2148 assert!(s.next().await.is_none(), "the stream ends after the panic");
2149
2150 let mid = InstrumentedSource::new(&PanicsMidNative, Labels::new("p", "src-panic", "rid"))
2151 .with_meter(Arc::new(UsageMeter::new()));
2152 let mut s = mid.stream_native(&ctx, fmt, 10);
2153 assert!(matches!(s.next().await, Some(Ok(_))));
2154 assert_eq!(panic_text(s.next().await), "panic in source: mid kaboom");
2155 assert!(s.next().await.is_none());
2156 assert_eq!(
2157 panic_errors_for("src-panic", "faucet_source_errors_total"),
2158 2
2159 );
2160 }
2161
2162 #[cfg(feature = "arrow")]
2163 #[allow(clippy::await_holding_lock)]
2164 #[tokio::test]
2165 async fn columnar_stream_panic_becomes_one_error_item() {
2166 use futures::StreamExt;
2167 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
2168 let _snap = snapshotter();
2169 let ctx = HashMap::new();
2170 for meter in [None, Some(Arc::new(UsageMeter::new()))] {
2171 let mut wrapped =
2172 InstrumentedSource::new(&PanickyStreams, Labels::new("p", "batches-panic", "rid"));
2173 if let Some(m) = meter {
2174 wrapped = wrapped.with_meter(m);
2175 }
2176 let mut s = wrapped.stream_batches(&ctx, 10);
2177 assert!(matches!(s.next().await, Some(Ok(_))));
2178 assert_eq!(
2179 panic_text(s.next().await),
2180 "panic in source: batches kaboom"
2181 );
2182 assert!(s.next().await.is_none());
2183 }
2184 assert_eq!(
2185 panic_errors_for("batches-panic", "faucet_source_errors_total"),
2186 2
2187 );
2188 }
2189}