laminar_connectors/lakehouse/delta_io/
table.rs1#[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#[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#[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#[cfg(feature = "delta-lake")]
66pub(super) fn path_to_url(path: &str) -> Result<Url, ConnectorError> {
67 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 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 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#[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#[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#[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 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 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 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 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 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#[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 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 if schema_evolution {
388 write_builder = write_builder.with_schema_mode(SchemaMode::Merge);
389 }
390
391 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 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#[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#[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}