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")]
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 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 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 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 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 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#[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 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 if schema_evolution {
315 write_builder = write_builder.with_schema_mode(SchemaMode::Merge);
316 }
317
318 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 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#[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}