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 fn stream_pages<'b>(
130 &'b self,
131 context: &'b HashMap<String, Value>,
132 batch_size: usize,
133 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'b>> {
134 let inner_stream = self.inner.stream_pages(context, batch_size);
135 let labels = self.labels.clone();
136 let connector = self.connector.clone();
137 let page_index = Arc::clone(&self.page_index);
138 let metric_labels = self.metric_labels();
139 let pipeline = self.labels.pipeline.clone();
140 let row = self.labels.row.clone();
141
142 Box::pin(async_stream::try_stream! {
143 struct InFlightGuard(Vec<Label>);
146 impl Drop for InFlightGuard {
147 fn drop(&mut self) {
148 gauge!("faucet_source_in_flight", self.0.clone()).decrement(1.0);
149 }
150 }
151 gauge!("faucet_source_in_flight", metric_labels.clone()).increment(1.0);
152 let _in_flight = InFlightGuard(metric_labels.clone());
153
154 let mut inner = inner_stream;
155 loop {
156 let idx = page_index.fetch_add(1, Ordering::Relaxed);
157 let span = info_span!(
158 "faucet.source.page",
159 pipeline = %pipeline,
160 row = %row,
161 run_id = %labels.run_id,
162 connector = %connector,
163 page_index = idx,
164 );
165 let mut _timer = DurationGuard::new(
170 "faucet_source_page_duration_seconds",
171 metric_labels.clone(),
172 );
173
174 let next = AssertUnwindSafe(async {
175 use futures::StreamExt;
176 inner.next().await
177 })
178 .catch_unwind()
179 .instrument(span)
180 .await;
181
182 match next {
183 Ok(Some(Ok(page))) => {
184 counter!("faucet_source_pages_total", metric_labels.clone()).increment(1);
185 counter!("faucet_source_records_total", metric_labels.clone())
186 .increment(page.records.len() as u64);
187 _timer.record_now();
193 yield page;
194 }
195 Ok(Some(Err(e))) => {
196 let mut l = metric_labels.clone();
197 l.push(Label::new("kind", SharedString::const_str(error_kind(&e))));
198 counter!("faucet_source_errors_total", l).increment(1);
199 Err(e)?;
200 }
201 Ok(None) => {
202 _timer.disarm();
203 break;
204 }
205 Err(panic) => {
206 let mut l = metric_labels.clone();
207 l.push(Label::new("kind", SharedString::const_str("Panic")));
208 counter!("faucet_source_errors_total", l).increment(1);
209 let msg = panic.downcast_ref::<&'static str>().map(|s| (*s).to_string())
210 .or_else(|| panic.downcast_ref::<String>().cloned())
211 .unwrap_or_else(|| "<non-string panic payload>".to_string());
212 Err(FaucetError::Custom(format!("panic in source: {msg}").into()))?;
213 }
214 }
215 }
216 })
217 }
218}
219
220pub(crate) fn error_kind(e: &FaucetError) -> &'static str {
223 match e {
224 FaucetError::Http(_) => "Http",
225 FaucetError::HttpStatus { .. } => "HttpStatus",
226 FaucetError::Json(_) => "Json",
227 FaucetError::JsonPath(_) => "JsonPath",
228 FaucetError::Auth(_) => "Auth",
229 FaucetError::RateLimited { .. } => "RateLimited",
230 FaucetError::Url(_) => "Url",
231 FaucetError::Transform(_) => "Transform",
232 FaucetError::Config(_) => "Config",
233 FaucetError::Source(_) => "Source",
234 FaucetError::Sink(_) => "Sink",
235 FaucetError::QualityFailure { .. } => "QualityFailure",
236 FaucetError::SchemaDrift { .. } => "SchemaDrift",
237 FaucetError::ContractViolation { .. } => "ContractViolation",
238 FaucetError::State(_) => "State",
239 FaucetError::CircuitOpen { .. } => "CircuitOpen",
240 FaucetError::Custom(_) => "Custom",
241 }
242}
243
244pub struct InstrumentedSink<'a, S: Sink + ?Sized> {
247 inner: &'a S,
248 labels: Labels,
249 connector: SharedString,
250 base_labels: Vec<Label>,
252}
253
254impl<'a, S: Sink + ?Sized> InstrumentedSink<'a, S> {
255 pub fn new(inner: &'a S, labels: Labels) -> Self {
256 let raw = inner.connector_name();
257 debug_assert!(
258 !raw.is_empty(),
259 "connector_name() must return a non-empty string"
260 );
261 let connector: SharedString = SharedString::const_str(guarded_connector_name(raw));
262 let base_labels = base_metric_labels(&labels, &connector);
263 Self {
264 inner,
265 labels,
266 connector,
267 base_labels,
268 }
269 }
270
271 fn metric_labels(&self) -> Vec<Label> {
272 self.base_labels.clone()
273 }
274
275 fn error_labels(&self, kind: &'static str) -> Vec<Label> {
276 let mut l = self.metric_labels();
277 l.push(Label::new("kind", SharedString::const_str(kind)));
278 l
279 }
280}
281
282#[async_trait]
283impl<'a, S: Sink + ?Sized> Sink for InstrumentedSink<'a, S> {
284 fn connector_name(&self) -> &'static str {
285 guarded_connector_name(self.inner.connector_name())
289 }
290
291 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
292 let span = info_span!(
293 "faucet.sink.write",
294 pipeline = %self.labels.pipeline,
295 row = %self.labels.row,
296 run_id = %self.labels.run_id,
297 connector = %self.connector,
298 records = records.len(),
299 );
300 let metric_labels = self.metric_labels();
301 gauge!("faucet_sink_in_flight", metric_labels.clone()).increment(1.0);
302
303 struct InFlightGuard(Vec<Label>);
306 impl Drop for InFlightGuard {
307 fn drop(&mut self) {
308 gauge!("faucet_sink_in_flight", self.0.clone()).decrement(1.0);
309 }
310 }
311 let _in_flight = InFlightGuard(metric_labels.clone());
312
313 let _timer =
314 DurationGuard::new("faucet_sink_write_duration_seconds", metric_labels.clone());
315
316 let result = AssertUnwindSafe(self.inner.write_batch(records))
317 .catch_unwind()
318 .instrument(span)
319 .await;
320
321 match result {
322 Ok(Ok(n)) => {
323 counter!("faucet_sink_writes_total", metric_labels.clone()).increment(1);
324 counter!("faucet_sink_records_total", metric_labels.clone()).increment(n as u64);
325 Ok(n)
326 }
327 Ok(Err(e)) => {
328 counter!(
329 "faucet_sink_errors_total",
330 self.error_labels(error_kind(&e))
331 )
332 .increment(1);
333 Err(e)
334 }
335 Err(panic) => {
336 counter!("faucet_sink_errors_total", self.error_labels("Panic")).increment(1);
337 let msg = panic
338 .downcast_ref::<&'static str>()
339 .map(|s| (*s).to_string())
340 .or_else(|| panic.downcast_ref::<String>().cloned())
341 .unwrap_or_else(|| "<non-string panic payload>".to_string());
342 Err(FaucetError::Custom(format!("panic in sink: {msg}").into()))
343 }
344 }
345 }
346
347 async fn write_batch_partial(
348 &self,
349 records: &[Value],
350 ) -> Result<Vec<crate::traits::RowOutcome>, FaucetError> {
351 let span = info_span!(
352 "faucet.sink.write_partial",
353 pipeline = %self.labels.pipeline,
354 row = %self.labels.row,
355 run_id = %self.labels.run_id,
356 connector = %self.connector,
357 records = records.len(),
358 );
359 let metric_labels = self.metric_labels();
360 gauge!("faucet_sink_in_flight", metric_labels.clone()).increment(1.0);
361
362 struct InFlightGuard(Vec<Label>);
365 impl Drop for InFlightGuard {
366 fn drop(&mut self) {
367 gauge!("faucet_sink_in_flight", self.0.clone()).decrement(1.0);
368 }
369 }
370 let _in_flight = InFlightGuard(metric_labels.clone());
371
372 let _timer =
373 DurationGuard::new("faucet_sink_write_duration_seconds", metric_labels.clone());
374
375 let result = AssertUnwindSafe(self.inner.write_batch_partial(records))
376 .catch_unwind()
377 .instrument(span)
378 .await;
379
380 match result {
381 Ok(Ok(outcomes)) => {
382 let success_count = outcomes.iter().filter(|o| o.is_ok()).count();
383 counter!("faucet_sink_writes_total", metric_labels.clone()).increment(1);
384 counter!("faucet_sink_records_total", metric_labels.clone())
385 .increment(success_count as u64);
386 Ok(outcomes)
387 }
388 Ok(Err(e)) => {
389 counter!(
390 "faucet_sink_errors_total",
391 self.error_labels(error_kind(&e))
392 )
393 .increment(1);
394 Err(e)
395 }
396 Err(panic) => {
397 counter!("faucet_sink_errors_total", self.error_labels("Panic")).increment(1);
398 let msg = panic
399 .downcast_ref::<&'static str>()
400 .map(|s| (*s).to_string())
401 .or_else(|| panic.downcast_ref::<String>().cloned())
402 .unwrap_or_else(|| "<non-string panic payload>".to_string());
403 Err(FaucetError::Custom(format!("panic in sink: {msg}").into()))
404 }
405 }
406 }
407
408 async fn flush(&self) -> Result<(), FaucetError> {
409 let span = info_span!(
410 "faucet.sink.flush",
411 pipeline = %self.labels.pipeline,
412 row = %self.labels.row,
413 run_id = %self.labels.run_id,
414 connector = %self.connector,
415 );
416 let metric_labels = self.metric_labels();
417 let _timer =
418 DurationGuard::new("faucet_sink_flush_duration_seconds", metric_labels.clone());
419
420 let result = AssertUnwindSafe(self.inner.flush())
421 .catch_unwind()
422 .instrument(span)
423 .await;
424
425 match result {
426 Ok(Ok(())) => Ok(()),
427 Ok(Err(e)) => {
428 counter!(
429 "faucet_sink_errors_total",
430 self.error_labels(error_kind(&e))
431 )
432 .increment(1);
433 Err(e)
434 }
435 Err(panic) => {
436 counter!("faucet_sink_errors_total", self.error_labels("Panic")).increment(1);
437 let msg = panic
438 .downcast_ref::<&'static str>()
439 .map(|s| (*s).to_string())
440 .or_else(|| panic.downcast_ref::<String>().cloned())
441 .unwrap_or_else(|| "<non-string panic payload>".to_string());
442 Err(FaucetError::Custom(format!("panic in flush: {msg}").into()))
443 }
444 }
445 }
446
447 async fn current_schema(&self) -> Result<Option<Value>, FaucetError> {
455 self.inner.current_schema().await
456 }
457
458 fn supports_schema_evolution(&self) -> bool {
459 self.inner.supports_schema_evolution()
460 }
461
462 async fn evolve_schema(
463 &self,
464 evolution: &crate::drift::SchemaEvolution,
465 ) -> Result<(), FaucetError> {
466 self.inner.evolve_schema(evolution).await
467 }
468
469 fn supported_write_modes(&self) -> &'static [crate::write_mode::WriteMode] {
470 self.inner.supported_write_modes()
471 }
472
473 fn supports_idempotent_writes(&self) -> bool {
474 self.inner.supports_idempotent_writes()
475 }
476
477 fn sink_guarantee(&self) -> crate::idempotency::SinkGuarantee {
478 self.inner.sink_guarantee()
479 }
480
481 fn dedups_by_key(&self) -> bool {
482 self.inner.dedups_by_key()
483 }
484
485 async fn write_batch_idempotent(
486 &self,
487 records: &[Value],
488 scope: &str,
489 token: &str,
490 ) -> Result<usize, FaucetError> {
491 self.inner
492 .write_batch_idempotent(records, scope, token)
493 .await
494 }
495
496 async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
497 self.inner.last_committed_token(scope).await
498 }
499}
500
501#[cfg(test)]
502pub(crate) mod source_tests {
503 use super::*;
504 use async_trait::async_trait;
505 use futures::StreamExt;
506 use metrics_util::debugging::{DebugValue, DebuggingRecorder, Snapshotter};
507 use serde_json::json;
508 use std::sync::{Mutex, OnceLock};
509
510 pub(crate) static LOCK: Mutex<()> = Mutex::new(());
513 static SNAPSHOTTER: OnceLock<Snapshotter> = OnceLock::new();
514
515 pub(crate) fn snapshotter() -> &'static Snapshotter {
516 SNAPSHOTTER.get_or_init(|| {
517 let recorder = DebuggingRecorder::new();
518 let snap = recorder.snapshotter();
519 let _ = metrics::set_global_recorder(recorder);
527 snap
528 })
529 }
530
531 pub(in crate::observability) fn labels() -> Labels {
532 Labels::new("p", "r", "rid")
533 }
534
535 struct MockSource(Vec<Value>);
536 #[async_trait]
537 impl Source for MockSource {
538 async fn fetch_with_context(
539 &self,
540 _: &HashMap<String, Value>,
541 ) -> Result<Vec<Value>, FaucetError> {
542 Ok(self.0.clone())
543 }
544 fn connector_name(&self) -> &'static str {
545 "mock"
546 }
547 }
548
549 struct PanickingSource;
550 #[async_trait]
551 impl Source for PanickingSource {
552 async fn fetch_with_context(
553 &self,
554 _: &HashMap<String, Value>,
555 ) -> Result<Vec<Value>, FaucetError> {
556 panic!("kaboom")
557 }
558 fn connector_name(&self) -> &'static str {
559 "panic-test"
560 }
561 }
562
563 struct EmptyNameSource;
567 #[async_trait]
568 impl Source for EmptyNameSource {
569 async fn fetch_with_context(
570 &self,
571 _: &HashMap<String, Value>,
572 ) -> Result<Vec<Value>, FaucetError> {
573 Ok(vec![])
574 }
575 fn connector_name(&self) -> &'static str {
576 ""
577 }
578 }
579
580 #[test]
581 fn empty_inner_connector_name_falls_back_to_unknown() {
582 let inner = EmptyNameSource;
583 let wrapped = InstrumentedSource {
587 inner: &inner,
588 labels: labels(),
589 connector: SharedString::const_str("unknown"),
590 base_labels: Vec::new(),
591 page_index: Arc::new(AtomicUsize::new(0)),
592 };
593 assert_eq!(
594 Source::connector_name(&wrapped),
595 "unknown",
596 "instrumented source must not leak an empty connector name"
597 );
598 }
599
600 #[tokio::test]
601 #[allow(clippy::await_holding_lock)]
602 async fn records_records_counter_per_page() {
603 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
604 let snap = snapshotter();
605 let inner = MockSource((0..5).map(|i| json!({"i": i})).collect());
606 let wrapped = InstrumentedSource::new(&inner, labels());
607 let ctx = HashMap::new();
608 let mut s = wrapped.stream_pages(&ctx, 2);
609 while s.next().await.is_some() {}
610 let snapshot = snap.snapshot();
611 let records: u64 = snapshot
612 .into_vec()
613 .into_iter()
614 .filter_map(|(key, _u, _d, v)| {
615 if key.key().name() == "faucet_source_records_total"
616 && let DebugValue::Counter(c) = v
617 {
618 return Some(c);
619 }
620 None
621 })
622 .sum();
623 assert!(
624 records >= 5,
625 "expected at least 5 records counted, got {records}"
626 );
627 }
628
629 struct PageCountSource(Vec<Value>);
632 #[async_trait]
633 impl Source for PageCountSource {
634 async fn fetch_with_context(
635 &self,
636 _: &HashMap<String, Value>,
637 ) -> Result<Vec<Value>, FaucetError> {
638 Ok(self.0.clone())
639 }
640 fn connector_name(&self) -> &'static str {
641 "page-count-probe"
642 }
643 }
644
645 #[tokio::test]
646 #[allow(clippy::await_holding_lock)]
647 async fn page_duration_records_one_sample_per_yielded_page() {
648 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
651 let snap = snapshotter();
652 let inner = PageCountSource((0..5).map(|i| json!({"i": i})).collect());
653 let wrapped = InstrumentedSource::new(&inner, labels());
654 let ctx = HashMap::new();
655 let mut s = wrapped.stream_pages(&ctx, 2);
656 let mut pages = 0usize;
657 while s.next().await.is_some() {
658 pages += 1;
659 }
660 assert_eq!(pages, 3, "expected 3 yielded pages");
661
662 let snapshot = snap.snapshot();
663 let samples: usize = snapshot
664 .into_vec()
665 .into_iter()
666 .filter_map(|(key, _u, _d, v)| {
667 if key.key().name() == "faucet_source_page_duration_seconds"
668 && key
669 .key()
670 .labels()
671 .any(|l| l.key() == "connector" && l.value() == "page-count-probe")
672 && let DebugValue::Histogram(h) = v
673 {
674 return Some(h.len());
675 }
676 None
677 })
678 .sum();
679 assert_eq!(
680 samples, pages,
681 "page-duration histogram must have exactly one sample per yielded \
682 page ({pages}), not page+1 (no spurious terminal sample)"
683 );
684 }
685
686 #[tokio::test]
687 #[allow(clippy::await_holding_lock)]
688 async fn maps_panic_to_custom_error_with_kind_panic() {
689 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
690 let _snap = snapshotter();
691 let inner = PanickingSource;
692 let wrapped = InstrumentedSource::new(&inner, labels());
693 let ctx = HashMap::new();
694 let mut s = wrapped.stream_pages(&ctx, 10);
695 let first = s
696 .next()
697 .await
698 .expect("stream yields at least one item before terminating");
699 assert!(matches!(first, Err(FaucetError::Custom(_))));
700 }
702
703 #[test]
706 fn error_kind_covers_all_variants() {
707 use std::time::Duration;
708 let cases: Vec<(FaucetError, &str)> = vec![
713 (
714 FaucetError::HttpStatus {
715 status: 500,
716 url: "u".into(),
717 body: "b".into(),
718 },
719 "HttpStatus",
720 ),
721 (
722 FaucetError::Json(serde_json::from_str::<Value>("nope").unwrap_err()),
723 "Json",
724 ),
725 (FaucetError::JsonPath("bad".into()), "JsonPath"),
726 (FaucetError::Auth("a".into()), "Auth"),
727 (
728 FaucetError::RateLimited(Duration::from_secs(1)),
729 "RateLimited",
730 ),
731 (FaucetError::Url("bad url".into()), "Url"),
732 (FaucetError::Transform("t".into()), "Transform"),
733 (FaucetError::Config("c".into()), "Config"),
734 (FaucetError::Source("s".into()), "Source"),
735 (FaucetError::Sink("s".into()), "Sink"),
736 (
737 FaucetError::QualityFailure {
738 check: "chk".into(),
739 message: "m".into(),
740 },
741 "QualityFailure",
742 ),
743 (FaucetError::State("st".into()), "State"),
744 (
745 FaucetError::CircuitOpen {
746 failures: 3,
747 cooldown: Duration::from_secs(60),
748 },
749 "CircuitOpen",
750 ),
751 (
752 FaucetError::Custom(Box::new(std::io::Error::other("boom"))),
753 "Custom",
754 ),
755 ];
756 for (err, expected) in cases {
757 assert_eq!(error_kind(&err), expected, "mismatch for {err:?}");
758 }
759 }
760
761 struct PassthroughSource {
767 seen_bookmark: Mutex<Option<Value>>,
768 }
769 #[async_trait]
770 impl Source for PassthroughSource {
771 async fn fetch_with_context(
772 &self,
773 _: &HashMap<String, Value>,
774 ) -> Result<Vec<Value>, FaucetError> {
775 Ok(vec![json!({"fwc": 1})])
776 }
777 async fn fetch_with_context_incremental(
778 &self,
779 _: &HashMap<String, Value>,
780 ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
781 Ok((vec![json!({"inc": 1})], Some(json!("bm"))))
782 }
783 fn state_key(&self) -> Option<String> {
784 Some("passthrough_key".into())
785 }
786 async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
787 *self.seen_bookmark.lock().unwrap() = Some(bookmark);
788 Ok(())
789 }
790 fn connector_name(&self) -> &'static str {
791 "passthrough"
792 }
793 }
794
795 #[tokio::test]
796 async fn source_passthroughs_delegate_to_inner() {
797 let inner = PassthroughSource {
798 seen_bookmark: Mutex::new(None),
799 };
800 let wrapped = InstrumentedSource::new(&inner, labels());
801
802 assert_eq!(wrapped.state_key(), Some("passthrough_key".to_string()));
804
805 let ctx = HashMap::new();
807 assert_eq!(
808 wrapped.fetch_with_context(&ctx).await.unwrap(),
809 vec![json!({"fwc": 1})]
810 );
811
812 let (recs, bm) = wrapped.fetch_with_context_incremental(&ctx).await.unwrap();
814 assert_eq!(recs, vec![json!({"inc": 1})]);
815 assert_eq!(bm, Some(json!("bm")));
816
817 wrapped.apply_start_bookmark(json!("resume")).await.unwrap();
819 assert_eq!(
820 *inner.seen_bookmark.lock().unwrap(),
821 Some(json!("resume")),
822 "apply_start_bookmark must reach the inner source"
823 );
824
825 assert!(!wrapped.supports_exactly_once());
827 assert_eq!(
828 wrapped.replay_guarantee(),
829 crate::idempotency::ReplayGuarantee::NonDeterministic
830 );
831 assert_eq!(wrapped.capture_resume_position().await.unwrap(), None);
832 }
833
834 struct ExactlyOnceSource;
837 #[async_trait]
838 impl Source for ExactlyOnceSource {
839 async fn fetch_with_context(
840 &self,
841 _context: &HashMap<String, Value>,
842 ) -> Result<Vec<Value>, FaucetError> {
843 Ok(vec![])
844 }
845 fn supports_exactly_once(&self) -> bool {
846 true
847 }
848 async fn capture_resume_position(&self) -> Result<Option<Value>, FaucetError> {
849 Ok(Some(json!("pos")))
850 }
851 fn connector_name(&self) -> &'static str {
852 "eo-source"
853 }
854 }
855
856 #[tokio::test]
857 async fn source_capability_passthroughs_delegate_to_inner() {
858 let inner = ExactlyOnceSource;
859 let wrapped = InstrumentedSource::new(&inner, labels());
860 assert!(wrapped.supports_exactly_once());
861 assert_eq!(
862 wrapped.replay_guarantee(),
863 crate::idempotency::ReplayGuarantee::Deterministic,
864 "typed capability derives through the wrapper"
865 );
866 assert_eq!(
867 wrapped.capture_resume_position().await.unwrap(),
868 Some(json!("pos"))
869 );
870 }
871}
872
873#[cfg(test)]
874mod sink_tests {
875 use super::source_tests::{LOCK, labels, snapshotter};
876 use super::*;
877 use async_trait::async_trait;
878 use metrics_util::debugging::DebugValue;
879 use serde_json::json;
880
881 struct MockSink(std::sync::Mutex<Vec<Value>>);
882 #[async_trait]
883 impl Sink for MockSink {
884 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
885 self.0.lock().unwrap().extend(records.iter().cloned());
886 Ok(records.len())
887 }
888 fn connector_name(&self) -> &'static str {
889 "mock-sink"
890 }
891 }
892
893 struct FailingSink;
894 #[async_trait]
895 impl Sink for FailingSink {
896 async fn write_batch(&self, _: &[Value]) -> Result<usize, FaucetError> {
897 Err(FaucetError::Sink("nope".into()))
898 }
899 fn connector_name(&self) -> &'static str {
900 "failing-sink"
901 }
902 }
903
904 struct EmptyNameSink;
905 #[async_trait]
906 impl Sink for EmptyNameSink {
907 async fn write_batch(&self, _: &[Value]) -> Result<usize, FaucetError> {
908 Ok(0)
909 }
910 fn connector_name(&self) -> &'static str {
911 ""
912 }
913 }
914
915 #[test]
916 fn empty_inner_connector_name_falls_back_to_unknown() {
917 let inner = EmptyNameSink;
918 let wrapped = InstrumentedSink {
922 inner: &inner,
923 labels: labels(),
924 connector: SharedString::const_str("unknown"),
925 base_labels: Vec::new(),
926 };
927 assert_eq!(
928 Sink::connector_name(&wrapped),
929 "unknown",
930 "instrumented sink must not leak an empty connector name"
931 );
932 }
933
934 struct CapableSink;
942 #[async_trait]
943 impl Sink for CapableSink {
944 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
945 Ok(records.len())
946 }
947 fn connector_name(&self) -> &'static str {
948 "capable-sink"
949 }
950 async fn current_schema(&self) -> Result<Option<Value>, FaucetError> {
951 Ok(Some(
952 json!({"type": "object", "properties": {"id": {"type": "integer"}}}),
953 ))
954 }
955 fn supports_schema_evolution(&self) -> bool {
956 true
957 }
958 fn supports_idempotent_writes(&self) -> bool {
959 true
960 }
961 fn supported_write_modes(&self) -> &'static [crate::write_mode::WriteMode] {
962 &[
963 crate::write_mode::WriteMode::Append,
964 crate::write_mode::WriteMode::Upsert,
965 ]
966 }
967 async fn last_committed_token(&self, _scope: &str) -> Result<Option<String>, FaucetError> {
968 Ok(Some("tok-1".into()))
969 }
970 fn dedups_by_key(&self) -> bool {
971 true
972 }
973 }
974
975 #[tokio::test]
976 async fn instrumented_sink_forwards_capability_methods_to_inner() {
977 let inner = CapableSink;
978 let wrapped = InstrumentedSink::new(&inner, labels());
979
980 assert_eq!(
983 wrapped.current_schema().await.unwrap(),
984 Some(json!({"type": "object", "properties": {"id": {"type": "integer"}}})),
985 "current_schema must delegate to the inner sink"
986 );
987 assert!(
988 wrapped.supports_schema_evolution(),
989 "supports_schema_evolution must delegate"
990 );
991 assert!(
993 wrapped.supports_idempotent_writes(),
994 "supports_idempotent_writes must delegate (exactly-once)"
995 );
996 assert!(
997 wrapped
998 .supported_write_modes()
999 .contains(&crate::write_mode::WriteMode::Upsert),
1000 "supported_write_modes must delegate"
1001 );
1002 assert_eq!(
1003 wrapped.last_committed_token("scope").await.unwrap(),
1004 Some("tok-1".to_string()),
1005 "last_committed_token must delegate"
1006 );
1007 assert_eq!(
1010 wrapped.sink_guarantee(),
1011 crate::idempotency::SinkGuarantee::AtomicWatermark,
1012 "sink_guarantee must delegate"
1013 );
1014 assert!(wrapped.dedups_by_key(), "dedups_by_key must delegate");
1015 }
1016
1017 #[tokio::test]
1018 #[allow(clippy::await_holding_lock)]
1019 async fn records_writes_and_records_counters() {
1020 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1021 let snap = snapshotter();
1022 let inner = MockSink(std::sync::Mutex::new(Vec::new()));
1023 let wrapped = InstrumentedSink::new(&inner, labels());
1024 wrapped
1025 .write_batch(&[json!({"a": 1}), json!({"a": 2})])
1026 .await
1027 .unwrap();
1028 let snapshot = snap.snapshot();
1029 let writes: u64 = snapshot
1030 .into_vec()
1031 .into_iter()
1032 .filter_map(|(key, _u, _d, v)| {
1033 if key.key().name() == "faucet_sink_writes_total"
1034 && let DebugValue::Counter(c) = v
1035 {
1036 return Some(c);
1037 }
1038 None
1039 })
1040 .sum();
1041 assert!(writes >= 1, "expected at least one write counted");
1042 }
1043
1044 #[tokio::test]
1045 #[allow(clippy::await_holding_lock)]
1046 async fn error_increments_errors_total_with_kind() {
1047 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1048 let snap = snapshotter();
1049 let inner = FailingSink;
1050 let wrapped = InstrumentedSink::new(&inner, labels());
1051 let _ = wrapped.write_batch(&[json!({})]).await;
1052 let snapshot = snap.snapshot();
1053 let found = snapshot.into_vec().into_iter().any(|(key, _u, _d, v)| {
1054 key.key().name() == "faucet_sink_errors_total"
1055 && key
1056 .key()
1057 .labels()
1058 .any(|l| l.key() == "kind" && l.value() == "Sink")
1059 && matches!(v, DebugValue::Counter(c) if c >= 1)
1060 });
1061 assert!(found, "expected sink_errors_total with kind=Sink");
1062 }
1063
1064 #[tokio::test]
1065 #[allow(clippy::await_holding_lock)]
1066 async fn instrumented_sink_write_batch_partial_counts_successful_outcomes() {
1067 use crate::traits::RowOutcome;
1068 use metrics_util::debugging::DebugValue;
1069
1070 struct MixedSink;
1072 #[async_trait]
1073 impl Sink for MixedSink {
1074 async fn write_batch(&self, _r: &[Value]) -> Result<usize, FaucetError> {
1075 unreachable!()
1076 }
1077 async fn write_batch_partial(
1078 &self,
1079 _r: &[Value],
1080 ) -> Result<Vec<RowOutcome>, FaucetError> {
1081 Ok(vec![
1082 Ok(()),
1083 Err(FaucetError::Sink("bad row".into())),
1084 Ok(()),
1085 ])
1086 }
1087 fn connector_name(&self) -> &'static str {
1088 "mixed"
1089 }
1090 }
1091
1092 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1093 let snap = snapshotter();
1094
1095 let inner = MixedSink;
1096 let wrapped = InstrumentedSink::new(&inner, labels());
1097 let _ = wrapped
1098 .write_batch_partial(&[json!({}), json!({}), json!({})])
1099 .await
1100 .unwrap();
1101
1102 let snapshot = snap.snapshot();
1110 let records: u64 = snapshot
1111 .into_vec()
1112 .into_iter()
1113 .filter_map(|(k, _u, _d, v): (metrics_util::CompositeKey, _, _, _)| {
1114 if k.key().name() == "faucet_sink_records_total"
1115 && k.key()
1116 .labels()
1117 .any(|l| l.key() == "connector" && l.value() == "mixed")
1118 && let DebugValue::Counter(c) = v
1119 {
1120 Some(c)
1121 } else {
1122 None
1123 }
1124 })
1125 .sum();
1126 assert!(
1127 records >= 2,
1128 "expected faucet_sink_records_total{{connector=mixed}} >= 2, got {records}"
1129 );
1130 }
1131
1132 #[tokio::test]
1135 #[allow(clippy::await_holding_lock)]
1136 async fn flush_error_increments_errors_total_and_propagates() {
1137 struct FlushFailSink;
1140 #[async_trait]
1141 impl Sink for FlushFailSink {
1142 async fn write_batch(&self, r: &[Value]) -> Result<usize, FaucetError> {
1143 Ok(r.len())
1144 }
1145 async fn flush(&self) -> Result<(), FaucetError> {
1146 Err(FaucetError::Sink("flush boom".into()))
1147 }
1148 fn connector_name(&self) -> &'static str {
1149 "flush-fail-sink"
1150 }
1151 }
1152
1153 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1154 let snap = snapshotter();
1155 let inner = FlushFailSink;
1156 let wrapped = InstrumentedSink::new(&inner, labels());
1157 let err = wrapped.flush().await.unwrap_err();
1158 assert!(matches!(&err, FaucetError::Sink(m) if m.contains("flush boom")));
1159
1160 let snapshot = snap.snapshot();
1161 let found = snapshot.into_vec().into_iter().any(|(key, _u, _d, v)| {
1162 key.key().name() == "faucet_sink_errors_total"
1163 && key
1164 .key()
1165 .labels()
1166 .any(|l| l.key() == "connector" && l.value() == "flush-fail-sink")
1167 && key
1168 .key()
1169 .labels()
1170 .any(|l| l.key() == "kind" && l.value() == "Sink")
1171 && matches!(v, DebugValue::Counter(c) if c >= 1)
1172 });
1173 assert!(
1174 found,
1175 "expected sink_errors_total{{connector=flush-fail-sink,kind=Sink}}"
1176 );
1177 }
1178
1179 struct PanickingSink;
1182 #[async_trait]
1183 impl Sink for PanickingSink {
1184 async fn write_batch(&self, _: &[Value]) -> Result<usize, FaucetError> {
1185 panic!("write kaboom")
1186 }
1187 async fn write_batch_partial(
1188 &self,
1189 _: &[Value],
1190 ) -> Result<Vec<crate::traits::RowOutcome>, FaucetError> {
1191 panic!("partial kaboom")
1192 }
1193 async fn flush(&self) -> Result<(), FaucetError> {
1194 panic!("flush kaboom")
1195 }
1196 fn connector_name(&self) -> &'static str {
1197 "panic-sink"
1198 }
1199 }
1200
1201 #[tokio::test]
1202 #[allow(clippy::await_holding_lock)]
1203 async fn write_batch_panic_maps_to_custom_error() {
1204 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1205 let _snap = snapshotter();
1206 let inner = PanickingSink;
1207 let wrapped = InstrumentedSink::new(&inner, labels());
1208 let err = wrapped.write_batch(&[json!({})]).await.unwrap_err();
1209 match err {
1210 FaucetError::Custom(b) => {
1211 assert!(b.to_string().contains("panic in sink: write kaboom"))
1212 }
1213 other => panic!("expected Custom panic error, got {other:?}"),
1214 }
1215 }
1216
1217 #[tokio::test]
1218 #[allow(clippy::await_holding_lock)]
1219 async fn write_batch_partial_panic_maps_to_custom_error() {
1220 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1221 let _snap = snapshotter();
1222 let inner = PanickingSink;
1223 let wrapped = InstrumentedSink::new(&inner, labels());
1224 let err = wrapped.write_batch_partial(&[json!({})]).await.unwrap_err();
1225 match err {
1226 FaucetError::Custom(b) => {
1227 assert!(b.to_string().contains("panic in sink: partial kaboom"))
1228 }
1229 other => panic!("expected Custom panic error, got {other:?}"),
1230 }
1231 }
1232
1233 #[tokio::test]
1234 #[allow(clippy::await_holding_lock)]
1235 async fn flush_panic_maps_to_custom_error() {
1236 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1237 let _snap = snapshotter();
1238 let inner = PanickingSink;
1239 let wrapped = InstrumentedSink::new(&inner, labels());
1240 let err = wrapped.flush().await.unwrap_err();
1241 match err {
1242 FaucetError::Custom(b) => {
1243 assert!(b.to_string().contains("panic in flush: flush kaboom"))
1244 }
1245 other => panic!("expected Custom panic error, got {other:?}"),
1246 }
1247 }
1248}