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/// Opens an existing Delta Lake table or creates a new one.
151///
152/// # Arguments
153///
154/// * `table_path` - Path to the Delta Lake table (local, `s3://`, `az://`, `gs://`)
155/// * `storage_options` - Storage credentials and configuration
156/// * `schema` - Optional Arrow schema for table creation (required if table doesn't exist)
157///
158/// # Returns
159///
160/// The opened `DeltaTable` handle.
161///
162/// # Errors
163///
164/// Returns `ConnectorError::ConnectionFailed` if the table cannot be opened or created.
165#[cfg(feature = "delta-lake")]
166#[allow(clippy::implicit_hasher)]
167pub async fn open_or_create_table(
168    table_path: &str,
169    mut storage_options: HashMap<String, String>,
170    schema: Option<&SchemaRef>,
171) -> Result<DeltaTable, ConnectorError> {
172    let url = if table_path.contains("://") {
173        let adapted = adapt_delta_location(table_path)?;
174        apply_url_derived_options(&mut storage_options, &adapted)?;
175        Url::parse(&adapted.url).map_err(|error| {
176            ConnectorError::ConfigurationError(format!(
177                "invalid canonical Delta storage URL: {error}"
178            ))
179        })?
180    } else {
181        path_to_url(table_path)?
182    };
183    #[cfg(feature = "delta-lake-gcs")]
184    if url.scheme() == "gs" {
185        super::gcs_factory::register_laminar_gcs_factory()?;
186    }
187    info!("opening Delta Lake table");
188
189    // Try to open or initialize the table. `url` and `storage_options` are
190    // not referenced after this call, so move rather than clone; repeated
191    // conflict-recovery opens otherwise copy the complete option map.
192    let table = DeltaTable::try_from_url_with_storage_options(url, storage_options)
193        .await
194        .map_err(|error| {
195            ConnectorError::ConnectionFailed(
196                format!(
197                    "failed to open Delta table ({}); verify the redacted provider diagnostics and storage configuration",
198                    super::attempt_error::delta_error_category(&error)
199                ),
200            )
201        })?;
202
203    // Check if the table is initialized (has state).
204    if table.version().is_some() {
205        info!(
206            version = table.version(),
207            "opened existing Delta Lake table"
208        );
209        return Ok(table);
210    }
211
212    // Table doesn't exist — create if we have a schema, otherwise defer to first write_batch().
213    let Some(schema) = schema else {
214        info!("table does not exist yet; will create on first write");
215        return Ok(table);
216    };
217
218    info!("creating new Delta Lake table");
219
220    // Convert Arrow schema to Delta Lake schema using TryIntoKernel.
221    let delta_schema: deltalake::kernel::StructType = widen_millisecond_timestamps(schema)
222        .as_ref()
223        .try_into_kernel()
224        .map_err(|e| ConnectorError::SchemaMismatch(format!("schema conversion failed: {e}")))?;
225
226    // Create the table.
227    let table = table
228        .create()
229        .with_columns(delta_schema.fields().cloned())
230        .await
231        .map_err(|error| {
232            ConnectorError::ConnectionFailed(
233                format!(
234                    "failed to create Delta table ({}); verify provider permissions and conditional-write support",
235                    super::attempt_error::delta_error_category(&error)
236                ),
237            )
238        })?;
239
240    info!(version = table.version(), "created new Delta Lake table");
241
242    Ok(table)
243}
244
245/// Writes batches to a Delta Lake table.
246///
247/// # Arguments
248///
249/// * `table` - The Delta Lake table handle (consumed and returned)
250/// * `batches` - Record batches to write
251/// * `save_mode` - Delta Lake save mode (Append, Overwrite, etc.)
252/// * `partition_columns` - Optional partition column name slice
253/// * `schema_evolution` - If true, auto-merge new columns into the table schema
254///
255/// # Returns
256///
257/// A tuple of (updated table handle, new Delta version).
258///
259/// # Errors
260///
261/// Preserves a typed delta-rs failure when the write operation fails.
262#[cfg(feature = "delta-lake")]
263#[allow(clippy::too_many_arguments)]
264pub(crate) async fn write_batches(
265    table: DeltaTable,
266    batches: Vec<RecordBatch>,
267    save_mode: SaveMode,
268    partition_columns: Option<&[String]>,
269    schema_evolution: bool,
270    target_file_size: Option<usize>,
271    writer_properties: Option<deltalake::parquet::file::properties::WriterProperties>,
272) -> Result<(DeltaTable, i64), DeltaWriteAttemptError> {
273    if batches.is_empty() {
274        debug!("no batches to write, skipping");
275        let version = from_delta_version(table.version().unwrap_or(0))
276            .map_err(DeltaWriteAttemptError::Local)?;
277        return Ok((table, version));
278    }
279
280    let batches = batches
281        .into_iter()
282        .map(widen_batch_millisecond_timestamps)
283        .collect::<Result<Vec<_>, _>>()
284        .map_err(DeltaWriteAttemptError::Local)?;
285
286    let total_rows: usize = batches.iter().map(RecordBatch::num_rows).sum();
287
288    debug!(
289        total_rows,
290        num_batches = batches.len(),
291        "writing batches to Delta Lake"
292    );
293
294    let mut write_builder = table
295        .write(batches)
296        .with_save_mode(save_mode)
297        .with_commit_properties(CommitProperties::default());
298
299    // Forward target file size to delta-rs so Parquet files match the
300    // user's configured size, not just the internal default.
301    if let Some(size) = target_file_size {
302        let size = u64::try_from(size)
303            .ok()
304            .and_then(std::num::NonZeroU64::new)
305            .ok_or_else(|| {
306                DeltaWriteAttemptError::Local(ConnectorError::ConfigurationError(
307                    "Delta target file size must be a non-zero u64".into(),
308                ))
309            })?;
310        write_builder = write_builder.with_target_file_size(Some(size));
311    }
312
313    // Enable schema evolution (additive column merge) if requested.
314    if schema_evolution {
315        write_builder = write_builder.with_schema_mode(SchemaMode::Merge);
316    }
317
318    // Add partition columns if specified.
319    if let Some(cols) = partition_columns {
320        if !cols.is_empty() {
321            write_builder = write_builder.with_partition_columns(cols.to_vec());
322        }
323    }
324
325    if let Some(props) = writer_properties {
326        write_builder = write_builder.with_writer_properties(props);
327    }
328
329    #[cfg(feature = "testing")]
330    delta_prepublication_fault_boundary().await?;
331
332    // Execute the write.
333    let table = write_builder.await.map_err(DeltaWriteAttemptError::Delta)?;
334
335    let version =
336        from_delta_version(table.version().unwrap_or(0)).map_err(DeltaWriteAttemptError::Local)?;
337
338    info!(version, total_rows, "committed Delta Lake transaction");
339
340    Ok((table, version))
341}
342
343#[cfg(all(feature = "delta-lake", feature = "testing"))]
344async fn delta_prepublication_fault_boundary() -> Result<(), DeltaWriteAttemptError> {
345    if std::env::var("LAMINAR_DELTA_FAULT_WORKER_MODE").as_deref() != Ok("before-publication") {
346        return Ok(());
347    }
348    let signal_directory = std::env::var_os("LAMINAR_DELTA_FAULT_SIGNAL_DIR")
349        .map(std::path::PathBuf::from)
350        .filter(|path| path.is_absolute())
351        .ok_or_else(|| {
352            DeltaWriteAttemptError::Local(ConnectorError::ConfigurationError(
353                "Delta test fault boundary requires an absolute signal directory".into(),
354            ))
355        })?;
356    std::fs::write(signal_directory.join("ready"), b"ready").map_err(|_| {
357        DeltaWriteAttemptError::Local(ConnectorError::WriteError(
358            "Delta test fault boundary could not publish its ready signal".into(),
359        ))
360    })?;
361    std::future::pending::<Result<(), DeltaWriteAttemptError>>().await
362}
363
364#[cfg(feature = "delta-lake")]
365pub(super) fn coordinated_transaction_ids(external_key: &str) -> (String, String) {
366    (
367        format!("{external_key}.checkpoint"),
368        format!("{external_key}.fence"),
369    )
370}
371
372#[cfg(feature = "delta-lake")]
373fn reject_transaction_retention(table: &DeltaTable) -> Result<(), ConnectorError> {
374    let snapshot = table
375        .snapshot()
376        .map_err(|error| classify_delta_metadata_error("read Delta snapshot", &error))?;
377    if let Some(value) = snapshot
378        .metadata()
379        .configuration()
380        .get(SET_TRANSACTION_RETENTION)
381    {
382        return Err(ConnectorError::ConfigurationError(format!(
383            "Delta coordinated commits require durable transaction cursors; table property \
384             '{SET_TRANSACTION_RETENTION}'='{value}' can expire them"
385        )));
386    }
387    Ok(())
388}
389
390/// Read the atomic checkpoint/fencing cursor for one coordinated namespace.
391///
392/// Both Delta `txn` actions must be present or absent. A partial pair is
393/// corruption, never a fresh target. Delta transaction versions are treated as
394/// opaque persisted values and checked explicitly rather than assumed to be
395/// monotonic.
396///
397/// # Errors
398/// Returns an error when the table cursor is unreadable, partial, or outside
399/// Laminar's checkpoint/fencing ranges.
400#[cfg(feature = "delta-lake")]
401pub async fn get_coordinated_cursor(
402    table: &DeltaTable,
403    external_key: &str,
404) -> Result<Option<CoordinatedCommitCursor>, ConnectorError> {
405    reject_transaction_retention(table)?;
406    let snapshot = table
407        .snapshot()
408        .map_err(|error| classify_delta_metadata_error("read Delta snapshot", &error))?;
409    let log_store = table.log_store();
410    let (checkpoint_id, fencing_token) = coordinated_transaction_ids(external_key);
411    let (checkpoint, token) = tokio::try_join!(
412        async {
413            snapshot
414                .transaction_version(log_store.as_ref(), &checkpoint_id)
415                .await
416                .map_err(|error| {
417                    classify_delta_metadata_error(
418                        &format!("read Delta checkpoint cursor '{checkpoint_id}'"),
419                        &error,
420                    )
421                })
422        },
423        async {
424            snapshot
425                .transaction_version(log_store.as_ref(), &fencing_token)
426                .await
427                .map_err(|error| {
428                    classify_delta_metadata_error(
429                        &format!("read Delta fencing cursor '{fencing_token}'"),
430                        &error,
431                    )
432                })
433        }
434    )?;
435
436    match (checkpoint, token) {
437        (None, None) => Ok(None),
438        (Some(checkpoint), Some(token)) => {
439            let checkpoint_id = u64::try_from(checkpoint).map_err(|_| {
440                ConnectorError::TransactionError(format!(
441                    "Delta coordinated checkpoint cursor '{external_key}' is negative"
442                ))
443            })?;
444            let fencing_token = u64::try_from(token).map_err(|_| {
445                ConnectorError::TransactionError(format!(
446                    "Delta coordinated fencing token '{external_key}' is negative"
447                ))
448            })?;
449            if fencing_token == 0 {
450                return Err(ConnectorError::TransactionError(format!(
451                    "Delta coordinated fencing token '{external_key}' is zero"
452                )));
453            }
454            Ok(Some(CoordinatedCommitCursor {
455                checkpoint_id,
456                fencing_token,
457            }))
458        }
459        _ => Err(ConnectorError::TransactionError(format!(
460            "Delta coordinated cursor '{external_key}' is corrupt: checkpoint and fence \
461             transaction actions must be present together"
462        ))),
463    }
464}