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    /// Delta reads are natively Arrow (each data file is a parquet stream), so
237    /// the source participates in the opt-in columnar fast path (#375): a
238    /// `delta → parquet` / `delta → delta` chain never materializes `Value`.
239    #[cfg(feature = "arrow")]
240    fn supports_columnar(&self) -> bool {
241        true
242    }
243
244    /// Stream the table as Arrow [`ColumnarPage`](faucet_core::ColumnarPage)s.
245    /// Mirrors `stream_pages` but yields each parquet
246    /// `RecordBatch` directly; Hive partition-column values (which live in the
247    /// file path, not the parquet data) are appended as constant Arrow columns
248    /// so the columnar output matches the row-wise output field-for-field.
249    #[cfg(feature = "arrow")]
250    fn stream_batches<'a>(
251        &'a self,
252        _context: &'a HashMap<String, Value>,
253        _batch_size: usize,
254    ) -> Pin<Box<dyn Stream<Item = Result<faucet_core::ColumnarPage, FaucetError>> + Send + 'a>>
255    {
256        Box::pin(async_stream::try_stream! {
257            let table = self.open().await?;
258            let (files, schema, partition_cols) = self.resolve(&table).await?;
259            let store = table.object_store();
260            let data_projection = self.data_projection(&partition_cols);
261            let requested: Option<&[String]> =
262                if self.config.columns.is_empty() { None } else { Some(&self.config.columns) };
263
264            for file in &files {
265                let reader = ParquetObjectReader::new(store.clone(), file.path.clone());
266                let mut builder = ParquetRecordBatchStreamBuilder::new(reader).await.map_err(|e| {
267                    FaucetError::Source(format!("delta: could not open data file '{}': {e}", file.path))
268                })?;
269                if self.config.batch_size > 0 {
270                    builder = builder.with_batch_size(self.config.batch_size);
271                }
272                if let Some(cols) = &data_projection {
273                    let pq = builder.parquet_schema();
274                    let present: Vec<&str> = cols
275                        .iter()
276                        .filter(|c| pq.columns().iter().any(|col| col.name() == c.as_str()))
277                        .map(String::as_str)
278                        .collect();
279                    let mask = ProjectionMask::columns(pq, present.iter().copied());
280                    builder = builder.with_projection(mask);
281                }
282                let mut batches = builder.build().map_err(|e| {
283                    FaucetError::Source(format!("delta: could not build reader for '{}': {e}", file.path))
284                })?;
285                while let Some(batch) = batches.next().await {
286                    let batch = batch.map_err(|e| {
287                        FaucetError::Source(format!("delta: read error in '{}': {e}", file.path))
288                    })?;
289                    if batch.num_rows() == 0 {
290                        continue;
291                    }
292                    let batch = append_partition_columns(batch, &file.partitions, &schema, requested)?;
293                    yield faucet_core::ColumnarPage { batch, bookmark: None };
294                }
295            }
296        })
297    }
298}
299
300/// Append Hive partition columns to a data-file `RecordBatch` as constant
301/// columns, honoring the same `requested`-projection semantics as
302/// [`merge_partitions`] (add a partition column only when unprojected-away, and
303/// never shadow a real data column of the same name). Each constant column is
304/// built through the core `Value → RecordBatch` shim with the table's declared
305/// Arrow type, so a partition value round-trips identically to the row path.
306#[cfg(feature = "arrow")]
307fn append_partition_columns(
308    batch: arrow::array::RecordBatch,
309    partitions: &HashMap<String, Value>,
310    table_schema: &SchemaRef,
311    requested: Option<&[String]>,
312) -> Result<arrow::array::RecordBatch, FaucetError> {
313    use arrow::datatypes::{Field, Schema};
314    use std::sync::Arc;
315
316    if partitions.is_empty() {
317        return Ok(batch);
318    }
319    let in_schema = batch.schema();
320    let n = batch.num_rows();
321    let mut fields: Vec<Arc<Field>> = in_schema.fields().iter().cloned().collect();
322    let mut columns = batch.columns().to_vec();
323
324    // Deterministic order so the output schema is stable run-to-run.
325    let mut keys: Vec<&String> = partitions.keys().collect();
326    keys.sort();
327    for k in keys {
328        if let Some(cols) = requested
329            && !cols.iter().any(|c| c == k)
330        {
331            continue;
332        }
333        if in_schema.field_with_name(k).is_ok() {
334            continue; // a real data column of this name wins (merge_partitions)
335        }
336        let field = table_schema
337            .field_with_name(k)
338            .cloned()
339            .unwrap_or_else(|_| Field::new(k, arrow::datatypes::DataType::Utf8, true));
340        let one_schema = Arc::new(Schema::new(vec![field.clone().with_nullable(true)]));
341        let mut obj = serde_json::Map::new();
342        obj.insert(k.clone(), partitions[k].clone());
343        let rows = vec![Value::Object(obj); n];
344        let col_batch = faucet_core::values_to_record_batch(&rows, one_schema)?;
345        fields.push(Arc::new(field));
346        columns.push(col_batch.column(0).clone());
347    }
348
349    arrow::array::RecordBatch::try_new(Arc::new(Schema::new(fields)), columns)
350        .map_err(|e| FaucetError::Source(format!("delta: assembling columnar batch failed: {e}")))
351}
352
353/// Parse Hive-style `col=value` segments out of a data file path, typing each
354/// value against the table's Arrow schema. Only the declared partition columns
355/// are extracted; unknown segments are ignored.
356fn parse_partition_values(
357    path: &str,
358    partition_cols: &[String],
359    schema: &SchemaRef,
360) -> HashMap<String, Value> {
361    let mut out = HashMap::new();
362    if partition_cols.is_empty() {
363        return out;
364    }
365    for segment in path.split('/') {
366        if let Some((k, v)) = segment.split_once('=')
367            && partition_cols.iter().any(|c| c == k)
368        {
369            let decoded = percent_decode(v);
370            let dt = schema
371                .field_with_name(k)
372                .ok()
373                .map(|f| f.data_type().clone())
374                .unwrap_or(DataType::Utf8);
375            out.insert(k.to_string(), coerce_partition_value(&decoded, &dt));
376        }
377    }
378    out
379}
380
381/// The Delta Hive-default-partition sentinel — represents a NULL partition
382/// value.
383const HIVE_NULL: &str = "__HIVE_DEFAULT_PARTITION__";
384
385/// Coerce a string partition value to JSON, typed by the column's Arrow type.
386fn coerce_partition_value(raw: &str, dt: &DataType) -> Value {
387    if raw == HIVE_NULL || raw.is_empty() {
388        return Value::Null;
389    }
390    match dt {
391        DataType::Boolean => match raw {
392            "true" => Value::Bool(true),
393            "false" => Value::Bool(false),
394            _ => Value::String(raw.to_string()),
395        },
396        DataType::Int8
397        | DataType::Int16
398        | DataType::Int32
399        | DataType::Int64
400        | DataType::UInt8
401        | DataType::UInt16
402        | DataType::UInt32
403        | DataType::UInt64 => raw
404            .parse::<i64>()
405            .map(|n| Value::Number(n.into()))
406            .unwrap_or_else(|_| Value::String(raw.to_string())),
407        DataType::Float32 | DataType::Float64 => {
408            serde_json::Number::from_f64(raw.parse::<f64>().unwrap_or(f64::NAN))
409                .map(Value::Number)
410                .unwrap_or_else(|| Value::String(raw.to_string()))
411        }
412        // Dates/timestamps/strings/decimals: keep the logical string form.
413        _ => Value::String(raw.to_string()),
414    }
415}
416
417/// Merge partition values into a data row, then narrow to `requested` columns
418/// (when a projection is configured). Partition values fill keys not present in
419/// the data (the file never stores them).
420fn merge_partitions(
421    row: &mut Value,
422    partitions: &HashMap<String, Value>,
423    requested: Option<&[String]>,
424) {
425    if let Value::Object(map) = row {
426        for (k, v) in partitions {
427            match requested {
428                Some(cols) if !cols.iter().any(|c| c == k) => continue,
429                _ => {
430                    map.entry(k.clone()).or_insert_with(|| v.clone());
431                }
432            }
433        }
434        if let Some(cols) = requested {
435            map.retain(|k, _| cols.iter().any(|c| c == k));
436        }
437    }
438}
439
440/// Minimal `%XX` percent-decoder for Hive-encoded partition path segments.
441/// Leaves malformed escapes untouched.
442fn percent_decode(s: &str) -> String {
443    if !s.contains('%') {
444        return s.to_string();
445    }
446    let bytes = s.as_bytes();
447    let mut out = Vec::with_capacity(bytes.len());
448    let mut i = 0;
449    while i < bytes.len() {
450        if bytes[i] == b'%' && i + 2 < bytes.len() {
451            let hi = (bytes[i + 1] as char).to_digit(16);
452            let lo = (bytes[i + 2] as char).to_digit(16);
453            if let (Some(h), Some(l)) = (hi, lo) {
454                out.push((h * 16 + l) as u8);
455                i += 3;
456                continue;
457            }
458        }
459        out.push(bytes[i]);
460        i += 1;
461    }
462    String::from_utf8_lossy(&out).into_owned()
463}
464
465#[cfg(test)]
466mod tests {
467    use super::*;
468    use arrow::datatypes::{Field, Schema};
469    use serde_json::json;
470    use std::sync::Arc;
471
472    fn schema() -> SchemaRef {
473        Arc::new(Schema::new(vec![
474            Field::new("id", DataType::Int64, true),
475            Field::new("dt", DataType::Utf8, true),
476            Field::new("region", DataType::Utf8, true),
477            Field::new("part", DataType::Int64, true),
478        ]))
479    }
480
481    #[test]
482    fn parses_typed_partition_values() {
483        let s = schema();
484        let cols = vec!["dt".to_string(), "part".to_string()];
485        let m = parse_partition_values("t/dt=2026-01-01/part=7/file.parquet", &cols, &s);
486        assert_eq!(m["dt"], json!("2026-01-01"));
487        assert_eq!(m["part"], json!(7));
488    }
489
490    #[test]
491    fn hive_null_becomes_json_null() {
492        let s = schema();
493        let cols = vec!["region".to_string()];
494        let m = parse_partition_values("t/region=__HIVE_DEFAULT_PARTITION__/f.parquet", &cols, &s);
495        assert_eq!(m["region"], Value::Null);
496    }
497
498    #[test]
499    fn percent_decoding_of_partition_values() {
500        let s = schema();
501        let cols = vec!["region".to_string()];
502        let m = parse_partition_values("t/region=a%2Fb/f.parquet", &cols, &s);
503        assert_eq!(m["region"], json!("a/b"));
504    }
505
506    #[test]
507    fn no_partition_columns_is_empty() {
508        let s = schema();
509        assert!(parse_partition_values("t/f.parquet", &[], &s).is_empty());
510    }
511
512    #[test]
513    fn merge_injects_and_projects() {
514        let mut row = json!({"id": 1});
515        let mut parts = HashMap::new();
516        parts.insert("dt".to_string(), json!("2026-01-01"));
517        merge_partitions(&mut row, &parts, None);
518        assert_eq!(row["dt"], json!("2026-01-01"));
519        assert_eq!(row["id"], json!(1));
520
521        // With projection, only requested keys survive.
522        let mut row2 = json!({"id": 1, "name": "x"});
523        let cols = vec!["id".to_string(), "dt".to_string()];
524        merge_partitions(&mut row2, &parts, Some(&cols));
525        assert_eq!(row2["id"], json!(1));
526        assert_eq!(row2["dt"], json!("2026-01-01"));
527        assert!(row2.get("name").is_none());
528    }
529
530    #[test]
531    fn coerce_bool_and_float() {
532        assert_eq!(
533            coerce_partition_value("true", &DataType::Boolean),
534            json!(true)
535        );
536        assert_eq!(
537            coerce_partition_value("1.5", &DataType::Float64),
538            json!(1.5)
539        );
540        assert_eq!(coerce_partition_value("x", &DataType::Int64), json!("x"));
541        // Non-parseable values for bool/float columns fall back to a string.
542        assert_eq!(
543            coerce_partition_value("maybe", &DataType::Boolean),
544            json!("maybe")
545        );
546        assert_eq!(
547            coerce_partition_value("nan-ish", &DataType::Float32),
548            json!("nan-ish")
549        );
550        // Empty and the Hive sentinel both become JSON null.
551        assert_eq!(coerce_partition_value("", &DataType::Utf8), Value::Null);
552        assert_eq!(
553            coerce_partition_value(HIVE_NULL, &DataType::Int64),
554            Value::Null
555        );
556        // A date column keeps the logical string form.
557        assert_eq!(
558            coerce_partition_value("2026-01-01", &DataType::Date32),
559            json!("2026-01-01")
560        );
561    }
562
563    #[tokio::test]
564    async fn source_trait_metadata_methods() {
565        use faucet_core::Source;
566        let src = DeltaSource::new(DeltaSourceConfig::new("file:///tmp/delta_src_meta"))
567            .await
568            .unwrap();
569        assert_eq!(src.connector_name(), "delta");
570        assert_eq!(src.dataset_uri(), "file:///tmp/delta_src_meta");
571        assert!(src.config_schema().is_object());
572    }
573
574    #[tokio::test]
575    async fn fetch_missing_table_errors() {
576        use faucet_core::Source;
577        let dir = tempfile::tempdir().unwrap();
578        let uri = dir
579            .path()
580            .join("no_such_table")
581            .to_string_lossy()
582            .into_owned();
583        let src = DeltaSource::new(DeltaSourceConfig::new(&uri))
584            .await
585            .unwrap();
586        // `open()` fails (not a Delta table) → mapped to FaucetError::Source.
587        let err = src.fetch_with_context(&HashMap::new()).await.unwrap_err();
588        assert!(matches!(err, FaucetError::Source(_)), "{err}");
589    }
590}