Skip to main content

faucet_core/observability/
decorator.rs

1//! Pipeline-internal decorators that emit spans + metrics around every
2//! source / sink trait call. See the design spec for the full vocabulary.
3
4use 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
22/// Count a native payload's bytes as they pass: a buffered payload at once, a
23/// streamed one chunk by chunk.
24fn 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
55type BoxedStream<'b, T> = Pin<Box<dyn Stream<Item = Result<T, FaucetError>> + Send + 'b>>;
56
57/// Run a source's fast-path stream behind `catch_unwind` (#872): a panic while
58/// opening the stream or polling an item becomes one `Panic`-kind error that
59/// ends the stream, exactly like the row path in `stream_pages`.
60fn panic_isolated<'b, T: Send + 'b>(
61    open: impl FnOnce() -> BoxedStream<'b, T>,
62    labels: Vec<Label>,
63) -> BoxedStream<'b, T> {
64    use futures::StreamExt;
65    let fail = move |panic: Box<dyn std::any::Any + Send>| {
66        let mut l = labels.clone();
67        l.push(Label::new("kind", SharedString::const_str("Panic")));
68        counter!("faucet_source_errors_total", l).increment(1);
69        FaucetError::Custom(format!("panic in source: {}", panic_message(&*panic)).into())
70    };
71    let inner = match std::panic::catch_unwind(AssertUnwindSafe(open)) {
72        Ok(stream) => stream,
73        Err(panic) => {
74            let err = fail(panic);
75            return Box::pin(futures::stream::once(async move { Err(err) }));
76        }
77    };
78    Box::pin(futures::stream::unfold(Some(inner), move |state| {
79        let fail = fail.clone();
80        async move {
81            let mut stream = state?;
82            match AssertUnwindSafe(stream.next()).catch_unwind().await {
83                Ok(Some(item)) => Some((item, Some(stream))),
84                Ok(None) => None,
85                Err(panic) => Some((Err(fail(panic)), None)),
86            }
87        }
88    }))
89}
90
91/// Guard an inner connector's `connector_name()` so an empty string maps to
92/// the `"unknown"` fallback. Used both for the `connector` metric label and the
93/// `connector_name()` passthrough so the two never disagree.
94fn guarded_connector_name(raw: &'static str) -> &'static str {
95    if raw.is_empty() { "unknown" } else { raw }
96}
97
98/// Build the base `pipeline` / `row` / `connector` label vec once. The two
99/// `pipeline` / `row` heap allocations and the vec construction happen a single
100/// time at decorator construction; per-call sites `clone()` this instead of
101/// rebuilding from the `Arc<str>` labels on every page / write / flush.
102fn base_metric_labels(labels: &Labels, connector: &SharedString) -> Vec<Label> {
103    vec![
104        Label::new("pipeline", SharedString::from(labels.pipeline.to_string())),
105        Label::new("row", SharedString::from(labels.row.to_string())),
106        Label::new("connector", connector.clone()),
107    ]
108}
109
110/// Wraps a `&dyn Source` (or any `&S: Source`) and emits spans + metrics
111/// around every call. Constructed by `Pipeline::run` and never exposed to
112/// end users; the wrapped source remains the user-facing object.
113pub struct InstrumentedSource<'a, S: Source + ?Sized> {
114    inner: &'a S,
115    labels: Labels,
116    connector: SharedString,
117    /// Precomputed `pipeline` / `row` / `connector` labels, cloned per call.
118    base_labels: Vec<Label>,
119    page_index: Arc<AtomicUsize>,
120    /// The run's usage meter (#704); `None` = count nothing beyond metrics.
121    meter: Option<Arc<UsageMeter>>,
122}
123
124impl<'a, S: Source + ?Sized> InstrumentedSource<'a, S> {
125    pub fn new(inner: &'a S, labels: Labels) -> Self {
126        let raw = inner.connector_name();
127        debug_assert!(
128            !raw.is_empty(),
129            "connector_name() must return a non-empty string"
130        );
131        let connector: SharedString = SharedString::const_str(guarded_connector_name(raw));
132        let base_labels = base_metric_labels(&labels, &connector);
133        Self {
134            inner,
135            labels,
136            connector,
137            base_labels,
138            page_index: Arc::new(AtomicUsize::new(0)),
139            meter: None,
140        }
141    }
142
143    /// Tally records and estimated bytes into a run's usage meter (#704).
144    pub fn with_meter(mut self, meter: Arc<UsageMeter>) -> Self {
145        self.meter = Some(meter);
146        self
147    }
148
149    fn metric_labels(&self) -> Vec<Label> {
150        self.base_labels.clone()
151    }
152
153    /// Returns `metric_labels()` with an additional `kind` label appended.
154    /// Used by `InstrumentedSink::write_batch` (Task 9) and any future
155    /// instrumentation paths where `self` is in scope.
156    #[allow(dead_code)]
157    fn error_labels(&self, kind: &'static str) -> Vec<Label> {
158        let mut l = self.metric_labels();
159        l.push(Label::new("kind", SharedString::const_str(kind)));
160        l
161    }
162}
163
164#[async_trait]
165impl<'a, S: Source + ?Sized> Source for InstrumentedSource<'a, S> {
166    fn connector_name(&self) -> &'static str {
167        // Return the guarded name so an inner connector that returns "" maps to
168        // the "unknown" fallback — keeping this passthrough consistent with the
169        // `connector` metric label rather than leaking an empty string.
170        guarded_connector_name(self.inner.connector_name())
171    }
172
173    // Every other hook reaches the wrapped connector unchanged; the columnar
174    // and native streams add only byte metering and panic isolation (#872) —
175    // the fast-path loops in `pipeline.rs` emit the source metrics.
176    crate::forward_source!(inner; except connector_name, stream_batches, stream_native, stream_pages);
177
178    #[cfg(feature = "arrow")]
179    fn stream_batches<'b>(
180        &'b self,
181        context: &'b HashMap<String, Value>,
182        batch_size: usize,
183    ) -> Pin<Box<dyn Stream<Item = Result<crate::columnar::ColumnarPage, FaucetError>> + Send + 'b>>
184    {
185        use futures::StreamExt;
186        let inner = panic_isolated(
187            || self.inner.stream_batches(context, batch_size),
188            self.metric_labels(),
189        );
190        let Some(meter) = self.meter.clone() else {
191            return inner;
192        };
193        let labels = self.metric_labels();
194        Box::pin(inner.inspect(move |page| {
195            if let Ok(page) = page {
196                let bytes = crate::columnar::batch_slice_bytes(&page.batch);
197                counter!("faucet_source_bytes_total", labels.clone()).increment(bytes);
198                meter.add_read(page.batch.num_rows() as u64, bytes);
199            }
200        }))
201    }
202
203    fn stream_native<'b>(
204        &'b self,
205        context: &'b HashMap<String, Value>,
206        format: crate::native::NativeFormat,
207        batch_size: usize,
208    ) -> Pin<Box<dyn Stream<Item = Result<crate::native::NativeBatch, FaucetError>> + Send + 'b>>
209    {
210        use futures::StreamExt;
211        let inner = panic_isolated(
212            || self.inner.stream_native(context, format, batch_size),
213            self.metric_labels(),
214        );
215        let Some(meter) = self.meter.clone() else {
216            return inner;
217        };
218        let labels = self.metric_labels();
219        Box::pin(inner.map(move |batch| {
220            batch.map(|mut b| {
221                let records = b.records.unwrap_or(0);
222                b.payload =
223                    metered_payload(b.payload, labels.clone(), "faucet_source_bytes_total", {
224                        let meter = Arc::clone(&meter);
225                        move |n| meter.add_read(0, n)
226                    });
227                meter.add_read(records, 0);
228                b
229            })
230        }))
231    }
232
233    fn stream_pages<'b>(
234        &'b self,
235        context: &'b HashMap<String, Value>,
236        batch_size: usize,
237    ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'b>> {
238        let inner_stream = self.inner.stream_pages(context, batch_size);
239        let labels = self.labels.clone();
240        let connector = self.connector.clone();
241        let page_index = Arc::clone(&self.page_index);
242        let meter = self.meter.clone();
243        let metric_labels = self.metric_labels();
244        let pipeline = self.labels.pipeline.clone();
245        let row = self.labels.row.clone();
246
247        Box::pin(async_stream::try_stream! {
248            // In-flight gauge tracks open streams. Decrement on drop so
249            // cancellation leaves the gauge consistent.
250            struct InFlightGuard(Vec<Label>);
251            impl Drop for InFlightGuard {
252                fn drop(&mut self) {
253                    gauge!("faucet_source_in_flight", self.0.clone()).decrement(1.0);
254                }
255            }
256            gauge!("faucet_source_in_flight", metric_labels.clone()).increment(1.0);
257            let _in_flight = InFlightGuard(metric_labels.clone());
258
259            let mut inner = inner_stream;
260            loop {
261                let idx = page_index.fetch_add(1, Ordering::Relaxed);
262                let span = info_span!(
263                    "faucet.source.page",
264                    pipeline = %pipeline,
265                    row = %row,
266                    run_id = %labels.run_id,
267                    connector = %connector,
268                    page_index = idx,
269                );
270                // Armed across the poll so a cancelled / panicking page-fetch
271                // still records the time spent. Disarmed on the terminal empty
272                // poll (`Ok(None)`) so end-of-stream doesn't record a spurious
273                // ~0 sample into the page-duration histogram.
274                let mut _timer = DurationGuard::new(
275                    "faucet_source_page_duration_seconds",
276                    metric_labels.clone(),
277                );
278
279                let next = AssertUnwindSafe(async {
280                    use futures::StreamExt;
281                    inner.next().await
282                })
283                .catch_unwind()
284                .instrument(span)
285                .await;
286
287                match next {
288                    Ok(Some(Ok(page))) => {
289                        counter!("faucet_source_pages_total", metric_labels.clone()).increment(1);
290                        counter!("faucet_source_records_total", metric_labels.clone())
291                            .increment(page.records.len() as u64);
292                        if let Some(m) = &meter {
293                            let bytes = estimate_page_bytes(&page.records);
294                            counter!("faucet_source_bytes_total", metric_labels.clone())
295                                .increment(bytes);
296                            m.add_read(page.records.len() as u64, bytes);
297                        }
298                        // Close the timing window BEFORE yielding: in an
299                        // `async_stream` the timer local persists across the
300                        // yield, so dropping it at scope-exit would fold the
301                        // downstream sink/consumer latency into the source's
302                        // page-duration histogram (audit #321 M10).
303                        _timer.record_now();
304                        yield page;
305                    }
306                    Ok(Some(Err(e))) => {
307                        let mut l = metric_labels.clone();
308                        l.push(Label::new("kind", SharedString::const_str(error_kind(&e))));
309                        counter!("faucet_source_errors_total", l).increment(1);
310                        Err(e)?;
311                    }
312                    Ok(None) => {
313                        _timer.disarm();
314                        break;
315                    }
316                    Err(panic) => {
317                        let mut l = metric_labels.clone();
318                        l.push(Label::new("kind", SharedString::const_str("Panic")));
319                        counter!("faucet_source_errors_total", l).increment(1);
320                        let msg = panic.downcast_ref::<&'static str>().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 source: {msg}").into()))?;
324                    }
325                }
326            }
327        })
328    }
329}
330
331/// Map a `FaucetError` variant to its stable `kind` label value. The match
332/// must be exhaustive; update when new variants are added.
333pub(crate) fn error_kind(e: &FaucetError) -> &'static str {
334    match e {
335        FaucetError::Http(_) => "Http",
336        FaucetError::HttpStatus { .. } => "HttpStatus",
337        FaucetError::Json(_) => "Json",
338        FaucetError::JsonPath(_) => "JsonPath",
339        FaucetError::Auth(_) => "Auth",
340        FaucetError::RateLimited { .. } => "RateLimited",
341        FaucetError::Url(_) => "Url",
342        FaucetError::Transform(_) => "Transform",
343        FaucetError::Config(_) => "Config",
344        FaucetError::Source(_) => "Source",
345        FaucetError::Sink(_) => "Sink",
346        FaucetError::QualityFailure { .. } => "QualityFailure",
347        FaucetError::SchemaDrift { .. } => "SchemaDrift",
348        FaucetError::ProfileDrift { .. } => "ProfileDrift",
349        FaucetError::PolicyViolation { .. } => "PolicyViolation",
350        FaucetError::BudgetExceeded { .. } => "BudgetExceeded",
351        FaucetError::RowsUnaccounted { .. } => "RowsUnaccounted",
352        FaucetError::ContractViolation { .. } => "ContractViolation",
353        FaucetError::State(_) => "State",
354        FaucetError::StateIncompatible { .. } => "StateIncompatible",
355        FaucetError::CircuitOpen { .. } => "CircuitOpen",
356        FaucetError::Timeout { .. } => "Timeout",
357        FaucetError::Cancelled { .. } => "Cancelled",
358        FaucetError::Custom(_) => "Custom",
359    }
360}
361
362/// Wraps a `&dyn Sink` (or any `&S: Sink`) and emits spans + metrics around
363/// `write_batch` and `flush`. Constructed by `Pipeline::run`.
364pub struct InstrumentedSink<'a, S: Sink + ?Sized> {
365    inner: &'a S,
366    labels: Labels,
367    connector: SharedString,
368    /// Precomputed `pipeline` / `row` / `connector` labels, cloned per call.
369    base_labels: Vec<Label>,
370    /// The run's usage meter (#704); `None` = count nothing beyond metrics.
371    meter: Option<Arc<UsageMeter>>,
372}
373
374impl<'a, S: Sink + ?Sized> InstrumentedSink<'a, S> {
375    pub fn new(inner: &'a S, labels: Labels) -> Self {
376        let raw = inner.connector_name();
377        debug_assert!(
378            !raw.is_empty(),
379            "connector_name() must return a non-empty string"
380        );
381        let connector: SharedString = SharedString::const_str(guarded_connector_name(raw));
382        let base_labels = base_metric_labels(&labels, &connector);
383        Self {
384            inner,
385            labels,
386            connector,
387            base_labels,
388            meter: None,
389        }
390    }
391
392    /// Tally accepted records and estimated bytes into a run's usage meter
393    /// (#704).
394    pub fn with_meter(mut self, meter: Arc<UsageMeter>) -> Self {
395        self.meter = Some(meter);
396        self
397    }
398
399    fn metric_labels(&self) -> Vec<Label> {
400        self.base_labels.clone()
401    }
402
403    fn error_labels(&self, kind: &'static str) -> Vec<Label> {
404        let mut l = self.metric_labels();
405        l.push(Label::new("kind", SharedString::const_str(kind)));
406        l
407    }
408
409    /// Count a panic from the wrapped sink and turn it into a typed error.
410    fn panicked(&self, what: &str, panic: Box<dyn std::any::Any + Send>) -> FaucetError {
411        counter!("faucet_sink_errors_total", self.error_labels("Panic")).increment(1);
412        FaucetError::Custom(format!("panic in {what}: {}", panic_message(&*panic)).into())
413    }
414
415    /// The instrumented write behind `write_batch` and `write_batch_reported`.
416    async fn write_instrumented(
417        &self,
418        records: &[Value],
419        reported: bool,
420        governed: Option<&crate::governance::GovernedPage<'_>>,
421    ) -> Result<crate::accounting::WriteReport, FaucetError> {
422        let span = info_span!(
423            "faucet.sink.write",
424            pipeline = %self.labels.pipeline,
425            row = %self.labels.row,
426            run_id = %self.labels.run_id,
427            connector = %self.connector,
428            records = records.len(),
429        );
430        let metric_labels = self.metric_labels();
431        gauge!("faucet_sink_in_flight", metric_labels.clone()).increment(1.0);
432
433        // RAII guard ensures the gauge is decremented even if write_batch
434        // panics or the future is cancelled.
435        struct InFlightGuard(Vec<Label>);
436        impl Drop for InFlightGuard {
437            fn drop(&mut self) {
438                gauge!("faucet_sink_in_flight", self.0.clone()).decrement(1.0);
439            }
440        }
441        let _in_flight = InFlightGuard(metric_labels.clone());
442
443        let _timer =
444            DurationGuard::new("faucet_sink_write_duration_seconds", metric_labels.clone());
445
446        let write = async {
447            if let Some(page) = governed {
448                self.inner.write_governed(page).await
449            } else if reported {
450                self.inner.write_batch_reported(records).await
451            } else {
452                self.inner
453                    .write_batch(records)
454                    .await
455                    .map(crate::accounting::WriteReport::accepted)
456            }
457        };
458        let result = AssertUnwindSafe(write)
459            .catch_unwind()
460            .instrument(span)
461            .await;
462
463        match result {
464            Ok(Ok(r)) => {
465                let n = r.accepted;
466                counter!("faucet_sink_writes_total", metric_labels.clone()).increment(1);
467                counter!("faucet_sink_records_total", metric_labels.clone()).increment(n as u64);
468                self.meter_written(records, n);
469                Ok(r)
470            }
471            Ok(Err(e)) => {
472                counter!(
473                    "faucet_sink_errors_total",
474                    self.error_labels(error_kind(&e))
475                )
476                .increment(1);
477                Err(e)
478            }
479            Err(panic) => {
480                counter!("faucet_sink_errors_total", self.error_labels("Panic")).increment(1);
481                let msg = panic
482                    .downcast_ref::<&'static str>()
483                    .map(|s| (*s).to_string())
484                    .or_else(|| panic.downcast_ref::<String>().cloned())
485                    .unwrap_or_else(|| "<non-string panic payload>".to_string());
486                Err(FaucetError::Custom(format!("panic in sink: {msg}").into()))
487            }
488        }
489    }
490
491    /// Count `accepted` records of `records` as written; when the sink
492    /// accepted a prefix, only that prefix's estimated size is attributed.
493    fn meter_written(&self, records: &[Value], accepted: usize) {
494        let Some(m) = &self.meter else {
495            return;
496        };
497        let bytes = if accepted >= records.len() {
498            estimate_page_bytes(records)
499        } else {
500            estimate_page_bytes(&records[..accepted])
501        };
502        counter!("faucet_sink_bytes_total", self.metric_labels()).increment(bytes);
503        m.add_written(accepted as u64, bytes);
504    }
505}
506
507#[async_trait]
508impl<'a, S: Sink + ?Sized> Sink for InstrumentedSink<'a, S> {
509    // Every hook not instrumented below reaches the wrapped connector
510    // unchanged: identity, capabilities, lifecycle and provenance must look
511    // through the decorator, or the feature behind each one goes inert.
512    crate::forward_sink!(inner; except connector_name, write_batch_columnar,
513        load_native, write_batch, write_batch_reported, write_governed, write_batch_partial,
514        flush, write_batch_idempotent, write_batch_idempotent_reported);
515
516    fn connector_name(&self) -> &'static str {
517        // Return the guarded name so an inner connector that returns "" maps to
518        // the "unknown" fallback — keeping this passthrough consistent with the
519        // `connector` metric label rather than leaking an empty string.
520        guarded_connector_name(self.inner.connector_name())
521    }
522
523    #[cfg(feature = "arrow")]
524    async fn write_batch_columnar(
525        &self,
526        batch: &arrow::array::RecordBatch,
527    ) -> Result<usize, FaucetError> {
528        let n = AssertUnwindSafe(async { self.inner.write_batch_columnar(batch).await })
529            .catch_unwind()
530            .await
531            .unwrap_or_else(|panic| Err(self.panicked("sink", panic)))?;
532        if let Some(m) = &self.meter {
533            let rows = batch.num_rows().max(1);
534            let bytes = crate::columnar::batch_slice_bytes(batch)
535                .saturating_mul(n.min(rows) as u64)
536                / rows as u64;
537            counter!("faucet_sink_bytes_total", self.metric_labels()).increment(bytes);
538            m.add_written(n as u64, bytes);
539        }
540        Ok(n)
541    }
542
543    async fn load_native(
544        &self,
545        batch: crate::native::NativeBatch,
546        scope: &str,
547        ctx: crate::native::NativeLoadContext,
548    ) -> Result<usize, FaucetError> {
549        let Some(meter) = self.meter.clone() else {
550            return AssertUnwindSafe(async { self.inner.load_native(batch, scope, ctx).await })
551                .catch_unwind()
552                .await
553                .unwrap_or_else(|panic| Err(self.panicked("sink", panic)));
554        };
555        let bytes = Arc::new(std::sync::atomic::AtomicU64::new(0));
556        let mut batch = batch;
557        batch.payload = metered_payload(
558            batch.payload,
559            self.metric_labels(),
560            "faucet_sink_bytes_total",
561            {
562                let bytes = Arc::clone(&bytes);
563                move |n| {
564                    bytes.fetch_add(n, Ordering::Relaxed);
565                }
566            },
567        );
568        let n = AssertUnwindSafe(async { self.inner.load_native(batch, scope, ctx).await })
569            .catch_unwind()
570            .await
571            .unwrap_or_else(|panic| Err(self.panicked("sink", panic)))?;
572        meter.add_written(n as u64, bytes.load(Ordering::Relaxed));
573        Ok(n)
574    }
575
576    async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
577        self.write_instrumented(records, false, None)
578            .await
579            .map(|r| r.accepted)
580    }
581
582    async fn write_batch_reported(
583        &self,
584        records: &[Value],
585    ) -> Result<crate::accounting::WriteReport, FaucetError> {
586        self.write_instrumented(records, true, None).await
587    }
588
589    async fn write_governed(
590        &self,
591        page: &crate::governance::GovernedPage<'_>,
592    ) -> Result<crate::accounting::WriteReport, FaucetError> {
593        self.write_instrumented(page.records(), true, Some(page))
594            .await
595    }
596
597    async fn write_batch_partial(
598        &self,
599        records: &[Value],
600    ) -> Result<Vec<crate::traits::RowOutcome>, FaucetError> {
601        let span = info_span!(
602            "faucet.sink.write_partial",
603            pipeline = %self.labels.pipeline,
604            row = %self.labels.row,
605            run_id = %self.labels.run_id,
606            connector = %self.connector,
607            records = records.len(),
608        );
609        let metric_labels = self.metric_labels();
610        gauge!("faucet_sink_in_flight", metric_labels.clone()).increment(1.0);
611
612        // RAII guard ensures the gauge is decremented even if write_batch_partial
613        // panics or the future is cancelled.
614        struct InFlightGuard(Vec<Label>);
615        impl Drop for InFlightGuard {
616            fn drop(&mut self) {
617                gauge!("faucet_sink_in_flight", self.0.clone()).decrement(1.0);
618            }
619        }
620        let _in_flight = InFlightGuard(metric_labels.clone());
621
622        let _timer =
623            DurationGuard::new("faucet_sink_write_duration_seconds", metric_labels.clone());
624
625        let result = AssertUnwindSafe(self.inner.write_batch_partial(records))
626            .catch_unwind()
627            .instrument(span)
628            .await;
629
630        match result {
631            Ok(Ok(outcomes)) => {
632                let success_count = outcomes.iter().filter(|o| o.is_ok()).count();
633                counter!("faucet_sink_writes_total", metric_labels.clone()).increment(1);
634                counter!("faucet_sink_records_total", metric_labels.clone())
635                    .increment(success_count as u64);
636                if let Some(m) = &self.meter {
637                    let bytes: u64 = outcomes
638                        .iter()
639                        .zip(records.iter())
640                        .filter(|(o, _)| o.is_ok())
641                        .map(|(_, r)| crate::usage::estimate_json_bytes(r))
642                        .sum();
643                    counter!("faucet_sink_bytes_total", metric_labels.clone()).increment(bytes);
644                    m.add_written(success_count as u64, bytes);
645                }
646                Ok(outcomes)
647            }
648            Ok(Err(e)) => {
649                counter!(
650                    "faucet_sink_errors_total",
651                    self.error_labels(error_kind(&e))
652                )
653                .increment(1);
654                Err(e)
655            }
656            Err(panic) => {
657                counter!("faucet_sink_errors_total", self.error_labels("Panic")).increment(1);
658                let msg = panic
659                    .downcast_ref::<&'static str>()
660                    .map(|s| (*s).to_string())
661                    .or_else(|| panic.downcast_ref::<String>().cloned())
662                    .unwrap_or_else(|| "<non-string panic payload>".to_string());
663                Err(FaucetError::Custom(format!("panic in sink: {msg}").into()))
664            }
665        }
666    }
667
668    async fn flush(&self) -> Result<(), FaucetError> {
669        let span = info_span!(
670            "faucet.sink.flush",
671            pipeline = %self.labels.pipeline,
672            row = %self.labels.row,
673            run_id = %self.labels.run_id,
674            connector = %self.connector,
675        );
676        let metric_labels = self.metric_labels();
677        let _timer =
678            DurationGuard::new("faucet_sink_flush_duration_seconds", metric_labels.clone());
679
680        let result = AssertUnwindSafe(self.inner.flush())
681            .catch_unwind()
682            .instrument(span)
683            .await;
684
685        match result {
686            Ok(Ok(())) => Ok(()),
687            Ok(Err(e)) => {
688                counter!(
689                    "faucet_sink_errors_total",
690                    self.error_labels(error_kind(&e))
691                )
692                .increment(1);
693                Err(e)
694            }
695            Err(panic) => {
696                counter!("faucet_sink_errors_total", self.error_labels("Panic")).increment(1);
697                let msg = panic
698                    .downcast_ref::<&'static str>()
699                    .map(|s| (*s).to_string())
700                    .or_else(|| panic.downcast_ref::<String>().cloned())
701                    .unwrap_or_else(|| "<non-string panic payload>".to_string());
702                Err(FaucetError::Custom(format!("panic in flush: {msg}").into()))
703            }
704        }
705    }
706
707    #[allow(deprecated)] // drives the 1.x lifecycle hook until faucet-core 2.0 (RFC 0014)
708    async fn write_batch_idempotent(
709        &self,
710        records: &[Value],
711        scope: &str,
712        token: &str,
713    ) -> Result<usize, FaucetError> {
714        self.idempotent_instrumented(
715            records,
716            self.inner.write_batch_idempotent(records, scope, token),
717            |n| *n,
718        )
719        .await
720    }
721
722    #[allow(deprecated)] // drives the 1.x lifecycle hook until faucet-core 2.0 (RFC 0014)
723    async fn write_batch_idempotent_reported(
724        &self,
725        records: &[Value],
726        scope: &str,
727        token: &str,
728    ) -> Result<crate::accounting::WriteReport, FaucetError> {
729        self.idempotent_instrumented(
730            records,
731            self.inner
732                .write_batch_idempotent_reported(records, scope, token),
733            |r| r.accepted,
734        )
735        .await
736    }
737}
738
739impl<'a, S: Sink + ?Sized> InstrumentedSink<'a, S> {
740    /// The instrumented exactly-once write behind `write_batch_idempotent` and
741    /// `write_batch_idempotent_reported`.
742    async fn idempotent_instrumented<T>(
743        &self,
744        records: &[Value],
745        write: impl std::future::Future<Output = Result<T, FaucetError>>,
746        accepted: impl Fn(&T) -> usize,
747    ) -> Result<T, FaucetError> {
748        let span = info_span!(
749            "faucet.sink.write_idempotent",
750            pipeline = %self.labels.pipeline,
751            row = %self.labels.row,
752            run_id = %self.labels.run_id,
753            connector = %self.connector,
754            records = records.len(),
755        );
756        let metric_labels = self.metric_labels();
757        gauge!("faucet_sink_in_flight", metric_labels.clone()).increment(1.0);
758        struct InFlightGuard(Vec<Label>);
759        impl Drop for InFlightGuard {
760            fn drop(&mut self) {
761                gauge!("faucet_sink_in_flight", self.0.clone()).decrement(1.0);
762            }
763        }
764        let _in_flight = InFlightGuard(metric_labels.clone());
765        let _timer =
766            DurationGuard::new("faucet_sink_write_duration_seconds", metric_labels.clone());
767
768        let result = AssertUnwindSafe(write)
769            .catch_unwind()
770            .instrument(span)
771            .await;
772        match result {
773            Ok(Ok(out)) => {
774                let n = accepted(&out);
775                counter!("faucet_sink_writes_total", metric_labels.clone()).increment(1);
776                counter!("faucet_sink_records_total", metric_labels).increment(n as u64);
777                self.meter_written(records, n);
778                Ok(out)
779            }
780            Ok(Err(e)) => {
781                counter!(
782                    "faucet_sink_errors_total",
783                    self.error_labels(error_kind(&e))
784                )
785                .increment(1);
786                Err(e)
787            }
788            Err(panic) => {
789                counter!("faucet_sink_errors_total", self.error_labels("Panic")).increment(1);
790                Err(FaucetError::Custom(
791                    format!("panic in sink: {}", panic_message(&*panic)).into(),
792                ))
793            }
794        }
795    }
796}
797
798#[cfg(test)]
799pub(crate) mod source_tests {
800    use super::*;
801    use async_trait::async_trait;
802    use futures::StreamExt;
803    use metrics_util::debugging::{DebugValue, DebuggingRecorder, Snapshotter};
804    use serde_json::json;
805    use std::sync::{Mutex, OnceLock};
806
807    /// A change-stream double with its own multi-table hooks (#731).
808    #[tokio::test]
809    async fn routed_double_fetches_nothing() {
810        use crate::Source as _;
811        assert!(
812            RoutedSource
813                .fetch_with_context(&Default::default())
814                .await
815                .unwrap()
816                .is_empty()
817        );
818    }
819
820    struct RoutedSource;
821
822    #[async_trait::async_trait]
823    impl crate::Source for RoutedSource {
824        fn set_cancel(&self, _cancel: tokio_util::sync::CancellationToken) -> bool {
825            true
826        }
827        async fn fetch_with_context(
828            &self,
829            _ctx: &std::collections::HashMap<String, serde_json::Value>,
830        ) -> Result<Vec<serde_json::Value>, crate::FaucetError> {
831            Ok(Vec::new())
832        }
833
834        fn record_table(&self, record: &serde_json::Value) -> Option<String> {
835            record.get("t")?.as_str().map(str::to_string)
836        }
837
838        fn position_le(&self, a: &serde_json::Value, b: &serde_json::Value) -> Option<bool> {
839            Some(a.as_u64()? <= b.as_u64()?)
840        }
841
842        fn position_min(&self, _positions: &[serde_json::Value]) -> Option<serde_json::Value> {
843            Some(serde_json::json!("inner-min"))
844        }
845
846        fn record_covered(
847            &self,
848            record: &serde_json::Value,
849            position: &serde_json::Value,
850        ) -> Option<bool> {
851            Some(record.get("p")?.as_u64()? < position.as_u64()?)
852        }
853    }
854
855    fn assert_forwards_multi_table_hooks(s: &dyn crate::Source) {
856        assert_eq!(
857            s.record_table(&serde_json::json!({"t": "public.a"}))
858                .as_deref(),
859            Some("public.a")
860        );
861        assert_eq!(
862            s.position_le(&serde_json::json!(1), &serde_json::json!(2)),
863            Some(true)
864        );
865        assert_eq!(
866            s.position_le(&serde_json::json!(3), &serde_json::json!(2)),
867            Some(false)
868        );
869        assert_eq!(
870            s.record_covered(&serde_json::json!({"p": 1}), &serde_json::json!(2)),
871            Some(true)
872        );
873        assert_eq!(
874            s.record_covered(&serde_json::json!({"p": 2}), &serde_json::json!(2)),
875            Some(false)
876        );
877        assert_eq!(
878            s.position_min(&[serde_json::json!(1), serde_json::json!(2)]),
879            Some(serde_json::json!("inner-min"))
880        );
881    }
882
883    #[test]
884    fn multi_table_hooks_are_forwarded_to_the_inner_source() {
885        let inner = RoutedSource;
886        let wrapped = InstrumentedSource::new(&inner, labels());
887        assert_forwards_multi_table_hooks(&wrapped);
888        wrapped.set_run_clock(chrono::Utc::now());
889        assert!(wrapped.set_cancel(tokio_util::sync::CancellationToken::new()));
890    }
891
892    // Process-global recorder shared across all observability tests in this
893    // crate. Task 5 established the same pattern.
894    pub(crate) static LOCK: Mutex<()> = Mutex::new(());
895    static SNAPSHOTTER: OnceLock<Snapshotter> = OnceLock::new();
896
897    pub(crate) fn snapshotter() -> &'static Snapshotter {
898        SNAPSHOTTER.get_or_init(|| {
899            let recorder = DebuggingRecorder::new();
900            let snap = recorder.snapshotter();
901            // First test installs; the OnceLock guarantees we never install
902            // twice. If something else (e.g. the timer test) already installed
903            // a recorder, `set_global_recorder` will Err — but in that case
904            // *our* snapshotter is disconnected from the live recorder. The
905            // workaround is for all observability tests to share one source of
906            // truth — this file. If a future test elsewhere installs a
907            // recorder first, restructure so all tests share this OnceLock.
908            let _ = metrics::set_global_recorder(recorder);
909            snap
910        })
911    }
912
913    pub(in crate::observability) fn labels() -> Labels {
914        Labels::new("p", "r", "rid")
915    }
916
917    struct MockSource(Vec<Value>);
918    #[async_trait]
919    impl Source for MockSource {
920        async fn fetch_with_context(
921            &self,
922            _: &HashMap<String, Value>,
923        ) -> Result<Vec<Value>, FaucetError> {
924            Ok(self.0.clone())
925        }
926        fn connector_name(&self) -> &'static str {
927            "mock"
928        }
929    }
930
931    struct PanickingSource;
932    #[async_trait]
933    impl Source for PanickingSource {
934        async fn fetch_with_context(
935            &self,
936            _: &HashMap<String, Value>,
937        ) -> Result<Vec<Value>, FaucetError> {
938            panic!("kaboom")
939        }
940        fn connector_name(&self) -> &'static str {
941            "panic-test"
942        }
943    }
944
945    // Inner connector that returns an empty name. The instrumented wrapper must
946    // map this to the `"unknown"` fallback so the `connector_name()` passthrough
947    // never disagrees with the `connector` metric label.
948    struct EmptyNameSource;
949    #[async_trait]
950    impl Source for EmptyNameSource {
951        async fn fetch_with_context(
952            &self,
953            _: &HashMap<String, Value>,
954        ) -> Result<Vec<Value>, FaucetError> {
955            Ok(vec![])
956        }
957        fn connector_name(&self) -> &'static str {
958            ""
959        }
960    }
961
962    #[test]
963    fn empty_inner_connector_name_falls_back_to_unknown() {
964        let inner = EmptyNameSource;
965        // `InstrumentedSource::new` debug_asserts on an empty inner name, so
966        // build the wrapper directly with the fallback name to exercise the
967        // passthrough without tripping the assertion in debug builds.
968        let wrapped = InstrumentedSource {
969            inner: &inner,
970            labels: labels(),
971            connector: SharedString::const_str("unknown"),
972            base_labels: Vec::new(),
973            page_index: Arc::new(AtomicUsize::new(0)),
974            meter: None,
975        };
976        assert_eq!(
977            Source::connector_name(&wrapped),
978            "unknown",
979            "instrumented source must not leak an empty connector name"
980        );
981    }
982
983    #[tokio::test]
984    #[allow(clippy::await_holding_lock)]
985    async fn records_records_counter_per_page() {
986        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
987        let snap = snapshotter();
988        let inner = MockSource((0..5).map(|i| json!({"i": i})).collect());
989        let wrapped = InstrumentedSource::new(&inner, labels());
990        let ctx = HashMap::new();
991        let mut s = wrapped.stream_pages(&ctx, 2);
992        while s.next().await.is_some() {}
993        let snapshot = snap.snapshot();
994        let records: u64 = snapshot
995            .into_vec()
996            .into_iter()
997            .filter_map(|(key, _u, _d, v)| {
998                if key.key().name() == "faucet_source_records_total"
999                    && let DebugValue::Counter(c) = v
1000                {
1001                    return Some(c);
1002                }
1003                None
1004            })
1005            .sum();
1006        assert!(
1007            records >= 5,
1008            "expected at least 5 records counted, got {records}"
1009        );
1010    }
1011
1012    // Source with a unique connector name so the page-duration histogram for
1013    // this run can be isolated in the shared global recorder.
1014    struct PageCountSource(Vec<Value>);
1015    #[async_trait]
1016    impl Source for PageCountSource {
1017        async fn fetch_with_context(
1018            &self,
1019            _: &HashMap<String, Value>,
1020        ) -> Result<Vec<Value>, FaucetError> {
1021            Ok(self.0.clone())
1022        }
1023        fn connector_name(&self) -> &'static str {
1024            "page-count-probe"
1025        }
1026    }
1027
1028    #[tokio::test]
1029    #[allow(clippy::await_holding_lock)]
1030    async fn page_duration_records_one_sample_per_yielded_page() {
1031        // 5 records at batch_size 2 → pages [2, 2, 1] = 3 yielded pages. The
1032        // terminal empty poll must NOT add a 4th (spurious ~0) sample.
1033        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1034        let snap = snapshotter();
1035        let inner = PageCountSource((0..5).map(|i| json!({"i": i})).collect());
1036        let wrapped = InstrumentedSource::new(&inner, labels());
1037        let ctx = HashMap::new();
1038        let mut s = wrapped.stream_pages(&ctx, 2);
1039        let mut pages = 0usize;
1040        while s.next().await.is_some() {
1041            pages += 1;
1042        }
1043        assert_eq!(pages, 3, "expected 3 yielded pages");
1044
1045        let snapshot = snap.snapshot();
1046        let samples: usize = snapshot
1047            .into_vec()
1048            .into_iter()
1049            .filter_map(|(key, _u, _d, v)| {
1050                if key.key().name() == "faucet_source_page_duration_seconds"
1051                    && key
1052                        .key()
1053                        .labels()
1054                        .any(|l| l.key() == "connector" && l.value() == "page-count-probe")
1055                    && let DebugValue::Histogram(h) = v
1056                {
1057                    return Some(h.len());
1058                }
1059                None
1060            })
1061            .sum();
1062        assert_eq!(
1063            samples, pages,
1064            "page-duration histogram must have exactly one sample per yielded \
1065             page ({pages}), not page+1 (no spurious terminal sample)"
1066        );
1067    }
1068
1069    #[tokio::test]
1070    #[allow(clippy::await_holding_lock)]
1071    async fn maps_panic_to_custom_error_with_kind_panic() {
1072        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1073        let _snap = snapshotter();
1074        let inner = PanickingSource;
1075        let wrapped = InstrumentedSource::new(&inner, labels());
1076        let ctx = HashMap::new();
1077        let mut s = wrapped.stream_pages(&ctx, 10);
1078        let first = s
1079            .next()
1080            .await
1081            .expect("stream yields at least one item before terminating");
1082        assert!(matches!(first, Err(FaucetError::Custom(_))));
1083        // Process did not abort — implicit by reaching this line.
1084    }
1085
1086    // ── error_kind: exhaustive variant → label mapping ───────────────────────
1087
1088    #[test]
1089    fn error_kind_covers_all_variants() {
1090        use std::time::Duration;
1091        // Build one of every non-`Http` FaucetError variant and assert its
1092        // stable label. (`Http` wraps a `reqwest::Error`, which has no public
1093        // constructor; it is exercised through the live request paths in the
1094        // connector crates' tests.)
1095        let cases: Vec<(FaucetError, &str)> = vec![
1096            (
1097                FaucetError::HttpStatus {
1098                    status: 500,
1099                    url: "u".into(),
1100                    body: "b".into(),
1101                },
1102                "HttpStatus",
1103            ),
1104            (
1105                FaucetError::Json(serde_json::from_str::<Value>("nope").unwrap_err()),
1106                "Json",
1107            ),
1108            (FaucetError::JsonPath("bad".into()), "JsonPath"),
1109            (FaucetError::Auth("a".into()), "Auth"),
1110            (
1111                FaucetError::RateLimited(Duration::from_secs(1)),
1112                "RateLimited",
1113            ),
1114            (FaucetError::Url("bad url".into()), "Url"),
1115            (FaucetError::Transform("t".into()), "Transform"),
1116            (FaucetError::Config("c".into()), "Config"),
1117            (FaucetError::Source("s".into()), "Source"),
1118            (FaucetError::Sink("s".into()), "Sink"),
1119            (
1120                FaucetError::QualityFailure {
1121                    check: "chk".into(),
1122                    message: "m".into(),
1123                },
1124                "QualityFailure",
1125            ),
1126            (FaucetError::State("st".into()), "State"),
1127            (
1128                FaucetError::CircuitOpen {
1129                    failures: 3,
1130                    cooldown: Duration::from_secs(60),
1131                },
1132                "CircuitOpen",
1133            ),
1134            (
1135                FaucetError::Timeout {
1136                    operation: "op".into(),
1137                    after: Duration::from_secs(1),
1138                },
1139                "Timeout",
1140            ),
1141            (
1142                FaucetError::Custom(Box::new(std::io::Error::other("boom"))),
1143                "Custom",
1144            ),
1145        ];
1146        for (err, expected) in cases {
1147            assert_eq!(error_kind(&err), expected, "mismatch for {err:?}");
1148        }
1149    }
1150
1151    // ── Source passthrough methods ───────────────────────────────────────────
1152
1153    // A source that overrides every passthrough so the instrumented wrapper's
1154    // delegating methods (state_key / apply_start_bookmark / fetch_with_context
1155    // / fetch_with_context_incremental) are exercised.
1156    struct PassthroughSource {
1157        seen_bookmark: Mutex<Option<Value>>,
1158    }
1159    #[async_trait]
1160    impl Source for PassthroughSource {
1161        async fn fetch_with_context(
1162            &self,
1163            _: &HashMap<String, Value>,
1164        ) -> Result<Vec<Value>, FaucetError> {
1165            Ok(vec![json!({"fwc": 1})])
1166        }
1167        async fn fetch_with_context_incremental(
1168            &self,
1169            _: &HashMap<String, Value>,
1170        ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
1171            Ok((vec![json!({"inc": 1})], Some(json!("bm"))))
1172        }
1173        fn state_key(&self) -> Option<String> {
1174            Some("passthrough_key".into())
1175        }
1176        async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
1177            *self.seen_bookmark.lock().unwrap() = Some(bookmark);
1178            Ok(())
1179        }
1180        fn connector_name(&self) -> &'static str {
1181            "passthrough"
1182        }
1183    }
1184
1185    #[tokio::test]
1186    async fn source_passthroughs_delegate_to_inner() {
1187        let inner = PassthroughSource {
1188            seen_bookmark: Mutex::new(None),
1189        };
1190        let wrapped = InstrumentedSource::new(&inner, labels());
1191
1192        // state_key passthrough
1193        assert_eq!(wrapped.state_key(), Some("passthrough_key".to_string()));
1194
1195        // fetch_with_context passthrough
1196        let ctx = HashMap::new();
1197        assert_eq!(
1198            wrapped.fetch_with_context(&ctx).await.unwrap(),
1199            vec![json!({"fwc": 1})]
1200        );
1201
1202        // fetch_with_context_incremental passthrough
1203        let (recs, bm) = wrapped.fetch_with_context_incremental(&ctx).await.unwrap();
1204        assert_eq!(recs, vec![json!({"inc": 1})]);
1205        assert_eq!(bm, Some(json!("bm")));
1206
1207        // apply_start_bookmark passthrough
1208        wrapped.apply_start_bookmark(json!("resume")).await.unwrap();
1209        assert_eq!(
1210            *inner.seen_bookmark.lock().unwrap(),
1211            Some(json!("resume")),
1212            "apply_start_bookmark must reach the inner source"
1213        );
1214
1215        // capability passthroughs: defaults for this inner source…
1216        assert!(!wrapped.supports_exactly_once());
1217        assert!(!wrapped.consumes_destructively());
1218        assert_eq!(
1219            wrapped.replay_guarantee(),
1220            crate::idempotency::ReplayGuarantee::NonDeterministic
1221        );
1222        assert_eq!(wrapped.capture_resume_position().await.unwrap(), None);
1223        assert_eq!(wrapped.lag().await.unwrap(), None);
1224    }
1225
1226    /// A source advertising exactly-once — the decorator must not mask it
1227    /// (the pipeline's mechanism selection reads these through the wrapper).
1228    struct ExactlyOnceSource;
1229    #[async_trait]
1230    impl Source for ExactlyOnceSource {
1231        async fn fetch_with_context(
1232            &self,
1233            _context: &HashMap<String, Value>,
1234        ) -> Result<Vec<Value>, FaucetError> {
1235            Ok(vec![])
1236        }
1237        fn supports_exactly_once(&self) -> bool {
1238            true
1239        }
1240        async fn capture_resume_position(&self) -> Result<Option<Value>, FaucetError> {
1241            Ok(Some(json!("pos")))
1242        }
1243        fn connector_name(&self) -> &'static str {
1244            "eo-source"
1245        }
1246    }
1247
1248    #[tokio::test]
1249    async fn source_capability_passthroughs_delegate_to_inner() {
1250        let inner = ExactlyOnceSource;
1251        let wrapped = InstrumentedSource::new(&inner, labels());
1252        assert!(wrapped.supports_exactly_once());
1253        assert_eq!(
1254            wrapped.replay_guarantee(),
1255            crate::idempotency::ReplayGuarantee::Deterministic,
1256            "typed capability derives through the wrapper"
1257        );
1258        assert_eq!(
1259            wrapped.capture_resume_position().await.unwrap(),
1260            Some(json!("pos"))
1261        );
1262    }
1263}
1264
1265#[allow(deprecated)] // the tests drive the 1.x lifecycle hooks directly (RFC 0014)
1266#[cfg(test)]
1267mod sink_tests {
1268    use super::source_tests::{LOCK, labels, snapshotter};
1269    use super::*;
1270    use async_trait::async_trait;
1271    use metrics_util::debugging::DebugValue;
1272    use serde_json::json;
1273
1274    struct MockSink(std::sync::Mutex<Vec<Value>>);
1275    #[async_trait]
1276    impl Sink for MockSink {
1277        async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1278            self.0.lock().unwrap().extend(records.iter().cloned());
1279            Ok(records.len())
1280        }
1281        fn connector_name(&self) -> &'static str {
1282            "mock-sink"
1283        }
1284    }
1285
1286    struct FailingSink;
1287    #[async_trait]
1288    impl Sink for FailingSink {
1289        async fn write_batch(&self, _: &[Value]) -> Result<usize, FaucetError> {
1290            Err(FaucetError::Sink("nope".into()))
1291        }
1292        fn connector_name(&self) -> &'static str {
1293            "failing-sink"
1294        }
1295    }
1296
1297    struct EmptyNameSink;
1298    #[async_trait]
1299    impl Sink for EmptyNameSink {
1300        async fn write_batch(&self, _: &[Value]) -> Result<usize, FaucetError> {
1301            Ok(0)
1302        }
1303        fn connector_name(&self) -> &'static str {
1304            ""
1305        }
1306    }
1307
1308    #[test]
1309    fn empty_inner_connector_name_falls_back_to_unknown() {
1310        let inner = EmptyNameSink;
1311        // `InstrumentedSink::new` debug_asserts on an empty inner name, so build
1312        // the wrapper directly with the fallback name to exercise the
1313        // passthrough without tripping the assertion in debug builds.
1314        let wrapped = InstrumentedSink {
1315            inner: &inner,
1316            labels: labels(),
1317            connector: SharedString::const_str("unknown"),
1318            base_labels: Vec::new(),
1319            meter: None,
1320        };
1321        assert_eq!(
1322            Sink::connector_name(&wrapped),
1323            "unknown",
1324            "instrumented sink must not leak an empty connector name"
1325        );
1326    }
1327
1328    /// Regression (#194): the pipeline drives the *wrapped* sink, so
1329    /// `InstrumentedSink` MUST forward the capability methods to the inner sink.
1330    /// Before this was fixed, the trait defaults (`current_schema -> None`,
1331    /// `supports_schema_evolution -> false`, `supports_idempotent_writes ->
1332    /// false`) silently disabled schema-drift, evolution, and exactly-once
1333    /// detection through the entire observability/CLI path even when the real
1334    /// sink supported them.
1335    struct CapableSink;
1336    #[async_trait]
1337    impl Sink for CapableSink {
1338        async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1339            Ok(records.len())
1340        }
1341        fn connector_name(&self) -> &'static str {
1342            "capable-sink"
1343        }
1344        async fn current_schema(&self) -> Result<Option<Value>, FaucetError> {
1345            Ok(Some(
1346                json!({"type": "object", "properties": {"id": {"type": "integer"}}}),
1347            ))
1348        }
1349        fn supports_schema_evolution(&self) -> bool {
1350            true
1351        }
1352        fn supports_idempotent_writes(&self) -> bool {
1353            true
1354        }
1355        fn supported_write_modes(&self) -> &'static [crate::write_mode::WriteMode] {
1356            &[
1357                crate::write_mode::WriteMode::Append,
1358                crate::write_mode::WriteMode::Upsert,
1359            ]
1360        }
1361        async fn last_committed_token(&self, _scope: &str) -> Result<Option<String>, FaucetError> {
1362            Ok(Some("tok-1".into()))
1363        }
1364        fn dedups_by_key(&self) -> bool {
1365            true
1366        }
1367    }
1368
1369    #[tokio::test]
1370    async fn instrumented_sink_forwards_capability_methods_to_inner() {
1371        let inner = CapableSink;
1372        let wrapped = InstrumentedSink::new(&inner, labels());
1373
1374        // Schema-drift (#194): the wrapper must surface the inner schema, not the
1375        // `None` default — otherwise drift detection is inert through the pipeline.
1376        assert_eq!(
1377            wrapped.current_schema().await.unwrap(),
1378            Some(json!({"type": "object", "properties": {"id": {"type": "integer"}}})),
1379            "current_schema must delegate to the inner sink"
1380        );
1381        assert!(
1382            wrapped.supports_schema_evolution(),
1383            "supports_schema_evolution must delegate"
1384        );
1385        // Pre-existing capabilities the wrapper must also forward.
1386        assert!(
1387            wrapped.supports_idempotent_writes(),
1388            "supports_idempotent_writes must delegate (exactly-once)"
1389        );
1390        assert!(
1391            wrapped
1392                .supported_write_modes()
1393                .contains(&crate::write_mode::WriteMode::Upsert),
1394            "supported_write_modes must delegate"
1395        );
1396        assert_eq!(
1397            wrapped.last_committed_token("scope").await.unwrap(),
1398            Some("tok-1".to_string()),
1399            "last_committed_token must delegate"
1400        );
1401        // Typed delivery capabilities (#292): the pipeline's mechanism
1402        // selection reads these through the wrapper.
1403        assert_eq!(
1404            wrapped.sink_guarantee(),
1405            crate::idempotency::SinkGuarantee::AtomicWatermark,
1406            "sink_guarantee must delegate"
1407        );
1408        assert!(wrapped.dedups_by_key(), "dedups_by_key must delegate");
1409        assert_eq!(
1410            wrapped.batch_atomicity(),
1411            crate::dlq::BatchAtomicity::BestEffort
1412        );
1413    }
1414
1415    #[tokio::test]
1416    #[allow(clippy::await_holding_lock)]
1417    async fn records_writes_and_records_counters() {
1418        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1419        let snap = snapshotter();
1420        let inner = MockSink(std::sync::Mutex::new(Vec::new()));
1421        let wrapped = InstrumentedSink::new(&inner, labels());
1422        wrapped
1423            .write_batch(&[json!({"a": 1}), json!({"a": 2})])
1424            .await
1425            .unwrap();
1426        let snapshot = snap.snapshot();
1427        let writes: u64 = snapshot
1428            .into_vec()
1429            .into_iter()
1430            .filter_map(|(key, _u, _d, v)| {
1431                if key.key().name() == "faucet_sink_writes_total"
1432                    && let DebugValue::Counter(c) = v
1433                {
1434                    return Some(c);
1435                }
1436                None
1437            })
1438            .sum();
1439        assert!(writes >= 1, "expected at least one write counted");
1440    }
1441
1442    #[tokio::test]
1443    #[allow(clippy::await_holding_lock)]
1444    async fn error_increments_errors_total_with_kind() {
1445        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1446        let snap = snapshotter();
1447        let inner = FailingSink;
1448        let wrapped = InstrumentedSink::new(&inner, labels());
1449        let _ = wrapped.write_batch(&[json!({})]).await;
1450        let snapshot = snap.snapshot();
1451        let found = snapshot.into_vec().into_iter().any(|(key, _u, _d, v)| {
1452            key.key().name() == "faucet_sink_errors_total"
1453                && key
1454                    .key()
1455                    .labels()
1456                    .any(|l| l.key() == "kind" && l.value() == "Sink")
1457                && matches!(v, DebugValue::Counter(c) if c >= 1)
1458        });
1459        assert!(found, "expected sink_errors_total with kind=Sink");
1460    }
1461
1462    #[tokio::test]
1463    #[allow(clippy::await_holding_lock)]
1464    async fn instrumented_sink_write_batch_partial_counts_successful_outcomes() {
1465        use crate::traits::RowOutcome;
1466        use metrics_util::debugging::DebugValue;
1467
1468        // Sink that returns 2 Ok + 1 Err.
1469        struct MixedSink;
1470        #[async_trait]
1471        impl Sink for MixedSink {
1472            async fn write_batch(&self, _r: &[Value]) -> Result<usize, FaucetError> {
1473                unreachable!()
1474            }
1475            async fn write_batch_partial(
1476                &self,
1477                _r: &[Value],
1478            ) -> Result<Vec<RowOutcome>, FaucetError> {
1479                Ok(vec![
1480                    Ok(()),
1481                    Err(FaucetError::Sink("bad row".into())),
1482                    Ok(()),
1483                ])
1484            }
1485            fn connector_name(&self) -> &'static str {
1486                "mixed"
1487            }
1488        }
1489
1490        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1491        let snap = snapshotter();
1492
1493        let inner = MixedSink;
1494        let wrapped = InstrumentedSink::new(&inner, labels());
1495        let _ = wrapped
1496            .write_batch_partial(&[json!({}), json!({}), json!({})])
1497            .await
1498            .unwrap();
1499
1500        // faucet_sink_records_total should reflect 2 (Ok count), not 3.
1501        // Filter to this test's own labels (connector="mixed") — prior tests in
1502        // the same `mod sink_tests` (e.g. records_writes_and_records_counters
1503        // for connector="mock-sink") leave entries in the shared global
1504        // recorder, and the HashMap-iteration order of `Snapshot::into_vec()`
1505        // is non-deterministic, so a naïve `find_map` returns an arbitrary
1506        // entry.
1507        let snapshot = snap.snapshot();
1508        let records: u64 = snapshot
1509            .into_vec()
1510            .into_iter()
1511            .filter_map(|(k, _u, _d, v): (metrics_util::CompositeKey, _, _, _)| {
1512                if k.key().name() == "faucet_sink_records_total"
1513                    && k.key()
1514                        .labels()
1515                        .any(|l| l.key() == "connector" && l.value() == "mixed")
1516                    && let DebugValue::Counter(c) = v
1517                {
1518                    Some(c)
1519                } else {
1520                    None
1521                }
1522            })
1523            .sum();
1524        assert!(
1525            records >= 2,
1526            "expected faucet_sink_records_total{{connector=mixed}} >= 2, got {records}"
1527        );
1528    }
1529
1530    // ── flush error path ─────────────────────────────────────────────────────
1531
1532    #[tokio::test]
1533    #[allow(clippy::await_holding_lock)]
1534    async fn flush_error_increments_errors_total_and_propagates() {
1535        // A sink whose flush() returns Err must surface the error and emit
1536        // faucet_sink_errors_total with the matching kind label.
1537        struct FlushFailSink;
1538        #[async_trait]
1539        impl Sink for FlushFailSink {
1540            async fn write_batch(&self, r: &[Value]) -> Result<usize, FaucetError> {
1541                Ok(r.len())
1542            }
1543            async fn flush(&self) -> Result<(), FaucetError> {
1544                Err(FaucetError::Sink("flush boom".into()))
1545            }
1546            fn connector_name(&self) -> &'static str {
1547                "flush-fail-sink"
1548            }
1549        }
1550
1551        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1552        let snap = snapshotter();
1553        let inner = FlushFailSink;
1554        let wrapped = InstrumentedSink::new(&inner, labels());
1555        let err = wrapped.flush().await.unwrap_err();
1556        assert!(matches!(&err, FaucetError::Sink(m) if m.contains("flush boom")));
1557
1558        let snapshot = snap.snapshot();
1559        let found = snapshot.into_vec().into_iter().any(|(key, _u, _d, v)| {
1560            key.key().name() == "faucet_sink_errors_total"
1561                && key
1562                    .key()
1563                    .labels()
1564                    .any(|l| l.key() == "connector" && l.value() == "flush-fail-sink")
1565                && key
1566                    .key()
1567                    .labels()
1568                    .any(|l| l.key() == "kind" && l.value() == "Sink")
1569                && matches!(v, DebugValue::Counter(c) if c >= 1)
1570        });
1571        assert!(
1572            found,
1573            "expected sink_errors_total{{connector=flush-fail-sink,kind=Sink}}"
1574        );
1575    }
1576
1577    // ── panic isolation on every sink call ───────────────────────────────────
1578
1579    struct PanickingSink;
1580    #[async_trait]
1581    impl Sink for PanickingSink {
1582        async fn write_batch(&self, _: &[Value]) -> Result<usize, FaucetError> {
1583            panic!("write kaboom")
1584        }
1585        async fn write_batch_partial(
1586            &self,
1587            _: &[Value],
1588        ) -> Result<Vec<crate::traits::RowOutcome>, FaucetError> {
1589            panic!("partial kaboom")
1590        }
1591        async fn flush(&self) -> Result<(), FaucetError> {
1592            panic!("flush kaboom")
1593        }
1594        fn connector_name(&self) -> &'static str {
1595            "panic-sink"
1596        }
1597    }
1598
1599    #[tokio::test]
1600    #[allow(clippy::await_holding_lock)]
1601    async fn write_batch_panic_maps_to_custom_error() {
1602        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1603        let _snap = snapshotter();
1604        let inner = PanickingSink;
1605        let wrapped = InstrumentedSink::new(&inner, labels());
1606        let err = wrapped.write_batch(&[json!({})]).await.unwrap_err();
1607        match err {
1608            FaucetError::Custom(b) => {
1609                assert!(b.to_string().contains("panic in sink: write kaboom"))
1610            }
1611            other => panic!("expected Custom panic error, got {other:?}"),
1612        }
1613    }
1614
1615    #[tokio::test]
1616    #[allow(clippy::await_holding_lock)]
1617    async fn write_batch_partial_panic_maps_to_custom_error() {
1618        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1619        let _snap = snapshotter();
1620        let inner = PanickingSink;
1621        let wrapped = InstrumentedSink::new(&inner, labels());
1622        let err = wrapped.write_batch_partial(&[json!({})]).await.unwrap_err();
1623        match err {
1624            FaucetError::Custom(b) => {
1625                assert!(b.to_string().contains("panic in sink: partial kaboom"))
1626            }
1627            other => panic!("expected Custom panic error, got {other:?}"),
1628        }
1629    }
1630
1631    #[tokio::test]
1632    #[allow(clippy::await_holding_lock)]
1633    async fn flush_panic_maps_to_custom_error() {
1634        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1635        let _snap = snapshotter();
1636        let inner = PanickingSink;
1637        let wrapped = InstrumentedSink::new(&inner, labels());
1638        let err = wrapped.flush().await.unwrap_err();
1639        match err {
1640            FaucetError::Custom(b) => {
1641                assert!(b.to_string().contains("panic in flush: flush kaboom"))
1642            }
1643            other => panic!("expected Custom panic error, got {other:?}"),
1644        }
1645    }
1646    /// `InstrumentedSink` must forward the identity + provenance methods.
1647    ///
1648    /// It wraps every sink whenever observability is active — i.e. the default
1649    /// build — and it forwarded neither of these, which review caught. The
1650    /// failure mode is silent: `local_outputs()` falling back to the trait
1651    /// default hides every file the inner sink created from the retention GC
1652    /// (#587), so those files are never reclaimed and nothing logs or errors.
1653    /// `dataset_uri()` falling back records `jsonl://unknown` in lineage and the
1654    /// catalog.
1655    ///
1656    /// The sibling decorators are covered in
1657    /// `tests/local_output_forwarding.rs`; this one lives here because the
1658    /// module is private.
1659    #[tokio::test]
1660    async fn instrumented_sink_forwards_identity_and_local_outputs() {
1661        use crate::local_outputs::{LocalOutput, LocalOutputLog};
1662
1663        struct FileSink {
1664            outputs: LocalOutputLog,
1665        }
1666
1667        #[async_trait]
1668        impl Sink for FileSink {
1669            async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
1670                Ok(records.len())
1671            }
1672            fn connector_name(&self) -> &'static str {
1673                "jsonl"
1674            }
1675            fn dataset_uri(&self) -> String {
1676                "file:///tmp/out.jsonl".to_string()
1677            }
1678            async fn local_outputs(&self) -> Vec<LocalOutput> {
1679                self.outputs.snapshot()
1680            }
1681        }
1682
1683        let outputs = LocalOutputLog::new();
1684        outputs.record_open("/tmp/out.jsonl", false);
1685        let inner = FileSink { outputs };
1686        let sink = InstrumentedSink::new(&inner, Labels::new("p", "r", "run-1"));
1687
1688        let reported = sink.local_outputs().await;
1689        assert_eq!(
1690            reported.len(),
1691            1,
1692            "InstrumentedSink must not hide the inner sink's files from the GC"
1693        );
1694        assert_eq!(reported[0].path, std::path::PathBuf::from("/tmp/out.jsonl"));
1695        assert!(
1696            !reported[0].pre_existing,
1697            "classification must survive verbatim"
1698        );
1699        assert_eq!(sink.dataset_uri(), "file:///tmp/out.jsonl");
1700    }
1701
1702    fn counters_for(row: &str) -> HashMap<String, u64> {
1703        let mut out = HashMap::new();
1704        for (key, _u, _d, v) in snapshotter().snapshot().into_vec() {
1705            let k = key.key();
1706            if k.labels().any(|l| l.key() == "row" && l.value() == row)
1707                && let DebugValue::Counter(c) = v
1708            {
1709                *out.entry(k.name().to_string()).or_default() += c;
1710            }
1711        }
1712        out
1713    }
1714
1715    #[tokio::test]
1716    async fn instrumented_sink_forwards_every_hook() {
1717        let probe = crate::forwarding::HookSink::default();
1718        let wrapped = InstrumentedSink::new(&probe, labels());
1719        crate::forwarding::assert_sink_forwards_every_hook(&wrapped, &probe, &[]).await;
1720    }
1721
1722    #[allow(clippy::await_holding_lock)]
1723    #[tokio::test]
1724    async fn idempotent_write_is_counted_metered_and_panic_guarded() {
1725        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
1726        let _snap = snapshotter();
1727        let probe = crate::forwarding::HookSink::default();
1728        let meter = Arc::new(UsageMeter::new());
1729        let wrapped = InstrumentedSink::new(&probe, Labels::new("p", "eo-row", "rid"))
1730            .with_meter(Arc::clone(&meter));
1731        let n = wrapped
1732            .write_batch_idempotent(&[json!({"a": 1}), json!({"a": 2})], "s", "t")
1733            .await
1734            .unwrap();
1735        assert_eq!(n, 2);
1736        let c = counters_for("eo-row");
1737        assert_eq!(c["faucet_sink_writes_total"], 1);
1738        assert_eq!(c["faucet_sink_records_total"], 2);
1739        assert_eq!(meter.snapshot().records_written, 2);
1740
1741        struct EoPanics;
1742        #[async_trait]
1743        impl Sink for EoPanics {
1744            async fn write_batch(&self, _: &[Value]) -> Result<usize, FaucetError> {
1745                Ok(0)
1746            }
1747            async fn write_batch_idempotent(
1748                &self,
1749                _: &[Value],
1750                _: &str,
1751                _: &str,
1752            ) -> Result<usize, FaucetError> {
1753                panic!("eo kaboom")
1754            }
1755        }
1756        let wrapped = InstrumentedSink::new(&EoPanics, Labels::new("p", "eo-panic", "rid"));
1757        let err = wrapped
1758            .write_batch_idempotent(&[json!({})], "s", "t")
1759            .await
1760            .unwrap_err();
1761        assert!(
1762            err.to_string().contains("panic in sink: eo kaboom"),
1763            "{err}"
1764        );
1765        assert_eq!(counters_for("eo-panic")["faucet_sink_errors_total"], 1);
1766
1767        struct EoFails;
1768        #[async_trait]
1769        impl Sink for EoFails {
1770            async fn write_batch(&self, _: &[Value]) -> Result<usize, FaucetError> {
1771                Ok(0)
1772            }
1773            async fn write_batch_idempotent(
1774                &self,
1775                _: &[Value],
1776                _: &str,
1777                _: &str,
1778            ) -> Result<usize, FaucetError> {
1779                Err(FaucetError::Sink("nope".into()))
1780            }
1781        }
1782        let wrapped = InstrumentedSink::new(&EoFails, Labels::new("p", "eo-err", "rid"));
1783        assert!(wrapped.write_batch_idempotent(&[], "s", "t").await.is_err());
1784        assert_eq!(counters_for("eo-err")["faucet_sink_errors_total"], 1);
1785    }
1786
1787    #[tokio::test]
1788    async fn native_loads_are_metered_for_buffered_and_streamed_payloads() {
1789        use crate::native::{NativeBatch, NativeFormat, NativeLoadContext, NativePayload};
1790        let probe = crate::forwarding::HookSink::default();
1791        let meter = Arc::new(UsageMeter::new());
1792        let wrapped = InstrumentedSink::new(&probe, labels()).with_meter(Arc::clone(&meter));
1793        let ctx = || NativeLoadContext {
1794            write_mode: crate::write_mode::WriteMode::Append,
1795            first_batch: true,
1796        };
1797        let batch = |payload| NativeBatch {
1798            format: NativeFormat::NdJson,
1799            payload,
1800            csv: Default::default(),
1801            records: Some(2),
1802            bookmark: None,
1803        };
1804        wrapped
1805            .load_native(batch(NativePayload::Bytes(vec![0; 10])), "s", ctx())
1806            .await
1807            .unwrap();
1808        let stream: Pin<Box<dyn Stream<Item = Result<Vec<u8>, FaucetError>> + Send>> =
1809            Box::pin(futures::stream::iter(vec![Ok(vec![0; 3]), Ok(vec![0; 4])]));
1810        struct Drains;
1811        #[async_trait]
1812        impl Sink for Drains {
1813            async fn write_batch(&self, _: &[Value]) -> Result<usize, FaucetError> {
1814                Ok(0)
1815            }
1816            async fn load_native(
1817                &self,
1818                batch: NativeBatch,
1819                _: &str,
1820                _: NativeLoadContext,
1821            ) -> Result<usize, FaucetError> {
1822                use futures::StreamExt;
1823                if let NativePayload::Stream(mut s) = batch.payload {
1824                    while s.next().await.is_some() {}
1825                }
1826                Ok(5)
1827            }
1828        }
1829        let wrapped2 = InstrumentedSink::new(&Drains, labels()).with_meter(Arc::clone(&meter));
1830        wrapped2
1831            .load_native(batch(NativePayload::Stream(stream)), "s", ctx())
1832            .await
1833            .unwrap();
1834        let snap = meter.snapshot();
1835        assert_eq!(snap.records_written, 7 + 5);
1836        assert_eq!(snap.bytes_written, 10 + 7);
1837    }
1838
1839    #[tokio::test]
1840    async fn native_source_batches_are_metered() {
1841        use crate::native::{NativeBatch, NativeFormat, NativePayload};
1842        struct Native;
1843        #[async_trait]
1844        impl Source for Native {
1845            async fn fetch_with_context(
1846                &self,
1847                _: &HashMap<String, Value>,
1848            ) -> Result<Vec<Value>, FaucetError> {
1849                Ok(vec![])
1850            }
1851            fn stream_native<'b>(
1852                &'b self,
1853                _: &'b HashMap<String, Value>,
1854                _: NativeFormat,
1855                _: usize,
1856            ) -> Pin<Box<dyn Stream<Item = Result<NativeBatch, FaucetError>> + Send + 'b>>
1857            {
1858                let b = NativeBatch {
1859                    format: NativeFormat::NdJson,
1860                    payload: NativePayload::Bytes(vec![1; 6]),
1861                    csv: Default::default(),
1862                    records: Some(3),
1863                    bookmark: None,
1864                };
1865                Box::pin(futures::stream::iter(vec![Ok(b)]))
1866            }
1867        }
1868        use futures::StreamExt;
1869        let meter = Arc::new(UsageMeter::new());
1870        let wrapped = InstrumentedSource::new(&Native, labels()).with_meter(Arc::clone(&meter));
1871        let ctx = HashMap::new();
1872        let got: Vec<_> = wrapped
1873            .stream_native(&ctx, NativeFormat::NdJson, 10)
1874            .collect()
1875            .await;
1876        assert_eq!(got.len(), 1);
1877        let snap = meter.snapshot();
1878        assert_eq!((snap.records_read, snap.bytes_read), (3, 6));
1879        let unmetered = InstrumentedSource::new(&Native, labels());
1880        assert_eq!(
1881            unmetered
1882                .stream_native(&ctx, NativeFormat::NdJson, 10)
1883                .count()
1884                .await,
1885            1
1886        );
1887    }
1888
1889    #[cfg(feature = "arrow")]
1890    #[tokio::test]
1891    async fn columnar_batches_are_metered_on_both_sides() {
1892        use crate::columnar::ColumnarPage;
1893        use arrow::array::{Int64Array, RecordBatch};
1894        use arrow::datatypes::{DataType, Field, Schema};
1895        fn batch() -> RecordBatch {
1896            RecordBatch::try_new(
1897                Arc::new(Schema::new(vec![Field::new("id", DataType::Int64, false)])),
1898                vec![Arc::new(Int64Array::from(vec![1, 2, 3]))],
1899            )
1900            .unwrap()
1901        }
1902        struct Cols;
1903        #[async_trait]
1904        impl Source for Cols {
1905            async fn fetch_with_context(
1906                &self,
1907                _: &HashMap<String, Value>,
1908            ) -> Result<Vec<Value>, FaucetError> {
1909                Ok(vec![])
1910            }
1911            fn stream_batches<'b>(
1912                &'b self,
1913                _: &'b HashMap<String, Value>,
1914                _: usize,
1915            ) -> Pin<Box<dyn Stream<Item = Result<ColumnarPage, FaucetError>> + Send + 'b>>
1916            {
1917                Box::pin(futures::stream::iter(vec![Ok(ColumnarPage {
1918                    batch: batch(),
1919                    bookmark: None,
1920                })]))
1921            }
1922        }
1923        use futures::StreamExt;
1924        let meter = Arc::new(UsageMeter::new());
1925        let src = InstrumentedSource::new(&Cols, labels()).with_meter(Arc::clone(&meter));
1926        let ctx = HashMap::new();
1927        assert_eq!(src.stream_batches(&ctx, 10).count().await, 1);
1928        let unmetered = InstrumentedSource::new(&Cols, labels());
1929        assert_eq!(unmetered.stream_batches(&ctx, 10).count().await, 1);
1930
1931        let probe = crate::forwarding::HookSink::default();
1932        let sink = InstrumentedSink::new(&probe, labels()).with_meter(Arc::clone(&meter));
1933        assert_eq!(sink.write_batch_columnar(&batch()).await.unwrap(), 3);
1934        let snap = meter.snapshot();
1935        assert_eq!((snap.records_read, snap.records_written), (3, 3));
1936        assert!(snap.bytes_read > 0);
1937        assert_eq!(snap.bytes_read, snap.bytes_written);
1938    }
1939
1940    // ── panic isolation on the columnar and native fast paths (#872) ─────────
1941
1942    fn panic_errors_for(row: &str, metric: &str) -> u64 {
1943        snapshotter()
1944            .snapshot()
1945            .into_vec()
1946            .into_iter()
1947            .filter_map(|(key, _u, _d, v)| {
1948                let k = key.key();
1949                let hit = k.name() == metric
1950                    && k.labels().any(|l| l.key() == "row" && l.value() == row)
1951                    && k.labels()
1952                        .any(|l| l.key() == "kind" && l.value() == "Panic");
1953                match (hit, v) {
1954                    (true, DebugValue::Counter(c)) => Some(c),
1955                    _ => None,
1956                }
1957            })
1958            .sum()
1959    }
1960
1961    fn assert_panic_error(result: Result<usize, FaucetError>, what: &str, msg: &str) {
1962        match result {
1963            Err(FaucetError::Custom(b)) => {
1964                assert_eq!(b.to_string(), format!("panic in {what}: {msg}"))
1965            }
1966            other => panic!("expected a panic error, got {other:?}"),
1967        }
1968    }
1969
1970    struct PanicsOnFastPaths;
1971    #[async_trait]
1972    impl Sink for PanicsOnFastPaths {
1973        async fn write_batch(&self, _: &[Value]) -> Result<usize, FaucetError> {
1974            Ok(0)
1975        }
1976        #[cfg(feature = "arrow")]
1977        async fn write_batch_columnar(
1978            &self,
1979            _batch: &arrow::array::RecordBatch,
1980        ) -> Result<usize, FaucetError> {
1981            panic!("columnar kaboom")
1982        }
1983        async fn load_native(
1984            &self,
1985            _batch: crate::native::NativeBatch,
1986            _scope: &str,
1987            _ctx: crate::native::NativeLoadContext,
1988        ) -> Result<usize, FaucetError> {
1989            panic!("native kaboom")
1990        }
1991    }
1992
1993    fn native_batch() -> crate::native::NativeBatch {
1994        crate::native::NativeBatch {
1995            format: crate::native::NativeFormat::NdJson,
1996            payload: crate::native::NativePayload::Bytes(b"{}\n".to_vec()),
1997            csv: crate::native::CsvDialect::default(),
1998            records: Some(1),
1999            bookmark: None,
2000        }
2001    }
2002
2003    fn load_ctx() -> crate::native::NativeLoadContext {
2004        crate::native::NativeLoadContext {
2005            write_mode: crate::write_mode::WriteMode::Append,
2006            first_batch: true,
2007        }
2008    }
2009
2010    #[allow(clippy::await_holding_lock)]
2011    #[tokio::test]
2012    async fn native_load_panic_is_a_typed_error_metered_or_not() {
2013        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
2014        let _snap = snapshotter();
2015        let plain =
2016            InstrumentedSink::new(&PanicsOnFastPaths, Labels::new("p", "native-panic", "rid"));
2017        let r = plain.load_native(native_batch(), "s", load_ctx()).await;
2018        assert_panic_error(r, "sink", "native kaboom");
2019        let metered =
2020            InstrumentedSink::new(&PanicsOnFastPaths, Labels::new("p", "native-panic", "rid"))
2021                .with_meter(Arc::new(UsageMeter::new()));
2022        let r = metered.load_native(native_batch(), "s", load_ctx()).await;
2023        assert_panic_error(r, "sink", "native kaboom");
2024        assert_eq!(
2025            panic_errors_for("native-panic", "faucet_sink_errors_total"),
2026            2
2027        );
2028    }
2029
2030    #[cfg(feature = "arrow")]
2031    fn one_row() -> arrow::array::RecordBatch {
2032        let schema = Arc::new(arrow::datatypes::Schema::new(vec![
2033            arrow::datatypes::Field::new("id", arrow::datatypes::DataType::Int64, false),
2034        ]));
2035        arrow::array::RecordBatch::try_new(
2036            schema,
2037            vec![Arc::new(arrow::array::Int64Array::from(vec![1]))],
2038        )
2039        .unwrap()
2040    }
2041
2042    #[cfg(feature = "arrow")]
2043    #[allow(clippy::await_holding_lock)]
2044    #[tokio::test]
2045    async fn columnar_write_panic_is_a_typed_error() {
2046        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
2047        let _snap = snapshotter();
2048        let wrapped =
2049            InstrumentedSink::new(&PanicsOnFastPaths, Labels::new("p", "col-panic", "rid"))
2050                .with_meter(Arc::new(UsageMeter::new()));
2051        let r = wrapped.write_batch_columnar(&one_row()).await;
2052        assert_panic_error(r, "sink", "columnar kaboom");
2053        assert_eq!(panic_errors_for("col-panic", "faucet_sink_errors_total"), 1);
2054    }
2055
2056    /// Panics opening its native stream; its columnar stream yields one page
2057    /// and then panics on the next poll.
2058    struct PanickyStreams;
2059    #[async_trait]
2060    impl Source for PanickyStreams {
2061        async fn fetch_with_context(
2062            &self,
2063            _: &HashMap<String, Value>,
2064        ) -> Result<Vec<Value>, FaucetError> {
2065            Ok(vec![])
2066        }
2067        #[cfg(feature = "arrow")]
2068        fn stream_batches<'a>(
2069            &'a self,
2070            _context: &'a HashMap<String, Value>,
2071            _batch_size: usize,
2072        ) -> Pin<
2073            Box<dyn Stream<Item = Result<crate::columnar::ColumnarPage, FaucetError>> + Send + 'a>,
2074        > {
2075            let mut polled = 0;
2076            Box::pin(futures::stream::poll_fn(move |_| {
2077                polled += 1;
2078                if polled == 1 {
2079                    std::task::Poll::Ready(Some(Ok(crate::columnar::ColumnarPage {
2080                        batch: one_row(),
2081                        bookmark: None,
2082                    })))
2083                } else {
2084                    panic!("batches kaboom")
2085                }
2086            }))
2087        }
2088        fn stream_native<'a>(
2089            &'a self,
2090            _context: &'a HashMap<String, Value>,
2091            _format: crate::native::NativeFormat,
2092            _batch_size: usize,
2093        ) -> Pin<Box<dyn Stream<Item = Result<crate::native::NativeBatch, FaucetError>> + Send + 'a>>
2094        {
2095            panic!("open kaboom")
2096        }
2097    }
2098
2099    /// Yields one native batch, then panics on the next poll.
2100    struct PanicsMidNative;
2101    #[async_trait]
2102    impl Source for PanicsMidNative {
2103        async fn fetch_with_context(
2104            &self,
2105            _: &HashMap<String, Value>,
2106        ) -> Result<Vec<Value>, FaucetError> {
2107            Ok(vec![])
2108        }
2109        fn stream_native<'a>(
2110            &'a self,
2111            _context: &'a HashMap<String, Value>,
2112            _format: crate::native::NativeFormat,
2113            _batch_size: usize,
2114        ) -> Pin<Box<dyn Stream<Item = Result<crate::native::NativeBatch, FaucetError>> + Send + 'a>>
2115        {
2116            let mut polled = 0;
2117            Box::pin(futures::stream::poll_fn(move |_| {
2118                polled += 1;
2119                if polled == 1 {
2120                    std::task::Poll::Ready(Some(Ok(native_batch())))
2121                } else {
2122                    panic!("mid kaboom")
2123                }
2124            }))
2125        }
2126    }
2127
2128    fn panic_text(item: Option<Result<impl std::fmt::Debug, FaucetError>>) -> String {
2129        match item {
2130            Some(Err(FaucetError::Custom(b))) => b.to_string(),
2131            other => panic!("expected a panic error item, got {other:?}"),
2132        }
2133    }
2134
2135    #[allow(clippy::await_holding_lock)]
2136    #[tokio::test]
2137    async fn native_stream_panics_become_one_error_item_that_ends_the_stream() {
2138        use futures::StreamExt;
2139        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
2140        let _snap = snapshotter();
2141        let ctx = HashMap::new();
2142        let fmt = crate::native::NativeFormat::NdJson;
2143
2144        let opening =
2145            InstrumentedSource::new(&PanickyStreams, Labels::new("p", "src-panic", "rid"));
2146        let mut s = opening.stream_native(&ctx, fmt, 10);
2147        assert_eq!(panic_text(s.next().await), "panic in source: open kaboom");
2148        assert!(s.next().await.is_none(), "the stream ends after the panic");
2149
2150        let mid = InstrumentedSource::new(&PanicsMidNative, Labels::new("p", "src-panic", "rid"))
2151            .with_meter(Arc::new(UsageMeter::new()));
2152        let mut s = mid.stream_native(&ctx, fmt, 10);
2153        assert!(matches!(s.next().await, Some(Ok(_))));
2154        assert_eq!(panic_text(s.next().await), "panic in source: mid kaboom");
2155        assert!(s.next().await.is_none());
2156        assert_eq!(
2157            panic_errors_for("src-panic", "faucet_source_errors_total"),
2158            2
2159        );
2160    }
2161
2162    #[cfg(feature = "arrow")]
2163    #[allow(clippy::await_holding_lock)]
2164    #[tokio::test]
2165    async fn columnar_stream_panic_becomes_one_error_item() {
2166        use futures::StreamExt;
2167        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
2168        let _snap = snapshotter();
2169        let ctx = HashMap::new();
2170        for meter in [None, Some(Arc::new(UsageMeter::new()))] {
2171            let mut wrapped =
2172                InstrumentedSource::new(&PanickyStreams, Labels::new("p", "batches-panic", "rid"));
2173            if let Some(m) = meter {
2174                wrapped = wrapped.with_meter(m);
2175            }
2176            let mut s = wrapped.stream_batches(&ctx, 10);
2177            assert!(matches!(s.next().await, Some(Ok(_))));
2178            assert_eq!(
2179                panic_text(s.next().await),
2180                "panic in source: batches kaboom"
2181            );
2182            assert!(s.next().await.is_none());
2183        }
2184        assert_eq!(
2185            panic_errors_for("batches-panic", "faucet_source_errors_total"),
2186            2
2187        );
2188    }
2189}