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