1use crate::error::FaucetError;
5use crate::observability::labels::Labels;
6use crate::observability::timer::DurationGuard;
7use crate::pipeline::StreamPage;
8use crate::traits::{Sink, Source};
9use async_trait::async_trait;
10use futures::FutureExt;
11use futures_core::Stream;
12use metrics::{Label, SharedString, counter, gauge};
13use serde_json::Value;
14use std::collections::HashMap;
15use std::panic::AssertUnwindSafe;
16use std::pin::Pin;
17use std::sync::Arc;
18use std::sync::atomic::{AtomicUsize, Ordering};
19use tracing::{Instrument, info_span};
20
21fn guarded_connector_name(raw: &'static str) -> &'static str {
25 if raw.is_empty() { "unknown" } else { raw }
26}
27
28fn base_metric_labels(labels: &Labels, connector: &SharedString) -> Vec<Label> {
33 vec![
34 Label::new("pipeline", SharedString::from(labels.pipeline.to_string())),
35 Label::new("row", SharedString::from(labels.row.to_string())),
36 Label::new("connector", connector.clone()),
37 ]
38}
39
40pub struct InstrumentedSource<'a, S: Source + ?Sized> {
44 inner: &'a S,
45 labels: Labels,
46 connector: SharedString,
47 base_labels: Vec<Label>,
49 page_index: Arc<AtomicUsize>,
50}
51
52impl<'a, S: Source + ?Sized> InstrumentedSource<'a, S> {
53 pub fn new(inner: &'a S, labels: Labels) -> Self {
54 let raw = inner.connector_name();
55 debug_assert!(
56 !raw.is_empty(),
57 "connector_name() must return a non-empty string"
58 );
59 let connector: SharedString = SharedString::const_str(guarded_connector_name(raw));
60 let base_labels = base_metric_labels(&labels, &connector);
61 Self {
62 inner,
63 labels,
64 connector,
65 base_labels,
66 page_index: Arc::new(AtomicUsize::new(0)),
67 }
68 }
69
70 fn metric_labels(&self) -> Vec<Label> {
71 self.base_labels.clone()
72 }
73
74 #[allow(dead_code)]
78 fn error_labels(&self, kind: &'static str) -> Vec<Label> {
79 let mut l = self.metric_labels();
80 l.push(Label::new("kind", SharedString::const_str(kind)));
81 l
82 }
83}
84
85#[async_trait]
86impl<'a, S: Source + ?Sized> Source for InstrumentedSource<'a, S> {
87 fn connector_name(&self) -> &'static str {
88 guarded_connector_name(self.inner.connector_name())
92 }
93
94 fn state_key(&self) -> Option<String> {
95 self.inner.state_key()
96 }
97
98 async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
99 self.inner.apply_start_bookmark(bookmark).await
100 }
101
102 fn supports_exactly_once(&self) -> bool {
103 self.inner.supports_exactly_once()
104 }
105
106 fn replay_guarantee(&self) -> crate::idempotency::ReplayGuarantee {
107 self.inner.replay_guarantee()
108 }
109
110 async fn capture_resume_position(&self) -> Result<Option<Value>, FaucetError> {
111 self.inner.capture_resume_position().await
112 }
113
114 async fn fetch_with_context(
115 &self,
116 context: &HashMap<String, Value>,
117 ) -> Result<Vec<Value>, FaucetError> {
118 self.inner.fetch_with_context(context).await
120 }
121
122 async fn fetch_with_context_incremental(
123 &self,
124 context: &HashMap<String, Value>,
125 ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
126 self.inner.fetch_with_context_incremental(context).await
127 }
128
129 #[cfg(feature = "arrow")]
133 fn supports_columnar(&self) -> bool {
134 self.inner.supports_columnar()
135 }
136
137 #[cfg(feature = "arrow")]
138 fn stream_batches<'b>(
139 &'b self,
140 context: &'b HashMap<String, Value>,
141 batch_size: usize,
142 ) -> Pin<Box<dyn Stream<Item = Result<crate::columnar::ColumnarPage, FaucetError>> + Send + 'b>>
143 {
144 self.inner.stream_batches(context, batch_size)
145 }
146
147 fn stream_pages<'b>(
148 &'b self,
149 context: &'b HashMap<String, Value>,
150 batch_size: usize,
151 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'b>> {
152 let inner_stream = self.inner.stream_pages(context, batch_size);
153 let labels = self.labels.clone();
154 let connector = self.connector.clone();
155 let page_index = Arc::clone(&self.page_index);
156 let metric_labels = self.metric_labels();
157 let pipeline = self.labels.pipeline.clone();
158 let row = self.labels.row.clone();
159
160 Box::pin(async_stream::try_stream! {
161 struct InFlightGuard(Vec<Label>);
164 impl Drop for InFlightGuard {
165 fn drop(&mut self) {
166 gauge!("faucet_source_in_flight", self.0.clone()).decrement(1.0);
167 }
168 }
169 gauge!("faucet_source_in_flight", metric_labels.clone()).increment(1.0);
170 let _in_flight = InFlightGuard(metric_labels.clone());
171
172 let mut inner = inner_stream;
173 loop {
174 let idx = page_index.fetch_add(1, Ordering::Relaxed);
175 let span = info_span!(
176 "faucet.source.page",
177 pipeline = %pipeline,
178 row = %row,
179 run_id = %labels.run_id,
180 connector = %connector,
181 page_index = idx,
182 );
183 let mut _timer = DurationGuard::new(
188 "faucet_source_page_duration_seconds",
189 metric_labels.clone(),
190 );
191
192 let next = AssertUnwindSafe(async {
193 use futures::StreamExt;
194 inner.next().await
195 })
196 .catch_unwind()
197 .instrument(span)
198 .await;
199
200 match next {
201 Ok(Some(Ok(page))) => {
202 counter!("faucet_source_pages_total", metric_labels.clone()).increment(1);
203 counter!("faucet_source_records_total", metric_labels.clone())
204 .increment(page.records.len() as u64);
205 _timer.record_now();
211 yield page;
212 }
213 Ok(Some(Err(e))) => {
214 let mut l = metric_labels.clone();
215 l.push(Label::new("kind", SharedString::const_str(error_kind(&e))));
216 counter!("faucet_source_errors_total", l).increment(1);
217 Err(e)?;
218 }
219 Ok(None) => {
220 _timer.disarm();
221 break;
222 }
223 Err(panic) => {
224 let mut l = metric_labels.clone();
225 l.push(Label::new("kind", SharedString::const_str("Panic")));
226 counter!("faucet_source_errors_total", l).increment(1);
227 let msg = panic.downcast_ref::<&'static str>().map(|s| (*s).to_string())
228 .or_else(|| panic.downcast_ref::<String>().cloned())
229 .unwrap_or_else(|| "<non-string panic payload>".to_string());
230 Err(FaucetError::Custom(format!("panic in source: {msg}").into()))?;
231 }
232 }
233 }
234 })
235 }
236}
237
238pub(crate) fn error_kind(e: &FaucetError) -> &'static str {
241 match e {
242 FaucetError::Http(_) => "Http",
243 FaucetError::HttpStatus { .. } => "HttpStatus",
244 FaucetError::Json(_) => "Json",
245 FaucetError::JsonPath(_) => "JsonPath",
246 FaucetError::Auth(_) => "Auth",
247 FaucetError::RateLimited { .. } => "RateLimited",
248 FaucetError::Url(_) => "Url",
249 FaucetError::Transform(_) => "Transform",
250 FaucetError::Config(_) => "Config",
251 FaucetError::Source(_) => "Source",
252 FaucetError::Sink(_) => "Sink",
253 FaucetError::QualityFailure { .. } => "QualityFailure",
254 FaucetError::SchemaDrift { .. } => "SchemaDrift",
255 FaucetError::ContractViolation { .. } => "ContractViolation",
256 FaucetError::State(_) => "State",
257 FaucetError::CircuitOpen { .. } => "CircuitOpen",
258 FaucetError::Custom(_) => "Custom",
259 }
260}
261
262pub struct InstrumentedSink<'a, S: Sink + ?Sized> {
265 inner: &'a S,
266 labels: Labels,
267 connector: SharedString,
268 base_labels: Vec<Label>,
270}
271
272impl<'a, S: Sink + ?Sized> InstrumentedSink<'a, S> {
273 pub fn new(inner: &'a S, labels: Labels) -> Self {
274 let raw = inner.connector_name();
275 debug_assert!(
276 !raw.is_empty(),
277 "connector_name() must return a non-empty string"
278 );
279 let connector: SharedString = SharedString::const_str(guarded_connector_name(raw));
280 let base_labels = base_metric_labels(&labels, &connector);
281 Self {
282 inner,
283 labels,
284 connector,
285 base_labels,
286 }
287 }
288
289 fn metric_labels(&self) -> Vec<Label> {
290 self.base_labels.clone()
291 }
292
293 fn error_labels(&self, kind: &'static str) -> Vec<Label> {
294 let mut l = self.metric_labels();
295 l.push(Label::new("kind", SharedString::const_str(kind)));
296 l
297 }
298}
299
300#[async_trait]
301impl<'a, S: Sink + ?Sized> Sink for InstrumentedSink<'a, S> {
302 fn connector_name(&self) -> &'static str {
303 guarded_connector_name(self.inner.connector_name())
307 }
308
309 #[cfg(feature = "arrow")]
312 fn supports_columnar(&self) -> bool {
313 self.inner.supports_columnar()
314 }
315
316 #[cfg(feature = "arrow")]
317 async fn write_batch_columnar(
318 &self,
319 batch: &arrow::array::RecordBatch,
320 ) -> Result<usize, FaucetError> {
321 self.inner.write_batch_columnar(batch).await
322 }
323
324 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
325 let span = info_span!(
326 "faucet.sink.write",
327 pipeline = %self.labels.pipeline,
328 row = %self.labels.row,
329 run_id = %self.labels.run_id,
330 connector = %self.connector,
331 records = records.len(),
332 );
333 let metric_labels = self.metric_labels();
334 gauge!("faucet_sink_in_flight", metric_labels.clone()).increment(1.0);
335
336 struct InFlightGuard(Vec<Label>);
339 impl Drop for InFlightGuard {
340 fn drop(&mut self) {
341 gauge!("faucet_sink_in_flight", self.0.clone()).decrement(1.0);
342 }
343 }
344 let _in_flight = InFlightGuard(metric_labels.clone());
345
346 let _timer =
347 DurationGuard::new("faucet_sink_write_duration_seconds", metric_labels.clone());
348
349 let result = AssertUnwindSafe(self.inner.write_batch(records))
350 .catch_unwind()
351 .instrument(span)
352 .await;
353
354 match result {
355 Ok(Ok(n)) => {
356 counter!("faucet_sink_writes_total", metric_labels.clone()).increment(1);
357 counter!("faucet_sink_records_total", metric_labels.clone()).increment(n as u64);
358 Ok(n)
359 }
360 Ok(Err(e)) => {
361 counter!(
362 "faucet_sink_errors_total",
363 self.error_labels(error_kind(&e))
364 )
365 .increment(1);
366 Err(e)
367 }
368 Err(panic) => {
369 counter!("faucet_sink_errors_total", self.error_labels("Panic")).increment(1);
370 let msg = panic
371 .downcast_ref::<&'static str>()
372 .map(|s| (*s).to_string())
373 .or_else(|| panic.downcast_ref::<String>().cloned())
374 .unwrap_or_else(|| "<non-string panic payload>".to_string());
375 Err(FaucetError::Custom(format!("panic in sink: {msg}").into()))
376 }
377 }
378 }
379
380 async fn write_batch_partial(
381 &self,
382 records: &[Value],
383 ) -> Result<Vec<crate::traits::RowOutcome>, FaucetError> {
384 let span = info_span!(
385 "faucet.sink.write_partial",
386 pipeline = %self.labels.pipeline,
387 row = %self.labels.row,
388 run_id = %self.labels.run_id,
389 connector = %self.connector,
390 records = records.len(),
391 );
392 let metric_labels = self.metric_labels();
393 gauge!("faucet_sink_in_flight", metric_labels.clone()).increment(1.0);
394
395 struct InFlightGuard(Vec<Label>);
398 impl Drop for InFlightGuard {
399 fn drop(&mut self) {
400 gauge!("faucet_sink_in_flight", self.0.clone()).decrement(1.0);
401 }
402 }
403 let _in_flight = InFlightGuard(metric_labels.clone());
404
405 let _timer =
406 DurationGuard::new("faucet_sink_write_duration_seconds", metric_labels.clone());
407
408 let result = AssertUnwindSafe(self.inner.write_batch_partial(records))
409 .catch_unwind()
410 .instrument(span)
411 .await;
412
413 match result {
414 Ok(Ok(outcomes)) => {
415 let success_count = outcomes.iter().filter(|o| o.is_ok()).count();
416 counter!("faucet_sink_writes_total", metric_labels.clone()).increment(1);
417 counter!("faucet_sink_records_total", metric_labels.clone())
418 .increment(success_count as u64);
419 Ok(outcomes)
420 }
421 Ok(Err(e)) => {
422 counter!(
423 "faucet_sink_errors_total",
424 self.error_labels(error_kind(&e))
425 )
426 .increment(1);
427 Err(e)
428 }
429 Err(panic) => {
430 counter!("faucet_sink_errors_total", self.error_labels("Panic")).increment(1);
431 let msg = panic
432 .downcast_ref::<&'static str>()
433 .map(|s| (*s).to_string())
434 .or_else(|| panic.downcast_ref::<String>().cloned())
435 .unwrap_or_else(|| "<non-string panic payload>".to_string());
436 Err(FaucetError::Custom(format!("panic in sink: {msg}").into()))
437 }
438 }
439 }
440
441 async fn flush(&self) -> Result<(), FaucetError> {
442 let span = info_span!(
443 "faucet.sink.flush",
444 pipeline = %self.labels.pipeline,
445 row = %self.labels.row,
446 run_id = %self.labels.run_id,
447 connector = %self.connector,
448 );
449 let metric_labels = self.metric_labels();
450 let _timer =
451 DurationGuard::new("faucet_sink_flush_duration_seconds", metric_labels.clone());
452
453 let result = AssertUnwindSafe(self.inner.flush())
454 .catch_unwind()
455 .instrument(span)
456 .await;
457
458 match result {
459 Ok(Ok(())) => Ok(()),
460 Ok(Err(e)) => {
461 counter!(
462 "faucet_sink_errors_total",
463 self.error_labels(error_kind(&e))
464 )
465 .increment(1);
466 Err(e)
467 }
468 Err(panic) => {
469 counter!("faucet_sink_errors_total", self.error_labels("Panic")).increment(1);
470 let msg = panic
471 .downcast_ref::<&'static str>()
472 .map(|s| (*s).to_string())
473 .or_else(|| panic.downcast_ref::<String>().cloned())
474 .unwrap_or_else(|| "<non-string panic payload>".to_string());
475 Err(FaucetError::Custom(format!("panic in flush: {msg}").into()))
476 }
477 }
478 }
479
480 async fn current_schema(&self) -> Result<Option<Value>, FaucetError> {
488 self.inner.current_schema().await
489 }
490
491 fn supports_schema_evolution(&self) -> bool {
492 self.inner.supports_schema_evolution()
493 }
494
495 async fn evolve_schema(
496 &self,
497 evolution: &crate::drift::SchemaEvolution,
498 ) -> Result<(), FaucetError> {
499 self.inner.evolve_schema(evolution).await
500 }
501
502 fn supported_write_modes(&self) -> &'static [crate::write_mode::WriteMode] {
503 self.inner.supported_write_modes()
504 }
505
506 fn supports_idempotent_writes(&self) -> bool {
507 self.inner.supports_idempotent_writes()
508 }
509
510 fn sink_guarantee(&self) -> crate::idempotency::SinkGuarantee {
511 self.inner.sink_guarantee()
512 }
513
514 fn dedups_by_key(&self) -> bool {
515 self.inner.dedups_by_key()
516 }
517
518 async fn write_batch_idempotent(
519 &self,
520 records: &[Value],
521 scope: &str,
522 token: &str,
523 ) -> Result<usize, FaucetError> {
524 self.inner
525 .write_batch_idempotent(records, scope, token)
526 .await
527 }
528
529 async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
530 self.inner.last_committed_token(scope).await
531 }
532}
533
534#[cfg(test)]
535pub(crate) mod source_tests {
536 use super::*;
537 use async_trait::async_trait;
538 use futures::StreamExt;
539 use metrics_util::debugging::{DebugValue, DebuggingRecorder, Snapshotter};
540 use serde_json::json;
541 use std::sync::{Mutex, OnceLock};
542
543 pub(crate) static LOCK: Mutex<()> = Mutex::new(());
546 static SNAPSHOTTER: OnceLock<Snapshotter> = OnceLock::new();
547
548 pub(crate) fn snapshotter() -> &'static Snapshotter {
549 SNAPSHOTTER.get_or_init(|| {
550 let recorder = DebuggingRecorder::new();
551 let snap = recorder.snapshotter();
552 let _ = metrics::set_global_recorder(recorder);
560 snap
561 })
562 }
563
564 pub(in crate::observability) fn labels() -> Labels {
565 Labels::new("p", "r", "rid")
566 }
567
568 struct MockSource(Vec<Value>);
569 #[async_trait]
570 impl Source for MockSource {
571 async fn fetch_with_context(
572 &self,
573 _: &HashMap<String, Value>,
574 ) -> Result<Vec<Value>, FaucetError> {
575 Ok(self.0.clone())
576 }
577 fn connector_name(&self) -> &'static str {
578 "mock"
579 }
580 }
581
582 struct PanickingSource;
583 #[async_trait]
584 impl Source for PanickingSource {
585 async fn fetch_with_context(
586 &self,
587 _: &HashMap<String, Value>,
588 ) -> Result<Vec<Value>, FaucetError> {
589 panic!("kaboom")
590 }
591 fn connector_name(&self) -> &'static str {
592 "panic-test"
593 }
594 }
595
596 struct EmptyNameSource;
600 #[async_trait]
601 impl Source for EmptyNameSource {
602 async fn fetch_with_context(
603 &self,
604 _: &HashMap<String, Value>,
605 ) -> Result<Vec<Value>, FaucetError> {
606 Ok(vec![])
607 }
608 fn connector_name(&self) -> &'static str {
609 ""
610 }
611 }
612
613 #[test]
614 fn empty_inner_connector_name_falls_back_to_unknown() {
615 let inner = EmptyNameSource;
616 let wrapped = InstrumentedSource {
620 inner: &inner,
621 labels: labels(),
622 connector: SharedString::const_str("unknown"),
623 base_labels: Vec::new(),
624 page_index: Arc::new(AtomicUsize::new(0)),
625 };
626 assert_eq!(
627 Source::connector_name(&wrapped),
628 "unknown",
629 "instrumented source must not leak an empty connector name"
630 );
631 }
632
633 #[tokio::test]
634 #[allow(clippy::await_holding_lock)]
635 async fn records_records_counter_per_page() {
636 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
637 let snap = snapshotter();
638 let inner = MockSource((0..5).map(|i| json!({"i": i})).collect());
639 let wrapped = InstrumentedSource::new(&inner, labels());
640 let ctx = HashMap::new();
641 let mut s = wrapped.stream_pages(&ctx, 2);
642 while s.next().await.is_some() {}
643 let snapshot = snap.snapshot();
644 let records: u64 = snapshot
645 .into_vec()
646 .into_iter()
647 .filter_map(|(key, _u, _d, v)| {
648 if key.key().name() == "faucet_source_records_total"
649 && let DebugValue::Counter(c) = v
650 {
651 return Some(c);
652 }
653 None
654 })
655 .sum();
656 assert!(
657 records >= 5,
658 "expected at least 5 records counted, got {records}"
659 );
660 }
661
662 struct PageCountSource(Vec<Value>);
665 #[async_trait]
666 impl Source for PageCountSource {
667 async fn fetch_with_context(
668 &self,
669 _: &HashMap<String, Value>,
670 ) -> Result<Vec<Value>, FaucetError> {
671 Ok(self.0.clone())
672 }
673 fn connector_name(&self) -> &'static str {
674 "page-count-probe"
675 }
676 }
677
678 #[tokio::test]
679 #[allow(clippy::await_holding_lock)]
680 async fn page_duration_records_one_sample_per_yielded_page() {
681 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
684 let snap = snapshotter();
685 let inner = PageCountSource((0..5).map(|i| json!({"i": i})).collect());
686 let wrapped = InstrumentedSource::new(&inner, labels());
687 let ctx = HashMap::new();
688 let mut s = wrapped.stream_pages(&ctx, 2);
689 let mut pages = 0usize;
690 while s.next().await.is_some() {
691 pages += 1;
692 }
693 assert_eq!(pages, 3, "expected 3 yielded pages");
694
695 let snapshot = snap.snapshot();
696 let samples: usize = snapshot
697 .into_vec()
698 .into_iter()
699 .filter_map(|(key, _u, _d, v)| {
700 if key.key().name() == "faucet_source_page_duration_seconds"
701 && key
702 .key()
703 .labels()
704 .any(|l| l.key() == "connector" && l.value() == "page-count-probe")
705 && let DebugValue::Histogram(h) = v
706 {
707 return Some(h.len());
708 }
709 None
710 })
711 .sum();
712 assert_eq!(
713 samples, pages,
714 "page-duration histogram must have exactly one sample per yielded \
715 page ({pages}), not page+1 (no spurious terminal sample)"
716 );
717 }
718
719 #[tokio::test]
720 #[allow(clippy::await_holding_lock)]
721 async fn maps_panic_to_custom_error_with_kind_panic() {
722 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
723 let _snap = snapshotter();
724 let inner = PanickingSource;
725 let wrapped = InstrumentedSource::new(&inner, labels());
726 let ctx = HashMap::new();
727 let mut s = wrapped.stream_pages(&ctx, 10);
728 let first = s
729 .next()
730 .await
731 .expect("stream yields at least one item before terminating");
732 assert!(matches!(first, Err(FaucetError::Custom(_))));
733 }
735
736 #[test]
739 fn error_kind_covers_all_variants() {
740 use std::time::Duration;
741 let cases: Vec<(FaucetError, &str)> = vec![
746 (
747 FaucetError::HttpStatus {
748 status: 500,
749 url: "u".into(),
750 body: "b".into(),
751 },
752 "HttpStatus",
753 ),
754 (
755 FaucetError::Json(serde_json::from_str::<Value>("nope").unwrap_err()),
756 "Json",
757 ),
758 (FaucetError::JsonPath("bad".into()), "JsonPath"),
759 (FaucetError::Auth("a".into()), "Auth"),
760 (
761 FaucetError::RateLimited(Duration::from_secs(1)),
762 "RateLimited",
763 ),
764 (FaucetError::Url("bad url".into()), "Url"),
765 (FaucetError::Transform("t".into()), "Transform"),
766 (FaucetError::Config("c".into()), "Config"),
767 (FaucetError::Source("s".into()), "Source"),
768 (FaucetError::Sink("s".into()), "Sink"),
769 (
770 FaucetError::QualityFailure {
771 check: "chk".into(),
772 message: "m".into(),
773 },
774 "QualityFailure",
775 ),
776 (FaucetError::State("st".into()), "State"),
777 (
778 FaucetError::CircuitOpen {
779 failures: 3,
780 cooldown: Duration::from_secs(60),
781 },
782 "CircuitOpen",
783 ),
784 (
785 FaucetError::Custom(Box::new(std::io::Error::other("boom"))),
786 "Custom",
787 ),
788 ];
789 for (err, expected) in cases {
790 assert_eq!(error_kind(&err), expected, "mismatch for {err:?}");
791 }
792 }
793
794 struct PassthroughSource {
800 seen_bookmark: Mutex<Option<Value>>,
801 }
802 #[async_trait]
803 impl Source for PassthroughSource {
804 async fn fetch_with_context(
805 &self,
806 _: &HashMap<String, Value>,
807 ) -> Result<Vec<Value>, FaucetError> {
808 Ok(vec![json!({"fwc": 1})])
809 }
810 async fn fetch_with_context_incremental(
811 &self,
812 _: &HashMap<String, Value>,
813 ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
814 Ok((vec![json!({"inc": 1})], Some(json!("bm"))))
815 }
816 fn state_key(&self) -> Option<String> {
817 Some("passthrough_key".into())
818 }
819 async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
820 *self.seen_bookmark.lock().unwrap() = Some(bookmark);
821 Ok(())
822 }
823 fn connector_name(&self) -> &'static str {
824 "passthrough"
825 }
826 }
827
828 #[tokio::test]
829 async fn source_passthroughs_delegate_to_inner() {
830 let inner = PassthroughSource {
831 seen_bookmark: Mutex::new(None),
832 };
833 let wrapped = InstrumentedSource::new(&inner, labels());
834
835 assert_eq!(wrapped.state_key(), Some("passthrough_key".to_string()));
837
838 let ctx = HashMap::new();
840 assert_eq!(
841 wrapped.fetch_with_context(&ctx).await.unwrap(),
842 vec![json!({"fwc": 1})]
843 );
844
845 let (recs, bm) = wrapped.fetch_with_context_incremental(&ctx).await.unwrap();
847 assert_eq!(recs, vec![json!({"inc": 1})]);
848 assert_eq!(bm, Some(json!("bm")));
849
850 wrapped.apply_start_bookmark(json!("resume")).await.unwrap();
852 assert_eq!(
853 *inner.seen_bookmark.lock().unwrap(),
854 Some(json!("resume")),
855 "apply_start_bookmark must reach the inner source"
856 );
857
858 assert!(!wrapped.supports_exactly_once());
860 assert_eq!(
861 wrapped.replay_guarantee(),
862 crate::idempotency::ReplayGuarantee::NonDeterministic
863 );
864 assert_eq!(wrapped.capture_resume_position().await.unwrap(), None);
865 }
866
867 struct ExactlyOnceSource;
870 #[async_trait]
871 impl Source for ExactlyOnceSource {
872 async fn fetch_with_context(
873 &self,
874 _context: &HashMap<String, Value>,
875 ) -> Result<Vec<Value>, FaucetError> {
876 Ok(vec![])
877 }
878 fn supports_exactly_once(&self) -> bool {
879 true
880 }
881 async fn capture_resume_position(&self) -> Result<Option<Value>, FaucetError> {
882 Ok(Some(json!("pos")))
883 }
884 fn connector_name(&self) -> &'static str {
885 "eo-source"
886 }
887 }
888
889 #[tokio::test]
890 async fn source_capability_passthroughs_delegate_to_inner() {
891 let inner = ExactlyOnceSource;
892 let wrapped = InstrumentedSource::new(&inner, labels());
893 assert!(wrapped.supports_exactly_once());
894 assert_eq!(
895 wrapped.replay_guarantee(),
896 crate::idempotency::ReplayGuarantee::Deterministic,
897 "typed capability derives through the wrapper"
898 );
899 assert_eq!(
900 wrapped.capture_resume_position().await.unwrap(),
901 Some(json!("pos"))
902 );
903 }
904}
905
906#[cfg(test)]
907mod sink_tests {
908 use super::source_tests::{LOCK, labels, snapshotter};
909 use super::*;
910 use async_trait::async_trait;
911 use metrics_util::debugging::DebugValue;
912 use serde_json::json;
913
914 struct MockSink(std::sync::Mutex<Vec<Value>>);
915 #[async_trait]
916 impl Sink for MockSink {
917 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
918 self.0.lock().unwrap().extend(records.iter().cloned());
919 Ok(records.len())
920 }
921 fn connector_name(&self) -> &'static str {
922 "mock-sink"
923 }
924 }
925
926 struct FailingSink;
927 #[async_trait]
928 impl Sink for FailingSink {
929 async fn write_batch(&self, _: &[Value]) -> Result<usize, FaucetError> {
930 Err(FaucetError::Sink("nope".into()))
931 }
932 fn connector_name(&self) -> &'static str {
933 "failing-sink"
934 }
935 }
936
937 struct EmptyNameSink;
938 #[async_trait]
939 impl Sink for EmptyNameSink {
940 async fn write_batch(&self, _: &[Value]) -> Result<usize, FaucetError> {
941 Ok(0)
942 }
943 fn connector_name(&self) -> &'static str {
944 ""
945 }
946 }
947
948 #[test]
949 fn empty_inner_connector_name_falls_back_to_unknown() {
950 let inner = EmptyNameSink;
951 let wrapped = InstrumentedSink {
955 inner: &inner,
956 labels: labels(),
957 connector: SharedString::const_str("unknown"),
958 base_labels: Vec::new(),
959 };
960 assert_eq!(
961 Sink::connector_name(&wrapped),
962 "unknown",
963 "instrumented sink must not leak an empty connector name"
964 );
965 }
966
967 struct CapableSink;
975 #[async_trait]
976 impl Sink for CapableSink {
977 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
978 Ok(records.len())
979 }
980 fn connector_name(&self) -> &'static str {
981 "capable-sink"
982 }
983 async fn current_schema(&self) -> Result<Option<Value>, FaucetError> {
984 Ok(Some(
985 json!({"type": "object", "properties": {"id": {"type": "integer"}}}),
986 ))
987 }
988 fn supports_schema_evolution(&self) -> bool {
989 true
990 }
991 fn supports_idempotent_writes(&self) -> bool {
992 true
993 }
994 fn supported_write_modes(&self) -> &'static [crate::write_mode::WriteMode] {
995 &[
996 crate::write_mode::WriteMode::Append,
997 crate::write_mode::WriteMode::Upsert,
998 ]
999 }
1000 async fn last_committed_token(&self, _scope: &str) -> Result<Option<String>, FaucetError> {
1001 Ok(Some("tok-1".into()))
1002 }
1003 fn dedups_by_key(&self) -> bool {
1004 true
1005 }
1006 }
1007
1008 #[tokio::test]
1009 async fn instrumented_sink_forwards_capability_methods_to_inner() {
1010 let inner = CapableSink;
1011 let wrapped = InstrumentedSink::new(&inner, labels());
1012
1013 assert_eq!(
1016 wrapped.current_schema().await.unwrap(),
1017 Some(json!({"type": "object", "properties": {"id": {"type": "integer"}}})),
1018 "current_schema must delegate to the inner sink"
1019 );
1020 assert!(
1021 wrapped.supports_schema_evolution(),
1022 "supports_schema_evolution must delegate"
1023 );
1024 assert!(
1026 wrapped.supports_idempotent_writes(),
1027 "supports_idempotent_writes must delegate (exactly-once)"
1028 );
1029 assert!(
1030 wrapped
1031 .supported_write_modes()
1032 .contains(&crate::write_mode::WriteMode::Upsert),
1033 "supported_write_modes must delegate"
1034 );
1035 assert_eq!(
1036 wrapped.last_committed_token("scope").await.unwrap(),
1037 Some("tok-1".to_string()),
1038 "last_committed_token must delegate"
1039 );
1040 assert_eq!(
1043 wrapped.sink_guarantee(),
1044 crate::idempotency::SinkGuarantee::AtomicWatermark,
1045 "sink_guarantee must delegate"
1046 );
1047 assert!(wrapped.dedups_by_key(), "dedups_by_key must delegate");
1048 }
1049
1050 #[tokio::test]
1051 #[allow(clippy::await_holding_lock)]
1052 async fn records_writes_and_records_counters() {
1053 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1054 let snap = snapshotter();
1055 let inner = MockSink(std::sync::Mutex::new(Vec::new()));
1056 let wrapped = InstrumentedSink::new(&inner, labels());
1057 wrapped
1058 .write_batch(&[json!({"a": 1}), json!({"a": 2})])
1059 .await
1060 .unwrap();
1061 let snapshot = snap.snapshot();
1062 let writes: u64 = snapshot
1063 .into_vec()
1064 .into_iter()
1065 .filter_map(|(key, _u, _d, v)| {
1066 if key.key().name() == "faucet_sink_writes_total"
1067 && let DebugValue::Counter(c) = v
1068 {
1069 return Some(c);
1070 }
1071 None
1072 })
1073 .sum();
1074 assert!(writes >= 1, "expected at least one write counted");
1075 }
1076
1077 #[tokio::test]
1078 #[allow(clippy::await_holding_lock)]
1079 async fn error_increments_errors_total_with_kind() {
1080 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1081 let snap = snapshotter();
1082 let inner = FailingSink;
1083 let wrapped = InstrumentedSink::new(&inner, labels());
1084 let _ = wrapped.write_batch(&[json!({})]).await;
1085 let snapshot = snap.snapshot();
1086 let found = snapshot.into_vec().into_iter().any(|(key, _u, _d, v)| {
1087 key.key().name() == "faucet_sink_errors_total"
1088 && key
1089 .key()
1090 .labels()
1091 .any(|l| l.key() == "kind" && l.value() == "Sink")
1092 && matches!(v, DebugValue::Counter(c) if c >= 1)
1093 });
1094 assert!(found, "expected sink_errors_total with kind=Sink");
1095 }
1096
1097 #[tokio::test]
1098 #[allow(clippy::await_holding_lock)]
1099 async fn instrumented_sink_write_batch_partial_counts_successful_outcomes() {
1100 use crate::traits::RowOutcome;
1101 use metrics_util::debugging::DebugValue;
1102
1103 struct MixedSink;
1105 #[async_trait]
1106 impl Sink for MixedSink {
1107 async fn write_batch(&self, _r: &[Value]) -> Result<usize, FaucetError> {
1108 unreachable!()
1109 }
1110 async fn write_batch_partial(
1111 &self,
1112 _r: &[Value],
1113 ) -> Result<Vec<RowOutcome>, FaucetError> {
1114 Ok(vec![
1115 Ok(()),
1116 Err(FaucetError::Sink("bad row".into())),
1117 Ok(()),
1118 ])
1119 }
1120 fn connector_name(&self) -> &'static str {
1121 "mixed"
1122 }
1123 }
1124
1125 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1126 let snap = snapshotter();
1127
1128 let inner = MixedSink;
1129 let wrapped = InstrumentedSink::new(&inner, labels());
1130 let _ = wrapped
1131 .write_batch_partial(&[json!({}), json!({}), json!({})])
1132 .await
1133 .unwrap();
1134
1135 let snapshot = snap.snapshot();
1143 let records: u64 = snapshot
1144 .into_vec()
1145 .into_iter()
1146 .filter_map(|(k, _u, _d, v): (metrics_util::CompositeKey, _, _, _)| {
1147 if k.key().name() == "faucet_sink_records_total"
1148 && k.key()
1149 .labels()
1150 .any(|l| l.key() == "connector" && l.value() == "mixed")
1151 && let DebugValue::Counter(c) = v
1152 {
1153 Some(c)
1154 } else {
1155 None
1156 }
1157 })
1158 .sum();
1159 assert!(
1160 records >= 2,
1161 "expected faucet_sink_records_total{{connector=mixed}} >= 2, got {records}"
1162 );
1163 }
1164
1165 #[tokio::test]
1168 #[allow(clippy::await_holding_lock)]
1169 async fn flush_error_increments_errors_total_and_propagates() {
1170 struct FlushFailSink;
1173 #[async_trait]
1174 impl Sink for FlushFailSink {
1175 async fn write_batch(&self, r: &[Value]) -> Result<usize, FaucetError> {
1176 Ok(r.len())
1177 }
1178 async fn flush(&self) -> Result<(), FaucetError> {
1179 Err(FaucetError::Sink("flush boom".into()))
1180 }
1181 fn connector_name(&self) -> &'static str {
1182 "flush-fail-sink"
1183 }
1184 }
1185
1186 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1187 let snap = snapshotter();
1188 let inner = FlushFailSink;
1189 let wrapped = InstrumentedSink::new(&inner, labels());
1190 let err = wrapped.flush().await.unwrap_err();
1191 assert!(matches!(&err, FaucetError::Sink(m) if m.contains("flush boom")));
1192
1193 let snapshot = snap.snapshot();
1194 let found = snapshot.into_vec().into_iter().any(|(key, _u, _d, v)| {
1195 key.key().name() == "faucet_sink_errors_total"
1196 && key
1197 .key()
1198 .labels()
1199 .any(|l| l.key() == "connector" && l.value() == "flush-fail-sink")
1200 && key
1201 .key()
1202 .labels()
1203 .any(|l| l.key() == "kind" && l.value() == "Sink")
1204 && matches!(v, DebugValue::Counter(c) if c >= 1)
1205 });
1206 assert!(
1207 found,
1208 "expected sink_errors_total{{connector=flush-fail-sink,kind=Sink}}"
1209 );
1210 }
1211
1212 struct PanickingSink;
1215 #[async_trait]
1216 impl Sink for PanickingSink {
1217 async fn write_batch(&self, _: &[Value]) -> Result<usize, FaucetError> {
1218 panic!("write kaboom")
1219 }
1220 async fn write_batch_partial(
1221 &self,
1222 _: &[Value],
1223 ) -> Result<Vec<crate::traits::RowOutcome>, FaucetError> {
1224 panic!("partial kaboom")
1225 }
1226 async fn flush(&self) -> Result<(), FaucetError> {
1227 panic!("flush kaboom")
1228 }
1229 fn connector_name(&self) -> &'static str {
1230 "panic-sink"
1231 }
1232 }
1233
1234 #[tokio::test]
1235 #[allow(clippy::await_holding_lock)]
1236 async fn write_batch_panic_maps_to_custom_error() {
1237 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1238 let _snap = snapshotter();
1239 let inner = PanickingSink;
1240 let wrapped = InstrumentedSink::new(&inner, labels());
1241 let err = wrapped.write_batch(&[json!({})]).await.unwrap_err();
1242 match err {
1243 FaucetError::Custom(b) => {
1244 assert!(b.to_string().contains("panic in sink: write kaboom"))
1245 }
1246 other => panic!("expected Custom panic error, got {other:?}"),
1247 }
1248 }
1249
1250 #[tokio::test]
1251 #[allow(clippy::await_holding_lock)]
1252 async fn write_batch_partial_panic_maps_to_custom_error() {
1253 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1254 let _snap = snapshotter();
1255 let inner = PanickingSink;
1256 let wrapped = InstrumentedSink::new(&inner, labels());
1257 let err = wrapped.write_batch_partial(&[json!({})]).await.unwrap_err();
1258 match err {
1259 FaucetError::Custom(b) => {
1260 assert!(b.to_string().contains("panic in sink: partial kaboom"))
1261 }
1262 other => panic!("expected Custom panic error, got {other:?}"),
1263 }
1264 }
1265
1266 #[tokio::test]
1267 #[allow(clippy::await_holding_lock)]
1268 async fn flush_panic_maps_to_custom_error() {
1269 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1270 let _snap = snapshotter();
1271 let inner = PanickingSink;
1272 let wrapped = InstrumentedSink::new(&inner, labels());
1273 let err = wrapped.flush().await.unwrap_err();
1274 match err {
1275 FaucetError::Custom(b) => {
1276 assert!(b.to_string().contains("panic in flush: flush kaboom"))
1277 }
1278 other => panic!("expected Custom panic error, got {other:?}"),
1279 }
1280 }
1281}