Skip to main content

faucet_lineage/
sampling.rs

1//! Sink/source wrappers that sample records for schema inference and count
2//! throughput for RUNNING heartbeats. Installed only when the corresponding
3//! lineage facets/events are enabled, so they add zero overhead otherwise.
4
5use async_trait::async_trait;
6use faucet_core::{FaucetError, RowOutcome, Sink, Source, StreamPage};
7use futures::{Stream, StreamExt};
8use serde_json::Value;
9use std::collections::HashMap;
10use std::pin::Pin;
11use std::sync::Mutex;
12use std::sync::atomic::{AtomicU64, Ordering};
13
14use crate::lifecycle::InferredSchema;
15
16/// Shared sampling/counter state for one dataset side.
17pub struct SampleState {
18    cap: usize,
19    count: AtomicU64,
20    sample: Mutex<Vec<Value>>,
21}
22
23impl SampleState {
24    pub fn new(cap: usize) -> Self {
25        Self {
26            cap,
27            count: AtomicU64::new(0),
28            sample: Mutex::new(Vec::new()),
29        }
30    }
31    pub fn count(&self) -> u64 {
32        self.count.load(Ordering::Relaxed)
33    }
34    fn observe(&self, records: &[Value]) {
35        self.count
36            .fetch_add(records.len() as u64, Ordering::Relaxed);
37        if self.cap == 0 {
38            return;
39        }
40        let mut s = self.sample.lock().unwrap();
41        for r in records {
42            if s.len() >= self.cap {
43                break;
44            }
45            s.push(r.clone());
46        }
47    }
48    /// A copy of the sampled records (bounded by the construction-time cap).
49    /// Used by consumers that run their own schema inference over the sample —
50    /// e.g. the CLI's Data Movement Catalog (#279), which feeds them to
51    /// `faucet_core::schema::infer_schema` for a drift-comparable shape.
52    pub fn samples(&self) -> Vec<Value> {
53        self.sample.lock().unwrap().clone()
54    }
55
56    /// Infer an ordered (name, OL-type) schema from the sampled records.
57    pub fn inferred_schema(&self) -> InferredSchema {
58        let sample = self.sample.lock().unwrap();
59        if sample.is_empty() {
60            return InferredSchema::default();
61        }
62        // Preserve first-seen field order across the sample.
63        let mut order: Vec<String> = Vec::new();
64        let mut types: HashMap<String, String> = HashMap::new();
65        for rec in sample.iter() {
66            if let Value::Object(map) = rec {
67                for (k, v) in map {
68                    if !types.contains_key(k) {
69                        order.push(k.clone());
70                    }
71                    types
72                        .entry(k.clone())
73                        .or_insert_with(|| ol_type_of(v).to_string());
74                }
75            }
76        }
77        InferredSchema {
78            fields: order
79                .into_iter()
80                .map(|k| {
81                    let t = types.remove(&k).unwrap_or_else(|| "string".into());
82                    (k, t)
83                })
84                .collect(),
85        }
86    }
87}
88
89fn ol_type_of(v: &Value) -> &'static str {
90    match v {
91        Value::Null => "null",
92        Value::Bool(_) => "boolean",
93        Value::Number(n) if n.is_i64() || n.is_u64() => "integer",
94        Value::Number(_) => "number",
95        Value::String(_) => "string",
96        Value::Array(_) => "array",
97        Value::Object(_) => "object",
98    }
99}
100
101/// Wraps a sink, sampling written records and counting throughput.
102pub struct SamplingSink {
103    inner: Box<dyn Sink>,
104    state: std::sync::Arc<SampleState>,
105}
106
107impl SamplingSink {
108    pub fn new(inner: Box<dyn Sink>, state: std::sync::Arc<SampleState>) -> Self {
109        Self { inner, state }
110    }
111}
112
113#[async_trait]
114impl Sink for SamplingSink {
115    async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
116        let n = self.inner.write_batch(records).await?;
117        self.state.observe(records);
118        Ok(n)
119    }
120    async fn write_batch_partial(&self, records: &[Value]) -> Result<Vec<RowOutcome>, FaucetError> {
121        let outcomes = self.inner.write_batch_partial(records).await?;
122        self.state.observe(records);
123        Ok(outcomes)
124    }
125    async fn flush(&self) -> Result<(), FaucetError> {
126        self.inner.flush().await
127    }
128    fn connector_name(&self) -> &'static str {
129        self.inner.connector_name()
130    }
131    fn dataset_uri(&self) -> String {
132        self.inner.dataset_uri()
133    }
134    // Capability + exactly-once passthroughs. Without these the wrapper's
135    // trait defaults would mask the inner sink's capabilities whenever lineage
136    // (or catalog) sampling is active — e.g. an exactly-once run would be
137    // rejected as "sink is not idempotent" purely because sampling was on.
138    fn supports_idempotent_writes(&self) -> bool {
139        self.inner.supports_idempotent_writes()
140    }
141    fn sink_guarantee(&self) -> faucet_core::SinkGuarantee {
142        self.inner.sink_guarantee()
143    }
144    fn dedups_by_key(&self) -> bool {
145        self.inner.dedups_by_key()
146    }
147    fn supported_write_modes(&self) -> &'static [faucet_core::WriteMode] {
148        self.inner.supported_write_modes()
149    }
150    async fn write_batch_idempotent(
151        &self,
152        records: &[Value],
153        scope: &str,
154        token: &str,
155    ) -> Result<usize, FaucetError> {
156        let n = self
157            .inner
158            .write_batch_idempotent(records, scope, token)
159            .await?;
160        self.state.observe(records);
161        Ok(n)
162    }
163    async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
164        self.inner.last_committed_token(scope).await
165    }
166    async fn current_schema(&self) -> Result<Option<Value>, FaucetError> {
167        self.inner.current_schema().await
168    }
169    fn supports_schema_evolution(&self) -> bool {
170        self.inner.supports_schema_evolution()
171    }
172    async fn evolve_schema(
173        &self,
174        evolution: &faucet_core::SchemaEvolution,
175    ) -> Result<(), FaucetError> {
176        self.inner.evolve_schema(evolution).await
177    }
178    fn is_overwrite(&self) -> bool {
179        self.inner.is_overwrite()
180    }
181    async fn begin_overwrite(&self) -> Result<(), FaucetError> {
182        self.inner.begin_overwrite().await
183    }
184    async fn commit_overwrite(&self) -> Result<(), FaucetError> {
185        self.inner.commit_overwrite().await
186    }
187    async fn abort_overwrite(&self) -> Result<(), FaucetError> {
188        self.inner.abort_overwrite().await
189    }
190}
191
192/// Wraps a source, sampling the records it yields (pre-transform input schema).
193pub struct SamplingSource {
194    inner: Box<dyn Source>,
195    state: std::sync::Arc<SampleState>,
196}
197
198impl SamplingSource {
199    pub fn new(inner: Box<dyn Source>, state: std::sync::Arc<SampleState>) -> Self {
200        Self { inner, state }
201    }
202}
203
204#[async_trait]
205impl Source for SamplingSource {
206    async fn fetch_with_context(
207        &self,
208        ctx: &HashMap<String, Value>,
209    ) -> Result<Vec<Value>, FaucetError> {
210        // Required method; sampling happens in `stream_pages` (the path the
211        // pipeline actually drives). Delegate so the trait is complete.
212        self.inner.fetch_with_context(ctx).await
213    }
214    /// Override `stream_pages` (NOT `fetch_*`) so native-streaming sources keep
215    /// their bounded-memory page stream — we tap the pages as they flow rather
216    /// than forcing the buffering default path.
217    fn stream_pages<'a>(
218        &'a self,
219        ctx: &'a HashMap<String, Value>,
220        batch_size: usize,
221    ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
222        let state = std::sync::Arc::clone(&self.state);
223        let inner = self.inner.stream_pages(ctx, batch_size);
224        Box::pin(faucet_core::async_stream::try_stream! {
225            let mut inner = inner;
226            while let Some(page) = inner.next().await {
227                let page = page?;
228                state.observe(&page.records);
229                yield page;
230            }
231        })
232    }
233    fn connector_name(&self) -> &'static str {
234        self.inner.connector_name()
235    }
236    fn dataset_uri(&self) -> String {
237        self.inner.dataset_uri()
238    }
239    fn state_key(&self) -> Option<String> {
240        self.inner.state_key()
241    }
242    async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
243        self.inner.apply_start_bookmark(bookmark).await
244    }
245    // Bookmark + capability passthroughs. `fetch_with_context_incremental`
246    // matters even though the pipeline drives `stream_pages`: an *outer*
247    // wrapper's default `stream_pages` builds on it, and the trait default
248    // would silently drop the inner source's bookmark (breaking incremental
249    // resume whenever sampling is active).
250    async fn fetch_with_context_incremental(
251        &self,
252        ctx: &HashMap<String, Value>,
253    ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
254        self.inner.fetch_with_context_incremental(ctx).await
255    }
256    fn supports_exactly_once(&self) -> bool {
257        self.inner.supports_exactly_once()
258    }
259    fn replay_guarantee(&self) -> faucet_core::ReplayGuarantee {
260        self.inner.replay_guarantee()
261    }
262    async fn capture_resume_position(&self) -> Result<Option<Value>, FaucetError> {
263        self.inner.capture_resume_position().await
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270    use async_trait::async_trait;
271    use faucet_core::{FaucetError, Sink};
272    use serde_json::{Value, json};
273    use std::sync::Arc;
274
275    struct CollectSink(std::sync::Mutex<Vec<Value>>);
276    #[async_trait]
277    impl Sink for CollectSink {
278        async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
279            self.0.lock().unwrap().extend(records.iter().cloned());
280            Ok(records.len())
281        }
282        fn connector_name(&self) -> &'static str {
283            "collect"
284        }
285    }
286
287    #[tokio::test]
288    async fn sink_counts_and_samples_first_n() {
289        let shared = Arc::new(SampleState::new(2));
290        let inner: Box<dyn Sink> = Box::new(CollectSink(Default::default()));
291        let s = SamplingSink::new(inner, Arc::clone(&shared));
292        s.write_batch(&[json!({"id":1,"name":"a"})]).await.unwrap();
293        s.write_batch(&[json!({"id":2}), json!({"id":3})])
294            .await
295            .unwrap();
296        assert_eq!(shared.count(), 3);
297        // only the first 2 records were retained for schema inference
298        let schema = shared.inferred_schema();
299        let names: Vec<&str> = schema.fields.iter().map(|(n, _)| n.as_str()).collect();
300        assert!(names.contains(&"id"));
301        assert!(names.contains(&"name"));
302    }
303
304    #[tokio::test]
305    async fn sampling_sink_forwards_overwrite_lifecycle() {
306        // The sampling wrapper must forward the overwrite lifecycle so an
307        // overwrite run still stages/swaps when lineage/catalog sampling is on.
308        struct OvwSink(Arc<std::sync::Mutex<Vec<&'static str>>>);
309        #[async_trait]
310        impl Sink for OvwSink {
311            async fn write_batch(&self, r: &[Value]) -> Result<usize, FaucetError> {
312                Ok(r.len())
313            }
314            fn is_overwrite(&self) -> bool {
315                true
316            }
317            async fn begin_overwrite(&self) -> Result<(), FaucetError> {
318                self.0.lock().unwrap().push("begin");
319                Ok(())
320            }
321            async fn commit_overwrite(&self) -> Result<(), FaucetError> {
322                self.0.lock().unwrap().push("commit");
323                Ok(())
324            }
325            async fn abort_overwrite(&self) -> Result<(), FaucetError> {
326                self.0.lock().unwrap().push("abort");
327                Ok(())
328            }
329        }
330        let log = Arc::new(std::sync::Mutex::new(Vec::new()));
331        let inner: Box<dyn Sink> = Box::new(OvwSink(Arc::clone(&log)));
332        let s = SamplingSink::new(inner, Arc::new(SampleState::new(2)));
333        assert!(s.is_overwrite());
334        // A write through the wrapper must forward to the inner sink too.
335        assert_eq!(s.write_batch(&[json!({"id": 1})]).await.unwrap(), 1);
336        s.begin_overwrite().await.unwrap();
337        s.commit_overwrite().await.unwrap();
338        s.abort_overwrite().await.unwrap();
339        assert_eq!(*log.lock().unwrap(), vec!["begin", "commit", "abort"]);
340    }
341
342    struct TwoRowSource;
343    #[async_trait]
344    impl faucet_core::Source for TwoRowSource {
345        async fn fetch_with_context(
346            &self,
347            _: &std::collections::HashMap<String, Value>,
348        ) -> Result<Vec<Value>, FaucetError> {
349            Ok(vec![json!({"id": 1}), json!({"id": 2})])
350        }
351        fn connector_name(&self) -> &'static str {
352            "tworow"
353        }
354    }
355
356    #[tokio::test]
357    async fn source_samples_streamed_pages_without_buffering_override() {
358        use faucet_core::Source as _;
359        use futures::StreamExt as _;
360        let shared = Arc::new(SampleState::new(10));
361        let s = SamplingSource::new(Box::new(TwoRowSource), Arc::clone(&shared));
362        let ctx = std::collections::HashMap::new();
363        let mut pages = s.stream_pages(&ctx, 1000);
364        while let Some(p) = pages.next().await {
365            let _ = p.unwrap();
366        }
367        assert_eq!(shared.count(), 2);
368        let schema = shared.inferred_schema();
369        let names: Vec<&str> = schema.fields.iter().map(|(n, _)| n.as_str()).collect();
370        assert!(names.contains(&"id"));
371    }
372
373    /// An idempotent, upsert-capable sink: the sampler must forward every
374    /// capability + exactly-once method instead of masking them with the trait
375    /// defaults (which would make an exactly-once run fail as "sink is not
376    /// idempotent" whenever lineage/catalog sampling is active).
377    struct IdemSink;
378    #[async_trait]
379    impl Sink for IdemSink {
380        async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
381            Ok(records.len())
382        }
383        fn connector_name(&self) -> &'static str {
384            "idem"
385        }
386        fn supports_idempotent_writes(&self) -> bool {
387            true
388        }
389        fn dedups_by_key(&self) -> bool {
390            true
391        }
392        fn supported_write_modes(&self) -> &'static [faucet_core::WriteMode] {
393            &[
394                faucet_core::WriteMode::Append,
395                faucet_core::WriteMode::Upsert,
396            ]
397        }
398        async fn write_batch_idempotent(
399            &self,
400            records: &[Value],
401            _scope: &str,
402            _token: &str,
403        ) -> Result<usize, FaucetError> {
404            Ok(records.len())
405        }
406        async fn last_committed_token(&self, _scope: &str) -> Result<Option<String>, FaucetError> {
407            Ok(Some("tok".into()))
408        }
409        fn supports_schema_evolution(&self) -> bool {
410            true
411        }
412        async fn current_schema(&self) -> Result<Option<Value>, FaucetError> {
413            Ok(Some(json!({"type": "object", "properties": {}})))
414        }
415    }
416
417    #[tokio::test]
418    async fn sink_forwards_capabilities_and_samples_idempotent_writes() {
419        let shared = Arc::new(SampleState::new(10));
420        let s = SamplingSink::new(Box::new(IdemSink), Arc::clone(&shared));
421        assert!(s.supports_idempotent_writes());
422        assert!(s.dedups_by_key());
423        assert_eq!(
424            s.sink_guarantee(),
425            faucet_core::SinkGuarantee::AtomicWatermark
426        );
427        assert!(
428            s.supported_write_modes()
429                .contains(&faucet_core::WriteMode::Upsert)
430        );
431        assert!(s.supports_schema_evolution());
432        assert!(s.current_schema().await.unwrap().is_some());
433        assert_eq!(
434            s.last_committed_token("k").await.unwrap(),
435            Some("tok".into())
436        );
437        // Idempotent writes are observed by the sampler like plain writes.
438        s.write_batch_idempotent(&[json!({"id": 1})], "k", "t")
439            .await
440            .unwrap();
441        assert_eq!(shared.count(), 1);
442    }
443
444    struct BookmarkedSource;
445    #[async_trait]
446    impl faucet_core::Source for BookmarkedSource {
447        async fn fetch_with_context(
448            &self,
449            _: &std::collections::HashMap<String, Value>,
450        ) -> Result<Vec<Value>, FaucetError> {
451            Ok(vec![json!({"id": 1})])
452        }
453        async fn fetch_with_context_incremental(
454            &self,
455            _: &std::collections::HashMap<String, Value>,
456        ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
457            Ok((vec![json!({"id": 1})], Some(json!("bm"))))
458        }
459        fn supports_exactly_once(&self) -> bool {
460            true
461        }
462        async fn capture_resume_position(&self) -> Result<Option<Value>, FaucetError> {
463            Ok(Some(json!("pos")))
464        }
465        fn connector_name(&self) -> &'static str {
466            "bookmarked"
467        }
468    }
469
470    #[tokio::test]
471    async fn source_forwards_bookmarks_and_capabilities() {
472        use faucet_core::Source as _;
473        let shared = Arc::new(SampleState::new(10));
474        let s = SamplingSource::new(Box::new(BookmarkedSource), Arc::clone(&shared));
475        // The incremental fetch must surface the inner bookmark — the trait
476        // default would silently drop it (breaking incremental resume when an
477        // outer wrapper's default stream_pages builds on this method).
478        let (_, bm) = s
479            .fetch_with_context_incremental(&std::collections::HashMap::new())
480            .await
481            .unwrap();
482        assert_eq!(bm, Some(json!("bm")));
483        assert!(s.supports_exactly_once());
484        assert_eq!(
485            s.replay_guarantee(),
486            faucet_core::ReplayGuarantee::Deterministic
487        );
488        assert_eq!(
489            s.capture_resume_position().await.unwrap(),
490            Some(json!("pos"))
491        );
492    }
493}