1use crate::error::FaucetError;
5use crate::observability::labels::Labels;
6use crate::observability::timer::DurationGuard;
7use crate::pipeline::StreamPage;
8use crate::traits::{Sink, Source};
9use crate::usage::{UsageMeter, estimate_page_bytes};
10use async_trait::async_trait;
11use futures::FutureExt;
12use futures_core::Stream;
13use metrics::{Label, SharedString, counter, gauge};
14use serde_json::Value;
15use std::collections::HashMap;
16use std::panic::AssertUnwindSafe;
17use std::pin::Pin;
18use std::sync::Arc;
19use std::sync::atomic::{AtomicUsize, Ordering};
20use tracing::{Instrument, info_span};
21
22fn metered_payload(
25 payload: crate::native::NativePayload,
26 labels: Vec<Label>,
27 metric: &'static str,
28 add: impl Fn(u64) + Send + Sync + 'static,
29) -> crate::native::NativePayload {
30 use crate::native::NativePayload;
31 use futures::StreamExt;
32 match payload {
33 NativePayload::Bytes(b) => {
34 counter!(metric, labels).increment(b.len() as u64);
35 add(b.len() as u64);
36 NativePayload::Bytes(b)
37 }
38 NativePayload::Stream(s) => NativePayload::Stream(Box::pin(s.inspect(move |chunk| {
39 if let Ok(c) = chunk {
40 counter!(metric, labels.clone()).increment(c.len() as u64);
41 add(c.len() as u64);
42 }
43 }))),
44 }
45}
46
47fn panic_message(panic: &(dyn std::any::Any + Send)) -> String {
48 panic
49 .downcast_ref::<&'static str>()
50 .map(|s| (*s).to_string())
51 .or_else(|| panic.downcast_ref::<String>().cloned())
52 .unwrap_or_else(|| "<non-string panic payload>".to_string())
53}
54
55fn guarded_connector_name(raw: &'static str) -> &'static str {
59 if raw.is_empty() { "unknown" } else { raw }
60}
61
62fn base_metric_labels(labels: &Labels, connector: &SharedString) -> Vec<Label> {
67 vec![
68 Label::new("pipeline", SharedString::from(labels.pipeline.to_string())),
69 Label::new("row", SharedString::from(labels.row.to_string())),
70 Label::new("connector", connector.clone()),
71 ]
72}
73
74pub struct InstrumentedSource<'a, S: Source + ?Sized> {
78 inner: &'a S,
79 labels: Labels,
80 connector: SharedString,
81 base_labels: Vec<Label>,
83 page_index: Arc<AtomicUsize>,
84 meter: Option<Arc<UsageMeter>>,
86}
87
88impl<'a, S: Source + ?Sized> InstrumentedSource<'a, S> {
89 pub fn new(inner: &'a S, labels: Labels) -> Self {
90 let raw = inner.connector_name();
91 debug_assert!(
92 !raw.is_empty(),
93 "connector_name() must return a non-empty string"
94 );
95 let connector: SharedString = SharedString::const_str(guarded_connector_name(raw));
96 let base_labels = base_metric_labels(&labels, &connector);
97 Self {
98 inner,
99 labels,
100 connector,
101 base_labels,
102 page_index: Arc::new(AtomicUsize::new(0)),
103 meter: None,
104 }
105 }
106
107 pub fn with_meter(mut self, meter: Arc<UsageMeter>) -> Self {
109 self.meter = Some(meter);
110 self
111 }
112
113 fn metric_labels(&self) -> Vec<Label> {
114 self.base_labels.clone()
115 }
116
117 #[allow(dead_code)]
121 fn error_labels(&self, kind: &'static str) -> Vec<Label> {
122 let mut l = self.metric_labels();
123 l.push(Label::new("kind", SharedString::const_str(kind)));
124 l
125 }
126}
127
128#[async_trait]
129impl<'a, S: Source + ?Sized> Source for InstrumentedSource<'a, S> {
130 fn connector_name(&self) -> &'static str {
131 guarded_connector_name(self.inner.connector_name())
135 }
136
137 fn set_roundtrip_recorder(
141 &self,
142 recorder: std::sync::Arc<crate::observability::RoundtripRecorder>,
143 ) {
144 self.inner.set_roundtrip_recorder(recorder);
145 }
146
147 fn set_run_clock(&self, now: chrono::DateTime<chrono::Utc>) {
148 self.inner.set_run_clock(now);
149 }
150
151 fn state_key(&self) -> Option<String> {
152 self.inner.state_key()
153 }
154
155 async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
156 self.inner.apply_start_bookmark(bookmark).await
157 }
158
159 fn supports_exactly_once(&self) -> bool {
160 self.inner.supports_exactly_once()
161 }
162 fn consumes_destructively(&self) -> bool {
163 self.inner.consumes_destructively()
164 }
165
166 fn replay_guarantee(&self) -> crate::idempotency::ReplayGuarantee {
167 self.inner.replay_guarantee()
168 }
169
170 async fn capture_resume_position(&self) -> Result<Option<Value>, FaucetError> {
171 self.inner.capture_resume_position().await
172 }
173 async fn lag(&self) -> Result<Option<crate::lag::SourceLag>, FaucetError> {
174 self.inner.lag().await
175 }
176
177 fn state_schema(&self) -> u32 {
178 self.inner.state_schema()
179 }
180
181 fn migrate_state(&self, from: u32, data: Value) -> Result<Value, FaucetError> {
182 self.inner.migrate_state(from, data)
183 }
184
185 fn record_table(&self, record: &Value) -> Option<String> {
186 self.inner.record_table(record)
187 }
188
189 fn position_le(&self, a: &Value, b: &Value) -> Option<bool> {
190 self.inner.position_le(a, b)
191 }
192
193 fn position_min(&self, positions: &[Value]) -> Option<Value> {
194 self.inner.position_min(positions)
195 }
196
197 async fn fetch_with_context(
198 &self,
199 context: &HashMap<String, Value>,
200 ) -> Result<Vec<Value>, FaucetError> {
201 self.inner.fetch_with_context(context).await
203 }
204
205 async fn fetch_with_context_incremental(
206 &self,
207 context: &HashMap<String, Value>,
208 ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
209 self.inner.fetch_with_context_incremental(context).await
210 }
211
212 #[cfg(feature = "arrow")]
216 fn supports_columnar(&self) -> bool {
217 self.inner.supports_columnar()
218 }
219
220 #[cfg(feature = "arrow")]
221 fn stream_batches<'b>(
222 &'b self,
223 context: &'b HashMap<String, Value>,
224 batch_size: usize,
225 ) -> Pin<Box<dyn Stream<Item = Result<crate::columnar::ColumnarPage, FaucetError>> + Send + 'b>>
226 {
227 use futures::StreamExt;
228 let inner = self.inner.stream_batches(context, batch_size);
229 let Some(meter) = self.meter.clone() else {
230 return inner;
231 };
232 let labels = self.metric_labels();
233 Box::pin(inner.inspect(move |page| {
234 if let Ok(page) = page {
235 let bytes = page.batch.get_array_memory_size() as u64;
236 counter!("faucet_source_bytes_total", labels.clone()).increment(bytes);
237 meter.add_read(page.batch.num_rows() as u64, bytes);
238 }
239 }))
240 }
241
242 fn native_output_formats(&self) -> &'static [crate::native::NativeFormat] {
245 self.inner.native_output_formats()
246 }
247
248 fn stream_native<'b>(
249 &'b self,
250 context: &'b HashMap<String, Value>,
251 format: crate::native::NativeFormat,
252 batch_size: usize,
253 ) -> Pin<Box<dyn Stream<Item = Result<crate::native::NativeBatch, FaucetError>> + Send + 'b>>
254 {
255 use futures::StreamExt;
256 let inner = self.inner.stream_native(context, format, batch_size);
257 let Some(meter) = self.meter.clone() else {
258 return inner;
259 };
260 let labels = self.metric_labels();
261 Box::pin(inner.map(move |batch| {
262 batch.map(|mut b| {
263 let records = b.records.unwrap_or(0);
264 b.payload =
265 metered_payload(b.payload, labels.clone(), "faucet_source_bytes_total", {
266 let meter = Arc::clone(&meter);
267 move |n| meter.add_read(0, n)
268 });
269 meter.add_read(records, 0);
270 b
271 })
272 }))
273 }
274
275 fn stream_pages<'b>(
276 &'b self,
277 context: &'b HashMap<String, Value>,
278 batch_size: usize,
279 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'b>> {
280 let inner_stream = self.inner.stream_pages(context, batch_size);
281 let labels = self.labels.clone();
282 let connector = self.connector.clone();
283 let page_index = Arc::clone(&self.page_index);
284 let meter = self.meter.clone();
285 let metric_labels = self.metric_labels();
286 let pipeline = self.labels.pipeline.clone();
287 let row = self.labels.row.clone();
288
289 Box::pin(async_stream::try_stream! {
290 struct InFlightGuard(Vec<Label>);
293 impl Drop for InFlightGuard {
294 fn drop(&mut self) {
295 gauge!("faucet_source_in_flight", self.0.clone()).decrement(1.0);
296 }
297 }
298 gauge!("faucet_source_in_flight", metric_labels.clone()).increment(1.0);
299 let _in_flight = InFlightGuard(metric_labels.clone());
300
301 let mut inner = inner_stream;
302 loop {
303 let idx = page_index.fetch_add(1, Ordering::Relaxed);
304 let span = info_span!(
305 "faucet.source.page",
306 pipeline = %pipeline,
307 row = %row,
308 run_id = %labels.run_id,
309 connector = %connector,
310 page_index = idx,
311 );
312 let mut _timer = DurationGuard::new(
317 "faucet_source_page_duration_seconds",
318 metric_labels.clone(),
319 );
320
321 let next = AssertUnwindSafe(async {
322 use futures::StreamExt;
323 inner.next().await
324 })
325 .catch_unwind()
326 .instrument(span)
327 .await;
328
329 match next {
330 Ok(Some(Ok(page))) => {
331 counter!("faucet_source_pages_total", metric_labels.clone()).increment(1);
332 counter!("faucet_source_records_total", metric_labels.clone())
333 .increment(page.records.len() as u64);
334 if let Some(m) = &meter {
335 let bytes = estimate_page_bytes(&page.records);
336 counter!("faucet_source_bytes_total", metric_labels.clone())
337 .increment(bytes);
338 m.add_read(page.records.len() as u64, bytes);
339 }
340 _timer.record_now();
346 yield page;
347 }
348 Ok(Some(Err(e))) => {
349 let mut l = metric_labels.clone();
350 l.push(Label::new("kind", SharedString::const_str(error_kind(&e))));
351 counter!("faucet_source_errors_total", l).increment(1);
352 Err(e)?;
353 }
354 Ok(None) => {
355 _timer.disarm();
356 break;
357 }
358 Err(panic) => {
359 let mut l = metric_labels.clone();
360 l.push(Label::new("kind", SharedString::const_str("Panic")));
361 counter!("faucet_source_errors_total", l).increment(1);
362 let msg = panic.downcast_ref::<&'static str>().map(|s| (*s).to_string())
363 .or_else(|| panic.downcast_ref::<String>().cloned())
364 .unwrap_or_else(|| "<non-string panic payload>".to_string());
365 Err(FaucetError::Custom(format!("panic in source: {msg}").into()))?;
366 }
367 }
368 }
369 })
370 }
371}
372
373pub(crate) fn error_kind(e: &FaucetError) -> &'static str {
376 match e {
377 FaucetError::Http(_) => "Http",
378 FaucetError::HttpStatus { .. } => "HttpStatus",
379 FaucetError::Json(_) => "Json",
380 FaucetError::JsonPath(_) => "JsonPath",
381 FaucetError::Auth(_) => "Auth",
382 FaucetError::RateLimited { .. } => "RateLimited",
383 FaucetError::Url(_) => "Url",
384 FaucetError::Transform(_) => "Transform",
385 FaucetError::Config(_) => "Config",
386 FaucetError::Source(_) => "Source",
387 FaucetError::Sink(_) => "Sink",
388 FaucetError::QualityFailure { .. } => "QualityFailure",
389 FaucetError::SchemaDrift { .. } => "SchemaDrift",
390 FaucetError::ProfileDrift { .. } => "ProfileDrift",
391 FaucetError::PolicyViolation { .. } => "PolicyViolation",
392 FaucetError::BudgetExceeded { .. } => "BudgetExceeded",
393 FaucetError::ContractViolation { .. } => "ContractViolation",
394 FaucetError::State(_) => "State",
395 FaucetError::StateIncompatible { .. } => "StateIncompatible",
396 FaucetError::CircuitOpen { .. } => "CircuitOpen",
397 FaucetError::Custom(_) => "Custom",
398 }
399}
400
401pub struct InstrumentedSink<'a, S: Sink + ?Sized> {
404 inner: &'a S,
405 labels: Labels,
406 connector: SharedString,
407 base_labels: Vec<Label>,
409 meter: Option<Arc<UsageMeter>>,
411}
412
413impl<'a, S: Sink + ?Sized> InstrumentedSink<'a, S> {
414 pub fn new(inner: &'a S, labels: Labels) -> Self {
415 let raw = inner.connector_name();
416 debug_assert!(
417 !raw.is_empty(),
418 "connector_name() must return a non-empty string"
419 );
420 let connector: SharedString = SharedString::const_str(guarded_connector_name(raw));
421 let base_labels = base_metric_labels(&labels, &connector);
422 Self {
423 inner,
424 labels,
425 connector,
426 base_labels,
427 meter: None,
428 }
429 }
430
431 pub fn with_meter(mut self, meter: Arc<UsageMeter>) -> Self {
434 self.meter = Some(meter);
435 self
436 }
437
438 fn metric_labels(&self) -> Vec<Label> {
439 self.base_labels.clone()
440 }
441
442 fn error_labels(&self, kind: &'static str) -> Vec<Label> {
443 let mut l = self.metric_labels();
444 l.push(Label::new("kind", SharedString::const_str(kind)));
445 l
446 }
447
448 fn meter_written(&self, records: &[Value], accepted: usize) {
451 let Some(m) = &self.meter else {
452 return;
453 };
454 let bytes = if accepted >= records.len() {
455 estimate_page_bytes(records)
456 } else {
457 estimate_page_bytes(&records[..accepted])
458 };
459 counter!("faucet_sink_bytes_total", self.metric_labels()).increment(bytes);
460 m.add_written(accepted as u64, bytes);
461 }
462}
463
464#[async_trait]
465impl<'a, S: Sink + ?Sized> Sink for InstrumentedSink<'a, S> {
466 async fn admit_page(&self, records: &[Value]) -> Result<(), FaucetError> {
467 self.inner.admit_page(records).await
468 }
469 fn connector_name(&self) -> &'static str {
470 guarded_connector_name(self.inner.connector_name())
474 }
475
476 fn set_roundtrip_recorder(
480 &self,
481 recorder: std::sync::Arc<crate::observability::RoundtripRecorder>,
482 ) {
483 self.inner.set_roundtrip_recorder(recorder);
484 }
485
486 fn dataset_uri(&self) -> String {
493 self.inner.dataset_uri()
494 }
495
496 async fn local_outputs(&self) -> Vec<crate::local_outputs::LocalOutput> {
497 self.inner.local_outputs().await
498 }
499
500 #[cfg(feature = "arrow")]
503 fn supports_columnar(&self) -> bool {
504 self.inner.supports_columnar()
505 }
506
507 #[cfg(feature = "arrow")]
508 async fn write_batch_columnar(
509 &self,
510 batch: &arrow::array::RecordBatch,
511 ) -> Result<usize, FaucetError> {
512 let n = self.inner.write_batch_columnar(batch).await?;
513 if let Some(m) = &self.meter {
514 let rows = batch.num_rows().max(1);
515 let bytes = (batch.get_array_memory_size() as u64).saturating_mul(n.min(rows) as u64)
516 / rows as u64;
517 counter!("faucet_sink_bytes_total", self.metric_labels()).increment(bytes);
518 m.add_written(n as u64, bytes);
519 }
520 Ok(n)
521 }
522
523 fn native_load_capabilities(&self) -> Vec<crate::native::NativeLoadCapability> {
526 self.inner.native_load_capabilities()
527 }
528
529 async fn load_native(
530 &self,
531 batch: crate::native::NativeBatch,
532 scope: &str,
533 ctx: crate::native::NativeLoadContext,
534 ) -> Result<usize, FaucetError> {
535 let Some(meter) = self.meter.clone() else {
536 return self.inner.load_native(batch, scope, ctx).await;
537 };
538 let bytes = Arc::new(std::sync::atomic::AtomicU64::new(0));
539 let mut batch = batch;
540 batch.payload = metered_payload(
541 batch.payload,
542 self.metric_labels(),
543 "faucet_sink_bytes_total",
544 {
545 let bytes = Arc::clone(&bytes);
546 move |n| {
547 bytes.fetch_add(n, Ordering::Relaxed);
548 }
549 },
550 );
551 let n = self.inner.load_native(batch, scope, ctx).await?;
552 meter.add_written(n as u64, bytes.load(Ordering::Relaxed));
553 Ok(n)
554 }
555
556 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
557 let span = info_span!(
558 "faucet.sink.write",
559 pipeline = %self.labels.pipeline,
560 row = %self.labels.row,
561 run_id = %self.labels.run_id,
562 connector = %self.connector,
563 records = records.len(),
564 );
565 let metric_labels = self.metric_labels();
566 gauge!("faucet_sink_in_flight", metric_labels.clone()).increment(1.0);
567
568 struct InFlightGuard(Vec<Label>);
571 impl Drop for InFlightGuard {
572 fn drop(&mut self) {
573 gauge!("faucet_sink_in_flight", self.0.clone()).decrement(1.0);
574 }
575 }
576 let _in_flight = InFlightGuard(metric_labels.clone());
577
578 let _timer =
579 DurationGuard::new("faucet_sink_write_duration_seconds", metric_labels.clone());
580
581 let result = AssertUnwindSafe(self.inner.write_batch(records))
582 .catch_unwind()
583 .instrument(span)
584 .await;
585
586 match result {
587 Ok(Ok(n)) => {
588 counter!("faucet_sink_writes_total", metric_labels.clone()).increment(1);
589 counter!("faucet_sink_records_total", metric_labels.clone()).increment(n as u64);
590 self.meter_written(records, n);
591 Ok(n)
592 }
593 Ok(Err(e)) => {
594 counter!(
595 "faucet_sink_errors_total",
596 self.error_labels(error_kind(&e))
597 )
598 .increment(1);
599 Err(e)
600 }
601 Err(panic) => {
602 counter!("faucet_sink_errors_total", self.error_labels("Panic")).increment(1);
603 let msg = panic
604 .downcast_ref::<&'static str>()
605 .map(|s| (*s).to_string())
606 .or_else(|| panic.downcast_ref::<String>().cloned())
607 .unwrap_or_else(|| "<non-string panic payload>".to_string());
608 Err(FaucetError::Custom(format!("panic in sink: {msg}").into()))
609 }
610 }
611 }
612
613 async fn write_batch_partial(
614 &self,
615 records: &[Value],
616 ) -> Result<Vec<crate::traits::RowOutcome>, FaucetError> {
617 let span = info_span!(
618 "faucet.sink.write_partial",
619 pipeline = %self.labels.pipeline,
620 row = %self.labels.row,
621 run_id = %self.labels.run_id,
622 connector = %self.connector,
623 records = records.len(),
624 );
625 let metric_labels = self.metric_labels();
626 gauge!("faucet_sink_in_flight", metric_labels.clone()).increment(1.0);
627
628 struct InFlightGuard(Vec<Label>);
631 impl Drop for InFlightGuard {
632 fn drop(&mut self) {
633 gauge!("faucet_sink_in_flight", self.0.clone()).decrement(1.0);
634 }
635 }
636 let _in_flight = InFlightGuard(metric_labels.clone());
637
638 let _timer =
639 DurationGuard::new("faucet_sink_write_duration_seconds", metric_labels.clone());
640
641 let result = AssertUnwindSafe(self.inner.write_batch_partial(records))
642 .catch_unwind()
643 .instrument(span)
644 .await;
645
646 match result {
647 Ok(Ok(outcomes)) => {
648 let success_count = outcomes.iter().filter(|o| o.is_ok()).count();
649 counter!("faucet_sink_writes_total", metric_labels.clone()).increment(1);
650 counter!("faucet_sink_records_total", metric_labels.clone())
651 .increment(success_count as u64);
652 if let Some(m) = &self.meter {
653 let bytes: u64 = outcomes
654 .iter()
655 .zip(records.iter())
656 .filter(|(o, _)| o.is_ok())
657 .map(|(_, r)| crate::usage::estimate_json_bytes(r))
658 .sum();
659 counter!("faucet_sink_bytes_total", metric_labels.clone()).increment(bytes);
660 m.add_written(success_count as u64, bytes);
661 }
662 Ok(outcomes)
663 }
664 Ok(Err(e)) => {
665 counter!(
666 "faucet_sink_errors_total",
667 self.error_labels(error_kind(&e))
668 )
669 .increment(1);
670 Err(e)
671 }
672 Err(panic) => {
673 counter!("faucet_sink_errors_total", self.error_labels("Panic")).increment(1);
674 let msg = panic
675 .downcast_ref::<&'static str>()
676 .map(|s| (*s).to_string())
677 .or_else(|| panic.downcast_ref::<String>().cloned())
678 .unwrap_or_else(|| "<non-string panic payload>".to_string());
679 Err(FaucetError::Custom(format!("panic in sink: {msg}").into()))
680 }
681 }
682 }
683
684 async fn flush(&self) -> Result<(), FaucetError> {
685 let span = info_span!(
686 "faucet.sink.flush",
687 pipeline = %self.labels.pipeline,
688 row = %self.labels.row,
689 run_id = %self.labels.run_id,
690 connector = %self.connector,
691 );
692 let metric_labels = self.metric_labels();
693 let _timer =
694 DurationGuard::new("faucet_sink_flush_duration_seconds", metric_labels.clone());
695
696 let result = AssertUnwindSafe(self.inner.flush())
697 .catch_unwind()
698 .instrument(span)
699 .await;
700
701 match result {
702 Ok(Ok(())) => Ok(()),
703 Ok(Err(e)) => {
704 counter!(
705 "faucet_sink_errors_total",
706 self.error_labels(error_kind(&e))
707 )
708 .increment(1);
709 Err(e)
710 }
711 Err(panic) => {
712 counter!("faucet_sink_errors_total", self.error_labels("Panic")).increment(1);
713 let msg = panic
714 .downcast_ref::<&'static str>()
715 .map(|s| (*s).to_string())
716 .or_else(|| panic.downcast_ref::<String>().cloned())
717 .unwrap_or_else(|| "<non-string panic payload>".to_string());
718 Err(FaucetError::Custom(format!("panic in flush: {msg}").into()))
719 }
720 }
721 }
722
723 async fn current_schema(&self) -> Result<Option<Value>, FaucetError> {
731 self.inner.current_schema().await
732 }
733
734 fn supports_schema_evolution(&self) -> bool {
735 self.inner.supports_schema_evolution()
736 }
737
738 async fn evolve_schema(
739 &self,
740 evolution: &crate::drift::SchemaEvolution,
741 ) -> Result<(), FaucetError> {
742 self.inner.evolve_schema(evolution).await
743 }
744
745 fn supported_write_modes(&self) -> &'static [crate::write_mode::WriteMode] {
746 self.inner.supported_write_modes()
747 }
748
749 fn supports_cleanup(&self) -> bool {
750 self.inner.supports_cleanup()
751 }
752
753 async fn cleanup_scope(
754 &self,
755 scope: &std::collections::BTreeMap<String, Value>,
756 seen: &crate::cleanup::SeenKeys,
757 ) -> Result<u64, FaucetError> {
758 self.inner.cleanup_scope(scope, seen).await
759 }
760
761 fn supports_idempotent_writes(&self) -> bool {
762 self.inner.supports_idempotent_writes()
763 }
764
765 fn sink_guarantee(&self) -> crate::idempotency::SinkGuarantee {
766 self.inner.sink_guarantee()
767 }
768
769 fn dedups_by_key(&self) -> bool {
770 self.inner.dedups_by_key()
771 }
772 fn batch_atomicity(&self) -> crate::dlq::BatchAtomicity {
773 self.inner.batch_atomicity()
774 }
775
776 async fn write_batch_idempotent(
777 &self,
778 records: &[Value],
779 scope: &str,
780 token: &str,
781 ) -> Result<usize, FaucetError> {
782 let span = info_span!(
783 "faucet.sink.write_idempotent",
784 pipeline = %self.labels.pipeline,
785 row = %self.labels.row,
786 run_id = %self.labels.run_id,
787 connector = %self.connector,
788 records = records.len(),
789 );
790 let metric_labels = self.metric_labels();
791 gauge!("faucet_sink_in_flight", metric_labels.clone()).increment(1.0);
792 struct InFlightGuard(Vec<Label>);
793 impl Drop for InFlightGuard {
794 fn drop(&mut self) {
795 gauge!("faucet_sink_in_flight", self.0.clone()).decrement(1.0);
796 }
797 }
798 let _in_flight = InFlightGuard(metric_labels.clone());
799 let _timer =
800 DurationGuard::new("faucet_sink_write_duration_seconds", metric_labels.clone());
801
802 let result = AssertUnwindSafe(self.inner.write_batch_idempotent(records, scope, token))
803 .catch_unwind()
804 .instrument(span)
805 .await;
806 match result {
807 Ok(Ok(n)) => {
808 counter!("faucet_sink_writes_total", metric_labels.clone()).increment(1);
809 counter!("faucet_sink_records_total", metric_labels).increment(n as u64);
810 self.meter_written(records, n);
811 Ok(n)
812 }
813 Ok(Err(e)) => {
814 counter!(
815 "faucet_sink_errors_total",
816 self.error_labels(error_kind(&e))
817 )
818 .increment(1);
819 Err(e)
820 }
821 Err(panic) => {
822 counter!("faucet_sink_errors_total", self.error_labels("Panic")).increment(1);
823 Err(FaucetError::Custom(
824 format!("panic in sink: {}", panic_message(&*panic)).into(),
825 ))
826 }
827 }
828 }
829
830 async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
831 self.inner.last_committed_token(scope).await
832 }
833
834 fn is_overwrite(&self) -> bool {
835 self.inner.is_overwrite()
836 }
837
838 async fn begin_overwrite(&self) -> Result<(), FaucetError> {
839 self.inner.begin_overwrite().await
840 }
841
842 async fn commit_overwrite(&self) -> Result<(), FaucetError> {
843 self.inner.commit_overwrite().await
844 }
845
846 async fn abort_overwrite(&self) -> Result<(), FaucetError> {
847 self.inner.abort_overwrite().await
848 }
849 async fn complete_run(&self) -> Result<(), FaucetError> {
850 self.inner.complete_run().await
851 }
852
853 fn write_batch_is_replay_safe(&self) -> bool {
854 self.inner.write_batch_is_replay_safe()
855 }
856
857 fn supports_staged_load(&self) -> bool {
858 self.inner.supports_staged_load()
859 }
860
861 async fn overwrite_staging_exists(&self) -> Result<Option<bool>, FaucetError> {
862 self.inner.overwrite_staging_exists().await
863 }
864
865 fn supports_rollback(&self) -> bool {
866 self.inner.supports_rollback()
867 }
868
869 async fn rollback_run(
870 &self,
871 run_id: &str,
872 opts: &crate::rollback::RollbackOptions,
873 ) -> Result<crate::rollback::RollbackOutcome, FaucetError> {
874 self.inner.rollback_run(run_id, opts).await
875 }
876
877 async fn forget_run(&self, run_id: &str) -> Result<(), FaucetError> {
878 self.inner.forget_run(run_id).await
879 }
880
881 async fn rewind_commit_token(
882 &self,
883 scope: &str,
884 token: Option<&str>,
885 ) -> Result<(), FaucetError> {
886 self.inner.rewind_commit_token(scope, token).await
887 }
888
889 fn readback_source(&self) -> Option<(String, Value)> {
890 self.inner.readback_source()
891 }
892
893 fn config_schema(&self) -> Value {
894 self.inner.config_schema()
895 }
896
897 async fn check(
898 &self,
899 ctx: &crate::check::CheckContext,
900 ) -> Result<crate::check::CheckReport, FaucetError> {
901 self.inner.check(ctx).await
902 }
903}
904
905#[cfg(test)]
906pub(crate) mod source_tests {
907 use super::*;
908 use async_trait::async_trait;
909 use futures::StreamExt;
910 use metrics_util::debugging::{DebugValue, DebuggingRecorder, Snapshotter};
911 use serde_json::json;
912 use std::sync::{Mutex, OnceLock};
913
914 #[tokio::test]
916 async fn routed_double_fetches_nothing() {
917 use crate::Source as _;
918 assert!(
919 RoutedSource
920 .fetch_with_context(&Default::default())
921 .await
922 .unwrap()
923 .is_empty()
924 );
925 }
926
927 struct RoutedSource;
928
929 #[async_trait::async_trait]
930 impl crate::Source for RoutedSource {
931 async fn fetch_with_context(
932 &self,
933 _ctx: &std::collections::HashMap<String, serde_json::Value>,
934 ) -> Result<Vec<serde_json::Value>, crate::FaucetError> {
935 Ok(Vec::new())
936 }
937
938 fn record_table(&self, record: &serde_json::Value) -> Option<String> {
939 record.get("t")?.as_str().map(str::to_string)
940 }
941
942 fn position_le(&self, a: &serde_json::Value, b: &serde_json::Value) -> Option<bool> {
943 Some(a.as_u64()? <= b.as_u64()?)
944 }
945
946 fn position_min(&self, _positions: &[serde_json::Value]) -> Option<serde_json::Value> {
947 Some(serde_json::json!("inner-min"))
948 }
949 }
950
951 fn assert_forwards_multi_table_hooks(s: &dyn crate::Source) {
952 assert_eq!(
953 s.record_table(&serde_json::json!({"t": "public.a"}))
954 .as_deref(),
955 Some("public.a")
956 );
957 assert_eq!(
958 s.position_le(&serde_json::json!(1), &serde_json::json!(2)),
959 Some(true)
960 );
961 assert_eq!(
962 s.position_le(&serde_json::json!(3), &serde_json::json!(2)),
963 Some(false)
964 );
965 assert_eq!(
966 s.position_min(&[serde_json::json!(1), serde_json::json!(2)]),
967 Some(serde_json::json!("inner-min"))
968 );
969 }
970
971 #[test]
972 fn multi_table_hooks_are_forwarded_to_the_inner_source() {
973 let inner = RoutedSource;
974 let wrapped = InstrumentedSource::new(&inner, labels());
975 assert_forwards_multi_table_hooks(&wrapped);
976 wrapped.set_run_clock(chrono::Utc::now());
977 }
978
979 pub(crate) static LOCK: Mutex<()> = Mutex::new(());
982 static SNAPSHOTTER: OnceLock<Snapshotter> = OnceLock::new();
983
984 pub(crate) fn snapshotter() -> &'static Snapshotter {
985 SNAPSHOTTER.get_or_init(|| {
986 let recorder = DebuggingRecorder::new();
987 let snap = recorder.snapshotter();
988 let _ = metrics::set_global_recorder(recorder);
996 snap
997 })
998 }
999
1000 pub(in crate::observability) fn labels() -> Labels {
1001 Labels::new("p", "r", "rid")
1002 }
1003
1004 struct MockSource(Vec<Value>);
1005 #[async_trait]
1006 impl Source for MockSource {
1007 async fn fetch_with_context(
1008 &self,
1009 _: &HashMap<String, Value>,
1010 ) -> Result<Vec<Value>, FaucetError> {
1011 Ok(self.0.clone())
1012 }
1013 fn connector_name(&self) -> &'static str {
1014 "mock"
1015 }
1016 }
1017
1018 struct PanickingSource;
1019 #[async_trait]
1020 impl Source for PanickingSource {
1021 async fn fetch_with_context(
1022 &self,
1023 _: &HashMap<String, Value>,
1024 ) -> Result<Vec<Value>, FaucetError> {
1025 panic!("kaboom")
1026 }
1027 fn connector_name(&self) -> &'static str {
1028 "panic-test"
1029 }
1030 }
1031
1032 struct EmptyNameSource;
1036 #[async_trait]
1037 impl Source for EmptyNameSource {
1038 async fn fetch_with_context(
1039 &self,
1040 _: &HashMap<String, Value>,
1041 ) -> Result<Vec<Value>, FaucetError> {
1042 Ok(vec![])
1043 }
1044 fn connector_name(&self) -> &'static str {
1045 ""
1046 }
1047 }
1048
1049 #[test]
1050 fn empty_inner_connector_name_falls_back_to_unknown() {
1051 let inner = EmptyNameSource;
1052 let wrapped = InstrumentedSource {
1056 inner: &inner,
1057 labels: labels(),
1058 connector: SharedString::const_str("unknown"),
1059 base_labels: Vec::new(),
1060 page_index: Arc::new(AtomicUsize::new(0)),
1061 meter: None,
1062 };
1063 assert_eq!(
1064 Source::connector_name(&wrapped),
1065 "unknown",
1066 "instrumented source must not leak an empty connector name"
1067 );
1068 }
1069
1070 #[tokio::test]
1071 #[allow(clippy::await_holding_lock)]
1072 async fn records_records_counter_per_page() {
1073 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1074 let snap = snapshotter();
1075 let inner = MockSource((0..5).map(|i| json!({"i": i})).collect());
1076 let wrapped = InstrumentedSource::new(&inner, labels());
1077 let ctx = HashMap::new();
1078 let mut s = wrapped.stream_pages(&ctx, 2);
1079 while s.next().await.is_some() {}
1080 let snapshot = snap.snapshot();
1081 let records: u64 = snapshot
1082 .into_vec()
1083 .into_iter()
1084 .filter_map(|(key, _u, _d, v)| {
1085 if key.key().name() == "faucet_source_records_total"
1086 && let DebugValue::Counter(c) = v
1087 {
1088 return Some(c);
1089 }
1090 None
1091 })
1092 .sum();
1093 assert!(
1094 records >= 5,
1095 "expected at least 5 records counted, got {records}"
1096 );
1097 }
1098
1099 struct PageCountSource(Vec<Value>);
1102 #[async_trait]
1103 impl Source for PageCountSource {
1104 async fn fetch_with_context(
1105 &self,
1106 _: &HashMap<String, Value>,
1107 ) -> Result<Vec<Value>, FaucetError> {
1108 Ok(self.0.clone())
1109 }
1110 fn connector_name(&self) -> &'static str {
1111 "page-count-probe"
1112 }
1113 }
1114
1115 #[tokio::test]
1116 #[allow(clippy::await_holding_lock)]
1117 async fn page_duration_records_one_sample_per_yielded_page() {
1118 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1121 let snap = snapshotter();
1122 let inner = PageCountSource((0..5).map(|i| json!({"i": i})).collect());
1123 let wrapped = InstrumentedSource::new(&inner, labels());
1124 let ctx = HashMap::new();
1125 let mut s = wrapped.stream_pages(&ctx, 2);
1126 let mut pages = 0usize;
1127 while s.next().await.is_some() {
1128 pages += 1;
1129 }
1130 assert_eq!(pages, 3, "expected 3 yielded pages");
1131
1132 let snapshot = snap.snapshot();
1133 let samples: usize = snapshot
1134 .into_vec()
1135 .into_iter()
1136 .filter_map(|(key, _u, _d, v)| {
1137 if key.key().name() == "faucet_source_page_duration_seconds"
1138 && key
1139 .key()
1140 .labels()
1141 .any(|l| l.key() == "connector" && l.value() == "page-count-probe")
1142 && let DebugValue::Histogram(h) = v
1143 {
1144 return Some(h.len());
1145 }
1146 None
1147 })
1148 .sum();
1149 assert_eq!(
1150 samples, pages,
1151 "page-duration histogram must have exactly one sample per yielded \
1152 page ({pages}), not page+1 (no spurious terminal sample)"
1153 );
1154 }
1155
1156 #[tokio::test]
1157 #[allow(clippy::await_holding_lock)]
1158 async fn maps_panic_to_custom_error_with_kind_panic() {
1159 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1160 let _snap = snapshotter();
1161 let inner = PanickingSource;
1162 let wrapped = InstrumentedSource::new(&inner, labels());
1163 let ctx = HashMap::new();
1164 let mut s = wrapped.stream_pages(&ctx, 10);
1165 let first = s
1166 .next()
1167 .await
1168 .expect("stream yields at least one item before terminating");
1169 assert!(matches!(first, Err(FaucetError::Custom(_))));
1170 }
1172
1173 #[test]
1176 fn error_kind_covers_all_variants() {
1177 use std::time::Duration;
1178 let cases: Vec<(FaucetError, &str)> = vec![
1183 (
1184 FaucetError::HttpStatus {
1185 status: 500,
1186 url: "u".into(),
1187 body: "b".into(),
1188 },
1189 "HttpStatus",
1190 ),
1191 (
1192 FaucetError::Json(serde_json::from_str::<Value>("nope").unwrap_err()),
1193 "Json",
1194 ),
1195 (FaucetError::JsonPath("bad".into()), "JsonPath"),
1196 (FaucetError::Auth("a".into()), "Auth"),
1197 (
1198 FaucetError::RateLimited(Duration::from_secs(1)),
1199 "RateLimited",
1200 ),
1201 (FaucetError::Url("bad url".into()), "Url"),
1202 (FaucetError::Transform("t".into()), "Transform"),
1203 (FaucetError::Config("c".into()), "Config"),
1204 (FaucetError::Source("s".into()), "Source"),
1205 (FaucetError::Sink("s".into()), "Sink"),
1206 (
1207 FaucetError::QualityFailure {
1208 check: "chk".into(),
1209 message: "m".into(),
1210 },
1211 "QualityFailure",
1212 ),
1213 (FaucetError::State("st".into()), "State"),
1214 (
1215 FaucetError::CircuitOpen {
1216 failures: 3,
1217 cooldown: Duration::from_secs(60),
1218 },
1219 "CircuitOpen",
1220 ),
1221 (
1222 FaucetError::Custom(Box::new(std::io::Error::other("boom"))),
1223 "Custom",
1224 ),
1225 ];
1226 for (err, expected) in cases {
1227 assert_eq!(error_kind(&err), expected, "mismatch for {err:?}");
1228 }
1229 }
1230
1231 struct PassthroughSource {
1237 seen_bookmark: Mutex<Option<Value>>,
1238 }
1239 #[async_trait]
1240 impl Source for PassthroughSource {
1241 async fn fetch_with_context(
1242 &self,
1243 _: &HashMap<String, Value>,
1244 ) -> Result<Vec<Value>, FaucetError> {
1245 Ok(vec![json!({"fwc": 1})])
1246 }
1247 async fn fetch_with_context_incremental(
1248 &self,
1249 _: &HashMap<String, Value>,
1250 ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
1251 Ok((vec![json!({"inc": 1})], Some(json!("bm"))))
1252 }
1253 fn state_key(&self) -> Option<String> {
1254 Some("passthrough_key".into())
1255 }
1256 async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
1257 *self.seen_bookmark.lock().unwrap() = Some(bookmark);
1258 Ok(())
1259 }
1260 fn connector_name(&self) -> &'static str {
1261 "passthrough"
1262 }
1263 }
1264
1265 #[tokio::test]
1266 async fn source_passthroughs_delegate_to_inner() {
1267 let inner = PassthroughSource {
1268 seen_bookmark: Mutex::new(None),
1269 };
1270 let wrapped = InstrumentedSource::new(&inner, labels());
1271
1272 assert_eq!(wrapped.state_key(), Some("passthrough_key".to_string()));
1274
1275 let ctx = HashMap::new();
1277 assert_eq!(
1278 wrapped.fetch_with_context(&ctx).await.unwrap(),
1279 vec![json!({"fwc": 1})]
1280 );
1281
1282 let (recs, bm) = wrapped.fetch_with_context_incremental(&ctx).await.unwrap();
1284 assert_eq!(recs, vec![json!({"inc": 1})]);
1285 assert_eq!(bm, Some(json!("bm")));
1286
1287 wrapped.apply_start_bookmark(json!("resume")).await.unwrap();
1289 assert_eq!(
1290 *inner.seen_bookmark.lock().unwrap(),
1291 Some(json!("resume")),
1292 "apply_start_bookmark must reach the inner source"
1293 );
1294
1295 assert!(!wrapped.supports_exactly_once());
1297 assert!(!wrapped.consumes_destructively());
1298 assert_eq!(
1299 wrapped.replay_guarantee(),
1300 crate::idempotency::ReplayGuarantee::NonDeterministic
1301 );
1302 assert_eq!(wrapped.capture_resume_position().await.unwrap(), None);
1303 assert_eq!(wrapped.lag().await.unwrap(), None);
1304 }
1305
1306 struct ExactlyOnceSource;
1309 #[async_trait]
1310 impl Source for ExactlyOnceSource {
1311 async fn fetch_with_context(
1312 &self,
1313 _context: &HashMap<String, Value>,
1314 ) -> Result<Vec<Value>, FaucetError> {
1315 Ok(vec![])
1316 }
1317 fn supports_exactly_once(&self) -> bool {
1318 true
1319 }
1320 async fn capture_resume_position(&self) -> Result<Option<Value>, FaucetError> {
1321 Ok(Some(json!("pos")))
1322 }
1323 fn connector_name(&self) -> &'static str {
1324 "eo-source"
1325 }
1326 }
1327
1328 #[tokio::test]
1329 async fn source_capability_passthroughs_delegate_to_inner() {
1330 let inner = ExactlyOnceSource;
1331 let wrapped = InstrumentedSource::new(&inner, labels());
1332 assert!(wrapped.supports_exactly_once());
1333 assert_eq!(
1334 wrapped.replay_guarantee(),
1335 crate::idempotency::ReplayGuarantee::Deterministic,
1336 "typed capability derives through the wrapper"
1337 );
1338 assert_eq!(
1339 wrapped.capture_resume_position().await.unwrap(),
1340 Some(json!("pos"))
1341 );
1342 }
1343}
1344
1345#[cfg(test)]
1346mod sink_tests {
1347 use super::source_tests::{LOCK, labels, snapshotter};
1348 use super::*;
1349 use async_trait::async_trait;
1350 use metrics_util::debugging::DebugValue;
1351 use serde_json::json;
1352
1353 struct MockSink(std::sync::Mutex<Vec<Value>>);
1354 #[async_trait]
1355 impl Sink for MockSink {
1356 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1357 self.0.lock().unwrap().extend(records.iter().cloned());
1358 Ok(records.len())
1359 }
1360 fn connector_name(&self) -> &'static str {
1361 "mock-sink"
1362 }
1363 }
1364
1365 struct FailingSink;
1366 #[async_trait]
1367 impl Sink for FailingSink {
1368 async fn write_batch(&self, _: &[Value]) -> Result<usize, FaucetError> {
1369 Err(FaucetError::Sink("nope".into()))
1370 }
1371 fn connector_name(&self) -> &'static str {
1372 "failing-sink"
1373 }
1374 }
1375
1376 struct EmptyNameSink;
1377 #[async_trait]
1378 impl Sink for EmptyNameSink {
1379 async fn write_batch(&self, _: &[Value]) -> Result<usize, FaucetError> {
1380 Ok(0)
1381 }
1382 fn connector_name(&self) -> &'static str {
1383 ""
1384 }
1385 }
1386
1387 #[test]
1388 fn empty_inner_connector_name_falls_back_to_unknown() {
1389 let inner = EmptyNameSink;
1390 let wrapped = InstrumentedSink {
1394 inner: &inner,
1395 labels: labels(),
1396 connector: SharedString::const_str("unknown"),
1397 base_labels: Vec::new(),
1398 meter: None,
1399 };
1400 assert_eq!(
1401 Sink::connector_name(&wrapped),
1402 "unknown",
1403 "instrumented sink must not leak an empty connector name"
1404 );
1405 }
1406
1407 struct CapableSink;
1415 #[async_trait]
1416 impl Sink for CapableSink {
1417 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1418 Ok(records.len())
1419 }
1420 fn connector_name(&self) -> &'static str {
1421 "capable-sink"
1422 }
1423 async fn current_schema(&self) -> Result<Option<Value>, FaucetError> {
1424 Ok(Some(
1425 json!({"type": "object", "properties": {"id": {"type": "integer"}}}),
1426 ))
1427 }
1428 fn supports_schema_evolution(&self) -> bool {
1429 true
1430 }
1431 fn supports_idempotent_writes(&self) -> bool {
1432 true
1433 }
1434 fn supported_write_modes(&self) -> &'static [crate::write_mode::WriteMode] {
1435 &[
1436 crate::write_mode::WriteMode::Append,
1437 crate::write_mode::WriteMode::Upsert,
1438 ]
1439 }
1440 async fn last_committed_token(&self, _scope: &str) -> Result<Option<String>, FaucetError> {
1441 Ok(Some("tok-1".into()))
1442 }
1443 fn dedups_by_key(&self) -> bool {
1444 true
1445 }
1446 }
1447
1448 #[tokio::test]
1449 async fn instrumented_sink_forwards_capability_methods_to_inner() {
1450 let inner = CapableSink;
1451 let wrapped = InstrumentedSink::new(&inner, labels());
1452
1453 assert_eq!(
1456 wrapped.current_schema().await.unwrap(),
1457 Some(json!({"type": "object", "properties": {"id": {"type": "integer"}}})),
1458 "current_schema must delegate to the inner sink"
1459 );
1460 assert!(
1461 wrapped.supports_schema_evolution(),
1462 "supports_schema_evolution must delegate"
1463 );
1464 assert!(
1466 wrapped.supports_idempotent_writes(),
1467 "supports_idempotent_writes must delegate (exactly-once)"
1468 );
1469 assert!(
1470 wrapped
1471 .supported_write_modes()
1472 .contains(&crate::write_mode::WriteMode::Upsert),
1473 "supported_write_modes must delegate"
1474 );
1475 assert_eq!(
1476 wrapped.last_committed_token("scope").await.unwrap(),
1477 Some("tok-1".to_string()),
1478 "last_committed_token must delegate"
1479 );
1480 assert_eq!(
1483 wrapped.sink_guarantee(),
1484 crate::idempotency::SinkGuarantee::AtomicWatermark,
1485 "sink_guarantee must delegate"
1486 );
1487 assert!(wrapped.dedups_by_key(), "dedups_by_key must delegate");
1488 assert_eq!(
1489 wrapped.batch_atomicity(),
1490 crate::dlq::BatchAtomicity::BestEffort
1491 );
1492 }
1493
1494 #[tokio::test]
1495 #[allow(clippy::await_holding_lock)]
1496 async fn records_writes_and_records_counters() {
1497 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1498 let snap = snapshotter();
1499 let inner = MockSink(std::sync::Mutex::new(Vec::new()));
1500 let wrapped = InstrumentedSink::new(&inner, labels());
1501 wrapped
1502 .write_batch(&[json!({"a": 1}), json!({"a": 2})])
1503 .await
1504 .unwrap();
1505 let snapshot = snap.snapshot();
1506 let writes: u64 = snapshot
1507 .into_vec()
1508 .into_iter()
1509 .filter_map(|(key, _u, _d, v)| {
1510 if key.key().name() == "faucet_sink_writes_total"
1511 && let DebugValue::Counter(c) = v
1512 {
1513 return Some(c);
1514 }
1515 None
1516 })
1517 .sum();
1518 assert!(writes >= 1, "expected at least one write counted");
1519 }
1520
1521 #[tokio::test]
1522 #[allow(clippy::await_holding_lock)]
1523 async fn error_increments_errors_total_with_kind() {
1524 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1525 let snap = snapshotter();
1526 let inner = FailingSink;
1527 let wrapped = InstrumentedSink::new(&inner, labels());
1528 let _ = wrapped.write_batch(&[json!({})]).await;
1529 let snapshot = snap.snapshot();
1530 let found = snapshot.into_vec().into_iter().any(|(key, _u, _d, v)| {
1531 key.key().name() == "faucet_sink_errors_total"
1532 && key
1533 .key()
1534 .labels()
1535 .any(|l| l.key() == "kind" && l.value() == "Sink")
1536 && matches!(v, DebugValue::Counter(c) if c >= 1)
1537 });
1538 assert!(found, "expected sink_errors_total with kind=Sink");
1539 }
1540
1541 #[tokio::test]
1542 #[allow(clippy::await_holding_lock)]
1543 async fn instrumented_sink_write_batch_partial_counts_successful_outcomes() {
1544 use crate::traits::RowOutcome;
1545 use metrics_util::debugging::DebugValue;
1546
1547 struct MixedSink;
1549 #[async_trait]
1550 impl Sink for MixedSink {
1551 async fn write_batch(&self, _r: &[Value]) -> Result<usize, FaucetError> {
1552 unreachable!()
1553 }
1554 async fn write_batch_partial(
1555 &self,
1556 _r: &[Value],
1557 ) -> Result<Vec<RowOutcome>, FaucetError> {
1558 Ok(vec![
1559 Ok(()),
1560 Err(FaucetError::Sink("bad row".into())),
1561 Ok(()),
1562 ])
1563 }
1564 fn connector_name(&self) -> &'static str {
1565 "mixed"
1566 }
1567 }
1568
1569 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1570 let snap = snapshotter();
1571
1572 let inner = MixedSink;
1573 let wrapped = InstrumentedSink::new(&inner, labels());
1574 let _ = wrapped
1575 .write_batch_partial(&[json!({}), json!({}), json!({})])
1576 .await
1577 .unwrap();
1578
1579 let snapshot = snap.snapshot();
1587 let records: u64 = snapshot
1588 .into_vec()
1589 .into_iter()
1590 .filter_map(|(k, _u, _d, v): (metrics_util::CompositeKey, _, _, _)| {
1591 if k.key().name() == "faucet_sink_records_total"
1592 && k.key()
1593 .labels()
1594 .any(|l| l.key() == "connector" && l.value() == "mixed")
1595 && let DebugValue::Counter(c) = v
1596 {
1597 Some(c)
1598 } else {
1599 None
1600 }
1601 })
1602 .sum();
1603 assert!(
1604 records >= 2,
1605 "expected faucet_sink_records_total{{connector=mixed}} >= 2, got {records}"
1606 );
1607 }
1608
1609 #[tokio::test]
1612 #[allow(clippy::await_holding_lock)]
1613 async fn flush_error_increments_errors_total_and_propagates() {
1614 struct FlushFailSink;
1617 #[async_trait]
1618 impl Sink for FlushFailSink {
1619 async fn write_batch(&self, r: &[Value]) -> Result<usize, FaucetError> {
1620 Ok(r.len())
1621 }
1622 async fn flush(&self) -> Result<(), FaucetError> {
1623 Err(FaucetError::Sink("flush boom".into()))
1624 }
1625 fn connector_name(&self) -> &'static str {
1626 "flush-fail-sink"
1627 }
1628 }
1629
1630 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1631 let snap = snapshotter();
1632 let inner = FlushFailSink;
1633 let wrapped = InstrumentedSink::new(&inner, labels());
1634 let err = wrapped.flush().await.unwrap_err();
1635 assert!(matches!(&err, FaucetError::Sink(m) if m.contains("flush boom")));
1636
1637 let snapshot = snap.snapshot();
1638 let found = snapshot.into_vec().into_iter().any(|(key, _u, _d, v)| {
1639 key.key().name() == "faucet_sink_errors_total"
1640 && key
1641 .key()
1642 .labels()
1643 .any(|l| l.key() == "connector" && l.value() == "flush-fail-sink")
1644 && key
1645 .key()
1646 .labels()
1647 .any(|l| l.key() == "kind" && l.value() == "Sink")
1648 && matches!(v, DebugValue::Counter(c) if c >= 1)
1649 });
1650 assert!(
1651 found,
1652 "expected sink_errors_total{{connector=flush-fail-sink,kind=Sink}}"
1653 );
1654 }
1655
1656 struct PanickingSink;
1659 #[async_trait]
1660 impl Sink for PanickingSink {
1661 async fn write_batch(&self, _: &[Value]) -> Result<usize, FaucetError> {
1662 panic!("write kaboom")
1663 }
1664 async fn write_batch_partial(
1665 &self,
1666 _: &[Value],
1667 ) -> Result<Vec<crate::traits::RowOutcome>, FaucetError> {
1668 panic!("partial kaboom")
1669 }
1670 async fn flush(&self) -> Result<(), FaucetError> {
1671 panic!("flush kaboom")
1672 }
1673 fn connector_name(&self) -> &'static str {
1674 "panic-sink"
1675 }
1676 }
1677
1678 #[tokio::test]
1679 #[allow(clippy::await_holding_lock)]
1680 async fn write_batch_panic_maps_to_custom_error() {
1681 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1682 let _snap = snapshotter();
1683 let inner = PanickingSink;
1684 let wrapped = InstrumentedSink::new(&inner, labels());
1685 let err = wrapped.write_batch(&[json!({})]).await.unwrap_err();
1686 match err {
1687 FaucetError::Custom(b) => {
1688 assert!(b.to_string().contains("panic in sink: write kaboom"))
1689 }
1690 other => panic!("expected Custom panic error, got {other:?}"),
1691 }
1692 }
1693
1694 #[tokio::test]
1695 #[allow(clippy::await_holding_lock)]
1696 async fn write_batch_partial_panic_maps_to_custom_error() {
1697 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1698 let _snap = snapshotter();
1699 let inner = PanickingSink;
1700 let wrapped = InstrumentedSink::new(&inner, labels());
1701 let err = wrapped.write_batch_partial(&[json!({})]).await.unwrap_err();
1702 match err {
1703 FaucetError::Custom(b) => {
1704 assert!(b.to_string().contains("panic in sink: partial kaboom"))
1705 }
1706 other => panic!("expected Custom panic error, got {other:?}"),
1707 }
1708 }
1709
1710 #[tokio::test]
1711 #[allow(clippy::await_holding_lock)]
1712 async fn flush_panic_maps_to_custom_error() {
1713 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1714 let _snap = snapshotter();
1715 let inner = PanickingSink;
1716 let wrapped = InstrumentedSink::new(&inner, labels());
1717 let err = wrapped.flush().await.unwrap_err();
1718 match err {
1719 FaucetError::Custom(b) => {
1720 assert!(b.to_string().contains("panic in flush: flush kaboom"))
1721 }
1722 other => panic!("expected Custom panic error, got {other:?}"),
1723 }
1724 }
1725 #[tokio::test]
1739 async fn instrumented_sink_forwards_identity_and_local_outputs() {
1740 use crate::local_outputs::{LocalOutput, LocalOutputLog};
1741
1742 struct FileSink {
1743 outputs: LocalOutputLog,
1744 }
1745
1746 #[async_trait]
1747 impl Sink for FileSink {
1748 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1749 Ok(records.len())
1750 }
1751 fn connector_name(&self) -> &'static str {
1752 "jsonl"
1753 }
1754 fn dataset_uri(&self) -> String {
1755 "file:///tmp/out.jsonl".to_string()
1756 }
1757 async fn local_outputs(&self) -> Vec<LocalOutput> {
1758 self.outputs.snapshot()
1759 }
1760 }
1761
1762 let outputs = LocalOutputLog::new();
1763 outputs.record_open("/tmp/out.jsonl", false);
1764 let inner = FileSink { outputs };
1765 let sink = InstrumentedSink::new(&inner, Labels::new("p", "r", "run-1"));
1766
1767 let reported = sink.local_outputs().await;
1768 assert_eq!(
1769 reported.len(),
1770 1,
1771 "InstrumentedSink must not hide the inner sink's files from the GC"
1772 );
1773 assert_eq!(reported[0].path, std::path::PathBuf::from("/tmp/out.jsonl"));
1774 assert!(
1775 !reported[0].pre_existing,
1776 "classification must survive verbatim"
1777 );
1778 assert_eq!(sink.dataset_uri(), "file:///tmp/out.jsonl");
1779 }
1780
1781 fn counters_for(row: &str) -> HashMap<String, u64> {
1782 let mut out = HashMap::new();
1783 for (key, _u, _d, v) in snapshotter().snapshot().into_vec() {
1784 let k = key.key();
1785 if k.labels().any(|l| l.key() == "row" && l.value() == row)
1786 && let DebugValue::Counter(c) = v
1787 {
1788 *out.entry(k.name().to_string()).or_default() += c;
1789 }
1790 }
1791 out
1792 }
1793
1794 #[tokio::test]
1795 async fn instrumented_sink_forwards_every_hook() {
1796 let probe = crate::sink_forwarding::HookSink::default();
1797 let wrapped = InstrumentedSink::new(&probe, labels());
1798 crate::sink_forwarding::assert_forwards_every_hook(&wrapped, &probe, &[]).await;
1799 }
1800
1801 #[allow(clippy::await_holding_lock)]
1802 #[tokio::test]
1803 async fn idempotent_write_is_counted_metered_and_panic_guarded() {
1804 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1805 let _snap = snapshotter();
1806 let probe = crate::sink_forwarding::HookSink::default();
1807 let meter = Arc::new(UsageMeter::new());
1808 let wrapped = InstrumentedSink::new(&probe, Labels::new("p", "eo-row", "rid"))
1809 .with_meter(Arc::clone(&meter));
1810 let n = wrapped
1811 .write_batch_idempotent(&[json!({"a": 1}), json!({"a": 2})], "s", "t")
1812 .await
1813 .unwrap();
1814 assert_eq!(n, 2);
1815 let c = counters_for("eo-row");
1816 assert_eq!(c["faucet_sink_writes_total"], 1);
1817 assert_eq!(c["faucet_sink_records_total"], 2);
1818 assert_eq!(meter.snapshot().records_written, 2);
1819
1820 struct EoPanics;
1821 #[async_trait]
1822 impl Sink for EoPanics {
1823 async fn write_batch(&self, _: &[Value]) -> Result<usize, FaucetError> {
1824 Ok(0)
1825 }
1826 async fn write_batch_idempotent(
1827 &self,
1828 _: &[Value],
1829 _: &str,
1830 _: &str,
1831 ) -> Result<usize, FaucetError> {
1832 panic!("eo kaboom")
1833 }
1834 }
1835 let wrapped = InstrumentedSink::new(&EoPanics, Labels::new("p", "eo-panic", "rid"));
1836 let err = wrapped
1837 .write_batch_idempotent(&[json!({})], "s", "t")
1838 .await
1839 .unwrap_err();
1840 assert!(
1841 err.to_string().contains("panic in sink: eo kaboom"),
1842 "{err}"
1843 );
1844 assert_eq!(counters_for("eo-panic")["faucet_sink_errors_total"], 1);
1845
1846 struct EoFails;
1847 #[async_trait]
1848 impl Sink for EoFails {
1849 async fn write_batch(&self, _: &[Value]) -> Result<usize, FaucetError> {
1850 Ok(0)
1851 }
1852 async fn write_batch_idempotent(
1853 &self,
1854 _: &[Value],
1855 _: &str,
1856 _: &str,
1857 ) -> Result<usize, FaucetError> {
1858 Err(FaucetError::Sink("nope".into()))
1859 }
1860 }
1861 let wrapped = InstrumentedSink::new(&EoFails, Labels::new("p", "eo-err", "rid"));
1862 assert!(wrapped.write_batch_idempotent(&[], "s", "t").await.is_err());
1863 assert_eq!(counters_for("eo-err")["faucet_sink_errors_total"], 1);
1864 }
1865
1866 #[tokio::test]
1867 async fn native_loads_are_metered_for_buffered_and_streamed_payloads() {
1868 use crate::native::{NativeBatch, NativeFormat, NativeLoadContext, NativePayload};
1869 let probe = crate::sink_forwarding::HookSink::default();
1870 let meter = Arc::new(UsageMeter::new());
1871 let wrapped = InstrumentedSink::new(&probe, labels()).with_meter(Arc::clone(&meter));
1872 let ctx = || NativeLoadContext {
1873 write_mode: crate::write_mode::WriteMode::Append,
1874 first_batch: true,
1875 };
1876 let batch = |payload| NativeBatch {
1877 format: NativeFormat::NdJson,
1878 payload,
1879 csv: Default::default(),
1880 records: Some(2),
1881 bookmark: None,
1882 };
1883 wrapped
1884 .load_native(batch(NativePayload::Bytes(vec![0; 10])), "s", ctx())
1885 .await
1886 .unwrap();
1887 let stream: Pin<Box<dyn Stream<Item = Result<Vec<u8>, FaucetError>> + Send>> =
1888 Box::pin(futures::stream::iter(vec![Ok(vec![0; 3]), Ok(vec![0; 4])]));
1889 struct Drains;
1890 #[async_trait]
1891 impl Sink for Drains {
1892 async fn write_batch(&self, _: &[Value]) -> Result<usize, FaucetError> {
1893 Ok(0)
1894 }
1895 async fn load_native(
1896 &self,
1897 batch: NativeBatch,
1898 _: &str,
1899 _: NativeLoadContext,
1900 ) -> Result<usize, FaucetError> {
1901 use futures::StreamExt;
1902 if let NativePayload::Stream(mut s) = batch.payload {
1903 while s.next().await.is_some() {}
1904 }
1905 Ok(5)
1906 }
1907 }
1908 let wrapped2 = InstrumentedSink::new(&Drains, labels()).with_meter(Arc::clone(&meter));
1909 wrapped2
1910 .load_native(batch(NativePayload::Stream(stream)), "s", ctx())
1911 .await
1912 .unwrap();
1913 let snap = meter.snapshot();
1914 assert_eq!(snap.records_written, 7 + 5);
1915 assert_eq!(snap.bytes_written, 10 + 7);
1916 }
1917
1918 #[tokio::test]
1919 async fn native_source_batches_are_metered() {
1920 use crate::native::{NativeBatch, NativeFormat, NativePayload};
1921 struct Native;
1922 #[async_trait]
1923 impl Source for Native {
1924 async fn fetch_with_context(
1925 &self,
1926 _: &HashMap<String, Value>,
1927 ) -> Result<Vec<Value>, FaucetError> {
1928 Ok(vec![])
1929 }
1930 fn stream_native<'b>(
1931 &'b self,
1932 _: &'b HashMap<String, Value>,
1933 _: NativeFormat,
1934 _: usize,
1935 ) -> Pin<Box<dyn Stream<Item = Result<NativeBatch, FaucetError>> + Send + 'b>>
1936 {
1937 let b = NativeBatch {
1938 format: NativeFormat::NdJson,
1939 payload: NativePayload::Bytes(vec![1; 6]),
1940 csv: Default::default(),
1941 records: Some(3),
1942 bookmark: None,
1943 };
1944 Box::pin(futures::stream::iter(vec![Ok(b)]))
1945 }
1946 }
1947 use futures::StreamExt;
1948 let meter = Arc::new(UsageMeter::new());
1949 let wrapped = InstrumentedSource::new(&Native, labels()).with_meter(Arc::clone(&meter));
1950 let ctx = HashMap::new();
1951 let got: Vec<_> = wrapped
1952 .stream_native(&ctx, NativeFormat::NdJson, 10)
1953 .collect()
1954 .await;
1955 assert_eq!(got.len(), 1);
1956 let snap = meter.snapshot();
1957 assert_eq!((snap.records_read, snap.bytes_read), (3, 6));
1958 let unmetered = InstrumentedSource::new(&Native, labels());
1959 assert_eq!(
1960 unmetered
1961 .stream_native(&ctx, NativeFormat::NdJson, 10)
1962 .count()
1963 .await,
1964 1
1965 );
1966 }
1967
1968 #[cfg(feature = "arrow")]
1969 #[tokio::test]
1970 async fn columnar_batches_are_metered_on_both_sides() {
1971 use crate::columnar::ColumnarPage;
1972 use arrow::array::{Int64Array, RecordBatch};
1973 use arrow::datatypes::{DataType, Field, Schema};
1974 fn batch() -> RecordBatch {
1975 RecordBatch::try_new(
1976 Arc::new(Schema::new(vec![Field::new("id", DataType::Int64, false)])),
1977 vec![Arc::new(Int64Array::from(vec![1, 2, 3]))],
1978 )
1979 .unwrap()
1980 }
1981 struct Cols;
1982 #[async_trait]
1983 impl Source for Cols {
1984 async fn fetch_with_context(
1985 &self,
1986 _: &HashMap<String, Value>,
1987 ) -> Result<Vec<Value>, FaucetError> {
1988 Ok(vec![])
1989 }
1990 fn stream_batches<'b>(
1991 &'b self,
1992 _: &'b HashMap<String, Value>,
1993 _: usize,
1994 ) -> Pin<Box<dyn Stream<Item = Result<ColumnarPage, FaucetError>> + Send + 'b>>
1995 {
1996 Box::pin(futures::stream::iter(vec![Ok(ColumnarPage {
1997 batch: batch(),
1998 bookmark: None,
1999 })]))
2000 }
2001 }
2002 use futures::StreamExt;
2003 let meter = Arc::new(UsageMeter::new());
2004 let src = InstrumentedSource::new(&Cols, labels()).with_meter(Arc::clone(&meter));
2005 let ctx = HashMap::new();
2006 assert_eq!(src.stream_batches(&ctx, 10).count().await, 1);
2007 let unmetered = InstrumentedSource::new(&Cols, labels());
2008 assert_eq!(unmetered.stream_batches(&ctx, 10).count().await, 1);
2009
2010 let probe = crate::sink_forwarding::HookSink::default();
2011 let sink = InstrumentedSink::new(&probe, labels()).with_meter(Arc::clone(&meter));
2012 assert_eq!(sink.write_batch_columnar(&batch()).await.unwrap(), 3);
2013 let snap = meter.snapshot();
2014 assert_eq!((snap.records_read, snap.records_written), (3, 3));
2015 assert!(snap.bytes_read > 0);
2016 assert_eq!(snap.bytes_read, snap.bytes_written);
2017 }
2018}