Skip to main content

faucet_source_delta/
stream.rs

1//! Delta Lake source stream executor.
2//!
3//! Reads a Delta table's active data files at the latest version (or a pinned
4//! `version` / `timestamp`) and yields each row as a `serde_json::Value`
5//! object. No datafusion: the active file set comes from the Delta log
6//! (`get_files_by_partitions`) and each parquet file is streamed through the async
7//! Arrow reader faucet's Parquet source uses. Partition-column values (which
8//! live in the Hive-style path, not the file) are reconstructed and merged
9//! back into every row, typed against the table schema.
10
11use std::collections::HashMap;
12use std::pin::Pin;
13
14use arrow::datatypes::{DataType, SchemaRef};
15use async_trait::async_trait;
16use faucet_common_delta::convert::record_batch_to_json;
17use faucet_core::{FaucetError, Stream, StreamPage};
18use futures::StreamExt;
19use object_store::path::Path as ObjPath;
20use parquet::arrow::ProjectionMask;
21use parquet::arrow::async_reader::{ParquetObjectReader, ParquetRecordBatchStreamBuilder};
22use serde_json::Value;
23
24use crate::config::DeltaSourceConfig;
25
26/// A source that reads an Apache Delta Lake table into JSON records.
27pub struct DeltaSource {
28    config: DeltaSourceConfig,
29}
30
31/// One active data file plus the partition values encoded in its path.
32struct DataFile {
33    path: ObjPath,
34    /// `col -> JSON value` for every partition column, typed against the table
35    /// schema. `null` for the Hive default-partition sentinel.
36    partitions: HashMap<String, Value>,
37}
38
39impl DeltaSource {
40    /// Build a new Delta source. Validates config eagerly; the table is opened
41    /// on each read so time-travel/version pins re-resolve.
42    pub async fn new(config: DeltaSourceConfig) -> Result<Self, FaucetError> {
43        config
44            .validate()
45            .map_err(|e| FaucetError::Config(format!("invalid delta source config: {e}")))?;
46        config.connection.register_handlers();
47        Ok(Self { config })
48    }
49
50    /// Open the table at the configured version / timestamp / latest.
51    async fn open(&self) -> Result<deltalake::DeltaTable, FaucetError> {
52        match (self.config.version, &self.config.timestamp) {
53            (Some(v), _) => self.config.connection.open_at_version(v).await,
54            (None, Some(ts)) => self.config.connection.open_at_timestamp(ts).await,
55            (None, None) => self.config.connection.open().await,
56        }
57    }
58
59    /// Resolve the active files + their partition values, and the table's Arrow
60    /// schema (used to type partition values and validate projection).
61    async fn resolve(
62        &self,
63        table: &deltalake::DeltaTable,
64    ) -> Result<(Vec<DataFile>, SchemaRef, Vec<String>), FaucetError> {
65        let state = table
66            .snapshot()
67            .map_err(|e| FaucetError::Source(format!("delta: table has no snapshot: {e}")))?;
68        let arrow_schema = state.snapshot().arrow_schema();
69        let partition_cols = state.metadata().partition_columns().to_vec();
70
71        let paths = table
72            .get_files_by_partitions(&[])
73            .await
74            .map_err(|e| FaucetError::Source(format!("delta: could not list table files: {e}")))?;
75
76        let files = paths
77            .into_iter()
78            .map(|path| {
79                let partitions =
80                    parse_partition_values(path.as_ref(), &partition_cols, &arrow_schema);
81                DataFile { path, partitions }
82            })
83            .collect();
84        Ok((files, arrow_schema, partition_cols))
85    }
86
87    /// The projection over the *data* file columns: the requested columns minus
88    /// any partition columns (which are not stored in the file). `None` (read
89    /// all file columns) when no projection is configured.
90    fn data_projection(&self, partition_cols: &[String]) -> Option<Vec<String>> {
91        if self.config.columns.is_empty() {
92            return None;
93        }
94        Some(
95            self.config
96                .columns
97                .iter()
98                .filter(|c| !partition_cols.contains(c))
99                .cloned()
100                .collect(),
101        )
102    }
103}
104
105#[async_trait]
106impl faucet_core::Source for DeltaSource {
107    fn config_schema(&self) -> Value {
108        serde_json::to_value(faucet_core::schema_for!(DeltaSourceConfig))
109            .expect("schema serialization")
110    }
111
112    fn connector_name(&self) -> &'static str {
113        "delta"
114    }
115
116    fn dataset_uri(&self) -> String {
117        self.config.connection.redacted_uri()
118    }
119
120    async fn check(
121        &self,
122        ctx: &faucet_core::check::CheckContext,
123    ) -> Result<faucet_core::check::CheckReport, FaucetError> {
124        use faucet_core::check::{CheckReport, Probe};
125        let started = std::time::Instant::now();
126        // Metadata-only open (no data scan). The source needs the table to
127        // exist, so an absent table fails the probe.
128        let probe =
129            match tokio::time::timeout(ctx.timeout, self.config.connection.open_optional()).await {
130                Ok(Ok(Some(_))) => Probe::pass("table", started.elapsed()),
131                Ok(Ok(None)) => Probe::fail_hint(
132                    "table",
133                    started.elapsed(),
134                    format!(
135                        "delta source: no Delta table at '{}'",
136                        self.config.connection.redacted_uri()
137                    ),
138                    "Verify table_uri points at an existing Delta table.",
139                ),
140                Ok(Err(e)) => Probe::fail_hint(
141                    "table",
142                    started.elapsed(),
143                    format!("delta source probe failed: {e}"),
144                    "Verify table_uri, credentials, and object-store reachability.",
145                ),
146                Err(_) => Probe::fail_hint(
147                    "table",
148                    started.elapsed(),
149                    format!("delta source probe timed out after {:?}", ctx.timeout),
150                    "Check object-store network reachability.",
151                ),
152            };
153        Ok(CheckReport::single(probe))
154    }
155
156    async fn fetch_with_context(
157        &self,
158        _context: &HashMap<String, Value>,
159    ) -> Result<Vec<Value>, FaucetError> {
160        let mut out = Vec::new();
161        let mut stream = self.stream_pages(_context, self.config.batch_size);
162        while let Some(page) = stream.next().await {
163            out.extend(page?.records);
164        }
165        Ok(out)
166    }
167
168    fn stream_pages<'a>(
169        &'a self,
170        _context: &'a HashMap<String, Value>,
171        _batch_size: usize,
172    ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
173        Box::pin(async_stream::try_stream! {
174            let table = self.open().await?;
175            let (files, _schema, partition_cols) = self.resolve(&table).await?;
176            let store = table.object_store();
177            let data_projection = self.data_projection(&partition_cols);
178            let requested: Option<&[String]> =
179                if self.config.columns.is_empty() { None } else { Some(&self.config.columns) };
180
181            tracing::info!(
182                files = files.len(),
183                uri = %self.config.connection.redacted_uri(),
184                "delta source resolved active files",
185            );
186
187            for file in &files {
188                let reader = ParquetObjectReader::new(store.clone(), file.path.clone());
189                let mut builder = ParquetRecordBatchStreamBuilder::new(reader).await.map_err(|e| {
190                    FaucetError::Source(format!(
191                        "delta: could not open data file '{}': {e}",
192                        file.path
193                    ))
194                })?;
195
196                if self.config.batch_size > 0 {
197                    builder = builder.with_batch_size(self.config.batch_size);
198                }
199                if let Some(cols) = &data_projection {
200                    // Only project columns actually present in this file. A
201                    // requested column that is neither a data column here nor a
202                    // partition column is genuinely absent → surface it.
203                    let pq = builder.parquet_schema();
204                    let present: Vec<&str> = cols
205                        .iter()
206                        .filter(|c| pq.columns().iter().any(|col| col.name() == c.as_str()))
207                        .map(String::as_str)
208                        .collect();
209                    let mask = ProjectionMask::columns(pq, present.iter().copied());
210                    builder = builder.with_projection(mask);
211                }
212
213                let mut batches = builder.build().map_err(|e| {
214                    FaucetError::Source(format!(
215                        "delta: could not build reader for '{}': {e}",
216                        file.path
217                    ))
218                })?;
219
220                while let Some(batch) = batches.next().await {
221                    let batch = batch.map_err(|e| {
222                        FaucetError::Source(format!("delta: read error in '{}': {e}", file.path))
223                    })?;
224                    let mut rows = record_batch_to_json(&batch)?;
225                    if !rows.is_empty() {
226                        for row in &mut rows {
227                            merge_partitions(row, &file.partitions, requested);
228                        }
229                        yield StreamPage { records: rows, bookmark: None };
230                    }
231                }
232            }
233        })
234    }
235}
236
237/// Parse Hive-style `col=value` segments out of a data file path, typing each
238/// value against the table's Arrow schema. Only the declared partition columns
239/// are extracted; unknown segments are ignored.
240fn parse_partition_values(
241    path: &str,
242    partition_cols: &[String],
243    schema: &SchemaRef,
244) -> HashMap<String, Value> {
245    let mut out = HashMap::new();
246    if partition_cols.is_empty() {
247        return out;
248    }
249    for segment in path.split('/') {
250        if let Some((k, v)) = segment.split_once('=')
251            && partition_cols.iter().any(|c| c == k)
252        {
253            let decoded = percent_decode(v);
254            let dt = schema
255                .field_with_name(k)
256                .ok()
257                .map(|f| f.data_type().clone())
258                .unwrap_or(DataType::Utf8);
259            out.insert(k.to_string(), coerce_partition_value(&decoded, &dt));
260        }
261    }
262    out
263}
264
265/// The Delta Hive-default-partition sentinel — represents a NULL partition
266/// value.
267const HIVE_NULL: &str = "__HIVE_DEFAULT_PARTITION__";
268
269/// Coerce a string partition value to JSON, typed by the column's Arrow type.
270fn coerce_partition_value(raw: &str, dt: &DataType) -> Value {
271    if raw == HIVE_NULL || raw.is_empty() {
272        return Value::Null;
273    }
274    match dt {
275        DataType::Boolean => match raw {
276            "true" => Value::Bool(true),
277            "false" => Value::Bool(false),
278            _ => Value::String(raw.to_string()),
279        },
280        DataType::Int8
281        | DataType::Int16
282        | DataType::Int32
283        | DataType::Int64
284        | DataType::UInt8
285        | DataType::UInt16
286        | DataType::UInt32
287        | DataType::UInt64 => raw
288            .parse::<i64>()
289            .map(|n| Value::Number(n.into()))
290            .unwrap_or_else(|_| Value::String(raw.to_string())),
291        DataType::Float32 | DataType::Float64 => {
292            serde_json::Number::from_f64(raw.parse::<f64>().unwrap_or(f64::NAN))
293                .map(Value::Number)
294                .unwrap_or_else(|| Value::String(raw.to_string()))
295        }
296        // Dates/timestamps/strings/decimals: keep the logical string form.
297        _ => Value::String(raw.to_string()),
298    }
299}
300
301/// Merge partition values into a data row, then narrow to `requested` columns
302/// (when a projection is configured). Partition values fill keys not present in
303/// the data (the file never stores them).
304fn merge_partitions(
305    row: &mut Value,
306    partitions: &HashMap<String, Value>,
307    requested: Option<&[String]>,
308) {
309    if let Value::Object(map) = row {
310        for (k, v) in partitions {
311            match requested {
312                Some(cols) if !cols.iter().any(|c| c == k) => continue,
313                _ => {
314                    map.entry(k.clone()).or_insert_with(|| v.clone());
315                }
316            }
317        }
318        if let Some(cols) = requested {
319            map.retain(|k, _| cols.iter().any(|c| c == k));
320        }
321    }
322}
323
324/// Minimal `%XX` percent-decoder for Hive-encoded partition path segments.
325/// Leaves malformed escapes untouched.
326fn percent_decode(s: &str) -> String {
327    if !s.contains('%') {
328        return s.to_string();
329    }
330    let bytes = s.as_bytes();
331    let mut out = Vec::with_capacity(bytes.len());
332    let mut i = 0;
333    while i < bytes.len() {
334        if bytes[i] == b'%' && i + 2 < bytes.len() {
335            let hi = (bytes[i + 1] as char).to_digit(16);
336            let lo = (bytes[i + 2] as char).to_digit(16);
337            if let (Some(h), Some(l)) = (hi, lo) {
338                out.push((h * 16 + l) as u8);
339                i += 3;
340                continue;
341            }
342        }
343        out.push(bytes[i]);
344        i += 1;
345    }
346    String::from_utf8_lossy(&out).into_owned()
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352    use arrow::datatypes::{Field, Schema};
353    use serde_json::json;
354    use std::sync::Arc;
355
356    fn schema() -> SchemaRef {
357        Arc::new(Schema::new(vec![
358            Field::new("id", DataType::Int64, true),
359            Field::new("dt", DataType::Utf8, true),
360            Field::new("region", DataType::Utf8, true),
361            Field::new("part", DataType::Int64, true),
362        ]))
363    }
364
365    #[test]
366    fn parses_typed_partition_values() {
367        let s = schema();
368        let cols = vec!["dt".to_string(), "part".to_string()];
369        let m = parse_partition_values("t/dt=2026-01-01/part=7/file.parquet", &cols, &s);
370        assert_eq!(m["dt"], json!("2026-01-01"));
371        assert_eq!(m["part"], json!(7));
372    }
373
374    #[test]
375    fn hive_null_becomes_json_null() {
376        let s = schema();
377        let cols = vec!["region".to_string()];
378        let m = parse_partition_values("t/region=__HIVE_DEFAULT_PARTITION__/f.parquet", &cols, &s);
379        assert_eq!(m["region"], Value::Null);
380    }
381
382    #[test]
383    fn percent_decoding_of_partition_values() {
384        let s = schema();
385        let cols = vec!["region".to_string()];
386        let m = parse_partition_values("t/region=a%2Fb/f.parquet", &cols, &s);
387        assert_eq!(m["region"], json!("a/b"));
388    }
389
390    #[test]
391    fn no_partition_columns_is_empty() {
392        let s = schema();
393        assert!(parse_partition_values("t/f.parquet", &[], &s).is_empty());
394    }
395
396    #[test]
397    fn merge_injects_and_projects() {
398        let mut row = json!({"id": 1});
399        let mut parts = HashMap::new();
400        parts.insert("dt".to_string(), json!("2026-01-01"));
401        merge_partitions(&mut row, &parts, None);
402        assert_eq!(row["dt"], json!("2026-01-01"));
403        assert_eq!(row["id"], json!(1));
404
405        // With projection, only requested keys survive.
406        let mut row2 = json!({"id": 1, "name": "x"});
407        let cols = vec!["id".to_string(), "dt".to_string()];
408        merge_partitions(&mut row2, &parts, Some(&cols));
409        assert_eq!(row2["id"], json!(1));
410        assert_eq!(row2["dt"], json!("2026-01-01"));
411        assert!(row2.get("name").is_none());
412    }
413
414    #[test]
415    fn coerce_bool_and_float() {
416        assert_eq!(
417            coerce_partition_value("true", &DataType::Boolean),
418            json!(true)
419        );
420        assert_eq!(
421            coerce_partition_value("1.5", &DataType::Float64),
422            json!(1.5)
423        );
424        assert_eq!(coerce_partition_value("x", &DataType::Int64), json!("x"));
425        // Non-parseable values for bool/float columns fall back to a string.
426        assert_eq!(
427            coerce_partition_value("maybe", &DataType::Boolean),
428            json!("maybe")
429        );
430        assert_eq!(
431            coerce_partition_value("nan-ish", &DataType::Float32),
432            json!("nan-ish")
433        );
434        // Empty and the Hive sentinel both become JSON null.
435        assert_eq!(coerce_partition_value("", &DataType::Utf8), Value::Null);
436        assert_eq!(
437            coerce_partition_value(HIVE_NULL, &DataType::Int64),
438            Value::Null
439        );
440        // A date column keeps the logical string form.
441        assert_eq!(
442            coerce_partition_value("2026-01-01", &DataType::Date32),
443            json!("2026-01-01")
444        );
445    }
446
447    #[tokio::test]
448    async fn source_trait_metadata_methods() {
449        use faucet_core::Source;
450        let src = DeltaSource::new(DeltaSourceConfig::new("file:///tmp/delta_src_meta"))
451            .await
452            .unwrap();
453        assert_eq!(src.connector_name(), "delta");
454        assert_eq!(src.dataset_uri(), "file:///tmp/delta_src_meta");
455        assert!(src.config_schema().is_object());
456    }
457
458    #[tokio::test]
459    async fn fetch_missing_table_errors() {
460        use faucet_core::Source;
461        let dir = tempfile::tempdir().unwrap();
462        let uri = dir
463            .path()
464            .join("no_such_table")
465            .to_string_lossy()
466            .into_owned();
467        let src = DeltaSource::new(DeltaSourceConfig::new(&uri))
468            .await
469            .unwrap();
470        // `open()` fails (not a Delta table) → mapped to FaucetError::Source.
471        let err = src.fetch_with_context(&HashMap::new()).await.unwrap_err();
472        assert!(matches!(err, FaucetError::Source(_)), "{err}");
473    }
474}