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}
135
136/// Wraps a source, sampling the records it yields (pre-transform input schema).
137pub struct SamplingSource {
138    inner: Box<dyn Source>,
139    state: std::sync::Arc<SampleState>,
140}
141
142impl SamplingSource {
143    pub fn new(inner: Box<dyn Source>, state: std::sync::Arc<SampleState>) -> Self {
144        Self { inner, state }
145    }
146}
147
148#[async_trait]
149impl Source for SamplingSource {
150    async fn fetch_with_context(
151        &self,
152        ctx: &HashMap<String, Value>,
153    ) -> Result<Vec<Value>, FaucetError> {
154        // Required method; sampling happens in `stream_pages` (the path the
155        // pipeline actually drives). Delegate so the trait is complete.
156        self.inner.fetch_with_context(ctx).await
157    }
158    /// Override `stream_pages` (NOT `fetch_*`) so native-streaming sources keep
159    /// their bounded-memory page stream — we tap the pages as they flow rather
160    /// than forcing the buffering default path.
161    fn stream_pages<'a>(
162        &'a self,
163        ctx: &'a HashMap<String, Value>,
164        batch_size: usize,
165    ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
166        let state = std::sync::Arc::clone(&self.state);
167        let inner = self.inner.stream_pages(ctx, batch_size);
168        Box::pin(faucet_core::async_stream::try_stream! {
169            let mut inner = inner;
170            while let Some(page) = inner.next().await {
171                let page = page?;
172                state.observe(&page.records);
173                yield page;
174            }
175        })
176    }
177    fn connector_name(&self) -> &'static str {
178        self.inner.connector_name()
179    }
180    fn dataset_uri(&self) -> String {
181        self.inner.dataset_uri()
182    }
183    fn state_key(&self) -> Option<String> {
184        self.inner.state_key()
185    }
186    async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
187        self.inner.apply_start_bookmark(bookmark).await
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194    use async_trait::async_trait;
195    use faucet_core::{FaucetError, Sink};
196    use serde_json::{Value, json};
197    use std::sync::Arc;
198
199    struct CollectSink(std::sync::Mutex<Vec<Value>>);
200    #[async_trait]
201    impl Sink for CollectSink {
202        async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
203            self.0.lock().unwrap().extend(records.iter().cloned());
204            Ok(records.len())
205        }
206        fn connector_name(&self) -> &'static str {
207            "collect"
208        }
209    }
210
211    #[tokio::test]
212    async fn sink_counts_and_samples_first_n() {
213        let shared = Arc::new(SampleState::new(2));
214        let inner: Box<dyn Sink> = Box::new(CollectSink(Default::default()));
215        let s = SamplingSink::new(inner, Arc::clone(&shared));
216        s.write_batch(&[json!({"id":1,"name":"a"})]).await.unwrap();
217        s.write_batch(&[json!({"id":2}), json!({"id":3})])
218            .await
219            .unwrap();
220        assert_eq!(shared.count(), 3);
221        // only the first 2 records were retained for schema inference
222        let schema = shared.inferred_schema();
223        let names: Vec<&str> = schema.fields.iter().map(|(n, _)| n.as_str()).collect();
224        assert!(names.contains(&"id"));
225        assert!(names.contains(&"name"));
226    }
227
228    struct TwoRowSource;
229    #[async_trait]
230    impl faucet_core::Source for TwoRowSource {
231        async fn fetch_with_context(
232            &self,
233            _: &std::collections::HashMap<String, Value>,
234        ) -> Result<Vec<Value>, FaucetError> {
235            Ok(vec![json!({"id": 1}), json!({"id": 2})])
236        }
237        fn connector_name(&self) -> &'static str {
238            "tworow"
239        }
240    }
241
242    #[tokio::test]
243    async fn source_samples_streamed_pages_without_buffering_override() {
244        use faucet_core::Source as _;
245        use futures::StreamExt as _;
246        let shared = Arc::new(SampleState::new(10));
247        let s = SamplingSource::new(Box::new(TwoRowSource), Arc::clone(&shared));
248        let ctx = std::collections::HashMap::new();
249        let mut pages = s.stream_pages(&ctx, 1000);
250        while let Some(p) = pages.next().await {
251            let _ = p.unwrap();
252        }
253        assert_eq!(shared.count(), 2);
254        let schema = shared.inferred_schema();
255        let names: Vec<&str> = schema.fields.iter().map(|(n, _)| n.as_str()).collect();
256        assert!(names.contains(&"id"));
257    }
258}