1#[cfg(feature = "delta-lake")]
19use std::collections::{BTreeMap, HashMap, HashSet};
20
21#[cfg(feature = "delta-lake")]
22use std::sync::Arc;
23
24#[cfg(feature = "delta-lake")]
25use std::sync::atomic::{AtomicUsize, Ordering};
26
27#[cfg(feature = "delta-lake")]
28use std::time::Duration;
29
30#[cfg(feature = "delta-lake")]
31use arrow_array::RecordBatch;
32
33#[cfg(feature = "delta-lake")]
34use arrow_schema::SchemaRef;
35
36#[cfg(feature = "delta-lake")]
38use deltalake::kernel::engine::arrow_conversion::TryIntoKernel as _;
39
40#[cfg(feature = "delta-lake")]
41use deltalake::kernel::transaction::CommitProperties;
42
43#[cfg(feature = "delta-lake")]
44use deltalake::kernel::Transaction;
45
46#[cfg(feature = "delta-lake")]
47use deltalake::operations::write::SchemaMode;
48
49#[cfg(feature = "delta-lake")]
50use deltalake::protocol::SaveMode;
51
52#[cfg(feature = "delta-lake")]
53use deltalake::DeltaTable;
54
55#[cfg(feature = "delta-lake")]
56use tracing::{debug, info, warn};
57
58#[cfg(feature = "delta-lake")]
59use url::Url;
60
61#[cfg(feature = "delta-lake")]
62use crate::error::ConnectorError;
63
64#[cfg(feature = "delta-lake")]
65use crate::connector::{
66 CoordinatedCommitBatch, CoordinatedCommitCursor, MAX_COORDINATED_COMMIT_BATCH_BYTES,
67};
68
69#[cfg(feature = "delta-lake")]
70use super::commit_descriptor::{DeltaCommitDescriptor, DeltaTableBinding};
71
72#[cfg(feature = "delta-lake")]
73const SET_TRANSACTION_RETENTION: &str = "delta.setTransactionRetentionDuration";
74
75#[cfg(feature = "delta-lake")]
76const COORDINATED_HEAD_CONCURRENCY: usize = 16;
77
78#[cfg(feature = "delta-lake")]
83const COORDINATED_REQUEST_TIMEOUT: &str = "30s";
84#[cfg(feature = "delta-lake")]
85const COORDINATED_CONNECT_TIMEOUT: &str = "10s";
86#[cfg(feature = "delta-lake")]
87const COORDINATED_RETRY_TIMEOUT: &str = "30s";
88#[cfg(feature = "delta-lake")]
89const COORDINATED_HTTP_MAX_RETRIES: &str = "0";
90#[cfg(feature = "delta-lake")]
91const COORDINATED_MAX_BACKOFF: &str = "1s";
92#[cfg(feature = "delta-lake")]
93const COORDINATED_TERMINAL_IO_HORIZON: Duration = Duration::from_secs(24 * 60 * 60);
94#[cfg(feature = "delta-lake")]
95const COORDINATED_CLOCK_SKEW_MARGIN: Duration = Duration::from_secs(5 * 60);
96#[cfg(feature = "delta-lake")]
97const MIN_COORDINATED_DELETED_FILE_RETENTION: Duration = Duration::from_secs(7 * 24 * 60 * 60);
98
99#[cfg(feature = "delta-lake")]
100pub(super) fn bound_coordinated_storage_options(
101 mut options: HashMap<String, String>,
102) -> HashMap<String, String> {
103 options.retain(|key, _| {
104 !matches!(
105 key.to_ascii_lowercase().as_str(),
106 "timeout"
107 | "aws_timeout"
108 | "azure_timeout"
109 | "google_timeout"
110 | "connect_timeout"
111 | "aws_connect_timeout"
112 | "azure_connect_timeout"
113 | "google_connect_timeout"
114 | "max_retries"
115 | "retry_timeout"
116 | "max_backoff"
117 | "backoff.max_backoff"
118 | "backoff_config.max_backoff"
119 )
120 });
121 options.insert("timeout".into(), COORDINATED_REQUEST_TIMEOUT.into());
122 options.insert("connect_timeout".into(), COORDINATED_CONNECT_TIMEOUT.into());
123 options.insert("retry_timeout".into(), COORDINATED_RETRY_TIMEOUT.into());
124 options.insert("max_retries".into(), COORDINATED_HTTP_MAX_RETRIES.into());
125 options.insert("max_backoff".into(), COORDINATED_MAX_BACKOFF.into());
126 options
127}
128
129#[cfg(feature = "delta-lake")]
130fn effective_values<F>(
131 options: &HashMap<String, String>,
132 aliases: &[&str],
133 environment_keys: &[&str],
134 environment: &F,
135) -> Vec<String>
136where
137 F: Fn(&str) -> Option<String>,
138{
139 let explicit: Vec<String> = options
140 .iter()
141 .filter(|(key, _)| aliases.iter().any(|alias| key.eq_ignore_ascii_case(alias)))
142 .map(|(_, value)| value.clone())
143 .collect();
144 if !explicit.is_empty() {
145 return explicit;
146 }
147 environment_keys
148 .iter()
149 .filter_map(|key| environment(key))
150 .collect()
151}
152
153#[cfg(feature = "delta-lake")]
154fn has_effective_value<F>(
155 options: &HashMap<String, String>,
156 aliases: &[&str],
157 environment_keys: &[&str],
158 environment: &F,
159) -> bool
160where
161 F: Fn(&str) -> Option<String>,
162{
163 effective_values(options, aliases, environment_keys, environment)
164 .iter()
165 .any(|value| !value.trim().is_empty())
166}
167
168#[cfg(feature = "delta-lake")]
169fn is_truthy(value: &str) -> bool {
170 matches!(
171 value.trim().to_ascii_lowercase().as_str(),
172 "1" | "true" | "yes" | "y" | "on"
173 )
174}
175
176#[cfg(feature = "delta-lake")]
177fn validate_coordinated_s3_options<F>(
178 options: &HashMap<String, String>,
179 environment: &F,
180) -> Result<(), ConnectorError>
181where
182 F: Fn(&str) -> Option<String>,
183{
184 if has_effective_value(
185 options,
186 &[
187 "endpoint",
188 "endpoint_url",
189 "aws_endpoint",
190 "aws_endpoint_url",
191 ],
192 &["AWS_ENDPOINT", "AWS_ENDPOINT_URL"],
193 environment,
194 ) {
195 return Err(ConnectorError::ConfigurationError(
196 "Delta exactly-once does not admit custom S3 endpoints until their atomic-create behavior passes the release fault suite"
197 .into(),
198 ));
199 }
200 let conditional_put = effective_values(
201 options,
202 &["conditional_put", "aws_conditional_put"],
203 &["AWS_CONDITIONAL_PUT"],
204 environment,
205 );
206 if conditional_put
207 .iter()
208 .any(|value| !value.trim().eq_ignore_ascii_case("etag"))
209 {
210 return Err(ConnectorError::ConfigurationError(
211 "Delta exactly-once requires native S3 ETag conditional put; Dynamo and disabled conditional-put modes are not certified"
212 .into(),
213 ));
214 }
215 if has_effective_value(
216 options,
217 &["s3_locking_provider", "aws_s3_locking_provider"],
218 &["AWS_S3_LOCKING_PROVIDER"],
219 environment,
220 ) || effective_values(
221 options,
222 &["allow_unsafe_rename", "aws_s3_allow_unsafe_rename"],
223 &["AWS_S3_ALLOW_UNSAFE_RENAME"],
224 environment,
225 )
226 .iter()
227 .any(|value| is_truthy(value))
228 {
229 return Err(ConnectorError::ConfigurationError(
230 "Delta exactly-once requires the native conditional-put log store; locking-provider and unsafe-rename modes are not certified"
231 .into(),
232 ));
233 }
234 Ok(())
235}
236
237#[cfg(feature = "delta-lake")]
238fn validate_coordinated_azure_options<F>(
239 options: &HashMap<String, String>,
240 environment: &F,
241) -> Result<(), ConnectorError>
242where
243 F: Fn(&str) -> Option<String>,
244{
245 if has_effective_value(
246 options,
247 &["endpoint", "azure_endpoint", "azure_storage_endpoint"],
248 &["AZURE_ENDPOINT", "AZURE_STORAGE_ENDPOINT"],
249 environment,
250 ) || effective_values(
251 options,
252 &[
253 "use_emulator",
254 "azure_use_emulator",
255 "azure_storage_use_emulator",
256 ],
257 &["AZURE_USE_EMULATOR", "AZURE_STORAGE_USE_EMULATOR"],
258 environment,
259 )
260 .iter()
261 .any(|value| is_truthy(value))
262 {
263 return Err(ConnectorError::ConfigurationError(
264 "Delta exactly-once does not admit custom Azure endpoints or emulators until their atomic-create behavior passes the release fault suite"
265 .into(),
266 ));
267 }
268 Ok(())
269}
270
271#[cfg(feature = "delta-lake")]
272fn validate_coordinated_gcs_options<F>(
273 options: &HashMap<String, String>,
274 environment: &F,
275) -> Result<(), ConnectorError>
276where
277 F: Fn(&str) -> Option<String>,
278{
279 if has_effective_value(
280 options,
281 &[
282 "google_service_account",
283 "google_service_account_path",
284 "service_account",
285 "service_account_path",
286 ],
287 &["GOOGLE_SERVICE_ACCOUNT", "GOOGLE_SERVICE_ACCOUNT_PATH"],
288 environment,
289 ) {
290 return Err(ConnectorError::ConfigurationError(
291 "Delta exactly-once does not admit GCS service-account path files because they can override the storage endpoint; use workload identity, application-default credentials, or an inline key"
292 .into(),
293 ));
294 }
295 for key in effective_values(
296 options,
297 &["google_service_account_key", "service_account_key"],
298 &["GOOGLE_SERVICE_ACCOUNT_KEY"],
299 environment,
300 ) {
301 let document: serde_json::Value = serde_json::from_str(&key).map_err(|error| {
302 ConnectorError::ConfigurationError(format!(
303 "invalid GCS service-account key for Delta exactly-once: {error}"
304 ))
305 })?;
306 if document.get("gcs_base_url").is_some() {
307 return Err(ConnectorError::ConfigurationError(
308 "Delta exactly-once does not admit a custom gcs_base_url until its atomic-create behavior passes the release fault suite"
309 .into(),
310 ));
311 }
312 }
313 Ok(())
314}
315
316#[cfg(feature = "delta-lake")]
317fn validate_coordinated_storage_preflight_with_env<F>(
318 table_path: &str,
319 options: &HashMap<String, String>,
320 environment: &F,
321) -> Result<(), ConnectorError>
322where
323 F: Fn(&str) -> Option<String>,
324{
325 let scheme = table_path
326 .split_once("://")
327 .map_or("file", |(scheme, _)| scheme)
328 .to_ascii_lowercase();
329 match scheme.as_str() {
330 "s3" | "s3a" => validate_coordinated_s3_options(options, environment),
331 "az" | "azure" | "abfs" | "abfss" => {
332 validate_coordinated_azure_options(options, environment)
333 }
334 "gs" | "gcs" => validate_coordinated_gcs_options(options, environment),
335 _ => Ok(()),
336 }
337}
338
339#[cfg(feature = "delta-lake")]
340pub(super) fn validate_coordinated_storage_preflight(
341 table_path: &str,
342 options: &HashMap<String, String>,
343) -> Result<(), ConnectorError> {
344 validate_coordinated_storage_preflight_with_env(table_path, options, &|key| {
345 std::env::var(key).ok()
346 })
347}
348
349#[cfg(feature = "delta-lake")]
350fn is_certified_coordinated_log_store(name: &str) -> bool {
351 name == "DefaultLogStore"
352}
353
354#[cfg(feature = "delta-lake")]
355fn validate_coordinated_log_store(table: &DeltaTable) -> Result<(), ConnectorError> {
356 let log_store = table.log_store();
357 if !is_certified_coordinated_log_store(&log_store.name()) {
358 return Err(ConnectorError::ConfigurationError(format!(
359 "Delta exactly-once requires the single-step atomic-create DefaultLogStore; '{}' is not certified",
360 log_store.name()
361 )));
362 }
363 validate_coordinated_storage_preflight(
364 log_store.config().location().as_str(),
365 &log_store.config().options().raw,
366 )
367}
368
369#[cfg(feature = "delta-lake")]
373#[derive(Debug, thiserror::Error)]
374pub(crate) enum DeltaWriteAttemptError {
375 #[error(transparent)]
377 Local(#[from] ConnectorError),
378 #[error(transparent)]
380 Delta(#[from] deltalake::DeltaTableError),
381}
382
383#[cfg(feature = "delta-lake")]
384impl DeltaWriteAttemptError {
385 pub(crate) fn is_definite_optimistic_conflict(&self) -> bool {
388 matches!(self, Self::Delta(error) if is_definite_prewrite_conflict(error))
389 }
390}
391
392#[cfg(feature = "delta-lake")]
393fn is_definite_prewrite_conflict(error: &deltalake::DeltaTableError) -> bool {
394 use deltalake::kernel::transaction::{CommitConflictError, TransactionError};
395
396 matches!(
397 error,
398 deltalake::DeltaTableError::Transaction {
399 source: TransactionError::CommitConflict(
400 CommitConflictError::ConcurrentAppend
401 | CommitConflictError::ConcurrentDeleteRead
402 | CommitConflictError::ConcurrentDeleteDelete
403 | CommitConflictError::ConcurrentTransaction
404 )
405 }
406 )
407}
408
409#[cfg(feature = "delta-lake")]
410fn object_store_error_has_retryable_transport(error: &deltalake::ObjectStoreError) -> bool {
411 use delta_object_store::client::{HttpError, HttpErrorKind};
412
413 let deltalake::ObjectStoreError::Generic { source, .. } = error else {
416 return false;
417 };
418 let mut cause: Option<&(dyn std::error::Error + 'static)> = Some(source.as_ref());
419 while let Some(current) = cause {
420 if let Some(http) = current.downcast_ref::<HttpError>() {
421 return matches!(
422 http.kind(),
423 HttpErrorKind::Connect
424 | HttpErrorKind::Request
425 | HttpErrorKind::Timeout
426 | HttpErrorKind::Interrupted
427 );
428 }
429 cause = current.source();
430 }
431 false
432}
433
434#[cfg(feature = "delta-lake")]
435pub(crate) fn delta_error_has_retryable_transport(error: &deltalake::DeltaTableError) -> bool {
436 match error {
437 deltalake::DeltaTableError::ObjectStore { source }
438 | deltalake::DeltaTableError::Transaction {
439 source: deltalake::kernel::transaction::TransactionError::ObjectStore { source },
440 } => object_store_error_has_retryable_transport(source),
441 _ => false,
442 }
443}
444
445#[cfg(feature = "delta-lake")]
446pub(crate) fn is_definite_coordinated_nonpublication(error: &deltalake::DeltaTableError) -> bool {
447 use deltalake::kernel::transaction::TransactionError;
448
449 is_definite_prewrite_conflict(error)
450 || matches!(
451 error,
452 deltalake::DeltaTableError::VersionAlreadyExists(_)
453 | deltalake::DeltaTableError::Transaction {
454 source: TransactionError::VersionAlreadyExists(_)
455 | TransactionError::MaxCommitAttempts(_)
456 }
457 )
458}
459
460#[cfg(all(feature = "delta-lake", test))]
461#[derive(Clone)]
462pub(super) struct DelayedCoordinatedCatalogCommit {
463 pub(super) started: Arc<tokio::sync::Notify>,
464 pub(super) release: Arc<tokio::sync::Notify>,
465}
466
467#[cfg(all(feature = "delta-lake", test))]
468tokio::task_local! {
469 pub(super) static DELAY_COORDINATED_CATALOG_COMMIT: DelayedCoordinatedCatalogCommit;
470}
471
472#[cfg(feature = "delta-lake")]
473pub(super) const MAX_COORDINATED_ADD_ACTIONS: usize = 4_096;
474#[cfg(feature = "delta-lake")]
475const MAX_COORDINATED_PATH_BYTES: usize = 1_024;
476#[cfg(feature = "delta-lake")]
477const MAX_COORDINATED_STATS_BYTES: usize = 1024 * 1024;
478#[cfg(feature = "delta-lake")]
479const MAX_COORDINATED_PARTITION_ENTRIES: usize = 1_024;
480#[cfg(feature = "delta-lake")]
481const MAX_COORDINATED_PARTITION_BYTES: usize = 256 * 1024;
482#[cfg(feature = "delta-lake")]
483const MAX_COORDINATED_TAG_ENTRIES: usize = 1_024;
484#[cfg(feature = "delta-lake")]
485const MAX_COORDINATED_TAG_BYTES: usize = 256 * 1024;
486#[cfg(feature = "delta-lake")]
487const MAX_COORDINATED_TABLE_ID_BYTES: usize = 1_024;
488
489#[cfg(feature = "delta-lake")]
491fn path_to_url(path: &str) -> Result<Url, ConnectorError> {
492 if path.contains("://") {
494 Url::parse(path)
495 .map_err(|e| ConnectorError::ConfigurationError(format!("invalid URL '{path}': {e}")))
496 } else {
497 let path_buf = std::path::Path::new(path);
500 let normalized = if path_buf.exists() {
501 std::fs::canonicalize(path_buf).map_err(|e| {
502 ConnectorError::ConfigurationError(format!("invalid path '{path}': {e}"))
503 })?
504 } else {
505 if path_buf.is_absolute() {
508 path_buf.to_path_buf()
509 } else {
510 std::env::current_dir()
511 .map_err(|e| {
512 ConnectorError::ConfigurationError(format!("cannot get current dir: {e}"))
513 })?
514 .join(path_buf)
515 }
516 };
517
518 Url::from_directory_path(&normalized).map_err(|()| {
519 ConnectorError::ConfigurationError(format!(
520 "cannot convert path to URL: {}",
521 normalized.display()
522 ))
523 })
524 }
525}
526
527#[cfg(feature = "delta-lake")]
543#[allow(clippy::implicit_hasher)]
544pub async fn open_or_create_table(
545 table_path: &str,
546 storage_options: HashMap<String, String>,
547 schema: Option<&SchemaRef>,
548) -> Result<DeltaTable, ConnectorError> {
549 info!(table_path, "opening Delta Lake table");
550
551 let url = path_to_url(table_path)?;
552
553 let table = DeltaTable::try_from_url_with_storage_options(url, storage_options)
557 .await
558 .map_err(|e| ConnectorError::ConnectionFailed(format!("failed to open table: {e}")))?;
559
560 if table.version().is_some() {
562 info!(
563 table_path,
564 version = table.version(),
565 "opened existing Delta Lake table"
566 );
567 return Ok(table);
568 }
569
570 let Some(schema) = schema else {
572 info!(
573 table_path,
574 "table does not exist yet; will create on first write"
575 );
576 return Ok(table);
577 };
578
579 info!(table_path, "creating new Delta Lake table");
580
581 let delta_schema: deltalake::kernel::StructType = schema
583 .as_ref()
584 .try_into_kernel()
585 .map_err(|e| ConnectorError::SchemaMismatch(format!("schema conversion failed: {e}")))?;
586
587 let table = table
589 .create()
590 .with_columns(delta_schema.fields().cloned())
591 .await
592 .map_err(|e| ConnectorError::ConnectionFailed(format!("failed to create table: {e}")))?;
593
594 info!(
595 table_path,
596 version = table.version(),
597 "created new Delta Lake table"
598 );
599
600 Ok(table)
601}
602
603#[cfg(feature = "delta-lake")]
621#[allow(clippy::too_many_arguments)]
622pub(crate) async fn write_batches(
623 table: DeltaTable,
624 batches: Vec<RecordBatch>,
625 save_mode: SaveMode,
626 partition_columns: Option<&[String]>,
627 schema_evolution: bool,
628 target_file_size: Option<usize>,
629 writer_properties: Option<deltalake::parquet::file::properties::WriterProperties>,
630) -> Result<(DeltaTable, i64), DeltaWriteAttemptError> {
631 if batches.is_empty() {
632 debug!("no batches to write, skipping");
633 let version = table.version().unwrap_or(0);
634 return Ok((table, version));
635 }
636
637 let total_rows: usize = batches.iter().map(RecordBatch::num_rows).sum();
638
639 debug!(
640 total_rows,
641 num_batches = batches.len(),
642 "writing batches to Delta Lake"
643 );
644
645 let mut write_builder = table
646 .write(batches)
647 .with_save_mode(save_mode)
648 .with_commit_properties(CommitProperties::default());
649
650 if let Some(size) = target_file_size {
653 write_builder = write_builder.with_target_file_size(size);
654 }
655
656 if schema_evolution {
658 write_builder = write_builder.with_schema_mode(SchemaMode::Merge);
659 }
660
661 if let Some(cols) = partition_columns {
663 if !cols.is_empty() {
664 write_builder = write_builder.with_partition_columns(cols.to_vec());
665 }
666 }
667
668 if let Some(props) = writer_properties {
669 write_builder = write_builder.with_writer_properties(props);
670 }
671
672 let table = write_builder.await.map_err(DeltaWriteAttemptError::Delta)?;
674
675 let version = table.version().unwrap_or(0);
676
677 info!(version, total_rows, "committed Delta Lake transaction");
678
679 Ok((table, version))
680}
681
682#[cfg(feature = "delta-lake")]
683fn coordinated_transaction_ids(external_key: &str) -> (String, String) {
684 (
685 format!("{external_key}.checkpoint"),
686 format!("{external_key}.fence"),
687 )
688}
689
690#[cfg(feature = "delta-lake")]
691fn reject_transaction_retention(table: &DeltaTable) -> Result<(), ConnectorError> {
692 let snapshot = table.snapshot().map_err(|error| {
693 ConnectorError::TransactionError(format!("read Delta snapshot: {error}"))
694 })?;
695 if let Some(value) = snapshot
696 .metadata()
697 .configuration()
698 .get(SET_TRANSACTION_RETENTION)
699 {
700 return Err(ConnectorError::ConfigurationError(format!(
701 "Delta coordinated commits require durable transaction cursors; table property \
702 '{SET_TRANSACTION_RETENTION}'='{value}' can expire them"
703 )));
704 }
705 Ok(())
706}
707
708#[cfg(feature = "delta-lake")]
719pub async fn get_coordinated_cursor(
720 table: &DeltaTable,
721 external_key: &str,
722) -> Result<Option<CoordinatedCommitCursor>, ConnectorError> {
723 reject_transaction_retention(table)?;
724 let snapshot = table.snapshot().map_err(|error| {
725 ConnectorError::TransactionError(format!("read Delta snapshot: {error}"))
726 })?;
727 let log_store = table.log_store();
728 let (checkpoint_id, fencing_token) = coordinated_transaction_ids(external_key);
729 let (checkpoint, token) = tokio::try_join!(
730 async {
731 snapshot
732 .transaction_version(log_store.as_ref(), &checkpoint_id)
733 .await
734 .map_err(|error| {
735 ConnectorError::TransactionError(format!(
736 "read Delta checkpoint cursor '{checkpoint_id}': {error}"
737 ))
738 })
739 },
740 async {
741 snapshot
742 .transaction_version(log_store.as_ref(), &fencing_token)
743 .await
744 .map_err(|error| {
745 ConnectorError::TransactionError(format!(
746 "read Delta fencing cursor '{fencing_token}': {error}"
747 ))
748 })
749 }
750 )?;
751
752 match (checkpoint, token) {
753 (None, None) => Ok(None),
754 (Some(checkpoint), Some(token)) => {
755 let checkpoint_id = u64::try_from(checkpoint).map_err(|_| {
756 ConnectorError::TransactionError(format!(
757 "Delta coordinated checkpoint cursor '{external_key}' is negative"
758 ))
759 })?;
760 let fencing_token = u64::try_from(token).map_err(|_| {
761 ConnectorError::TransactionError(format!(
762 "Delta coordinated fencing token '{external_key}' is negative"
763 ))
764 })?;
765 if fencing_token == 0 {
766 return Err(ConnectorError::TransactionError(format!(
767 "Delta coordinated fencing token '{external_key}' is zero"
768 )));
769 }
770 Ok(Some(CoordinatedCommitCursor {
771 checkpoint_id,
772 fencing_token,
773 }))
774 }
775 _ => Err(ConnectorError::TransactionError(format!(
776 "Delta coordinated cursor '{external_key}' is corrupt: checkpoint and fence \
777 transaction actions must be present together"
778 ))),
779 }
780}
781
782#[cfg(feature = "delta-lake")]
783#[derive(serde::Serialize)]
784struct DeltaProtocolFingerprint {
785 min_reader_version: i32,
786 min_writer_version: i32,
787 reader_features: Vec<String>,
788 writer_features: Vec<String>,
789}
790
791#[cfg(feature = "delta-lake")]
792#[derive(serde::Serialize)]
793struct DeltaWriteMetadataFingerprint<'a> {
794 table_id: &'a str,
795 schema: &'a deltalake::kernel::StructType,
796 partition_columns: &'a [String],
797 configuration: BTreeMap<&'a str, &'a str>,
798 protocol: DeltaProtocolFingerprint,
799}
800
801#[cfg(feature = "delta-lake")]
802fn sorted_protocol_features<T: ToString>(features: Option<&[T]>) -> Vec<String> {
803 let mut features: Vec<String> = features
804 .unwrap_or_default()
805 .iter()
806 .map(ToString::to_string)
807 .collect();
808 features.sort_unstable();
809 features
810}
811
812#[cfg(feature = "delta-lake")]
813pub(super) fn coordinated_table_binding(
814 table: &DeltaTable,
815) -> Result<DeltaTableBinding, ConnectorError> {
816 let snapshot = table.snapshot().map_err(|error| {
817 ConnectorError::TransactionError(format!("read Delta staging snapshot: {error}"))
818 })?;
819 let metadata = snapshot.metadata();
820 let table_id = metadata.id();
821 if table_id.is_empty() || table_id.len() > MAX_COORDINATED_TABLE_ID_BYTES {
822 return Err(ConnectorError::TransactionError(
823 "Delta table id is empty or exceeds the coordinated descriptor limit".into(),
824 ));
825 }
826 let schema = metadata.parse_schema().map_err(|error| {
827 ConnectorError::TransactionError(format!("parse Delta table schema: {error}"))
828 })?;
829 let configuration = metadata
830 .configuration()
831 .iter()
832 .map(|(key, value)| (key.as_str(), value.as_str()))
833 .collect();
834 let protocol = snapshot.protocol();
835 let fingerprint = DeltaWriteMetadataFingerprint {
836 table_id,
837 schema: &schema,
838 partition_columns: metadata.partition_columns(),
839 configuration,
840 protocol: DeltaProtocolFingerprint {
841 min_reader_version: protocol.min_reader_version(),
842 min_writer_version: protocol.min_writer_version(),
843 reader_features: sorted_protocol_features(protocol.reader_features()),
844 writer_features: sorted_protocol_features(protocol.writer_features()),
845 },
846 };
847 let write_metadata_sha256 = laminar_core::checkpoint::canonical_json_sha256(&fingerprint)
848 .map_err(|error| {
849 ConnectorError::TransactionError(format!("canonicalize Delta write metadata: {error}"))
850 })?;
851 Ok(DeltaTableBinding {
852 table_id: table_id.to_owned(),
853 write_metadata_sha256,
854 })
855}
856
857#[cfg(feature = "delta-lake")]
858fn validate_table_binding(binding: &DeltaTableBinding) -> Result<(), ConnectorError> {
859 if binding.table_id.is_empty() || binding.table_id.len() > MAX_COORDINATED_TABLE_ID_BYTES {
860 return Err(ConnectorError::TransactionError(
861 "Delta coordinated descriptor has an invalid table id".into(),
862 ));
863 }
864 if binding.write_metadata_sha256.len() != 64
865 || !binding
866 .write_metadata_sha256
867 .bytes()
868 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
869 {
870 return Err(ConnectorError::TransactionError(
871 "Delta coordinated descriptor has a non-canonical metadata digest".into(),
872 ));
873 }
874 Ok(())
875}
876
877#[cfg(feature = "delta-lake")]
878fn ensure_publication_deadline(
879 deadline: tokio::time::Instant,
880 operation: &str,
881) -> Result<(), ConnectorError> {
882 if deadline <= tokio::time::Instant::now() {
883 Err(ConnectorError::TransactionError(format!(
884 "Delta coordinated publication deadline elapsed during {operation}; the external outcome must be reconciled from its cursor"
885 )))
886 } else {
887 Ok(())
888 }
889}
890
891#[cfg(feature = "delta-lake")]
893pub(super) fn encode_commit_descriptor(
894 binding: &DeltaTableBinding,
895 adds: &[deltalake::kernel::Add],
896) -> Result<Vec<u8>, ConnectorError> {
897 super::commit_descriptor::encode(binding, adds)
898}
899
900#[cfg(feature = "delta-lake")]
901pub(super) fn encoded_add_array_len(
902 adds: &[deltalake::kernel::Add],
903) -> Result<usize, ConnectorError> {
904 super::commit_descriptor::encoded_add_array_len(adds)
905}
906
907#[cfg(feature = "delta-lake")]
908fn validate_descriptor_batch_lengths(
909 lengths: impl IntoIterator<Item = usize>,
910 deadline: tokio::time::Instant,
911) -> Result<(), ConnectorError> {
912 let mut total_bytes = 0usize;
913 for (index, length) in lengths.into_iter().enumerate() {
914 if index % 64 == 0 {
915 ensure_publication_deadline(deadline, "descriptor batch admission")?;
916 }
917 if length > crate::connector::MAX_COORDINATED_COMMIT_PAYLOAD_BYTES {
918 return Err(ConnectorError::TransactionError(format!(
919 "Delta coordinated descriptor exceeds the fixed {} byte per-participant limit",
920 crate::connector::MAX_COORDINATED_COMMIT_PAYLOAD_BYTES
921 )));
922 }
923 total_bytes = total_bytes.checked_add(length).ok_or_else(|| {
924 ConnectorError::TransactionError(
925 "Delta coordinated descriptor byte count overflow".into(),
926 )
927 })?;
928 if total_bytes > MAX_COORDINATED_COMMIT_BATCH_BYTES {
929 return Err(ConnectorError::TransactionError(format!(
930 "Delta coordinated descriptors exceed the fixed {MAX_COORDINATED_COMMIT_BATCH_BYTES} byte batch limit"
931 )));
932 }
933 }
934 ensure_publication_deadline(deadline, "descriptor batch admission")
935}
936
937#[cfg(feature = "delta-lake")]
938fn decode_commit_descriptors_until(
939 descriptors: &[Vec<u8>],
940 deadline: tokio::time::Instant,
941) -> Result<Option<DeltaCommitDescriptor>, ConnectorError> {
942 validate_descriptor_batch_lengths(descriptors.iter().map(Vec::len), deadline)?;
943
944 let mut binding = None;
945 let mut adds = Vec::new();
946 for bytes in descriptors {
947 ensure_publication_deadline(deadline, "descriptor decoding")?;
948 let descriptor = super::commit_descriptor::decode(bytes)?;
949 ensure_publication_deadline(deadline, "descriptor decoding")?;
950 validate_table_binding(&descriptor.binding)?;
951 if descriptor.adds.is_empty() {
952 return Err(ConnectorError::TransactionError(
953 "Delta coordinated payload contains an empty descriptor".into(),
954 ));
955 }
956 match &binding {
957 Some(expected) if expected != &descriptor.binding => {
958 return Err(ConnectorError::TransactionError(
959 "Delta coordinated descriptors bind different table metadata".into(),
960 ));
961 }
962 None => binding = Some(descriptor.binding),
963 Some(_) => {}
964 }
965 let projected = adds
966 .len()
967 .checked_add(descriptor.adds.len())
968 .ok_or_else(|| {
969 ConnectorError::TransactionError(
970 "Delta coordinated Add action count overflow".into(),
971 )
972 })?;
973 if projected > MAX_COORDINATED_ADD_ACTIONS {
974 return Err(ConnectorError::TransactionError(format!(
975 "Delta coordinated publication exceeds the fixed {MAX_COORDINATED_ADD_ACTIONS} Add action limit"
976 )));
977 }
978 adds.extend(descriptor.adds);
979 }
980 Ok(binding.map(|binding| DeltaCommitDescriptor { binding, adds }))
981}
982
983#[cfg(all(feature = "delta-lake", test))]
984pub(super) fn decode_commit_descriptors(
985 descriptors: &[Vec<u8>],
986) -> Result<Option<DeltaCommitDescriptor>, ConnectorError> {
987 decode_commit_descriptors_until(
988 descriptors,
989 tokio::time::Instant::now() + Duration::from_secs(30),
990 )
991}
992
993#[cfg(feature = "delta-lake")]
994#[derive(Clone)]
995struct CoordinatedObject {
996 path: deltalake::Path,
997 expected_size: u64,
998}
999
1000#[cfg(feature = "delta-lake")]
1001fn decode_percent_once(value: &str) -> Result<Option<String>, ConnectorError> {
1002 fn hex(byte: u8) -> Option<u8> {
1003 match byte {
1004 b'0'..=b'9' => Some(byte - b'0'),
1005 b'a'..=b'f' => Some(byte - b'a' + 10),
1006 b'A'..=b'F' => Some(byte - b'A' + 10),
1007 _ => None,
1008 }
1009 }
1010
1011 let bytes = value.as_bytes();
1012 let mut decoded = Vec::with_capacity(bytes.len());
1013 let mut index = 0;
1014 let mut changed = false;
1015 while index < bytes.len() {
1016 if bytes[index] == b'%' && index + 2 < bytes.len() {
1017 if let (Some(high), Some(low)) = (hex(bytes[index + 1]), hex(bytes[index + 2])) {
1018 decoded.push((high << 4) | low);
1019 index += 3;
1020 changed = true;
1021 continue;
1022 }
1023 }
1024 decoded.push(bytes[index]);
1025 index += 1;
1026 }
1027 if !changed {
1028 return Ok(None);
1029 }
1030 String::from_utf8(decoded).map(Some).map_err(|_| {
1031 ConnectorError::TransactionError(
1032 "Delta coordinated Add path contains non-UTF-8 percent encoding".into(),
1033 )
1034 })
1035}
1036
1037#[cfg(feature = "delta-lake")]
1038fn validate_path_segment(segment: &str, first: bool) -> Result<String, ConnectorError> {
1039 let mut current = segment.to_owned();
1040 for _ in 0..=4 {
1041 let trimmed = current.trim_end_matches(['.', ' ']);
1042 if trimmed.len() != current.len()
1043 || trimmed.is_empty()
1044 || trimmed == "."
1045 || trimmed == ".."
1046 || current.contains('/')
1047 || current.contains('\\')
1048 || (first && trimmed.eq_ignore_ascii_case("_delta_log"))
1049 || (first
1050 && trimmed.as_bytes().get(1) == Some(&b':')
1051 && trimmed.as_bytes()[0].is_ascii_alphabetic())
1052 {
1053 return Err(ConnectorError::TransactionError(format!(
1054 "Delta coordinated Add path has an unsafe segment: '{segment}'"
1055 )));
1056 }
1057 let Some(decoded) = decode_percent_once(¤t)? else {
1058 return Ok(current.to_ascii_lowercase());
1059 };
1060 if decoded == current {
1061 return Ok(current.to_ascii_lowercase());
1062 }
1063 current = decoded;
1064 }
1065 Err(ConnectorError::TransactionError(format!(
1066 "Delta coordinated Add path has excessive percent-encoding depth: '{segment}'"
1067 )))
1068}
1069
1070#[cfg(feature = "delta-lake")]
1071fn bounded_map_bytes<'a>(
1072 entries: impl Iterator<Item = (&'a String, Option<&'a String>)>,
1073 limit: usize,
1074 context: &str,
1075) -> Result<(), ConnectorError> {
1076 let mut total = 0usize;
1077 for (key, value) in entries {
1078 total = total
1079 .checked_add(key.len())
1080 .and_then(|bytes| bytes.checked_add(value.map_or(0, String::len)))
1081 .ok_or_else(|| {
1082 ConnectorError::TransactionError(format!(
1083 "Delta coordinated Add {context} byte count overflow"
1084 ))
1085 })?;
1086 if total > limit {
1087 return Err(ConnectorError::TransactionError(format!(
1088 "Delta coordinated Add {context} exceeds the fixed {limit} byte limit"
1089 )));
1090 }
1091 }
1092 Ok(())
1093}
1094
1095#[cfg(feature = "delta-lake")]
1096fn validate_coordinated_descriptors(
1097 adds: &[deltalake::kernel::Add],
1098 partition_columns: &[String],
1099 deadline: tokio::time::Instant,
1100) -> Result<Vec<CoordinatedObject>, ConnectorError> {
1101 if adds.len() > MAX_COORDINATED_ADD_ACTIONS {
1102 return Err(ConnectorError::TransactionError(format!(
1103 "Delta coordinated publication exceeds the fixed {MAX_COORDINATED_ADD_ACTIONS} Add action limit"
1104 )));
1105 }
1106 let expected_partitions: HashSet<&str> = partition_columns.iter().map(String::as_str).collect();
1107 let mut normalized_paths = HashSet::with_capacity(adds.len());
1108 let mut objects = Vec::with_capacity(adds.len());
1109
1110 for (index, add) in adds.iter().enumerate() {
1111 if index % 64 == 0 {
1112 ensure_publication_deadline(deadline, "descriptor validation")?;
1113 }
1114 let raw_path = add.path.as_str();
1115 if raw_path.is_empty()
1116 || raw_path.len() > MAX_COORDINATED_PATH_BYTES
1117 || raw_path.starts_with('/')
1118 || raw_path.starts_with('\\')
1119 || raw_path.ends_with('/')
1120 || raw_path.contains('\\')
1121 || Url::parse(raw_path).is_ok()
1122 {
1123 return Err(ConnectorError::TransactionError(format!(
1124 "Delta coordinated Add path must be a non-empty relative object path: \
1125 '{raw_path}'"
1126 )));
1127 }
1128 let path = deltalake::Path::parse(raw_path).map_err(|error| {
1129 ConnectorError::TransactionError(format!(
1130 "invalid Delta coordinated Add path '{raw_path}': {error}"
1131 ))
1132 })?;
1133 let mut normalized_path = String::with_capacity(raw_path.len());
1134 for (segment_index, segment) in raw_path.split('/').enumerate() {
1135 if segment_index != 0 {
1136 normalized_path.push('/');
1137 }
1138 normalized_path.push_str(&validate_path_segment(segment, segment_index == 0)?);
1139 }
1140 if !path
1141 .extension()
1142 .is_some_and(|extension| extension.eq_ignore_ascii_case("parquet"))
1143 {
1144 return Err(ConnectorError::TransactionError(format!(
1145 "Delta coordinated Add path is not a Parquet data file: '{raw_path}'"
1146 )));
1147 }
1148 if !normalized_paths.insert(normalized_path) {
1149 return Err(ConnectorError::TransactionError(format!(
1150 "duplicate Windows-equivalent Delta coordinated Add path '{raw_path}'"
1151 )));
1152 }
1153 let expected_size = u64::try_from(add.size).map_err(|_| {
1154 ConnectorError::TransactionError(format!(
1155 "Delta coordinated Add '{raw_path}' has negative size {}",
1156 add.size
1157 ))
1158 })?;
1159 if expected_size == 0 {
1160 return Err(ConnectorError::TransactionError(format!(
1161 "Delta coordinated Add '{raw_path}' has zero size"
1162 )));
1163 }
1164 if add.modification_time < 0 {
1165 return Err(ConnectorError::TransactionError(format!(
1166 "Delta coordinated Add '{raw_path}' has negative modification time {}",
1167 add.modification_time
1168 )));
1169 }
1170 if !add.data_change {
1171 return Err(ConnectorError::TransactionError(format!(
1172 "Delta coordinated Add '{raw_path}' must be a data change"
1173 )));
1174 }
1175 if add.deletion_vector.is_some() {
1176 return Err(ConnectorError::TransactionError(format!(
1177 "Delta coordinated append Add '{raw_path}' cannot reference a deletion vector (the fixed limit is zero)"
1178 )));
1179 }
1180 if add.partition_values.len() > MAX_COORDINATED_PARTITION_ENTRIES {
1181 return Err(ConnectorError::TransactionError(format!(
1182 "Delta coordinated Add '{raw_path}' exceeds the fixed {MAX_COORDINATED_PARTITION_ENTRIES} partition entry limit"
1183 )));
1184 }
1185 if add.partition_values.len() != expected_partitions.len()
1186 || !add
1187 .partition_values
1188 .keys()
1189 .all(|column| expected_partitions.contains(column.as_str()))
1190 {
1191 return Err(ConnectorError::TransactionError(format!(
1192 "Delta coordinated Add '{raw_path}' partition values do not match the live table"
1193 )));
1194 }
1195 bounded_map_bytes(
1196 add.partition_values
1197 .iter()
1198 .map(|(key, value)| (key, value.as_ref())),
1199 MAX_COORDINATED_PARTITION_BYTES,
1200 "partition metadata",
1201 )?;
1202 if let Some(stats) = &add.stats {
1203 if stats.len() > MAX_COORDINATED_STATS_BYTES {
1204 return Err(ConnectorError::TransactionError(format!(
1205 "Delta coordinated Add '{raw_path}' statistics exceed the fixed {MAX_COORDINATED_STATS_BYTES} byte limit"
1206 )));
1207 }
1208 let value: serde_json::Value = serde_json::from_str(stats).map_err(|error| {
1209 ConnectorError::TransactionError(format!(
1210 "Delta coordinated Add '{raw_path}' has invalid statistics: {error}"
1211 ))
1212 })?;
1213 if !value.is_object() {
1214 return Err(ConnectorError::TransactionError(format!(
1215 "Delta coordinated Add '{raw_path}' statistics must be a JSON object"
1216 )));
1217 }
1218 }
1219 if let Some(tags) = &add.tags {
1220 if tags.len() > MAX_COORDINATED_TAG_ENTRIES {
1221 return Err(ConnectorError::TransactionError(format!(
1222 "Delta coordinated Add '{raw_path}' exceeds the fixed {MAX_COORDINATED_TAG_ENTRIES} tag entry limit"
1223 )));
1224 }
1225 bounded_map_bytes(
1226 tags.iter().map(|(key, value)| (key, value.as_ref())),
1227 MAX_COORDINATED_TAG_BYTES,
1228 "tag metadata",
1229 )?;
1230 }
1231 if add
1232 .clustering_provider
1233 .as_ref()
1234 .is_some_and(|provider| provider.len() > 256)
1235 {
1236 return Err(ConnectorError::TransactionError(format!(
1237 "Delta coordinated Add '{raw_path}' clustering provider exceeds 256 bytes"
1238 )));
1239 }
1240
1241 objects.push(CoordinatedObject {
1242 path,
1243 expected_size,
1244 });
1245 }
1246
1247 ensure_publication_deadline(deadline, "descriptor validation")?;
1248 Ok(objects)
1249}
1250
1251#[cfg(feature = "delta-lake")]
1252fn validate_coordinated_retention(retention: Duration) -> Result<(), ConnectorError> {
1253 if retention < MIN_COORDINATED_DELETED_FILE_RETENTION {
1254 return Err(ConnectorError::ConfigurationError(format!(
1255 "Delta exactly-once requires deleted-file retention of at least {MIN_COORDINATED_DELETED_FILE_RETENTION:?}; table config is {retention:?}",
1256 )));
1257 }
1258 Ok(())
1259}
1260
1261#[cfg(feature = "delta-lake")]
1262async fn validate_coordinated_objects(
1263 table: &DeltaTable,
1264 objects: Vec<CoordinatedObject>,
1265 deleted_file_retention: Duration,
1266 required_alive_until: chrono::DateTime<chrono::Utc>,
1267 deadline: tokio::time::Instant,
1268) -> Result<(), ConnectorError> {
1269 if objects.is_empty() {
1270 return Ok(());
1271 }
1272 validate_coordinated_retention(deleted_file_retention)?;
1273
1274 let retention = chrono::Duration::from_std(deleted_file_retention).map_err(|_| {
1275 ConnectorError::TransactionError(
1276 "Delta deleted-file retention duration exceeds the supported clock range".into(),
1277 )
1278 })?;
1279 let objects = Arc::new(objects);
1280 let next = Arc::new(AtomicUsize::new(0));
1281 let store = table.log_store().object_store(None);
1282 let mut workers = tokio::task::JoinSet::new();
1283
1284 for _ in 0..COORDINATED_HEAD_CONCURRENCY.min(objects.len()) {
1285 let objects = Arc::clone(&objects);
1286 let next = Arc::clone(&next);
1287 let store = Arc::clone(&store);
1288 workers.spawn(async move {
1289 loop {
1290 let index = next.fetch_add(1, Ordering::Relaxed);
1291 let Some(object) = objects.get(index) else {
1292 return Ok::<(), ConnectorError>(());
1293 };
1294 let metadata = tokio::time::timeout_at(deadline, store.head(&object.path))
1295 .await
1296 .map_err(|_| {
1297 ConnectorError::TransactionError(format!(
1298 "Delta coordinated object HEAD exceeded the publication deadline for '{}'",
1299 object.path
1300 ))
1301 })?
1302 .map_err(|error| {
1303 ConnectorError::TransactionError(format!(
1304 "HEAD Delta coordinated object '{}': {error}",
1305 object.path
1306 ))
1307 })?;
1308 if metadata.size != object.expected_size {
1309 return Err(ConnectorError::TransactionError(format!(
1310 "Delta coordinated object '{}' size mismatch: descriptor {}, physical {}",
1311 object.path, object.expected_size, metadata.size
1312 )));
1313 }
1314 let vacuum_eligible_at = metadata
1315 .last_modified
1316 .checked_add_signed(retention)
1317 .ok_or_else(|| {
1318 ConnectorError::TransactionError(format!(
1319 "Delta coordinated object '{}' recovery horizon exceeds the supported \
1320 clock range",
1321 object.path
1322 ))
1323 })?;
1324 if vacuum_eligible_at <= required_alive_until {
1325 return Err(ConnectorError::TransactionError(format!(
1326 "Delta coordinated object '{}' is at or inside the recovery-horizon safety \
1327 margin: vacuum eligible at {vacuum_eligible_at}, publication must remain \
1328 safe through {required_alive_until}",
1329 object.path
1330 )));
1331 }
1332 }
1333 });
1334 }
1335
1336 while let Some(result) = workers.join_next().await {
1337 match result {
1338 Ok(Ok(())) => {}
1339 Ok(Err(error)) => {
1340 workers.abort_all();
1341 return Err(error);
1342 }
1343 Err(error) => {
1344 workers.abort_all();
1345 return Err(ConnectorError::Internal(format!(
1346 "Delta coordinated object validation worker failed: {error}"
1347 )));
1348 }
1349 }
1350 }
1351 Ok(())
1352}
1353
1354#[cfg(feature = "delta-lake")]
1355enum PreparedCoordinatedPublication {
1356 AlreadyCommitted,
1357 Commit {
1358 adds: Vec<deltalake::kernel::Add>,
1359 binding: Option<DeltaTableBinding>,
1360 cursor: CoordinatedCommitCursor,
1361 descriptor_count: usize,
1362 },
1363}
1364
1365#[cfg(feature = "delta-lake")]
1366struct CoordinatedPublicationOutcome {
1367 descriptor_count: usize,
1368}
1369
1370#[cfg(feature = "delta-lake")]
1371async fn publish_coordinated<F>(
1372 table: &DeltaTable,
1373 external_key: &str,
1374 deadline: tokio::time::Instant,
1375 prepare: F,
1376) -> Result<CoordinatedPublicationOutcome, ConnectorError>
1377where
1378 F: Fn(
1379 Option<CoordinatedCommitCursor>,
1380 ) -> Result<PreparedCoordinatedPublication, ConnectorError>
1381 + Send,
1382{
1383 use deltalake::kernel::transaction::CommitBuilder;
1384 use deltalake::kernel::Action;
1385 use deltalake::protocol::DeltaOperation;
1386 use deltalake::table::config::TablePropertiesExt as _;
1387
1388 ensure_publication_deadline(deadline, "admission")?;
1389 let publication_budget = deadline.saturating_duration_since(tokio::time::Instant::now());
1390 let budget_on_clock = chrono::Duration::from_std(publication_budget).map_err(|_| {
1391 ConnectorError::TransactionError(
1392 "Delta coordinated publication budget exceeds the supported clock range".into(),
1393 )
1394 })?;
1395 let terminal_horizon =
1396 chrono::Duration::from_std(COORDINATED_TERMINAL_IO_HORIZON).map_err(|_| {
1397 ConnectorError::TransactionError(
1398 "Delta coordinated terminal I/O horizon exceeds the supported clock range".into(),
1399 )
1400 })?;
1401 let clock_skew_margin =
1402 chrono::Duration::from_std(COORDINATED_CLOCK_SKEW_MARGIN).map_err(|_| {
1403 ConnectorError::TransactionError(
1404 "Delta coordinated clock-skew margin exceeds the supported clock range".into(),
1405 )
1406 })?;
1407 let required_alive_until = chrono::Utc::now()
1408 .checked_add_signed(budget_on_clock)
1409 .and_then(|deadline| deadline.checked_add_signed(terminal_horizon))
1410 .and_then(|horizon| horizon.checked_add_signed(clock_skew_margin))
1411 .ok_or_else(|| {
1412 ConnectorError::TransactionError(
1413 "Delta coordinated recovery horizon exceeds the supported clock range".into(),
1414 )
1415 })?;
1416
1417 let mut current = table.clone();
1419 validate_coordinated_log_store(¤t)?;
1420 tokio::time::timeout_at(deadline, current.update_state())
1421 .await
1422 .map_err(|_| {
1423 ConnectorError::WriteError(
1424 "Delta coordinated snapshot refresh exceeded the publication deadline without dispatching a commit"
1425 .into(),
1426 )
1427 })?
1428 .map_err(|error| {
1429 ConnectorError::WriteError(format!(
1430 "refresh Delta coordinated publication snapshot before commit dispatch: {error}"
1431 ))
1432 })?;
1433 ensure_publication_deadline(deadline, "snapshot refresh")?;
1434 let observed = tokio::time::timeout_at(deadline, get_coordinated_cursor(¤t, external_key))
1435 .await
1436 .map_err(|_| {
1437 ConnectorError::WriteError(
1438 "Delta coordinated cursor read exceeded the publication deadline without dispatching a commit"
1439 .into(),
1440 )
1441 })??;
1442 ensure_publication_deadline(deadline, "cursor read")?;
1443 let PreparedCoordinatedPublication::Commit {
1444 adds,
1445 binding,
1446 cursor,
1447 descriptor_count,
1448 } = prepare(observed)?
1449 else {
1450 current.version().ok_or_else(|| {
1451 ConnectorError::TransactionError(
1452 "Delta coordinated cursor exists on an unversioned table".into(),
1453 )
1454 })?;
1455 return Ok(CoordinatedPublicationOutcome {
1456 descriptor_count: 0,
1457 });
1458 };
1459 if cursor.fencing_token == 0 {
1460 return Err(ConnectorError::TransactionError(
1461 "Delta coordinated fencing token must be non-zero".into(),
1462 ));
1463 }
1464 let checkpoint_id = i64::try_from(cursor.checkpoint_id).map_err(|_| {
1465 ConnectorError::TransactionError(
1466 "checkpoint id exceeds Delta transaction-version range".into(),
1467 )
1468 })?;
1469 let fencing_token = i64::try_from(cursor.fencing_token).map_err(|_| {
1470 ConnectorError::TransactionError(
1471 "fencing token exceeds Delta transaction-version range".into(),
1472 )
1473 })?;
1474
1475 let snapshot = current
1476 .snapshot()
1477 .map_err(|error| ConnectorError::TransactionError(format!("snapshot: {error}")))?;
1478 if adds.is_empty() {
1479 if binding.is_some() {
1480 return Err(ConnectorError::TransactionError(
1481 "empty Delta coordinated publication unexpectedly carries a table binding".into(),
1482 ));
1483 }
1484 } else {
1485 let binding = binding.as_ref().ok_or_else(|| {
1486 ConnectorError::TransactionError(
1487 "non-empty Delta coordinated publication has no table binding".into(),
1488 )
1489 })?;
1490 let current_binding = coordinated_table_binding(¤t)?;
1491 if binding != ¤t_binding {
1492 return Err(ConnectorError::TransactionError(format!(
1493 "Delta coordinated descriptor table binding changed before publication (staged table '{}', live table '{}')",
1494 binding.table_id, current_binding.table_id
1495 )));
1496 }
1497 }
1498 let partition_columns = snapshot.metadata().partition_columns().clone();
1499 let objects = validate_coordinated_descriptors(&adds, &partition_columns, deadline)?;
1500 validate_coordinated_objects(
1501 ¤t,
1502 objects,
1503 snapshot.table_config().deleted_file_retention_duration(),
1504 required_alive_until,
1505 deadline,
1506 )
1507 .await?;
1508 ensure_publication_deadline(deadline, "object validation")?;
1509
1510 let partition_by = (!partition_columns.is_empty()).then_some(partition_columns);
1511 let operation = DeltaOperation::Write {
1512 mode: SaveMode::Append,
1513 partition_by,
1514 predicate: None,
1515 };
1516 let actions: Vec<Action> = adds.into_iter().map(Action::Add).collect();
1517 let (checkpoint_transaction_id, fence_transaction_id) =
1518 coordinated_transaction_ids(external_key);
1519 let props = CommitProperties::default()
1523 .with_max_retries(0)
1524 .with_application_transactions(vec![
1525 Transaction::new(checkpoint_transaction_id, checkpoint_id),
1526 Transaction::new(fence_transaction_id, fencing_token),
1527 ]);
1528 ensure_publication_deadline(deadline, "catalog commit preparation")?;
1529 let pre_commit = CommitBuilder::from(props).with_actions(actions).build(
1530 Some(snapshot),
1531 current.log_store(),
1532 operation,
1533 );
1534 let prepared_commit = tokio::time::timeout_at(
1535 deadline,
1536 pre_commit.into_prepared_commit_future(),
1537 )
1538 .await
1539 .map_err(|_| {
1540 ConnectorError::WriteError(
1541 "Delta coordinated commit preparation exceeded the publication deadline without dispatching a catalog write"
1542 .into(),
1543 )
1544 })?
1545 .map_err(|error| {
1546 if delta_error_has_retryable_transport(&error) {
1547 ConnectorError::WriteError(format!(
1548 "prepare Delta coordinated commit before catalog dispatch: {error}"
1549 ))
1550 } else {
1551 ConnectorError::TransactionError(format!(
1552 "prepare Delta coordinated commit before catalog dispatch: {error}"
1553 ))
1554 }
1555 })?;
1556 ensure_publication_deadline(deadline, "catalog commit admission")?;
1557
1558 #[cfg(test)]
1559 if let Ok(delay) = DELAY_COORDINATED_CATALOG_COMMIT.try_with(Clone::clone) {
1560 delay.started.notify_one();
1561 delay.release.notified().await;
1562 }
1563
1564 match prepared_commit.await {
1568 Ok(_post_commit) => Ok(CoordinatedPublicationOutcome { descriptor_count }),
1569 Err(error) => {
1570 let reconciliation = async {
1571 let mut reconciled = current.clone();
1572 reconciled.update_state().await.map_err(|refresh_error| {
1573 format!("refresh after prepared-commit failure: {refresh_error}")
1574 })?;
1575 get_coordinated_cursor(&reconciled, external_key)
1576 .await
1577 .map_err(|cursor_error| {
1578 format!("read cursor after prepared-commit failure: {cursor_error}")
1579 })
1580 }
1581 .await;
1582 if reconciliation.as_ref() == Ok(&Some(cursor)) {
1583 return Ok(CoordinatedPublicationOutcome { descriptor_count });
1584 }
1585 if is_definite_coordinated_nonpublication(&error) {
1586 return Err(ConnectorError::WriteError(format!(
1587 "Delta coordinated optimistic collision did not publish: {error}"
1588 )));
1589 }
1590 let retryable = delta_error_has_retryable_transport(&error);
1591 let reconciliation = match reconciliation {
1592 Ok(observed) => format!("reconciliation observed cursor {observed:?}"),
1593 Err(reconciliation_error) => reconciliation_error,
1594 };
1595 Err(ConnectorError::outcome_unknown(
1596 format!(
1597 "Delta coordinated catalog write was dispatched but its outcome is not known: {error}; {reconciliation}"
1598 ),
1599 retryable,
1600 ))
1601 }
1602 }
1603}
1604
1605#[cfg(feature = "delta-lake")]
1611pub(super) async fn commit_batch_coordinated(
1612 table: &DeltaTable,
1613 batch: &CoordinatedCommitBatch,
1614 deadline: tokio::time::Instant,
1615) -> Result<usize, ConnectorError> {
1616 let external_key = batch.namespace.external_key();
1617 let outcome = publish_coordinated(table, &external_key, deadline, |observed_cursor| {
1618 batch
1619 .validate_observed_cursor(observed_cursor)
1620 .map_err(|error| {
1621 ConnectorError::TransactionError(format!(
1622 "Delta coordinated cursor continuity check failed: {error}"
1623 ))
1624 })?;
1625 if let Some(cursor) = observed_cursor {
1626 if cursor.checkpoint_id == batch.target.checkpoint_id
1627 && cursor.fencing_token == batch.fencing_token
1628 {
1629 return Ok(PreparedCoordinatedPublication::AlreadyCommitted);
1630 }
1631 if cursor.checkpoint_id > batch.target.checkpoint_id
1632 && cursor.fencing_token == batch.fencing_token
1633 {
1634 return Err(ConnectorError::TransactionError(format!(
1635 "Delta coordinated cursor {} is already above exact target {}; the target cannot be inferred as the timed-out publication",
1636 cursor.checkpoint_id, batch.target.checkpoint_id
1637 )));
1638 }
1639 }
1640
1641 let observed_checkpoint_id = observed_cursor.map_or(0, |cursor| cursor.checkpoint_id);
1642 let descriptors: Vec<Vec<u8>> = batch
1643 .entries
1644 .iter()
1645 .filter(|entry| entry.attempt.checkpoint_id > observed_checkpoint_id)
1646 .filter_map(|entry| entry.payload.clone())
1647 .collect();
1648 let descriptor = decode_commit_descriptors_until(&descriptors, deadline)?;
1649 let (binding, adds) = descriptor.map_or((None, Vec::new()), |descriptor| {
1650 (Some(descriptor.binding), descriptor.adds)
1651 });
1652 Ok(PreparedCoordinatedPublication::Commit {
1653 adds: adds.clone(),
1654 binding: binding.clone(),
1655 cursor: CoordinatedCommitCursor {
1656 checkpoint_id: batch.target.checkpoint_id,
1657 fencing_token: batch.fencing_token,
1658 },
1659 descriptor_count: descriptors.len(),
1660 })
1661 })
1662 .await?;
1663 Ok(outcome.descriptor_count)
1664}
1665
1666#[cfg(all(feature = "delta-lake", test))]
1669pub(super) async fn commit_adds_coordinated(
1670 table: &DeltaTable,
1671 adds: Vec<deltalake::kernel::Add>,
1672 external_key: &str,
1673 cursor: CoordinatedCommitCursor,
1674 deadline: tokio::time::Instant,
1675) -> Result<(), ConnectorError> {
1676 let binding = (!adds.is_empty())
1677 .then(|| coordinated_table_binding(table))
1678 .transpose()?;
1679 publish_coordinated(table, external_key, deadline, |observed_cursor| {
1680 if let Some(observed) = observed_cursor {
1681 if observed.fencing_token > cursor.fencing_token {
1682 return Err(ConnectorError::TransactionError(format!(
1683 "Delta fencing token {} is stale; target already records {}",
1684 cursor.fencing_token, observed.fencing_token
1685 )));
1686 }
1687 if observed.checkpoint_id > cursor.checkpoint_id {
1688 return Err(ConnectorError::TransactionError(format!(
1689 "Delta checkpoint cursor would roll back from {} to {}",
1690 observed.checkpoint_id, cursor.checkpoint_id
1691 )));
1692 }
1693 if observed.checkpoint_id == cursor.checkpoint_id {
1694 if observed.fencing_token != cursor.fencing_token {
1695 return Err(ConnectorError::TransactionError(format!(
1696 "Delta checkpoint {} already records fencing token {}; it cannot \
1697 change to {}",
1698 cursor.checkpoint_id, observed.fencing_token, cursor.fencing_token
1699 )));
1700 }
1701 return Ok(PreparedCoordinatedPublication::AlreadyCommitted);
1702 }
1703 }
1704 Ok(PreparedCoordinatedPublication::Commit {
1705 adds: adds.clone(),
1706 binding: binding.clone(),
1707 cursor,
1708 descriptor_count: 1,
1709 })
1710 })
1711 .await?;
1712 Ok(())
1713}
1714
1715#[cfg(feature = "delta-lake")]
1719#[must_use]
1720pub fn get_partition_columns(table: &DeltaTable) -> Vec<String> {
1721 match table.snapshot() {
1722 Ok(snapshot) => snapshot.snapshot().metadata().partition_columns().clone(),
1723 Err(_) => Vec::new(),
1724 }
1725}
1726
1727#[cfg(feature = "delta-lake")]
1741pub fn get_table_schema(table: &DeltaTable) -> Result<SchemaRef, ConnectorError> {
1742 let state = table
1743 .snapshot()
1744 .map_err(|e| ConnectorError::SchemaMismatch(format!("table has no snapshot: {e}")))?;
1745
1746 Ok(state.snapshot().arrow_schema())
1748}
1749
1750#[cfg(feature = "delta-lake")]
1756pub async fn get_latest_version(table: &mut DeltaTable) -> Result<i64, ConnectorError> {
1757 let log_store = table.log_store();
1758 let current = table.version().unwrap_or(0);
1759 log_store
1760 .get_latest_version(current)
1761 .await
1762 .map_err(|e| ConnectorError::ReadError(format!("failed to get latest version: {e}")))
1763}
1764
1765#[cfg(feature = "delta-lake")]
1785pub async fn read_batches_at_version(
1786 table: &mut DeltaTable,
1787 version: i64,
1788 max_records: usize,
1789) -> Result<(Vec<RecordBatch>, bool), ConnectorError> {
1790 use datafusion::prelude::SessionContext;
1791 use tokio_stream::StreamExt;
1792
1793 table
1795 .load_version(version)
1796 .await
1797 .map_err(|e| ConnectorError::ReadError(format!("failed to load version {version}: {e}")))?;
1798
1799 debug!(version, "Delta Lake: loaded version for reading");
1800
1801 let provider =
1803 table.table_provider().build().await.map_err(|e| {
1804 ConnectorError::ReadError(format!("failed to build table provider: {e}"))
1805 })?;
1806
1807 let ctx = SessionContext::new();
1808 ctx.register_table("delta_source_scan", Arc::new(provider))
1809 .map_err(|e| ConnectorError::ReadError(format!("failed to register scan table: {e}")))?;
1810
1811 let df = ctx
1813 .sql("SELECT * FROM delta_source_scan")
1814 .await
1815 .map_err(|e| ConnectorError::ReadError(format!("scan query failed: {e}")))?;
1816
1817 let df = if max_records < usize::MAX {
1818 df.limit(0, Some(max_records))
1819 .map_err(|e| ConnectorError::ReadError(format!("limit failed: {e}")))?
1820 } else {
1821 df
1822 };
1823
1824 let mut stream = df
1826 .execute_stream()
1827 .await
1828 .map_err(|e| ConnectorError::ReadError(format!("stream execution failed: {e}")))?;
1829
1830 let mut batches = Vec::new();
1831 let mut total_rows: usize = 0;
1832
1833 while let Some(result) = stream.next().await {
1834 let batch =
1835 result.map_err(|e| ConnectorError::ReadError(format!("stream batch failed: {e}")))?;
1836 if batch.num_rows() == 0 {
1837 continue;
1838 }
1839 total_rows += batch.num_rows();
1840 batches.push(batch);
1841
1842 if total_rows >= max_records {
1844 break;
1845 }
1846 }
1847
1848 let fully_consumed = if total_rows >= max_records {
1852 stream.next().await.is_none()
1853 } else {
1854 true
1855 };
1856
1857 debug!(
1858 version,
1859 num_batches = batches.len(),
1860 total_rows,
1861 fully_consumed,
1862 "Delta Lake: scanned version"
1863 );
1864
1865 Ok((batches, fully_consumed))
1866}
1867
1868#[cfg(feature = "delta-lake")]
1882#[allow(clippy::too_many_lines)]
1883pub async fn read_version_diff(
1884 table: &mut DeltaTable,
1885 version: i64,
1886 max_records: usize,
1887 partition_filter: Option<&str>,
1888) -> Result<(Vec<RecordBatch>, bool), ConnectorError> {
1889 const MAX_DIRECT_READ_BYTES: u64 = 256 * 1024 * 1024;
1892
1893 if version <= 0 {
1895 return read_batches_at_version(table, version, max_records).await;
1896 }
1897
1898 let log_store = table.log_store();
1901 let store = log_store.object_store(None);
1902
1903 let commit_data = log_store
1904 .read_commit_entry(version)
1905 .await
1906 .map_err(|e| ConnectorError::ReadError(format!("read commit {version}: {e}")))?
1907 .ok_or_else(|| {
1908 ConnectorError::ReadError(format!(
1909 "version {version} not available (cleaned up or never existed)"
1910 ))
1911 })?;
1912 let commit_str = std::str::from_utf8(&commit_data)
1913 .map_err(|e| ConnectorError::ReadError(format!("commit log is not valid UTF-8: {e}")))?;
1914
1915 let mut added_paths = Vec::new();
1918 let mut removed_paths = std::collections::HashSet::new();
1919 for line in commit_str.lines() {
1920 let line = line.trim();
1921 if line.is_empty() {
1922 continue;
1923 }
1924 if let Ok(obj) = serde_json::from_str::<serde_json::Value>(line) {
1925 if let Some(add) = obj.get("add") {
1926 if let Some(path) = add.get("path").and_then(|p| p.as_str()) {
1927 added_paths.push(decode_delta_path(path));
1928 }
1929 }
1930 if let Some(remove) = obj.get("remove") {
1931 if let Some(path) = remove.get("path").and_then(|p| p.as_str()) {
1932 removed_paths.insert(decode_delta_path(path));
1933 }
1934 }
1935 }
1936 }
1937
1938 added_paths.retain(|p| !removed_paths.contains(p));
1940
1941 if added_paths.is_empty() {
1942 debug!(
1943 version,
1944 num_removed = removed_paths.len(),
1945 "Delta Lake: no net-new add actions in version"
1946 );
1947 return Ok((Vec::new(), true));
1948 }
1949
1950 debug!(
1951 version,
1952 num_added_files = added_paths.len(),
1953 num_removed_files = removed_paths.len(),
1954 "Delta Lake: reading added files"
1955 );
1956
1957 table
1959 .load_version(version)
1960 .await
1961 .map_err(|e| ConnectorError::ReadError(format!("failed to load version {version}: {e}")))?;
1962
1963 let table_schema = table
1964 .snapshot()
1965 .map(|s| s.snapshot().arrow_schema())
1966 .map_err(|e| ConnectorError::ReadError(format!("no snapshot at version {version}: {e}")))?;
1967
1968 let added_paths = if let Some(filter) = partition_filter {
1971 filter_paths_by_partition(&added_paths, filter)
1972 } else {
1973 added_paths
1974 };
1975
1976 let mut batches = Vec::new();
1980 let mut total_rows: usize = 0;
1981
1982 for file_path in &added_paths {
1983 if total_rows >= max_records {
1984 break;
1985 }
1986
1987 let obj_path = deltalake::Path::from(file_path.as_str());
1988
1989 let file_meta = store
1992 .head(&obj_path)
1993 .await
1994 .map_err(|e| ConnectorError::ReadError(format!("failed to stat '{file_path}': {e}")))?;
1995 if file_meta.size > MAX_DIRECT_READ_BYTES {
1996 warn!(
1997 file_path,
1998 file_size = file_meta.size,
1999 "file too large for direct read, falling back to DataFusion scan"
2000 );
2001 return read_batches_at_version(table, version, max_records).await;
2002 }
2003
2004 let file_bytes = get_with_retry(&store, &obj_path, file_path).await?;
2005
2006 let parquet_reader =
2007 deltalake::parquet::arrow::arrow_reader::ArrowReaderBuilder::try_new(file_bytes)
2008 .map_err(|e| {
2009 ConnectorError::ReadError(format!(
2010 "failed to open Parquet file '{file_path}': {e}"
2011 ))
2012 })?;
2013
2014 let remaining = max_records.saturating_sub(total_rows).saturating_add(1);
2016 let reader = parquet_reader.with_limit(remaining).build().map_err(|e| {
2017 ConnectorError::ReadError(format!("failed to build reader for '{file_path}': {e}"))
2018 })?;
2019
2020 for result in reader {
2021 let batch: RecordBatch = result.map_err(|e| {
2022 ConnectorError::ReadError(format!("Parquet read error in '{file_path}': {e}"))
2023 })?;
2024 if batch.num_rows() == 0 {
2025 continue;
2026 }
2027
2028 let batch = if batch.schema() == table_schema {
2031 batch
2032 } else {
2033 align_batch_to_schema(&batch, &table_schema)?
2034 };
2035
2036 total_rows += batch.num_rows();
2037 batches.push(batch);
2038
2039 if total_rows >= max_records {
2040 break;
2041 }
2042 }
2043 }
2044
2045 let fully_consumed = total_rows <= max_records;
2048 if !fully_consumed {
2049 let excess = total_rows - max_records;
2051 let len = batches.len();
2052 if len > 0 {
2053 let last = &batches[len - 1];
2054 if last.num_rows() > excess {
2055 batches[len - 1] = last.slice(0, last.num_rows() - excess);
2056 } else {
2057 batches.pop();
2058 }
2059 }
2060 }
2061
2062 debug!(
2063 version,
2064 num_batches = batches.len(),
2065 fully_consumed,
2066 num_added_files = added_paths.len(),
2067 "Delta Lake: read version diff"
2068 );
2069
2070 Ok((batches, fully_consumed))
2071}
2072
2073#[cfg(feature = "delta-lake")]
2076async fn get_with_retry(
2077 store: &Arc<dyn deltalake::ObjectStore>,
2078 path: &deltalake::Path,
2079 display_path: &str,
2080) -> Result<bytes::Bytes, ConnectorError> {
2081 let backoff = [200u64, 1000, 4000];
2082 let mut last_err = None;
2083
2084 for attempt in 0..=backoff.len() {
2085 match store.get(path).await {
2086 Ok(result) => {
2087 return result.bytes().await.map_err(|e| {
2088 ConnectorError::ReadError(format!(
2089 "failed to read bytes of '{display_path}': {e}"
2090 ))
2091 });
2092 }
2093 Err(e) => {
2094 let msg = e.to_string();
2095 if msg.contains("not found") || msg.contains("404") {
2096 return Err(ConnectorError::ReadError(format!(
2097 "file not found '{display_path}': {e}"
2098 )));
2099 }
2100 if let Some(&delay) = backoff.get(attempt) {
2101 warn!(
2102 attempt = attempt + 1,
2103 delay_ms = delay,
2104 error = %e,
2105 path = display_path,
2106 "object_store read failed, retrying"
2107 );
2108 tokio::time::sleep(std::time::Duration::from_millis(delay)).await;
2109 }
2110 last_err = Some(e);
2111 }
2112 }
2113 }
2114
2115 Err(ConnectorError::ReadError(format!(
2116 "failed to read '{display_path}' after {} attempts: {}",
2117 backoff.len() + 1,
2118 last_err.map_or_else(|| "unknown".to_string(), |e| e.to_string())
2119 )))
2120}
2121
2122#[cfg(feature = "delta-lake")]
2129fn filter_paths_by_partition(paths: &[String], filter: &str) -> Vec<String> {
2130 let mut required_segments: Vec<String> = Vec::new();
2132 for clause in filter
2133 .split_whitespace()
2134 .collect::<Vec<_>>()
2135 .join(" ")
2136 .split(" AND ")
2137 {
2138 let clause = clause.trim();
2139 if let Some((col, val)) = clause.split_once('=') {
2140 let col = col.trim();
2141 let val = val.trim().trim_matches('\'').trim_matches('"');
2142 if !col.is_empty() && !val.is_empty() {
2143 required_segments.push(format!("{col}={val}"));
2144 }
2145 }
2146 }
2147
2148 if required_segments.is_empty() {
2149 return paths.to_vec();
2150 }
2151
2152 paths
2153 .iter()
2154 .filter(|path| required_segments.iter().all(|seg| path.contains(seg)))
2155 .cloned()
2156 .collect()
2157}
2158
2159#[cfg(feature = "delta-lake")]
2164fn decode_delta_path(encoded: &str) -> String {
2165 url::Url::parse(&format!("file:///{encoded}")).map_or_else(
2166 |_| encoded.to_string(),
2167 |u| {
2168 let p = u.path();
2169 p.strip_prefix('/').unwrap_or(p).to_string()
2170 },
2171 )
2172}
2173
2174#[cfg(feature = "delta-lake")]
2178fn align_batch_to_schema(
2179 batch: &RecordBatch,
2180 target_schema: &SchemaRef,
2181) -> Result<RecordBatch, ConnectorError> {
2182 use arrow_array::new_null_array;
2183
2184 let mut columns = Vec::with_capacity(target_schema.fields().len());
2185 for field in target_schema.fields() {
2186 if let Ok(col_idx) = batch.schema().index_of(field.name()) {
2187 columns.push(batch.column(col_idx).clone());
2188 } else {
2189 columns.push(new_null_array(field.data_type(), batch.num_rows()));
2190 }
2191 }
2192
2193 RecordBatch::try_new(target_schema.clone(), columns).map_err(|e| {
2194 ConnectorError::ReadError(format!("failed to align batch to table schema: {e}"))
2195 })
2196}
2197
2198#[cfg(feature = "delta-lake")]
2207pub async fn read_cdf_batches(
2208 table: DeltaTable,
2209 start_version: i64,
2210 end_version: i64,
2211) -> Result<Vec<RecordBatch>, ConnectorError> {
2212 use datafusion::prelude::SessionContext;
2213 use tokio_stream::StreamExt;
2214
2215 debug!(start_version, end_version, "reading CDF batches");
2216
2217 let ctx = SessionContext::new();
2218
2219 let session_state = ctx.state();
2221
2222 let cdf_builder = table
2223 .scan_cdf()
2224 .with_starting_version(start_version)
2225 .with_ending_version(end_version);
2226
2227 let plan = cdf_builder
2228 .build(&session_state, None)
2229 .await
2230 .map_err(|e| ConnectorError::ReadError(format!("CDF scan build failed: {e}")))?;
2231
2232 let task_ctx = ctx.task_ctx();
2234 let mut stream = datafusion::physical_plan::execute_stream(plan, task_ctx)
2235 .map_err(|e| ConnectorError::ReadError(format!("CDF stream execution failed: {e}")))?;
2236
2237 let mut batches = Vec::new();
2238 while let Some(result) = stream.next().await {
2239 let batch: RecordBatch = result
2240 .map_err(|e| ConnectorError::ReadError(format!("CDF stream batch failed: {e}")))?;
2241 if batch.num_rows() > 0 {
2242 batches.push(batch);
2243 }
2244 }
2245
2246 debug!(
2247 start_version,
2248 end_version,
2249 num_batches = batches.len(),
2250 "CDF scan complete"
2251 );
2252
2253 Ok(batches)
2254}
2255
2256#[cfg(feature = "delta-lake")]
2264pub fn map_cdf_to_changelog(batch: &RecordBatch) -> Result<Option<RecordBatch>, ConnectorError> {
2265 use arrow_array::StringArray;
2266
2267 let schema = batch.schema();
2268 let Ok(ct_idx) = schema.index_of("_change_type") else {
2269 return Ok(Some(batch.clone()));
2270 };
2271
2272 let change_type = batch
2273 .column(ct_idx)
2274 .as_any()
2275 .downcast_ref::<StringArray>()
2276 .ok_or_else(|| ConnectorError::ReadError("_change_type is not Utf8".into()))?;
2277
2278 let (keep, ops): (Vec<bool>, Vec<Option<&str>>) = (0..batch.num_rows())
2280 .map(|i| match change_type.value(i) {
2281 "update_postimage" => (true, Some("U")),
2282 "delete" => (true, Some("D")),
2283 "update_preimage" => (false, Some("")),
2284 _ => (true, Some("I")), })
2286 .unzip();
2287
2288 let filter = arrow_array::BooleanArray::from(keep);
2289 let filtered = arrow_select::filter::filter_record_batch(batch, &filter)
2290 .map_err(|e| ConnectorError::ReadError(format!("CDF filter failed: {e}")))?;
2291 if filtered.num_rows() == 0 {
2292 return Ok(None);
2293 }
2294
2295 let op_arr: StringArray = ops.into_iter().collect();
2297 let op_filtered = arrow_select::filter::filter(&op_arr, &filter)
2298 .map_err(|e| ConnectorError::ReadError(format!("CDF op filter: {e}")))?;
2299
2300 let cdf_meta = ["_change_type", "_commit_version", "_commit_timestamp"];
2302 let mut fields = Vec::new();
2303 let mut columns: Vec<Arc<dyn arrow_array::Array>> = Vec::new();
2304 for (i, field) in filtered.schema().fields().iter().enumerate() {
2305 if !cdf_meta.contains(&field.name().as_str()) {
2306 fields.push(field.clone());
2307 columns.push(filtered.column(i).clone());
2308 }
2309 }
2310 fields.push(Arc::new(arrow_schema::Field::new(
2311 "_op",
2312 arrow_schema::DataType::Utf8,
2313 false,
2314 )));
2315 columns.push(op_filtered);
2316
2317 RecordBatch::try_new(Arc::new(arrow_schema::Schema::new(fields)), columns)
2318 .map(Some)
2319 .map_err(|e| ConnectorError::ReadError(format!("CDF batch rebuild: {e}")))
2320}
2321
2322#[cfg(feature = "delta-lake")]
2324#[derive(Debug)]
2325pub struct MergeResult {
2326 pub rows_inserted: usize,
2328 pub rows_updated: usize,
2330 pub rows_deleted: usize,
2332}
2333
2334#[cfg(feature = "delta-lake")]
2347#[allow(clippy::too_many_lines)]
2348pub(crate) async fn merge_changelog(
2349 table: DeltaTable,
2350 source_batch: RecordBatch,
2351 key_columns: &[String],
2352 schema_evolution: bool,
2353 writer_properties: Option<deltalake::parquet::file::properties::WriterProperties>,
2354 ctx: &datafusion::prelude::SessionContext,
2355) -> Result<(DeltaTable, MergeResult), DeltaWriteAttemptError> {
2356 use datafusion::prelude::*;
2357 use deltalake::kernel::transaction::CommitProperties;
2358
2359 const CDC_COLUMNS: &[&str] = &["_op", "_ts_ms"];
2360
2361 if source_batch.num_rows() == 0 {
2362 return Ok((
2363 table,
2364 MergeResult {
2365 rows_inserted: 0,
2366 rows_updated: 0,
2367 rows_deleted: 0,
2368 },
2369 ));
2370 }
2371
2372 debug!(
2373 key_columns = ?key_columns,
2374 source_rows = source_batch.num_rows(),
2375 "performing atomic changelog MERGE"
2376 );
2377
2378 let source_df = ctx.read_batch(source_batch).map_err(|e| {
2379 ConnectorError::WriteError(format!("failed to create source DataFrame: {e}"))
2380 })?;
2381
2382 let predicate = key_columns
2384 .iter()
2385 .map(|k| col(format!("target.{k}")).eq(col(format!("source.{k}"))))
2386 .reduce(Expr::and)
2387 .ok_or_else(|| {
2388 ConnectorError::ConfigurationError("merge requires at least one key column".into())
2389 })?;
2390
2391 let source_schema = source_df.schema().clone();
2392 let key_set: std::collections::HashSet<&str> = key_columns.iter().map(String::as_str).collect();
2393
2394 let all_user_columns: Vec<String> = source_schema
2396 .fields()
2397 .iter()
2398 .map(|f| f.name().clone())
2399 .filter(|name| !CDC_COLUMNS.contains(&name.as_str()))
2400 .collect();
2401
2402 let non_key_user_columns: Vec<String> = all_user_columns
2403 .iter()
2404 .filter(|c| !key_set.contains(c.as_str()))
2405 .cloned()
2406 .collect();
2407
2408 let upsert_pred = col("source._op").in_list(vec![lit("I"), lit("U"), lit("r")], false);
2410 let delete_pred = col("source._op").eq(lit("D"));
2411
2412 let non_key_for_update = non_key_user_columns;
2413 let all_for_insert = all_user_columns;
2414
2415 let mut merge_builder = table
2416 .merge(source_df, predicate)
2417 .with_source_alias("source")
2418 .with_target_alias("target")
2419 .with_commit_properties(CommitProperties::default())
2420 .when_matched_update(|update| {
2421 let mut u = update.predicate(upsert_pred.clone());
2422 for col_name in &non_key_for_update {
2423 u = u.update(col_name.as_str(), col(format!("source.{col_name}")));
2424 }
2425 u
2426 })
2427 .map_err(|e| ConnectorError::WriteError(format!("merge matched-update failed: {e}")))?
2428 .when_matched_delete(|delete| delete.predicate(delete_pred))
2429 .map_err(|e| ConnectorError::WriteError(format!("merge matched-delete failed: {e}")))?
2430 .when_not_matched_insert(|insert| {
2431 let mut ins = insert.predicate(upsert_pred);
2432 for col_name in &all_for_insert {
2433 ins = ins.set(col_name.as_str(), col(format!("source.{col_name}")));
2434 }
2435 ins
2436 })
2437 .map_err(|e| ConnectorError::WriteError(format!("merge not-matched-insert failed: {e}")))?;
2438
2439 if schema_evolution {
2440 merge_builder = merge_builder.with_merge_schema(true);
2441 }
2442
2443 if let Some(props) = writer_properties {
2444 merge_builder = merge_builder.with_writer_properties(props);
2445 }
2446
2447 let (table, metrics) = merge_builder.await.map_err(DeltaWriteAttemptError::Delta)?;
2448
2449 let result = MergeResult {
2450 rows_inserted: metrics.num_target_rows_inserted,
2451 rows_updated: metrics.num_target_rows_updated,
2452 rows_deleted: metrics.num_target_rows_deleted,
2453 };
2454
2455 info!(
2456 rows_inserted = result.rows_inserted,
2457 rows_updated = result.rows_updated,
2458 rows_deleted = result.rows_deleted,
2459 "Delta Lake changelog MERGE complete"
2460 );
2461
2462 Ok((table, result))
2463}
2464
2465#[cfg(feature = "delta-lake")]
2475#[allow(clippy::implicit_hasher, clippy::unused_async)]
2476pub async fn resolve_catalog_options(
2477 catalog: &super::delta_config::DeltaCatalogType,
2478 #[allow(unused_variables)] catalog_database: Option<&str>,
2479 #[allow(unused_variables)] catalog_name: Option<&str>,
2480 _catalog_schema: Option<&str>,
2481 table_path: &str,
2482 base_storage_options: &HashMap<String, String>,
2483) -> Result<(String, HashMap<String, String>), ConnectorError> {
2484 use super::delta_config::DeltaCatalogType;
2485
2486 match catalog {
2487 DeltaCatalogType::None => Ok((table_path.to_string(), base_storage_options.clone())),
2488 #[cfg(feature = "delta-lake-glue")]
2489 DeltaCatalogType::Glue => {
2490 use deltalake::DataCatalog;
2491 let database = catalog_database.ok_or_else(|| {
2492 ConnectorError::ConfigurationError(
2493 "Glue catalog requires 'catalog.database'".into(),
2494 )
2495 })?;
2496 let glue = deltalake_catalog_glue::GlueDataCatalog::from_env()
2497 .await
2498 .map_err(|e| {
2499 ConnectorError::ConnectionFailed(format!("failed to init Glue catalog: {e}"))
2500 })?;
2501 let resolved = glue
2502 .get_table_storage_location(catalog_name.map(String::from), database, table_path)
2503 .await
2504 .map_err(|e| {
2505 ConnectorError::ConnectionFailed(format!(
2506 "Glue catalog lookup failed for '{database}.{table_path}': {e}"
2507 ))
2508 })?;
2509 info!(
2510 glue_database = database,
2511 table = table_path,
2512 resolved_path = %resolved,
2513 "resolved table path via Glue catalog"
2514 );
2515 Ok((resolved, base_storage_options.clone()))
2516 }
2517 #[cfg(not(feature = "delta-lake-glue"))]
2518 DeltaCatalogType::Glue => Err(ConnectorError::ConfigurationError(
2519 "Glue catalog requires the 'delta-lake-glue' feature. \
2520 Build with: cargo build --features delta-lake-glue"
2521 .into(),
2522 )),
2523 #[cfg(feature = "delta-lake-unity")]
2524 DeltaCatalogType::Unity {
2525 workspace_url,
2526 access_token,
2527 } => {
2528 let full_name = table_path.strip_prefix("uc://").unwrap_or(table_path);
2534
2535 let storage_location = super::unity_catalog::get_table_storage_location(
2536 workspace_url,
2537 access_token,
2538 full_name,
2539 )
2540 .await?;
2541
2542 Ok((storage_location, base_storage_options.clone()))
2543 }
2544 #[cfg(not(feature = "delta-lake-unity"))]
2545 DeltaCatalogType::Unity { .. } => Err(ConnectorError::ConfigurationError(
2546 "Unity catalog requires the 'delta-lake-unity' feature. \
2547 Build with: cargo build --features delta-lake-unity"
2548 .into(),
2549 )),
2550 }
2551}
2552
2553#[cfg(all(test, feature = "delta-lake"))]
2558mod tests;