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_cleanup(&self) -> bool {
507 self.inner.supports_cleanup()
508 }
509
510 async fn cleanup_scope(
511 &self,
512 scope: &std::collections::BTreeMap<String, Value>,
513 seen: &crate::cleanup::SeenKeys,
514 ) -> Result<u64, FaucetError> {
515 self.inner.cleanup_scope(scope, seen).await
516 }
517
518 fn supports_idempotent_writes(&self) -> bool {
519 self.inner.supports_idempotent_writes()
520 }
521
522 fn sink_guarantee(&self) -> crate::idempotency::SinkGuarantee {
523 self.inner.sink_guarantee()
524 }
525
526 fn dedups_by_key(&self) -> bool {
527 self.inner.dedups_by_key()
528 }
529
530 async fn write_batch_idempotent(
531 &self,
532 records: &[Value],
533 scope: &str,
534 token: &str,
535 ) -> Result<usize, FaucetError> {
536 self.inner
537 .write_batch_idempotent(records, scope, token)
538 .await
539 }
540
541 async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
542 self.inner.last_committed_token(scope).await
543 }
544
545 fn is_overwrite(&self) -> bool {
546 self.inner.is_overwrite()
547 }
548
549 async fn begin_overwrite(&self) -> Result<(), FaucetError> {
550 self.inner.begin_overwrite().await
551 }
552
553 async fn commit_overwrite(&self) -> Result<(), FaucetError> {
554 self.inner.commit_overwrite().await
555 }
556
557 async fn abort_overwrite(&self) -> Result<(), FaucetError> {
558 self.inner.abort_overwrite().await
559 }
560}
561
562#[cfg(test)]
563pub(crate) mod source_tests {
564 use super::*;
565 use async_trait::async_trait;
566 use futures::StreamExt;
567 use metrics_util::debugging::{DebugValue, DebuggingRecorder, Snapshotter};
568 use serde_json::json;
569 use std::sync::{Mutex, OnceLock};
570
571 pub(crate) static LOCK: Mutex<()> = Mutex::new(());
574 static SNAPSHOTTER: OnceLock<Snapshotter> = OnceLock::new();
575
576 pub(crate) fn snapshotter() -> &'static Snapshotter {
577 SNAPSHOTTER.get_or_init(|| {
578 let recorder = DebuggingRecorder::new();
579 let snap = recorder.snapshotter();
580 let _ = metrics::set_global_recorder(recorder);
588 snap
589 })
590 }
591
592 pub(in crate::observability) fn labels() -> Labels {
593 Labels::new("p", "r", "rid")
594 }
595
596 struct MockSource(Vec<Value>);
597 #[async_trait]
598 impl Source for MockSource {
599 async fn fetch_with_context(
600 &self,
601 _: &HashMap<String, Value>,
602 ) -> Result<Vec<Value>, FaucetError> {
603 Ok(self.0.clone())
604 }
605 fn connector_name(&self) -> &'static str {
606 "mock"
607 }
608 }
609
610 struct PanickingSource;
611 #[async_trait]
612 impl Source for PanickingSource {
613 async fn fetch_with_context(
614 &self,
615 _: &HashMap<String, Value>,
616 ) -> Result<Vec<Value>, FaucetError> {
617 panic!("kaboom")
618 }
619 fn connector_name(&self) -> &'static str {
620 "panic-test"
621 }
622 }
623
624 struct EmptyNameSource;
628 #[async_trait]
629 impl Source for EmptyNameSource {
630 async fn fetch_with_context(
631 &self,
632 _: &HashMap<String, Value>,
633 ) -> Result<Vec<Value>, FaucetError> {
634 Ok(vec![])
635 }
636 fn connector_name(&self) -> &'static str {
637 ""
638 }
639 }
640
641 #[test]
642 fn empty_inner_connector_name_falls_back_to_unknown() {
643 let inner = EmptyNameSource;
644 let wrapped = InstrumentedSource {
648 inner: &inner,
649 labels: labels(),
650 connector: SharedString::const_str("unknown"),
651 base_labels: Vec::new(),
652 page_index: Arc::new(AtomicUsize::new(0)),
653 };
654 assert_eq!(
655 Source::connector_name(&wrapped),
656 "unknown",
657 "instrumented source must not leak an empty connector name"
658 );
659 }
660
661 #[tokio::test]
662 #[allow(clippy::await_holding_lock)]
663 async fn records_records_counter_per_page() {
664 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
665 let snap = snapshotter();
666 let inner = MockSource((0..5).map(|i| json!({"i": i})).collect());
667 let wrapped = InstrumentedSource::new(&inner, labels());
668 let ctx = HashMap::new();
669 let mut s = wrapped.stream_pages(&ctx, 2);
670 while s.next().await.is_some() {}
671 let snapshot = snap.snapshot();
672 let records: u64 = snapshot
673 .into_vec()
674 .into_iter()
675 .filter_map(|(key, _u, _d, v)| {
676 if key.key().name() == "faucet_source_records_total"
677 && let DebugValue::Counter(c) = v
678 {
679 return Some(c);
680 }
681 None
682 })
683 .sum();
684 assert!(
685 records >= 5,
686 "expected at least 5 records counted, got {records}"
687 );
688 }
689
690 struct PageCountSource(Vec<Value>);
693 #[async_trait]
694 impl Source for PageCountSource {
695 async fn fetch_with_context(
696 &self,
697 _: &HashMap<String, Value>,
698 ) -> Result<Vec<Value>, FaucetError> {
699 Ok(self.0.clone())
700 }
701 fn connector_name(&self) -> &'static str {
702 "page-count-probe"
703 }
704 }
705
706 #[tokio::test]
707 #[allow(clippy::await_holding_lock)]
708 async fn page_duration_records_one_sample_per_yielded_page() {
709 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
712 let snap = snapshotter();
713 let inner = PageCountSource((0..5).map(|i| json!({"i": i})).collect());
714 let wrapped = InstrumentedSource::new(&inner, labels());
715 let ctx = HashMap::new();
716 let mut s = wrapped.stream_pages(&ctx, 2);
717 let mut pages = 0usize;
718 while s.next().await.is_some() {
719 pages += 1;
720 }
721 assert_eq!(pages, 3, "expected 3 yielded pages");
722
723 let snapshot = snap.snapshot();
724 let samples: usize = snapshot
725 .into_vec()
726 .into_iter()
727 .filter_map(|(key, _u, _d, v)| {
728 if key.key().name() == "faucet_source_page_duration_seconds"
729 && key
730 .key()
731 .labels()
732 .any(|l| l.key() == "connector" && l.value() == "page-count-probe")
733 && let DebugValue::Histogram(h) = v
734 {
735 return Some(h.len());
736 }
737 None
738 })
739 .sum();
740 assert_eq!(
741 samples, pages,
742 "page-duration histogram must have exactly one sample per yielded \
743 page ({pages}), not page+1 (no spurious terminal sample)"
744 );
745 }
746
747 #[tokio::test]
748 #[allow(clippy::await_holding_lock)]
749 async fn maps_panic_to_custom_error_with_kind_panic() {
750 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
751 let _snap = snapshotter();
752 let inner = PanickingSource;
753 let wrapped = InstrumentedSource::new(&inner, labels());
754 let ctx = HashMap::new();
755 let mut s = wrapped.stream_pages(&ctx, 10);
756 let first = s
757 .next()
758 .await
759 .expect("stream yields at least one item before terminating");
760 assert!(matches!(first, Err(FaucetError::Custom(_))));
761 }
763
764 #[test]
767 fn error_kind_covers_all_variants() {
768 use std::time::Duration;
769 let cases: Vec<(FaucetError, &str)> = vec![
774 (
775 FaucetError::HttpStatus {
776 status: 500,
777 url: "u".into(),
778 body: "b".into(),
779 },
780 "HttpStatus",
781 ),
782 (
783 FaucetError::Json(serde_json::from_str::<Value>("nope").unwrap_err()),
784 "Json",
785 ),
786 (FaucetError::JsonPath("bad".into()), "JsonPath"),
787 (FaucetError::Auth("a".into()), "Auth"),
788 (
789 FaucetError::RateLimited(Duration::from_secs(1)),
790 "RateLimited",
791 ),
792 (FaucetError::Url("bad url".into()), "Url"),
793 (FaucetError::Transform("t".into()), "Transform"),
794 (FaucetError::Config("c".into()), "Config"),
795 (FaucetError::Source("s".into()), "Source"),
796 (FaucetError::Sink("s".into()), "Sink"),
797 (
798 FaucetError::QualityFailure {
799 check: "chk".into(),
800 message: "m".into(),
801 },
802 "QualityFailure",
803 ),
804 (FaucetError::State("st".into()), "State"),
805 (
806 FaucetError::CircuitOpen {
807 failures: 3,
808 cooldown: Duration::from_secs(60),
809 },
810 "CircuitOpen",
811 ),
812 (
813 FaucetError::Custom(Box::new(std::io::Error::other("boom"))),
814 "Custom",
815 ),
816 ];
817 for (err, expected) in cases {
818 assert_eq!(error_kind(&err), expected, "mismatch for {err:?}");
819 }
820 }
821
822 struct PassthroughSource {
828 seen_bookmark: Mutex<Option<Value>>,
829 }
830 #[async_trait]
831 impl Source for PassthroughSource {
832 async fn fetch_with_context(
833 &self,
834 _: &HashMap<String, Value>,
835 ) -> Result<Vec<Value>, FaucetError> {
836 Ok(vec![json!({"fwc": 1})])
837 }
838 async fn fetch_with_context_incremental(
839 &self,
840 _: &HashMap<String, Value>,
841 ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
842 Ok((vec![json!({"inc": 1})], Some(json!("bm"))))
843 }
844 fn state_key(&self) -> Option<String> {
845 Some("passthrough_key".into())
846 }
847 async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
848 *self.seen_bookmark.lock().unwrap() = Some(bookmark);
849 Ok(())
850 }
851 fn connector_name(&self) -> &'static str {
852 "passthrough"
853 }
854 }
855
856 #[tokio::test]
857 async fn source_passthroughs_delegate_to_inner() {
858 let inner = PassthroughSource {
859 seen_bookmark: Mutex::new(None),
860 };
861 let wrapped = InstrumentedSource::new(&inner, labels());
862
863 assert_eq!(wrapped.state_key(), Some("passthrough_key".to_string()));
865
866 let ctx = HashMap::new();
868 assert_eq!(
869 wrapped.fetch_with_context(&ctx).await.unwrap(),
870 vec![json!({"fwc": 1})]
871 );
872
873 let (recs, bm) = wrapped.fetch_with_context_incremental(&ctx).await.unwrap();
875 assert_eq!(recs, vec![json!({"inc": 1})]);
876 assert_eq!(bm, Some(json!("bm")));
877
878 wrapped.apply_start_bookmark(json!("resume")).await.unwrap();
880 assert_eq!(
881 *inner.seen_bookmark.lock().unwrap(),
882 Some(json!("resume")),
883 "apply_start_bookmark must reach the inner source"
884 );
885
886 assert!(!wrapped.supports_exactly_once());
888 assert_eq!(
889 wrapped.replay_guarantee(),
890 crate::idempotency::ReplayGuarantee::NonDeterministic
891 );
892 assert_eq!(wrapped.capture_resume_position().await.unwrap(), None);
893 }
894
895 struct ExactlyOnceSource;
898 #[async_trait]
899 impl Source for ExactlyOnceSource {
900 async fn fetch_with_context(
901 &self,
902 _context: &HashMap<String, Value>,
903 ) -> Result<Vec<Value>, FaucetError> {
904 Ok(vec![])
905 }
906 fn supports_exactly_once(&self) -> bool {
907 true
908 }
909 async fn capture_resume_position(&self) -> Result<Option<Value>, FaucetError> {
910 Ok(Some(json!("pos")))
911 }
912 fn connector_name(&self) -> &'static str {
913 "eo-source"
914 }
915 }
916
917 #[tokio::test]
918 async fn source_capability_passthroughs_delegate_to_inner() {
919 let inner = ExactlyOnceSource;
920 let wrapped = InstrumentedSource::new(&inner, labels());
921 assert!(wrapped.supports_exactly_once());
922 assert_eq!(
923 wrapped.replay_guarantee(),
924 crate::idempotency::ReplayGuarantee::Deterministic,
925 "typed capability derives through the wrapper"
926 );
927 assert_eq!(
928 wrapped.capture_resume_position().await.unwrap(),
929 Some(json!("pos"))
930 );
931 }
932}
933
934#[cfg(test)]
935mod sink_tests {
936 use super::source_tests::{LOCK, labels, snapshotter};
937 use super::*;
938 use async_trait::async_trait;
939 use metrics_util::debugging::DebugValue;
940 use serde_json::json;
941
942 struct MockSink(std::sync::Mutex<Vec<Value>>);
943 #[async_trait]
944 impl Sink for MockSink {
945 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
946 self.0.lock().unwrap().extend(records.iter().cloned());
947 Ok(records.len())
948 }
949 fn connector_name(&self) -> &'static str {
950 "mock-sink"
951 }
952 }
953
954 struct FailingSink;
955 #[async_trait]
956 impl Sink for FailingSink {
957 async fn write_batch(&self, _: &[Value]) -> Result<usize, FaucetError> {
958 Err(FaucetError::Sink("nope".into()))
959 }
960 fn connector_name(&self) -> &'static str {
961 "failing-sink"
962 }
963 }
964
965 struct EmptyNameSink;
966 #[async_trait]
967 impl Sink for EmptyNameSink {
968 async fn write_batch(&self, _: &[Value]) -> Result<usize, FaucetError> {
969 Ok(0)
970 }
971 fn connector_name(&self) -> &'static str {
972 ""
973 }
974 }
975
976 #[test]
977 fn empty_inner_connector_name_falls_back_to_unknown() {
978 let inner = EmptyNameSink;
979 let wrapped = InstrumentedSink {
983 inner: &inner,
984 labels: labels(),
985 connector: SharedString::const_str("unknown"),
986 base_labels: Vec::new(),
987 };
988 assert_eq!(
989 Sink::connector_name(&wrapped),
990 "unknown",
991 "instrumented sink must not leak an empty connector name"
992 );
993 }
994
995 struct CapableSink;
1003 #[async_trait]
1004 impl Sink for CapableSink {
1005 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1006 Ok(records.len())
1007 }
1008 fn connector_name(&self) -> &'static str {
1009 "capable-sink"
1010 }
1011 async fn current_schema(&self) -> Result<Option<Value>, FaucetError> {
1012 Ok(Some(
1013 json!({"type": "object", "properties": {"id": {"type": "integer"}}}),
1014 ))
1015 }
1016 fn supports_schema_evolution(&self) -> bool {
1017 true
1018 }
1019 fn supports_idempotent_writes(&self) -> bool {
1020 true
1021 }
1022 fn supported_write_modes(&self) -> &'static [crate::write_mode::WriteMode] {
1023 &[
1024 crate::write_mode::WriteMode::Append,
1025 crate::write_mode::WriteMode::Upsert,
1026 ]
1027 }
1028 async fn last_committed_token(&self, _scope: &str) -> Result<Option<String>, FaucetError> {
1029 Ok(Some("tok-1".into()))
1030 }
1031 fn dedups_by_key(&self) -> bool {
1032 true
1033 }
1034 }
1035
1036 #[tokio::test]
1037 async fn instrumented_sink_forwards_capability_methods_to_inner() {
1038 let inner = CapableSink;
1039 let wrapped = InstrumentedSink::new(&inner, labels());
1040
1041 assert_eq!(
1044 wrapped.current_schema().await.unwrap(),
1045 Some(json!({"type": "object", "properties": {"id": {"type": "integer"}}})),
1046 "current_schema must delegate to the inner sink"
1047 );
1048 assert!(
1049 wrapped.supports_schema_evolution(),
1050 "supports_schema_evolution must delegate"
1051 );
1052 assert!(
1054 wrapped.supports_idempotent_writes(),
1055 "supports_idempotent_writes must delegate (exactly-once)"
1056 );
1057 assert!(
1058 wrapped
1059 .supported_write_modes()
1060 .contains(&crate::write_mode::WriteMode::Upsert),
1061 "supported_write_modes must delegate"
1062 );
1063 assert_eq!(
1064 wrapped.last_committed_token("scope").await.unwrap(),
1065 Some("tok-1".to_string()),
1066 "last_committed_token must delegate"
1067 );
1068 assert_eq!(
1071 wrapped.sink_guarantee(),
1072 crate::idempotency::SinkGuarantee::AtomicWatermark,
1073 "sink_guarantee must delegate"
1074 );
1075 assert!(wrapped.dedups_by_key(), "dedups_by_key must delegate");
1076 }
1077
1078 #[tokio::test]
1079 #[allow(clippy::await_holding_lock)]
1080 async fn records_writes_and_records_counters() {
1081 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1082 let snap = snapshotter();
1083 let inner = MockSink(std::sync::Mutex::new(Vec::new()));
1084 let wrapped = InstrumentedSink::new(&inner, labels());
1085 wrapped
1086 .write_batch(&[json!({"a": 1}), json!({"a": 2})])
1087 .await
1088 .unwrap();
1089 let snapshot = snap.snapshot();
1090 let writes: u64 = snapshot
1091 .into_vec()
1092 .into_iter()
1093 .filter_map(|(key, _u, _d, v)| {
1094 if key.key().name() == "faucet_sink_writes_total"
1095 && let DebugValue::Counter(c) = v
1096 {
1097 return Some(c);
1098 }
1099 None
1100 })
1101 .sum();
1102 assert!(writes >= 1, "expected at least one write counted");
1103 }
1104
1105 #[tokio::test]
1106 #[allow(clippy::await_holding_lock)]
1107 async fn error_increments_errors_total_with_kind() {
1108 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1109 let snap = snapshotter();
1110 let inner = FailingSink;
1111 let wrapped = InstrumentedSink::new(&inner, labels());
1112 let _ = wrapped.write_batch(&[json!({})]).await;
1113 let snapshot = snap.snapshot();
1114 let found = snapshot.into_vec().into_iter().any(|(key, _u, _d, v)| {
1115 key.key().name() == "faucet_sink_errors_total"
1116 && key
1117 .key()
1118 .labels()
1119 .any(|l| l.key() == "kind" && l.value() == "Sink")
1120 && matches!(v, DebugValue::Counter(c) if c >= 1)
1121 });
1122 assert!(found, "expected sink_errors_total with kind=Sink");
1123 }
1124
1125 #[tokio::test]
1126 #[allow(clippy::await_holding_lock)]
1127 async fn instrumented_sink_write_batch_partial_counts_successful_outcomes() {
1128 use crate::traits::RowOutcome;
1129 use metrics_util::debugging::DebugValue;
1130
1131 struct MixedSink;
1133 #[async_trait]
1134 impl Sink for MixedSink {
1135 async fn write_batch(&self, _r: &[Value]) -> Result<usize, FaucetError> {
1136 unreachable!()
1137 }
1138 async fn write_batch_partial(
1139 &self,
1140 _r: &[Value],
1141 ) -> Result<Vec<RowOutcome>, FaucetError> {
1142 Ok(vec![
1143 Ok(()),
1144 Err(FaucetError::Sink("bad row".into())),
1145 Ok(()),
1146 ])
1147 }
1148 fn connector_name(&self) -> &'static str {
1149 "mixed"
1150 }
1151 }
1152
1153 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1154 let snap = snapshotter();
1155
1156 let inner = MixedSink;
1157 let wrapped = InstrumentedSink::new(&inner, labels());
1158 let _ = wrapped
1159 .write_batch_partial(&[json!({}), json!({}), json!({})])
1160 .await
1161 .unwrap();
1162
1163 let snapshot = snap.snapshot();
1171 let records: u64 = snapshot
1172 .into_vec()
1173 .into_iter()
1174 .filter_map(|(k, _u, _d, v): (metrics_util::CompositeKey, _, _, _)| {
1175 if k.key().name() == "faucet_sink_records_total"
1176 && k.key()
1177 .labels()
1178 .any(|l| l.key() == "connector" && l.value() == "mixed")
1179 && let DebugValue::Counter(c) = v
1180 {
1181 Some(c)
1182 } else {
1183 None
1184 }
1185 })
1186 .sum();
1187 assert!(
1188 records >= 2,
1189 "expected faucet_sink_records_total{{connector=mixed}} >= 2, got {records}"
1190 );
1191 }
1192
1193 #[tokio::test]
1196 #[allow(clippy::await_holding_lock)]
1197 async fn flush_error_increments_errors_total_and_propagates() {
1198 struct FlushFailSink;
1201 #[async_trait]
1202 impl Sink for FlushFailSink {
1203 async fn write_batch(&self, r: &[Value]) -> Result<usize, FaucetError> {
1204 Ok(r.len())
1205 }
1206 async fn flush(&self) -> Result<(), FaucetError> {
1207 Err(FaucetError::Sink("flush boom".into()))
1208 }
1209 fn connector_name(&self) -> &'static str {
1210 "flush-fail-sink"
1211 }
1212 }
1213
1214 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1215 let snap = snapshotter();
1216 let inner = FlushFailSink;
1217 let wrapped = InstrumentedSink::new(&inner, labels());
1218 let err = wrapped.flush().await.unwrap_err();
1219 assert!(matches!(&err, FaucetError::Sink(m) if m.contains("flush boom")));
1220
1221 let snapshot = snap.snapshot();
1222 let found = snapshot.into_vec().into_iter().any(|(key, _u, _d, v)| {
1223 key.key().name() == "faucet_sink_errors_total"
1224 && key
1225 .key()
1226 .labels()
1227 .any(|l| l.key() == "connector" && l.value() == "flush-fail-sink")
1228 && key
1229 .key()
1230 .labels()
1231 .any(|l| l.key() == "kind" && l.value() == "Sink")
1232 && matches!(v, DebugValue::Counter(c) if c >= 1)
1233 });
1234 assert!(
1235 found,
1236 "expected sink_errors_total{{connector=flush-fail-sink,kind=Sink}}"
1237 );
1238 }
1239
1240 struct PanickingSink;
1243 #[async_trait]
1244 impl Sink for PanickingSink {
1245 async fn write_batch(&self, _: &[Value]) -> Result<usize, FaucetError> {
1246 panic!("write kaboom")
1247 }
1248 async fn write_batch_partial(
1249 &self,
1250 _: &[Value],
1251 ) -> Result<Vec<crate::traits::RowOutcome>, FaucetError> {
1252 panic!("partial kaboom")
1253 }
1254 async fn flush(&self) -> Result<(), FaucetError> {
1255 panic!("flush kaboom")
1256 }
1257 fn connector_name(&self) -> &'static str {
1258 "panic-sink"
1259 }
1260 }
1261
1262 #[tokio::test]
1263 #[allow(clippy::await_holding_lock)]
1264 async fn write_batch_panic_maps_to_custom_error() {
1265 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1266 let _snap = snapshotter();
1267 let inner = PanickingSink;
1268 let wrapped = InstrumentedSink::new(&inner, labels());
1269 let err = wrapped.write_batch(&[json!({})]).await.unwrap_err();
1270 match err {
1271 FaucetError::Custom(b) => {
1272 assert!(b.to_string().contains("panic in sink: write kaboom"))
1273 }
1274 other => panic!("expected Custom panic error, got {other:?}"),
1275 }
1276 }
1277
1278 #[tokio::test]
1279 #[allow(clippy::await_holding_lock)]
1280 async fn write_batch_partial_panic_maps_to_custom_error() {
1281 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1282 let _snap = snapshotter();
1283 let inner = PanickingSink;
1284 let wrapped = InstrumentedSink::new(&inner, labels());
1285 let err = wrapped.write_batch_partial(&[json!({})]).await.unwrap_err();
1286 match err {
1287 FaucetError::Custom(b) => {
1288 assert!(b.to_string().contains("panic in sink: partial kaboom"))
1289 }
1290 other => panic!("expected Custom panic error, got {other:?}"),
1291 }
1292 }
1293
1294 #[tokio::test]
1295 #[allow(clippy::await_holding_lock)]
1296 async fn flush_panic_maps_to_custom_error() {
1297 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1298 let _snap = snapshotter();
1299 let inner = PanickingSink;
1300 let wrapped = InstrumentedSink::new(&inner, labels());
1301 let err = wrapped.flush().await.unwrap_err();
1302 match err {
1303 FaucetError::Custom(b) => {
1304 assert!(b.to_string().contains("panic in flush: flush kaboom"))
1305 }
1306 other => panic!("expected Custom panic error, got {other:?}"),
1307 }
1308 }
1309}