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}
179
180/// Wraps a source, sampling the records it yields (pre-transform input schema).
181pub struct SamplingSource {
182    inner: Box<dyn Source>,
183    state: std::sync::Arc<SampleState>,
184}
185
186impl SamplingSource {
187    pub fn new(inner: Box<dyn Source>, state: std::sync::Arc<SampleState>) -> Self {
188        Self { inner, state }
189    }
190}
191
192#[async_trait]
193impl Source for SamplingSource {
194    async fn fetch_with_context(
195        &self,
196        ctx: &HashMap<String, Value>,
197    ) -> Result<Vec<Value>, FaucetError> {
198        // Required method; sampling happens in `stream_pages` (the path the
199        // pipeline actually drives). Delegate so the trait is complete.
200        self.inner.fetch_with_context(ctx).await
201    }
202    /// Override `stream_pages` (NOT `fetch_*`) so native-streaming sources keep
203    /// their bounded-memory page stream — we tap the pages as they flow rather
204    /// than forcing the buffering default path.
205    fn stream_pages<'a>(
206        &'a self,
207        ctx: &'a HashMap<String, Value>,
208        batch_size: usize,
209    ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
210        let state = std::sync::Arc::clone(&self.state);
211        let inner = self.inner.stream_pages(ctx, batch_size);
212        Box::pin(faucet_core::async_stream::try_stream! {
213            let mut inner = inner;
214            while let Some(page) = inner.next().await {
215                let page = page?;
216                state.observe(&page.records);
217                yield page;
218            }
219        })
220    }
221    fn connector_name(&self) -> &'static str {
222        self.inner.connector_name()
223    }
224    fn dataset_uri(&self) -> String {
225        self.inner.dataset_uri()
226    }
227    fn state_key(&self) -> Option<String> {
228        self.inner.state_key()
229    }
230    async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
231        self.inner.apply_start_bookmark(bookmark).await
232    }
233    // Bookmark + capability passthroughs. `fetch_with_context_incremental`
234    // matters even though the pipeline drives `stream_pages`: an *outer*
235    // wrapper's default `stream_pages` builds on it, and the trait default
236    // would silently drop the inner source's bookmark (breaking incremental
237    // resume whenever sampling is active).
238    async fn fetch_with_context_incremental(
239        &self,
240        ctx: &HashMap<String, Value>,
241    ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
242        self.inner.fetch_with_context_incremental(ctx).await
243    }
244    fn supports_exactly_once(&self) -> bool {
245        self.inner.supports_exactly_once()
246    }
247    fn replay_guarantee(&self) -> faucet_core::ReplayGuarantee {
248        self.inner.replay_guarantee()
249    }
250    async fn capture_resume_position(&self) -> Result<Option<Value>, FaucetError> {
251        self.inner.capture_resume_position().await
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258    use async_trait::async_trait;
259    use faucet_core::{FaucetError, Sink};
260    use serde_json::{Value, json};
261    use std::sync::Arc;
262
263    struct CollectSink(std::sync::Mutex<Vec<Value>>);
264    #[async_trait]
265    impl Sink for CollectSink {
266        async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
267            self.0.lock().unwrap().extend(records.iter().cloned());
268            Ok(records.len())
269        }
270        fn connector_name(&self) -> &'static str {
271            "collect"
272        }
273    }
274
275    #[tokio::test]
276    async fn sink_counts_and_samples_first_n() {
277        let shared = Arc::new(SampleState::new(2));
278        let inner: Box<dyn Sink> = Box::new(CollectSink(Default::default()));
279        let s = SamplingSink::new(inner, Arc::clone(&shared));
280        s.write_batch(&[json!({"id":1,"name":"a"})]).await.unwrap();
281        s.write_batch(&[json!({"id":2}), json!({"id":3})])
282            .await
283            .unwrap();
284        assert_eq!(shared.count(), 3);
285        // only the first 2 records were retained for schema inference
286        let schema = shared.inferred_schema();
287        let names: Vec<&str> = schema.fields.iter().map(|(n, _)| n.as_str()).collect();
288        assert!(names.contains(&"id"));
289        assert!(names.contains(&"name"));
290    }
291
292    struct TwoRowSource;
293    #[async_trait]
294    impl faucet_core::Source for TwoRowSource {
295        async fn fetch_with_context(
296            &self,
297            _: &std::collections::HashMap<String, Value>,
298        ) -> Result<Vec<Value>, FaucetError> {
299            Ok(vec![json!({"id": 1}), json!({"id": 2})])
300        }
301        fn connector_name(&self) -> &'static str {
302            "tworow"
303        }
304    }
305
306    #[tokio::test]
307    async fn source_samples_streamed_pages_without_buffering_override() {
308        use faucet_core::Source as _;
309        use futures::StreamExt as _;
310        let shared = Arc::new(SampleState::new(10));
311        let s = SamplingSource::new(Box::new(TwoRowSource), Arc::clone(&shared));
312        let ctx = std::collections::HashMap::new();
313        let mut pages = s.stream_pages(&ctx, 1000);
314        while let Some(p) = pages.next().await {
315            let _ = p.unwrap();
316        }
317        assert_eq!(shared.count(), 2);
318        let schema = shared.inferred_schema();
319        let names: Vec<&str> = schema.fields.iter().map(|(n, _)| n.as_str()).collect();
320        assert!(names.contains(&"id"));
321    }
322
323    /// An idempotent, upsert-capable sink: the sampler must forward every
324    /// capability + exactly-once method instead of masking them with the trait
325    /// defaults (which would make an exactly-once run fail as "sink is not
326    /// idempotent" whenever lineage/catalog sampling is active).
327    struct IdemSink;
328    #[async_trait]
329    impl Sink for IdemSink {
330        async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
331            Ok(records.len())
332        }
333        fn connector_name(&self) -> &'static str {
334            "idem"
335        }
336        fn supports_idempotent_writes(&self) -> bool {
337            true
338        }
339        fn dedups_by_key(&self) -> bool {
340            true
341        }
342        fn supported_write_modes(&self) -> &'static [faucet_core::WriteMode] {
343            &[
344                faucet_core::WriteMode::Append,
345                faucet_core::WriteMode::Upsert,
346            ]
347        }
348        async fn write_batch_idempotent(
349            &self,
350            records: &[Value],
351            _scope: &str,
352            _token: &str,
353        ) -> Result<usize, FaucetError> {
354            Ok(records.len())
355        }
356        async fn last_committed_token(&self, _scope: &str) -> Result<Option<String>, FaucetError> {
357            Ok(Some("tok".into()))
358        }
359        fn supports_schema_evolution(&self) -> bool {
360            true
361        }
362        async fn current_schema(&self) -> Result<Option<Value>, FaucetError> {
363            Ok(Some(json!({"type": "object", "properties": {}})))
364        }
365    }
366
367    #[tokio::test]
368    async fn sink_forwards_capabilities_and_samples_idempotent_writes() {
369        let shared = Arc::new(SampleState::new(10));
370        let s = SamplingSink::new(Box::new(IdemSink), Arc::clone(&shared));
371        assert!(s.supports_idempotent_writes());
372        assert!(s.dedups_by_key());
373        assert_eq!(
374            s.sink_guarantee(),
375            faucet_core::SinkGuarantee::AtomicWatermark
376        );
377        assert!(
378            s.supported_write_modes()
379                .contains(&faucet_core::WriteMode::Upsert)
380        );
381        assert!(s.supports_schema_evolution());
382        assert!(s.current_schema().await.unwrap().is_some());
383        assert_eq!(
384            s.last_committed_token("k").await.unwrap(),
385            Some("tok".into())
386        );
387        // Idempotent writes are observed by the sampler like plain writes.
388        s.write_batch_idempotent(&[json!({"id": 1})], "k", "t")
389            .await
390            .unwrap();
391        assert_eq!(shared.count(), 1);
392    }
393
394    struct BookmarkedSource;
395    #[async_trait]
396    impl faucet_core::Source for BookmarkedSource {
397        async fn fetch_with_context(
398            &self,
399            _: &std::collections::HashMap<String, Value>,
400        ) -> Result<Vec<Value>, FaucetError> {
401            Ok(vec![json!({"id": 1})])
402        }
403        async fn fetch_with_context_incremental(
404            &self,
405            _: &std::collections::HashMap<String, Value>,
406        ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
407            Ok((vec![json!({"id": 1})], Some(json!("bm"))))
408        }
409        fn supports_exactly_once(&self) -> bool {
410            true
411        }
412        async fn capture_resume_position(&self) -> Result<Option<Value>, FaucetError> {
413            Ok(Some(json!("pos")))
414        }
415        fn connector_name(&self) -> &'static str {
416            "bookmarked"
417        }
418    }
419
420    #[tokio::test]
421    async fn source_forwards_bookmarks_and_capabilities() {
422        use faucet_core::Source as _;
423        let shared = Arc::new(SampleState::new(10));
424        let s = SamplingSource::new(Box::new(BookmarkedSource), Arc::clone(&shared));
425        // The incremental fetch must surface the inner bookmark — the trait
426        // default would silently drop it (breaking incremental resume when an
427        // outer wrapper's default stream_pages builds on this method).
428        let (_, bm) = s
429            .fetch_with_context_incremental(&std::collections::HashMap::new())
430            .await
431            .unwrap();
432        assert_eq!(bm, Some(json!("bm")));
433        assert!(s.supports_exactly_once());
434        assert_eq!(
435            s.replay_guarantee(),
436            faucet_core::ReplayGuarantee::Deterministic
437        );
438        assert_eq!(
439            s.capture_resume_position().await.unwrap(),
440            Some(json!("pos"))
441        );
442    }
443}