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    /// Infer an ordered (name, OL-type) schema from the sampled records.
49    pub fn inferred_schema(&self) -> InferredSchema {
50        let sample = self.sample.lock().unwrap();
51        if sample.is_empty() {
52            return InferredSchema::default();
53        }
54        // Preserve first-seen field order across the sample.
55        let mut order: Vec<String> = Vec::new();
56        let mut types: HashMap<String, String> = HashMap::new();
57        for rec in sample.iter() {
58            if let Value::Object(map) = rec {
59                for (k, v) in map {
60                    if !types.contains_key(k) {
61                        order.push(k.clone());
62                    }
63                    types
64                        .entry(k.clone())
65                        .or_insert_with(|| ol_type_of(v).to_string());
66                }
67            }
68        }
69        InferredSchema {
70            fields: order
71                .into_iter()
72                .map(|k| {
73                    let t = types.remove(&k).unwrap_or_else(|| "string".into());
74                    (k, t)
75                })
76                .collect(),
77        }
78    }
79}
80
81fn ol_type_of(v: &Value) -> &'static str {
82    match v {
83        Value::Null => "null",
84        Value::Bool(_) => "boolean",
85        Value::Number(n) if n.is_i64() || n.is_u64() => "integer",
86        Value::Number(_) => "number",
87        Value::String(_) => "string",
88        Value::Array(_) => "array",
89        Value::Object(_) => "object",
90    }
91}
92
93/// Wraps a sink, sampling written records and counting throughput.
94pub struct SamplingSink {
95    inner: Box<dyn Sink>,
96    state: std::sync::Arc<SampleState>,
97}
98
99impl SamplingSink {
100    pub fn new(inner: Box<dyn Sink>, state: std::sync::Arc<SampleState>) -> Self {
101        Self { inner, state }
102    }
103}
104
105#[async_trait]
106impl Sink for SamplingSink {
107    async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
108        let n = self.inner.write_batch(records).await?;
109        self.state.observe(records);
110        Ok(n)
111    }
112    async fn write_batch_partial(&self, records: &[Value]) -> Result<Vec<RowOutcome>, FaucetError> {
113        let outcomes = self.inner.write_batch_partial(records).await?;
114        self.state.observe(records);
115        Ok(outcomes)
116    }
117    async fn flush(&self) -> Result<(), FaucetError> {
118        self.inner.flush().await
119    }
120    fn connector_name(&self) -> &'static str {
121        self.inner.connector_name()
122    }
123    fn dataset_uri(&self) -> String {
124        self.inner.dataset_uri()
125    }
126}
127
128/// Wraps a source, sampling the records it yields (pre-transform input schema).
129pub struct SamplingSource {
130    inner: Box<dyn Source>,
131    state: std::sync::Arc<SampleState>,
132}
133
134impl SamplingSource {
135    pub fn new(inner: Box<dyn Source>, state: std::sync::Arc<SampleState>) -> Self {
136        Self { inner, state }
137    }
138}
139
140#[async_trait]
141impl Source for SamplingSource {
142    async fn fetch_with_context(
143        &self,
144        ctx: &HashMap<String, Value>,
145    ) -> Result<Vec<Value>, FaucetError> {
146        // Required method; sampling happens in `stream_pages` (the path the
147        // pipeline actually drives). Delegate so the trait is complete.
148        self.inner.fetch_with_context(ctx).await
149    }
150    /// Override `stream_pages` (NOT `fetch_*`) so native-streaming sources keep
151    /// their bounded-memory page stream — we tap the pages as they flow rather
152    /// than forcing the buffering default path.
153    fn stream_pages<'a>(
154        &'a self,
155        ctx: &'a HashMap<String, Value>,
156        batch_size: usize,
157    ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
158        let state = std::sync::Arc::clone(&self.state);
159        let inner = self.inner.stream_pages(ctx, batch_size);
160        Box::pin(faucet_core::async_stream::try_stream! {
161            let mut inner = inner;
162            while let Some(page) = inner.next().await {
163                let page = page?;
164                state.observe(&page.records);
165                yield page;
166            }
167        })
168    }
169    fn connector_name(&self) -> &'static str {
170        self.inner.connector_name()
171    }
172    fn dataset_uri(&self) -> String {
173        self.inner.dataset_uri()
174    }
175    fn state_key(&self) -> Option<String> {
176        self.inner.state_key()
177    }
178    async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
179        self.inner.apply_start_bookmark(bookmark).await
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use async_trait::async_trait;
187    use faucet_core::{FaucetError, Sink};
188    use serde_json::{Value, json};
189    use std::sync::Arc;
190
191    struct CollectSink(std::sync::Mutex<Vec<Value>>);
192    #[async_trait]
193    impl Sink for CollectSink {
194        async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
195            self.0.lock().unwrap().extend(records.iter().cloned());
196            Ok(records.len())
197        }
198        fn connector_name(&self) -> &'static str {
199            "collect"
200        }
201    }
202
203    #[tokio::test]
204    async fn sink_counts_and_samples_first_n() {
205        let shared = Arc::new(SampleState::new(2));
206        let inner: Box<dyn Sink> = Box::new(CollectSink(Default::default()));
207        let s = SamplingSink::new(inner, Arc::clone(&shared));
208        s.write_batch(&[json!({"id":1,"name":"a"})]).await.unwrap();
209        s.write_batch(&[json!({"id":2}), json!({"id":3})])
210            .await
211            .unwrap();
212        assert_eq!(shared.count(), 3);
213        // only the first 2 records were retained for schema inference
214        let schema = shared.inferred_schema();
215        let names: Vec<&str> = schema.fields.iter().map(|(n, _)| n.as_str()).collect();
216        assert!(names.contains(&"id"));
217        assert!(names.contains(&"name"));
218    }
219
220    struct TwoRowSource;
221    #[async_trait]
222    impl faucet_core::Source for TwoRowSource {
223        async fn fetch_with_context(
224            &self,
225            _: &std::collections::HashMap<String, Value>,
226        ) -> Result<Vec<Value>, FaucetError> {
227            Ok(vec![json!({"id": 1}), json!({"id": 2})])
228        }
229        fn connector_name(&self) -> &'static str {
230            "tworow"
231        }
232    }
233
234    #[tokio::test]
235    async fn source_samples_streamed_pages_without_buffering_override() {
236        use faucet_core::Source as _;
237        use futures::StreamExt as _;
238        let shared = Arc::new(SampleState::new(10));
239        let s = SamplingSource::new(Box::new(TwoRowSource), Arc::clone(&shared));
240        let ctx = std::collections::HashMap::new();
241        let mut pages = s.stream_pages(&ctx, 1000);
242        while let Some(p) = pages.next().await {
243            let _ = p.unwrap();
244        }
245        assert_eq!(shared.count(), 2);
246        let schema = shared.inferred_schema();
247        let names: Vec<&str> = schema.fields.iter().map(|(n, _)| n.as_str()).collect();
248        assert!(names.contains(&"id"));
249    }
250}