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