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