Skip to main content

laminar_connectors/lakehouse/delta_io/
table.rs

1//! Table opening, direct writes, and durable coordinated cursor reads.
2
3#[cfg(feature = "delta-lake")]
4use std::sync::Arc;
5
6use super::{
7    classify_delta_metadata_error, debug, from_delta_version, info, CommitProperties,
8    ConnectorError, CoordinatedCommitCursor, DeltaTable, DeltaWriteAttemptError, HashMap,
9    RecordBatch, SaveMode, SchemaMode, SchemaRef, Url, SET_TRANSACTION_RETENTION,
10};
11#[cfg(feature = "delta-lake")]
12use crate::storage::{AdaptedStorageLocation, StorageConsumer, StorageLocation};
13#[cfg(feature = "delta-lake")]
14use arrow_schema::{DataType, Schema, TimeUnit};
15use deltalake::kernel::engine::arrow_conversion::TryIntoKernel as _;
16#[cfg(feature = "delta-lake")]
17use deltalake::kernel::schema::cast::cast_record_batch;
18
19/// WHY: the kernel accepts only microsecond timestamps; values scale by
20/// `1_000`, timezone metadata is preserved, and nested timestamps stay
21/// rejected (no upstream schema-level normalizer exists).
22#[cfg(feature = "delta-lake")]
23pub(crate) fn widen_millisecond_timestamps(schema: &SchemaRef) -> SchemaRef {
24    let needs_widening = schema.fields().iter().any(|field| {
25        matches!(
26            field.data_type(),
27            DataType::Timestamp(TimeUnit::Millisecond, _)
28        )
29    });
30    if !needs_widening {
31        return Arc::clone(schema);
32    }
33    let fields: Vec<_> = schema
34        .fields()
35        .iter()
36        .map(|field| match field.data_type() {
37            DataType::Timestamp(TimeUnit::Millisecond, tz) => Arc::new(
38                field
39                    .as_ref()
40                    .clone()
41                    .with_data_type(DataType::Timestamp(TimeUnit::Microsecond, tz.clone())),
42            ),
43            _ => Arc::clone(field),
44        })
45        .collect();
46    Arc::new(Schema::new_with_metadata(fields, schema.metadata().clone()))
47}
48
49/// Casts via the kernel's own `cast_record_batch` (strict, no column
50/// addition) so schema validation is not weakened.
51#[cfg(feature = "delta-lake")]
52pub(crate) fn widen_batch_millisecond_timestamps(
53    batch: RecordBatch,
54) -> Result<RecordBatch, ConnectorError> {
55    let target = widen_millisecond_timestamps(&batch.schema());
56    if Arc::ptr_eq(&target, &batch.schema()) {
57        return Ok(batch);
58    }
59    cast_record_batch(&batch, target, false, false).map_err(|error| {
60        ConnectorError::SchemaMismatch(format!("millisecond timestamp widening failed: {error}"))
61    })
62}
63
64/// Converts a path string to a URL.
65#[cfg(feature = "delta-lake")]
66pub(super) fn path_to_url(path: &str) -> Result<Url, ConnectorError> {
67    // If it already looks like a URL, parse it directly.
68    if path.contains("://") {
69        let adapted = adapt_delta_location(path)?;
70        Url::parse(&adapted.url).map_err(|error| {
71            ConnectorError::ConfigurationError(format!(
72                "invalid canonical Delta storage URL: {error}"
73            ))
74        })
75    } else {
76        // Local path - convert to file URL.
77        // First canonicalize if it exists, otherwise use as-is.
78        let path_buf = std::path::Path::new(path);
79        let normalized = if path_buf.exists() {
80            std::fs::canonicalize(path_buf).map_err(|e| {
81                ConnectorError::ConfigurationError(format!("invalid path '{path}': {e}"))
82            })?
83        } else {
84            // For new tables, the path might not exist yet.
85            // Use absolute path if possible.
86            if path_buf.is_absolute() {
87                path_buf.to_path_buf()
88            } else {
89                std::env::current_dir()
90                    .map_err(|e| {
91                        ConnectorError::ConfigurationError(format!("cannot get current dir: {e}"))
92                    })?
93                    .join(path_buf)
94            }
95        };
96
97        Url::from_directory_path(&normalized).map_err(|()| {
98            ConnectorError::ConfigurationError(format!(
99                "cannot convert path to URL: {}",
100                normalized.display()
101            ))
102        })
103    }
104}
105
106#[cfg(feature = "delta-lake")]
107pub(super) fn adapt_delta_location(path: &str) -> Result<AdaptedStorageLocation, ConnectorError> {
108    StorageLocation::parse(path)
109        .and_then(|location| location.adapt(StorageConsumer::Delta))
110        .map_err(|error| ConnectorError::ConfigurationError(error.to_string()))
111}
112
113#[cfg(feature = "delta-lake")]
114pub(super) fn apply_url_derived_options(
115    options: &mut HashMap<String, String>,
116    adapted: &AdaptedStorageLocation,
117) -> Result<(), ConnectorError> {
118    for (key, value) in &adapted.derived_options {
119        let aliases: &[&str] = match key.as_str() {
120            "azure_storage_account_name" => &[
121                "azure_storage_account_name",
122                "azure_account_name",
123                "account_name",
124            ],
125            "azure_container_name" => &["azure_container_name", "container_name"],
126            "azure_endpoint" => &["azure_endpoint", "azure_storage_endpoint", "endpoint"],
127            _ => &[key.as_str()],
128        };
129        let mut configured_alias = false;
130        for (configured_key, configured_value) in options.iter().filter(|(candidate, _)| {
131            aliases
132                .iter()
133                .any(|alias| candidate.eq_ignore_ascii_case(alias))
134        }) {
135            configured_alias = true;
136            if configured_value != value {
137                return Err(ConnectorError::ConfigurationError(format!(
138                    "storage option '{configured_key}' conflicts with the Delta table URL authority"
139                )));
140            }
141        }
142        if configured_alias {
143            continue;
144        }
145        options.insert(key.clone(), value.clone());
146    }
147    Ok(())
148}
149
150#[cfg(feature = "delta-lake")]
151fn delta_table_url(
152    table_path: &str,
153    storage_options: &mut HashMap<String, String>,
154) -> Result<Url, ConnectorError> {
155    let url = if table_path.contains("://") {
156        let adapted = adapt_delta_location(table_path)?;
157        apply_url_derived_options(storage_options, &adapted)?;
158        Url::parse(&adapted.url).map_err(|error| {
159            ConnectorError::ConfigurationError(format!(
160                "invalid canonical Delta storage URL: {error}"
161            ))
162        })?
163    } else {
164        path_to_url(table_path)?
165    };
166    #[cfg(feature = "delta-lake-gcs")]
167    if url.scheme() == "gs" {
168        super::gcs_factory::register_laminar_gcs_factory()?;
169    }
170    Ok(url)
171}
172
173/// Opens an existing table's log metadata (version, `metaData`, protocol) without materializing
174/// its file list, which a reader that scans through the Delta kernel never needs.
175///
176/// # Errors
177/// Returns `ConnectorError::ConfigurationError` when no committed table exists at the location
178/// and `ConnectorError::ConnectionFailed` when it cannot be read.
179#[cfg(feature = "delta-lake")]
180#[allow(clippy::implicit_hasher)]
181pub async fn open_table_metadata(
182    table_path: &str,
183    mut storage_options: HashMap<String, String>,
184) -> Result<DeltaTable, ConnectorError> {
185    let url = delta_table_url(table_path, &mut storage_options)?;
186    let table = deltalake::DeltaTableBuilder::from_url(url)
187        .map(|builder| {
188            builder
189                .with_storage_options(storage_options)
190                .without_files()
191        })
192        .map_err(|error| {
193            ConnectorError::ConfigurationError(format!(
194                "invalid Delta table location ({})",
195                super::attempt_error::delta_error_category(&error)
196            ))
197        })?
198        .load()
199        .await
200        .map_err(|error| match error {
201            deltalake::DeltaTableError::NotATable(_) => ConnectorError::ConfigurationError(
202                "no committed Delta table exists at the configured location".into(),
203            ),
204            error => classify_delta_metadata_error("open Delta table metadata", &error),
205        })?;
206    Ok(table)
207}
208
209/// Opens an existing Delta Lake table or creates a new one.
210///
211/// # Arguments
212///
213/// * `table_path` - Path to the Delta Lake table (local, `s3://`, `az://`, `gs://`)
214/// * `storage_options` - Storage credentials and configuration
215/// * `schema` - Optional Arrow schema for table creation (required if table doesn't exist)
216///
217/// # Returns
218///
219/// The opened `DeltaTable` handle.
220///
221/// # Errors
222///
223/// Returns `ConnectorError::ConnectionFailed` if the table cannot be opened or created.
224#[cfg(feature = "delta-lake")]
225#[allow(clippy::implicit_hasher)]
226pub async fn open_or_create_table(
227    table_path: &str,
228    storage_options: HashMap<String, String>,
229    schema: Option<&SchemaRef>,
230) -> Result<DeltaTable, ConnectorError> {
231    open_or_create_partitioned_table(table_path, storage_options, schema, &[]).await
232}
233
234/// Opens an existing Delta table, or creates it with `schema` partitioned by
235/// `partition_columns` when it is absent and a schema is supplied. An existing
236/// table keeps its own partitioning; callers compare it with their configuration.
237///
238/// # Errors
239///
240/// Returns `ConnectorError::ConnectionFailed` if the table cannot be opened or created, or
241/// `ConnectorError::SchemaMismatch` when a partition column is absent from `schema`.
242#[cfg(feature = "delta-lake")]
243#[allow(clippy::implicit_hasher)]
244pub async fn open_or_create_partitioned_table(
245    table_path: &str,
246    mut storage_options: HashMap<String, String>,
247    schema: Option<&SchemaRef>,
248    partition_columns: &[String],
249) -> Result<DeltaTable, ConnectorError> {
250    let url = delta_table_url(table_path, &mut storage_options)?;
251    info!("opening Delta Lake table");
252
253    // Try to open or initialize the table. `url` and `storage_options` are
254    // not referenced after this call, so move rather than clone; repeated
255    // conflict-recovery opens otherwise copy the complete option map.
256    let table = DeltaTable::try_from_url_with_storage_options(url, storage_options)
257        .await
258        .map_err(|error| {
259            ConnectorError::ConnectionFailed(
260                format!(
261                    "failed to open Delta table ({}); verify the redacted provider diagnostics and storage configuration",
262                    super::attempt_error::delta_error_category(&error)
263                ),
264            )
265        })?;
266
267    // Check if the table is initialized (has state).
268    if table.version().is_some() {
269        info!(
270            version = table.version(),
271            "opened existing Delta Lake table"
272        );
273        return Ok(table);
274    }
275
276    // Table doesn't exist — create if we have a schema, otherwise defer to first write_batch().
277    let Some(schema) = schema else {
278        info!("table does not exist yet; will create on first write");
279        return Ok(table);
280    };
281
282    info!("creating new Delta Lake table");
283
284    // Convert Arrow schema to Delta Lake schema using TryIntoKernel.
285    let delta_schema: deltalake::kernel::StructType = widen_millisecond_timestamps(schema)
286        .as_ref()
287        .try_into_kernel()
288        .map_err(|e| ConnectorError::SchemaMismatch(format!("schema conversion failed: {e}")))?;
289    if let Some(missing) = partition_columns
290        .iter()
291        .find(|column| delta_schema.field(column.as_str()).is_none())
292    {
293        return Err(ConnectorError::SchemaMismatch(format!(
294            "partition column '{missing}' is not a column of the Delta table schema"
295        )));
296    }
297
298    // Create the table.
299    let table = table
300        .create()
301        .with_columns(delta_schema.fields().cloned())
302        .with_partition_columns(partition_columns.to_vec())
303        .await
304        .map_err(|error| {
305            ConnectorError::ConnectionFailed(
306                format!(
307                    "failed to create Delta table ({}); verify provider permissions and conditional-write support",
308                    super::attempt_error::delta_error_category(&error)
309                ),
310            )
311        })?;
312
313    info!(version = table.version(), "created new Delta Lake table");
314
315    Ok(table)
316}
317
318/// Writes batches to a Delta Lake table.
319///
320/// # Arguments
321///
322/// * `table` - The Delta Lake table handle (consumed and returned)
323/// * `batches` - Record batches to write
324/// * `save_mode` - Delta Lake save mode (Append, Overwrite, etc.)
325/// * `partition_columns` - Optional partition column name slice
326/// * `schema_evolution` - If true, auto-merge new columns into the table schema
327///
328/// # Returns
329///
330/// A tuple of (updated table handle, new Delta version).
331///
332/// # Errors
333///
334/// Preserves a typed delta-rs failure when the write operation fails.
335#[cfg(feature = "delta-lake")]
336#[allow(clippy::too_many_arguments)]
337pub(crate) async fn write_batches(
338    table: DeltaTable,
339    batches: Vec<RecordBatch>,
340    save_mode: SaveMode,
341    partition_columns: Option<&[String]>,
342    schema_evolution: bool,
343    target_file_size: Option<usize>,
344    writer_properties: Option<deltalake::parquet::file::properties::WriterProperties>,
345) -> Result<(DeltaTable, i64), DeltaWriteAttemptError> {
346    if batches.is_empty() {
347        debug!("no batches to write, skipping");
348        let version = from_delta_version(table.version().unwrap_or(0))
349            .map_err(DeltaWriteAttemptError::Local)?;
350        return Ok((table, version));
351    }
352
353    let batches = batches
354        .into_iter()
355        .map(widen_batch_millisecond_timestamps)
356        .collect::<Result<Vec<_>, _>>()
357        .map_err(DeltaWriteAttemptError::Local)?;
358
359    let total_rows: usize = batches.iter().map(RecordBatch::num_rows).sum();
360
361    debug!(
362        total_rows,
363        num_batches = batches.len(),
364        "writing batches to Delta Lake"
365    );
366
367    let mut write_builder = table
368        .write(batches)
369        .with_save_mode(save_mode)
370        .with_commit_properties(CommitProperties::default());
371
372    // Forward target file size to delta-rs so Parquet files match the
373    // user's configured size, not just the internal default.
374    if let Some(size) = target_file_size {
375        let size = u64::try_from(size)
376            .ok()
377            .and_then(std::num::NonZeroU64::new)
378            .ok_or_else(|| {
379                DeltaWriteAttemptError::Local(ConnectorError::ConfigurationError(
380                    "Delta target file size must be a non-zero u64".into(),
381                ))
382            })?;
383        write_builder = write_builder.with_target_file_size(Some(size));
384    }
385
386    // Enable schema evolution (additive column merge) if requested.
387    if schema_evolution {
388        write_builder = write_builder.with_schema_mode(SchemaMode::Merge);
389    }
390
391    // Add partition columns if specified.
392    if let Some(cols) = partition_columns {
393        if !cols.is_empty() {
394            write_builder = write_builder.with_partition_columns(cols.to_vec());
395        }
396    }
397
398    if let Some(props) = writer_properties {
399        write_builder = write_builder.with_writer_properties(props);
400    }
401
402    #[cfg(feature = "testing")]
403    delta_prepublication_fault_boundary().await?;
404
405    // Execute the write.
406    let table = write_builder.await.map_err(DeltaWriteAttemptError::Delta)?;
407
408    let version =
409        from_delta_version(table.version().unwrap_or(0)).map_err(DeltaWriteAttemptError::Local)?;
410
411    info!(version, total_rows, "committed Delta Lake transaction");
412
413    Ok((table, version))
414}
415
416#[cfg(all(feature = "delta-lake", feature = "testing"))]
417async fn delta_prepublication_fault_boundary() -> Result<(), DeltaWriteAttemptError> {
418    if std::env::var("LAMINAR_DELTA_FAULT_WORKER_MODE").as_deref() != Ok("before-publication") {
419        return Ok(());
420    }
421    let signal_directory = std::env::var_os("LAMINAR_DELTA_FAULT_SIGNAL_DIR")
422        .map(std::path::PathBuf::from)
423        .filter(|path| path.is_absolute())
424        .ok_or_else(|| {
425            DeltaWriteAttemptError::Local(ConnectorError::ConfigurationError(
426                "Delta test fault boundary requires an absolute signal directory".into(),
427            ))
428        })?;
429    std::fs::write(signal_directory.join("ready"), b"ready").map_err(|_| {
430        DeltaWriteAttemptError::Local(ConnectorError::WriteError(
431            "Delta test fault boundary could not publish its ready signal".into(),
432        ))
433    })?;
434    std::future::pending::<Result<(), DeltaWriteAttemptError>>().await
435}
436
437/// Coordinated append stages files through delta-rs `RecordBatchWriter`, which does not evaluate
438/// CHECK constraints, column invariants, or generation expressions (the `write`/`merge`
439/// operations do). Tables carrying such row obligations, or writer features delta-rs cannot
440/// honor, are rejected before any data is staged.
441///
442/// # Errors
443/// Returns `ConnectorError::ConfigurationError` naming the obligation and the remediation.
444#[cfg(feature = "delta-lake")]
445pub(crate) fn reject_unenforced_row_obligations(table: &DeltaTable) -> Result<(), ConnectorError> {
446    use deltalake::kernel::StructTypeExt as _;
447    use deltalake::DataCheck as _;
448
449    let snapshot = table
450        .snapshot()
451        .map_err(|error| classify_delta_metadata_error("read Delta snapshot", &error))?;
452    deltalake::kernel::transaction::PROTOCOL
453        .can_write_to(snapshot)
454        .map_err(|error| {
455            ConnectorError::ConfigurationError(format!(
456                "Delta coordinated append cannot honor this table's writer protocol: {error}"
457            ))
458        })?;
459    let remediation = "coordinated exactly-once append stages rows without evaluating it; use \
460                       at-least-once append (delta-rs write validates it) or remove the obligation";
461    if let Some(key) = snapshot
462        .metadata()
463        .configuration()
464        .keys()
465        .find(|key| key.starts_with("delta.constraints."))
466    {
467        return Err(ConnectorError::ConfigurationError(format!(
468            "Delta table declares CHECK constraint '{key}'; {remediation}"
469        )));
470    }
471    let schema = snapshot.schema();
472    let invariants = schema.get_invariants().map_err(|error| {
473        ConnectorError::SchemaMismatch(format!("invalid Delta column invariant: {error}"))
474    })?;
475    if let Some(invariant) = invariants.first() {
476        return Err(ConnectorError::ConfigurationError(format!(
477            "Delta column '{}' declares an invariant; {remediation}",
478            invariant.field_name
479        )));
480    }
481    let generated = schema.get_generated_columns().map_err(|error| {
482        ConnectorError::SchemaMismatch(format!("invalid Delta generated column: {error}"))
483    })?;
484    if let Some(column) = generated.first() {
485        return Err(ConnectorError::ConfigurationError(format!(
486            "Delta column '{}' is generated; {remediation}",
487            column.get_name()
488        )));
489    }
490    Ok(())
491}
492
493#[cfg(feature = "delta-lake")]
494pub(super) fn coordinated_transaction_ids(external_key: &str) -> (String, String) {
495    (
496        format!("{external_key}.checkpoint"),
497        format!("{external_key}.fence"),
498    )
499}
500
501#[cfg(feature = "delta-lake")]
502fn reject_transaction_retention(table: &DeltaTable) -> Result<(), ConnectorError> {
503    let snapshot = table
504        .snapshot()
505        .map_err(|error| classify_delta_metadata_error("read Delta snapshot", &error))?;
506    if let Some(value) = snapshot
507        .metadata()
508        .configuration()
509        .get(SET_TRANSACTION_RETENTION)
510    {
511        return Err(ConnectorError::ConfigurationError(format!(
512            "Delta coordinated commits require durable transaction cursors; table property \
513             '{SET_TRANSACTION_RETENTION}'='{value}' can expire them"
514        )));
515    }
516    Ok(())
517}
518
519/// Read the atomic checkpoint/fencing cursor for one coordinated namespace.
520///
521/// Both Delta `txn` actions must be present or absent. A partial pair is
522/// corruption, never a fresh target. Delta transaction versions are treated as
523/// opaque persisted values and checked explicitly rather than assumed to be
524/// monotonic.
525///
526/// # Errors
527/// Returns an error when the table cursor is unreadable, partial, or outside
528/// Laminar's checkpoint/fencing ranges.
529#[cfg(feature = "delta-lake")]
530pub async fn get_coordinated_cursor(
531    table: &DeltaTable,
532    external_key: &str,
533) -> Result<Option<CoordinatedCommitCursor>, ConnectorError> {
534    reject_transaction_retention(table)?;
535    let snapshot = table
536        .snapshot()
537        .map_err(|error| classify_delta_metadata_error("read Delta snapshot", &error))?;
538    let log_store = table.log_store();
539    let (checkpoint_id, fencing_token) = coordinated_transaction_ids(external_key);
540    let (checkpoint, token) = tokio::try_join!(
541        async {
542            snapshot
543                .transaction_version(log_store.as_ref(), &checkpoint_id)
544                .await
545                .map_err(|error| {
546                    classify_delta_metadata_error(
547                        &format!("read Delta checkpoint cursor '{checkpoint_id}'"),
548                        &error,
549                    )
550                })
551        },
552        async {
553            snapshot
554                .transaction_version(log_store.as_ref(), &fencing_token)
555                .await
556                .map_err(|error| {
557                    classify_delta_metadata_error(
558                        &format!("read Delta fencing cursor '{fencing_token}'"),
559                        &error,
560                    )
561                })
562        }
563    )?;
564
565    match (checkpoint, token) {
566        (None, None) => Ok(None),
567        (Some(checkpoint), Some(token)) => {
568            let checkpoint_id = u64::try_from(checkpoint).map_err(|_| {
569                ConnectorError::TransactionError(format!(
570                    "Delta coordinated checkpoint cursor '{external_key}' is negative"
571                ))
572            })?;
573            let fencing_token = u64::try_from(token).map_err(|_| {
574                ConnectorError::TransactionError(format!(
575                    "Delta coordinated fencing token '{external_key}' is negative"
576                ))
577            })?;
578            if fencing_token == 0 {
579                return Err(ConnectorError::TransactionError(format!(
580                    "Delta coordinated fencing token '{external_key}' is zero"
581                )));
582            }
583            Ok(Some(CoordinatedCommitCursor {
584                checkpoint_id,
585                fencing_token,
586            }))
587        }
588        _ => Err(ConnectorError::TransactionError(format!(
589            "Delta coordinated cursor '{external_key}' is corrupt: checkpoint and fence \
590             transaction actions must be present together"
591        ))),
592    }
593}