Skip to main content

laminar_connectors/lakehouse/delta_io/
read.rs

1//! Bounded snapshot and change-data-feed reads.
2
3use super::{
4    debug, from_delta_version, to_delta_version, Arc, ConnectorError, DeltaTable, RecordBatch,
5    SchemaRef,
6};
7
8/// Returns the table's partition columns, or an empty list if the snapshot is
9/// unavailable. Best-effort: used for clustering diagnostics, never for
10/// correctness, so a missing snapshot is not an error.
11#[cfg(feature = "delta-lake")]
12#[must_use]
13pub fn get_partition_columns(table: &DeltaTable) -> Vec<String> {
14    match table.snapshot() {
15        Ok(snapshot) => snapshot.snapshot().metadata().partition_columns().to_vec(),
16        Err(_) => Vec::new(),
17    }
18}
19
20/// Extracts the Arrow schema from a Delta Lake table.
21///
22/// # Arguments
23///
24/// * `table` - The Delta Lake table handle
25///
26/// # Returns
27///
28/// The table's Arrow schema.
29///
30/// # Errors
31///
32/// Returns `ConnectorError::SchemaMismatch` if schema extraction fails.
33#[cfg(feature = "delta-lake")]
34pub fn get_table_schema(table: &DeltaTable) -> Result<SchemaRef, ConnectorError> {
35    let state = table
36        .snapshot()
37        .map_err(|e| ConnectorError::SchemaMismatch(format!("table has no snapshot: {e}")))?;
38
39    // Use the pre-computed Arrow schema from the EagerSnapshot.
40    Ok(state.snapshot().arrow_schema())
41}
42
43/// Returns the latest committed version via the log store.
44///
45/// # Errors
46///
47/// Returns `ConnectorError::ReadError` on failure.
48#[cfg(feature = "delta-lake")]
49pub async fn get_latest_version(table: &mut DeltaTable) -> Result<i64, ConnectorError> {
50    let log_store = table.log_store();
51    let current = table.version().unwrap_or(0);
52    let version = log_store
53        .get_latest_version(current)
54        .await
55        .map_err(|e| ConnectorError::ReadError(format!("failed to get latest version: {e}")))?;
56    from_delta_version(version)
57}
58
59/// Reads record batches from a specific Delta Lake table version.
60///
61/// Loads the requested version, applies a `LIMIT` to bound memory usage,
62/// then streams results via `execute_stream` to avoid materializing the
63/// entire version in memory.
64///
65/// # Arguments
66///
67/// * `table` - Mutable reference to the Delta Lake table handle
68/// * `version` - The table version to read
69/// * `max_records` - Maximum number of records to return. Pass `usize::MAX`
70///   to read all records (unbounded).
71///
72/// # Errors
73///
74/// Returns `ConnectorError::ReadError` if the version cannot be loaded or scanned.
75///
76/// Returns `(batches, fully_consumed)` — `fully_consumed` is `false` when
77/// `max_records` truncated the result and more rows remain.
78#[cfg(feature = "delta-lake")]
79pub(crate) async fn read_batches_at_version(
80    table: &mut DeltaTable,
81    version: i64,
82    max_records: usize,
83) -> Result<(Vec<RecordBatch>, bool), ConnectorError> {
84    use datafusion::prelude::SessionContext;
85    use tokio_stream::StreamExt;
86
87    // Load the specific version.
88    let delta_version = to_delta_version(version)?;
89    table
90        .load_version(delta_version)
91        .await
92        .map_err(|e| ConnectorError::ReadError(format!("failed to load version {version}: {e}")))?;
93
94    debug!(version, "Delta Lake: loaded version for reading");
95
96    // Build a DeltaTableProvider via the builder and register it with DataFusion.
97    let provider =
98        table.table_provider().build().await.map_err(|e| {
99            ConnectorError::ReadError(format!("failed to build table provider: {e}"))
100        })?;
101
102    let ctx = SessionContext::new();
103    ctx.register_table("delta_source_scan", Arc::new(provider))
104        .map_err(|e| ConnectorError::ReadError(format!("failed to register scan table: {e}")))?;
105
106    // Apply LIMIT to bound memory: prevents OOM on large versions.
107    let df = ctx
108        .sql("SELECT * FROM delta_source_scan")
109        .await
110        .map_err(|e| ConnectorError::ReadError(format!("scan query failed: {e}")))?;
111
112    let df = if max_records < usize::MAX {
113        df.limit(0, Some(max_records))
114            .map_err(|e| ConnectorError::ReadError(format!("limit failed: {e}")))?
115    } else {
116        df
117    };
118
119    // Stream results instead of collect() to avoid materializing everything.
120    let mut stream = df
121        .execute_stream()
122        .await
123        .map_err(|e| ConnectorError::ReadError(format!("stream execution failed: {e}")))?;
124
125    let mut batches = Vec::new();
126    let mut total_rows: usize = 0;
127
128    while let Some(result) = stream.next().await {
129        let batch =
130            result.map_err(|e| ConnectorError::ReadError(format!("stream batch failed: {e}")))?;
131        if batch.num_rows() == 0 {
132            continue;
133        }
134        total_rows += batch.num_rows();
135        batches.push(batch);
136
137        // Respect max_records even between DataFusion batches.
138        if total_rows >= max_records {
139            break;
140        }
141    }
142
143    // If we stopped due to max_records, probe whether the stream has more.
144    // Without this, a version with exactly max_records rows would be
145    // misclassified as truncated and re-read forever.
146    let fully_consumed = if total_rows >= max_records {
147        stream.next().await.is_none()
148    } else {
149        true
150    };
151
152    debug!(
153        version,
154        num_batches = batches.len(),
155        total_rows,
156        fully_consumed,
157        "Delta Lake: scanned version"
158    );
159
160    Ok((batches, fully_consumed))
161}
162
163/// Reads CDF batches for a version range via `scan_cdf()`.
164///
165/// `scan_cdf(self)` consumes the supplied `DeltaTable`; callers retaining a
166/// table handle can pass a clone.
167/// Output includes `_change_type`, `_commit_version`, `_commit_timestamp`.
168///
169/// # Errors
170///
171/// Returns a read error on scan failure or a non-transient configuration error
172/// when the collected version range exceeds either hard limit.
173#[cfg(feature = "delta-lake")]
174pub async fn read_cdf_batches(
175    table: DeltaTable,
176    start_version: i64,
177    end_version: i64,
178    max_rows: usize,
179    max_bytes: usize,
180) -> Result<Vec<RecordBatch>, ConnectorError> {
181    use datafusion::prelude::SessionContext;
182    use tokio_stream::StreamExt;
183
184    debug!(start_version, end_version, "reading CDF batches");
185
186    let ctx = SessionContext::new();
187
188    // Clone session state so the RwLockReadGuard is dropped before await.
189    let session_state = ctx.state();
190
191    let start_delta_version = to_delta_version(start_version)?;
192    let end_delta_version = to_delta_version(end_version)?;
193    let cdf_builder = table
194        .scan_cdf()
195        .with_starting_version(start_delta_version)
196        .with_ending_version(end_delta_version);
197
198    let plan = cdf_builder
199        .build(&session_state, None)
200        .await
201        .map_err(map_cdf_scan_build_error)?;
202
203    // Execute the plan via DataFusion to get record batches.
204    let task_ctx = ctx.task_ctx();
205    let mut stream = datafusion::physical_plan::execute_stream(plan, task_ctx)
206        .map_err(|e| ConnectorError::ReadError(format!("CDF stream execution failed: {e}")))?;
207
208    let mut batches = Vec::new();
209    let mut rows = 0;
210    let mut bytes = 0;
211    while let Some(result) = stream.next().await {
212        let batch: RecordBatch = result
213            .map_err(|e| ConnectorError::ReadError(format!("CDF stream batch failed: {e}")))?;
214        if batch.num_rows() > 0 {
215            (rows, bytes) = checked_cdf_commit_usage(rows, bytes, &batch, max_rows, max_bytes)?;
216            batches.push(batch);
217        }
218    }
219
220    debug!(
221        start_version,
222        end_version,
223        num_batches = batches.len(),
224        rows,
225        bytes,
226        "CDF scan complete"
227    );
228
229    Ok(batches)
230}
231
232#[cfg(feature = "delta-lake")]
233pub(super) fn map_cdf_scan_build_error(error: deltalake::DeltaTableError) -> ConnectorError {
234    match error {
235        error @ (deltalake::DeltaTableError::ChangeDataNotEnabled { .. }
236        | deltalake::DeltaTableError::ChangeDataNotRecorded { .. }) => {
237            ConnectorError::ConfigurationError(format!(
238                "Delta CDF is unavailable for the requested history: {error}"
239            ))
240        }
241        error => ConnectorError::ReadError(format!("CDF scan build failed: {error}")),
242    }
243}
244
245#[cfg(feature = "delta-lake")]
246pub(super) fn checked_cdf_commit_usage(
247    rows: usize,
248    bytes: usize,
249    batch: &RecordBatch,
250    max_rows: usize,
251    max_bytes: usize,
252) -> Result<(usize, usize), ConnectorError> {
253    let rows = rows.checked_add(batch.num_rows()).ok_or_else(|| {
254        ConnectorError::ConfigurationError("Delta CDF commit row count overflowed".into())
255    })?;
256    let bytes = bytes
257        .checked_add(batch.get_array_memory_size())
258        .ok_or_else(|| {
259            ConnectorError::ConfigurationError("Delta CDF commit byte count overflowed".into())
260        })?;
261    if rows > max_rows {
262        return Err(ConnectorError::ConfigurationError(format!(
263            "Delta CDF commit exceeds the hard row limit: rows={rows}, limit={max_rows}"
264        )));
265    }
266    if bytes > max_bytes {
267        return Err(ConnectorError::ConfigurationError(format!(
268            "Delta CDF commit exceeds the hard byte limit: bytes={bytes}, limit={max_bytes}"
269        )));
270    }
271    Ok((rows, bytes))
272}
273
274/// Maps Delta CDF rows to the canonical signed-weight changelog and drops CDF
275/// metadata columns (`_change_type`, `_commit_version`, `_commit_timestamp`).
276///
277/// # Errors
278///
279/// Returns `ConnectorError::ReadError` when CDF metadata is missing or invalid,
280/// or when the output batch cannot be built.
281#[cfg(feature = "delta-lake")]
282pub fn map_cdf_to_changelog(batch: &RecordBatch) -> Result<RecordBatch, ConnectorError> {
283    use arrow_array::{Array, Int64Array, StringArray};
284    use laminar_core::changelog::WEIGHT_COLUMN;
285
286    let schema = batch.schema();
287    let ct_idx = schema
288        .index_of("_change_type")
289        .map_err(|_| ConnectorError::ReadError("CDF batch is missing _change_type".into()))?;
290    if let Some(field) = schema.fields().iter().find(|field| {
291        ["_op", "__op", WEIGHT_COLUMN]
292            .iter()
293            .any(|reserved| field.name().eq_ignore_ascii_case(reserved))
294    }) {
295        return Err(ConnectorError::ReadError(format!(
296            "CDF batch contains reserved mutation column '{}'",
297            field.name()
298        )));
299    }
300
301    let change_type = batch
302        .column(ct_idx)
303        .as_any()
304        .downcast_ref::<StringArray>()
305        .ok_or_else(|| ConnectorError::ReadError("_change_type is not Utf8".into()))?;
306
307    let mut weights = Vec::with_capacity(batch.num_rows());
308    for row in 0..batch.num_rows() {
309        if change_type.is_null(row) {
310            return Err(ConnectorError::ReadError(format!(
311                "CDF _change_type is null at row {row}"
312            )));
313        }
314        weights.push(match change_type.value(row) {
315            "insert" | "update_postimage" => 1,
316            "delete" | "update_preimage" => -1,
317            value => {
318                return Err(ConnectorError::ReadError(format!(
319                    "unknown CDF _change_type '{value}' at row {row}"
320                )));
321            }
322        });
323    }
324
325    // Rebuild batch: keep user columns, drop CDF metadata, append __weight.
326    let cdf_meta = ["_change_type", "_commit_version", "_commit_timestamp"];
327    let mut fields = Vec::new();
328    let mut columns: Vec<Arc<dyn arrow_array::Array>> = Vec::new();
329    for (i, field) in schema.fields().iter().enumerate() {
330        if !cdf_meta.contains(&field.name().as_str()) {
331            fields.push(field.clone());
332            columns.push(batch.column(i).clone());
333        }
334    }
335    fields.push(Arc::new(arrow_schema::Field::new(
336        WEIGHT_COLUMN,
337        arrow_schema::DataType::Int64,
338        false,
339    )));
340    columns.push(Arc::new(Int64Array::from(weights)));
341
342    RecordBatch::try_new(
343        Arc::new(arrow_schema::Schema::new_with_metadata(
344            fields,
345            schema.metadata().clone(),
346        )),
347        columns,
348    )
349    .map_err(|e| ConnectorError::ReadError(format!("CDF batch rebuild: {e}")))
350}