Skip to main content

faucet_core/profiling/
sink.rs

1//! [`ProfilingSink`] — a sink decorator that profiles every record the inner
2//! sink accepted (#708). Installed by the CLI executor outermost on the sink,
3//! so it observes records after transforms and masking and never sees the
4//! `_faucet_*` metadata columns a lower decorator adds — the profile
5//! describes exactly what landed in the destination, and a masked column's
6//! values are its masked values.
7
8use super::column::Profiler;
9use crate::error::FaucetError;
10use crate::traits::{RowOutcome, Sink};
11use async_trait::async_trait;
12use serde_json::Value;
13use std::sync::{Arc, Mutex};
14
15/// Wraps a sink and feeds accepted records into a shared [`Profiler`].
16pub struct ProfilingSink {
17    inner: Box<dyn Sink>,
18    profiler: Arc<Mutex<Profiler>>,
19}
20
21impl ProfilingSink {
22    pub fn new(inner: Box<dyn Sink>, profiler: Arc<Mutex<Profiler>>) -> Self {
23        Self { inner, profiler }
24    }
25
26    fn observe(&self, records: &[Value]) {
27        if let Ok(mut p) = self.profiler.lock() {
28            p.observe_page(records);
29        }
30    }
31}
32
33#[async_trait]
34impl Sink for ProfilingSink {
35    // Profiling needs parsed rows, so the native byte path stays off; everything
36    // else reaches the wrapped sink.
37    crate::forward_sink!(inner; except
38        write_batch, write_batch_reported, write_governed, write_batch_partial,
39        write_batch_idempotent, write_batch_idempotent_reported,
40        write_batch_columnar, native_load_capabilities, load_native);
41
42    async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
43        let n = self.inner.write_batch(records).await?;
44        self.observe(records);
45        Ok(n)
46    }
47
48    async fn write_batch_reported(
49        &self,
50        records: &[Value],
51    ) -> Result<crate::accounting::WriteReport, FaucetError> {
52        let r = self.inner.write_batch_reported(records).await?;
53        self.observe(records);
54        Ok(r)
55    }
56
57    async fn write_governed(
58        &self,
59        page: &crate::governance::GovernedPage<'_>,
60    ) -> Result<crate::accounting::WriteReport, FaucetError> {
61        let r = self.inner.write_governed(page).await?;
62        self.observe(page.records());
63        Ok(r)
64    }
65
66    /// Only rows the inner sink accepted are profiled — a row that failed into
67    /// the DLQ never reached the destination.
68    async fn write_batch_partial(&self, records: &[Value]) -> Result<Vec<RowOutcome>, FaucetError> {
69        let outcomes = self.inner.write_batch_partial(records).await?;
70        if outcomes.iter().all(|o| o.is_ok()) {
71            self.observe(records);
72        } else {
73            let accepted: Vec<Value> = records
74                .iter()
75                .zip(&outcomes)
76                .filter(|(_, o)| o.is_ok())
77                .map(|(r, _)| r.clone())
78                .collect();
79            self.observe(&accepted);
80        }
81        Ok(outcomes)
82    }
83
84    #[allow(deprecated)] // drives the 1.x lifecycle hook until faucet-core 2.0 (RFC 0014)
85    async fn write_batch_idempotent(
86        &self,
87        records: &[Value],
88        scope: &str,
89        token: &str,
90    ) -> Result<usize, FaucetError> {
91        let n = self
92            .inner
93            .write_batch_idempotent(records, scope, token)
94            .await?;
95        self.observe(records);
96        Ok(n)
97    }
98
99    #[allow(deprecated)] // drives the 1.x lifecycle hook until faucet-core 2.0 (RFC 0014)
100    async fn write_batch_idempotent_reported(
101        &self,
102        records: &[Value],
103        scope: &str,
104        token: &str,
105    ) -> Result<crate::accounting::WriteReport, FaucetError> {
106        let r = self
107            .inner
108            .write_batch_idempotent_reported(records, scope, token)
109            .await?;
110        self.observe(records);
111        Ok(r)
112    }
113
114    #[cfg(feature = "arrow")]
115    async fn write_batch_columnar(
116        &self,
117        batch: &arrow::array::RecordBatch,
118    ) -> Result<usize, FaucetError> {
119        let n = self.inner.write_batch_columnar(batch).await?;
120        if let Ok(values) = crate::columnar::record_batch_to_values(batch) {
121            self.observe(&values);
122        }
123        Ok(n)
124    }
125}
126
127#[allow(deprecated)] // the tests drive the 1.x lifecycle hooks directly (RFC 0014)
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use crate::profiling::ProfilingSpec;
132    use serde_json::json;
133
134    struct Partial;
135    #[async_trait]
136    impl Sink for Partial {
137        async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
138            Ok(records.len())
139        }
140        async fn write_batch_partial(
141            &self,
142            records: &[Value],
143        ) -> Result<Vec<RowOutcome>, FaucetError> {
144            Ok(records
145                .iter()
146                .map(|r| {
147                    if r["bad"].as_bool() == Some(true) {
148                        Err(FaucetError::Sink("rejected".into()))
149                    } else {
150                        Ok(())
151                    }
152                })
153                .collect())
154        }
155        fn connector_name(&self) -> &'static str {
156            "partial"
157        }
158        fn supports_idempotent_writes(&self) -> bool {
159            true
160        }
161        fn readback_source(&self) -> Option<(String, Value)> {
162            Some(("stub".into(), json!({})))
163        }
164    }
165
166    fn wrapped() -> (ProfilingSink, Arc<Mutex<Profiler>>) {
167        let profiler = Arc::new(Mutex::new(Profiler::new(ProfilingSpec::default())));
168        (
169            ProfilingSink::new(Box::new(Partial), Arc::clone(&profiler)),
170            profiler,
171        )
172    }
173
174    #[tokio::test]
175    async fn profiles_accepted_rows_only() {
176        let (sink, profiler) = wrapped();
177        sink.write_batch(&[json!({"a": 1}), json!({"a": 2})])
178            .await
179            .unwrap();
180        let outcomes = sink
181            .write_batch_partial(&[json!({"a": 3}), json!({"a": null, "bad": true})])
182            .await
183            .unwrap();
184        assert!(outcomes[1].is_err());
185        sink.write_batch_idempotent(&[json!({"a": 4})], "scope", "t")
186            .await
187            .unwrap();
188        let profile = profiler.lock().unwrap().finish();
189        assert_eq!(profile.rows, 4, "the rejected row is not profiled");
190        assert_eq!(profile.columns["a"].nulls, 0);
191        assert!(!profile.columns.contains_key("bad"));
192    }
193
194    #[tokio::test]
195    async fn all_accepted_partial_page_is_profiled_whole() {
196        let (sink, profiler) = wrapped();
197        sink.write_batch_partial(&[json!({"a": 1}), json!({"a": 2})])
198            .await
199            .unwrap();
200        assert_eq!(profiler.lock().unwrap().rows(), 2);
201    }
202
203    #[tokio::test]
204    async fn capabilities_forward_to_the_inner_sink() {
205        let (sink, _) = wrapped();
206        assert_eq!(sink.connector_name(), "partial");
207        assert!(sink.supports_idempotent_writes());
208        assert!(sink.readback_source().is_some());
209        assert!(sink.flush().await.is_ok());
210        assert!(sink.local_outputs().await.is_empty());
211        assert!(!sink.dedups_by_key());
212        assert_eq!(
213            sink.batch_atomicity(),
214            crate::dlq::BatchAtomicity::BestEffort
215        );
216        assert!(!sink.is_overwrite());
217        assert!(!sink.supports_rollback());
218        assert!(!sink.supports_cleanup());
219        assert!(!sink.supports_staged_load());
220        assert!(!sink.supports_schema_evolution());
221        assert!(sink.current_schema().await.unwrap().is_none());
222        assert!(sink.last_committed_token("s").await.unwrap().is_none());
223        assert!(sink.begin_overwrite().await.is_err(), "the default rejects");
224        assert!(sink.commit_overwrite().await.is_err());
225        assert!(sink.abort_overwrite().await.is_ok());
226        assert!(sink.complete_run().await.is_ok());
227        assert!(sink.forget_run("r").await.is_ok());
228        assert!(
229            sink.rewind_commit_token("s", None).await.is_err(),
230            "the default rejects"
231        );
232        let opts = crate::rollback::RollbackOptions {
233            run_id_column: "_faucet_run_id".into(),
234            mode: crate::rollback::RollbackMode::Append,
235            force: false,
236            dry_run: true,
237            later_runs: false,
238        };
239        assert!(sink.rollback_run("r", &opts).await.is_err());
240        assert!(
241            sink.cleanup_scope(&Default::default(), &crate::cleanup::SeenKeys::default())
242                .await
243                .is_err()
244        );
245        assert!(
246            sink.check(&crate::check::CheckContext::default())
247                .await
248                .is_ok()
249        );
250        assert_eq!(sink.dataset_uri(), Partial.dataset_uri());
251        assert_eq!(
252            sink.supported_write_modes(),
253            &[crate::write_mode::WriteMode::Append]
254        );
255        // Native loading is not advertised, so the pipeline profiles rows.
256        assert!(sink.native_load_capabilities().is_empty());
257    }
258
259    #[tokio::test]
260    async fn profiling_sink_forwards_every_hook_but_native() {
261        let probe = crate::forwarding::HookSink::default();
262        let sink = ProfilingSink::new(
263            Box::new(probe.clone()),
264            Arc::new(Mutex::new(Profiler::new(ProfilingSpec::default()))),
265        );
266        crate::forwarding::assert_sink_forwards_every_hook(
267            &sink,
268            &probe,
269            &["native_load_capabilities", "load_native"],
270        )
271        .await;
272    }
273}