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