Skip to main content

laminar_connectors/lakehouse/
delta_io.rs

1//! Delta Lake I/O integration module.
2//!
3//! This module provides the actual I/O operations for Delta Lake tables via the
4//! `deltalake` crate. All functions are feature-gated behind `delta-lake`.
5//!
6//! # Architecture
7//!
8//! The I/O module is separate from the business logic in [`delta.rs`](super::delta)
9//! to allow:
10//! - Testing business logic without the `deltalake` dependency
11//! - Clean separation of concerns (buffering/epoch management vs. actual writes)
12//! - Easy mocking for unit tests
13//!
14//! Coordinated exactly-once publication uses runtime-owned, stable transaction
15//! namespaces. Ordinary direct writes do not emit writer-local transaction
16//! actions because a process-random identity cannot deduplicate recovery.
17
18#[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// delta_kernel's TryIntoKernel trait is re-exported via deltalake.
37#[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// Coordinated publication uses a separate metadata-client budget. The caller
79// deadline owns preparation; after preparation, one conditional catalog
80// attempt is allowed to finish without cancellation. Bounding both the HTTP
81// and optimistic retry layers makes that terminal fence finite.
82#[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/// Preserves the typed delta-rs commit failure until the sink can classify
370/// publication certainty. Local planning failures have not dispatched a
371/// catalog commit.
372#[cfg(feature = "delta-lake")]
373#[derive(Debug, thiserror::Error)]
374pub(crate) enum DeltaWriteAttemptError {
375    /// Local validation or query construction failed before commit dispatch.
376    #[error(transparent)]
377    Local(#[from] ConnectorError),
378    /// delta-rs entered the write operation and returned its typed failure.
379    #[error(transparent)]
380    Delta(#[from] deltalake::DeltaTableError),
381}
382
383#[cfg(feature = "delta-lake")]
384impl DeltaWriteAttemptError {
385    /// True only when delta-rs proves this attempt lost an optimistic race and
386    /// did not publish a commit.
387    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    // Typed permanent/conditional variants fail closed. A Generic wrapper is
414    // retryable only when its source chain contains a typed transport failure.
415    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/// Converts a path string to a URL.
490#[cfg(feature = "delta-lake")]
491fn path_to_url(path: &str) -> Result<Url, ConnectorError> {
492    // If it already looks like a URL, parse it directly.
493    if path.contains("://") {
494        Url::parse(path)
495            .map_err(|e| ConnectorError::ConfigurationError(format!("invalid URL '{path}': {e}")))
496    } else {
497        // Local path - convert to file URL.
498        // First canonicalize if it exists, otherwise use as-is.
499        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            // For new tables, the path might not exist yet.
506            // Use absolute path if possible.
507            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/// Opens an existing Delta Lake table or creates a new one.
528///
529/// # Arguments
530///
531/// * `table_path` - Path to the Delta Lake table (local, `s3://`, `az://`, `gs://`)
532/// * `storage_options` - Storage credentials and configuration
533/// * `schema` - Optional Arrow schema for table creation (required if table doesn't exist)
534///
535/// # Returns
536///
537/// The opened `DeltaTable` handle.
538///
539/// # Errors
540///
541/// Returns `ConnectorError::ConnectionFailed` if the table cannot be opened or created.
542#[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    // Try to open or initialize the table. `url` and `storage_options` are
554    // not referenced after this call, so move rather than clone; repeated
555    // conflict-recovery opens otherwise copy the complete option map.
556    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    // Check if the table is initialized (has state).
561    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    // Table doesn't exist — create if we have a schema, otherwise defer to first write_batch().
571    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    // Convert Arrow schema to Delta Lake schema using TryIntoKernel.
582    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    // Create the table.
588    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/// Writes batches to a Delta Lake table.
604///
605/// # Arguments
606///
607/// * `table` - The Delta Lake table handle (consumed and returned)
608/// * `batches` - Record batches to write
609/// * `save_mode` - Delta Lake save mode (Append, Overwrite, etc.)
610/// * `partition_columns` - Optional partition column name slice
611/// * `schema_evolution` - If true, auto-merge new columns into the table schema
612///
613/// # Returns
614///
615/// A tuple of (updated table handle, new Delta version).
616///
617/// # Errors
618///
619/// Preserves a typed delta-rs failure when the write operation fails.
620#[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    // Forward target file size to delta-rs so Parquet files match the
651    // user's configured size, not just the internal default.
652    if let Some(size) = target_file_size {
653        write_builder = write_builder.with_target_file_size(size);
654    }
655
656    // Enable schema evolution (additive column merge) if requested.
657    if schema_evolution {
658        write_builder = write_builder.with_schema_mode(SchemaMode::Merge);
659    }
660
661    // Add partition columns if specified.
662    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    // Execute the write.
673    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/// Read the atomic checkpoint/fencing cursor for one coordinated namespace.
709///
710/// Both Delta `txn` actions must be present or absent. A partial pair is
711/// corruption, never a fresh target. Delta transaction versions are treated as
712/// opaque persisted values and checked explicitly rather than assumed to be
713/// monotonic.
714///
715/// # Errors
716/// Returns an error when the table cursor is unreadable, partial, or outside
717/// Laminar's checkpoint/fencing ranges.
718#[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/// Serialize table-bound `Add` actions into one durable descriptor.
892#[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(&current)? 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    // Cursor filtering and the commit base share one freshly updated snapshot.
1418    let mut current = table.clone();
1419    validate_coordinated_log_store(&current)?;
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(&current, 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(&current)?;
1491        if binding != &current_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        &current,
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    // Checkpoint recovery owns optimistic retries. delta-rs receives one
1520    // conditional catalog attempt so a timed-out terminal fence has a bounded
1521    // amount of provider work and cannot amplify descriptor HEAD traffic.
1522    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    // Only PreparedCommit may outlive the caller: it performs conflict checks
1565    // and the atomic log write. Dropping the returned PostCommit deliberately
1566    // keeps snapshot refresh, checkpoints, and log cleanup off this path.
1567    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/// Filter a validated checkpoint batch against one freshly observed cursor,
1606/// then publish the remaining Adds and target cursor from that same snapshot.
1607///
1608/// # Errors
1609/// Returns `ConnectorError::TransactionError` on validation or commit failure.
1610#[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/// Low-level cursor primitive retained only for focused unit tests. Production
1667/// must use `commit_batch_coordinated` so overlap filtering occurs after refresh.
1668#[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/// Returns the table's partition columns, or an empty list if the snapshot is
1716/// unavailable. Best-effort: used for clustering diagnostics, never for
1717/// correctness, so a missing snapshot is not an error.
1718#[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/// Extracts the Arrow schema from a Delta Lake table.
1728///
1729/// # Arguments
1730///
1731/// * `table` - The Delta Lake table handle
1732///
1733/// # Returns
1734///
1735/// The table's Arrow schema.
1736///
1737/// # Errors
1738///
1739/// Returns `ConnectorError::SchemaMismatch` if schema extraction fails.
1740#[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    // Use the pre-computed Arrow schema from the EagerSnapshot.
1747    Ok(state.snapshot().arrow_schema())
1748}
1749
1750/// Returns the latest committed version via the log store.
1751///
1752/// # Errors
1753///
1754/// Returns `ConnectorError::ReadError` on failure.
1755#[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/// Reads record batches from a specific Delta Lake table version.
1766///
1767/// Loads the requested version, applies a `LIMIT` to bound memory usage,
1768/// then streams results via `execute_stream` to avoid materializing the
1769/// entire version in memory.
1770///
1771/// # Arguments
1772///
1773/// * `table` - Mutable reference to the Delta Lake table handle
1774/// * `version` - The table version to read
1775/// * `max_records` - Maximum number of records to return. Pass `usize::MAX`
1776///   to read all records (unbounded).
1777///
1778/// # Errors
1779///
1780/// Returns `ConnectorError::ReadError` if the version cannot be loaded or scanned.
1781///
1782/// Returns `(batches, fully_consumed)` — `fully_consumed` is `false` when
1783/// `max_records` truncated the result and more rows remain.
1784#[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    // Load the specific version.
1794    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    // Build a DeltaTableProvider via the builder and register it with DataFusion.
1802    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    // Apply LIMIT to bound memory: prevents OOM on large versions.
1812    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    // Stream results instead of collect() to avoid materializing everything.
1825    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        // Respect max_records even between DataFusion batches.
1843        if total_rows >= max_records {
1844            break;
1845        }
1846    }
1847
1848    // If we stopped due to max_records, probe whether the stream has more.
1849    // Without this, a version with exactly max_records rows would be
1850    // misclassified as truncated and re-read forever.
1851    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/// Reads only the rows added in a specific Delta Lake version.
1869///
1870/// Parses `_delta_log/{version:020}.json` for `add` actions, then reads
1871/// only those Parquet files via the table's object store. This is
1872/// `O(new_files)` per version, not `O(table_size)`.
1873///
1874/// For version 0, delegates to [`read_batches_at_version`] (full snapshot).
1875///
1876/// # Errors
1877///
1878/// Returns `ConnectorError::ReadError` if the version cannot be loaded or read.
1879///
1880/// Returns `(batches, fully_consumed)` — see [`read_batches_at_version`].
1881#[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    // Maximum file size (256 MB) for direct in-memory Parquet reads.
1890    // Files larger than this fall back to DataFusion's streaming scan.
1891    const MAX_DIRECT_READ_BYTES: u64 = 256 * 1024 * 1024;
1892
1893    // For version 0, read the full snapshot (no previous version to diff).
1894    if version <= 0 {
1895        return read_batches_at_version(table, version, max_records).await;
1896    }
1897
1898    // Read the commit JSON via delta-rs's LogStore API (handles path
1899    // resolution, checkpoints, and retries correctly).
1900    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    // Each line in the commit JSON is a separate action object.
1916    // Collect both add and remove actions to compute the net-new files.
1917    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    // Exclude any added file whose path also appears in a remove action.
1939    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    // Load the version so we have the correct schema.
1958    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    // Filter file paths by partition predicate if provided.
1969    // Supports simple Hive-style equality: "col = 'val'" matches "col=val/" in path.
1970    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    // Read each added Parquet file as raw bytes via delta-rs's object_store,
1977    // then parse with parquet's in-memory ArrowReaderBuilder (avoids the
1978    // object_store 0.12 vs 0.13 version mismatch).
1979    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        // Check file size before downloading. Large files fall back to
1990        // DataFusion scan to avoid OOM on multi-GB Parquet files.
1991        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        // Read one extra row to probe whether the version is fully consumed.
2015        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            // Align the batch schema to the table schema (added files may
2029            // predate schema evolution and have fewer columns).
2030            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    // We probed one extra row per file. If total_rows > max_records, there's
2046    // more data — trim the excess and report not fully consumed.
2047    let fully_consumed = total_rows <= max_records;
2048    if !fully_consumed {
2049        // Trim the last batch to remove the probe row(s).
2050        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/// Reads a file from `object_store` with retry (3x, exponential backoff).
2074/// Does not retry 404s.
2075#[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/// Filters file paths by a Hive-style partition predicate.
2123///
2124/// Supports simple equality predicates: `col = 'val'` matches paths
2125/// containing `col=val/`. Multiple predicates joined by `AND` are all
2126/// required to match. Predicates that can't be parsed are ignored
2127/// (all paths pass through).
2128#[cfg(feature = "delta-lake")]
2129fn filter_paths_by_partition(paths: &[String], filter: &str) -> Vec<String> {
2130    // Parse simple "col = 'val'" or "col = val" predicates from AND-joined expressions.
2131    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/// Percent-decodes a file path from a Delta Lake commit JSON.
2160///
2161/// Delta Lake spec requires paths in `add`/`remove` actions to be
2162/// percent-encoded (e.g., `part%3D1/file.parquet` for `part=1/file.parquet`).
2163#[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/// Aligns a `RecordBatch` to a target schema by adding null columns for
2175/// missing fields. Used when reading Parquet files that predate schema
2176/// evolution (fewer columns than the current table schema).
2177#[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/// Reads CDF batches for a version range via `scan_cdf()`.
2199///
2200/// `scan_cdf(self)` consumes the `DeltaTable` — caller must re-open afterward.
2201/// Output includes `_change_type`, `_commit_version`, `_commit_timestamp`.
2202///
2203/// # Errors
2204///
2205/// Returns `ConnectorError::ReadError` on scan failure.
2206#[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    // Clone session state so the RwLockReadGuard is dropped before await.
2220    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    // Execute the plan via DataFusion to get record batches.
2233    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/// Maps CDF `_change_type` → `_op` (`I`/`U`/`D`), drops `update_preimage`
2257/// rows and CDF metadata columns (`_change_type`, `_commit_version`,
2258/// `_commit_timestamp`). Returns `None` if all rows were preimages.
2259///
2260/// # Errors
2261///
2262/// Returns `ConnectorError::ReadError` on Arrow operation failure.
2263#[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    // Build filter (drop preimage rows) and mapped _op values in one pass.
2279    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")), // insert + unknown → I
2285        })
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    // Build _op column from filtered ops.
2296    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    // Rebuild batch: keep user columns, drop CDF metadata, append _op.
2301    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/// Result of a MERGE (upsert) operation.
2323#[cfg(feature = "delta-lake")]
2324#[derive(Debug)]
2325pub struct MergeResult {
2326    /// Number of rows inserted.
2327    pub rows_inserted: usize,
2328    /// Number of rows updated.
2329    pub rows_updated: usize,
2330    /// Number of rows deleted.
2331    pub rows_deleted: usize,
2332}
2333
2334/// Atomic changelog MERGE: inserts, updates, and deletes in one Delta commit.
2335///
2336/// The source batch must contain an `_op` column (Utf8) with values:
2337/// - `"I"`, `"U"`, `"r"` → upsert (update if matched, insert if not)
2338/// - `"D"` → delete matched rows
2339///
2340/// Columns prefixed with `_` are excluded from SET clauses but remain
2341/// in the source `DataFrame` for predicate filtering.
2342///
2343/// # Errors
2344///
2345/// Returns `ConnectorError::WriteError` if the merge fails.
2346#[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    // Join predicate: target.k1 = source.k1 AND ...
2383    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    // Exclude CDC metadata columns from SET clauses (preserve user columns like _id).
2395    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    // Predicates for conditional clause execution.
2409    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/// Resolves catalog-aware table URI and merges catalog-specific storage options.
2466///
2467/// - `None`: returns table path and storage options as-is.
2468/// - `Glue`: calls AWS Glue API to resolve the table's S3 location.
2469/// - `Unity`: injects workspace URL and access token into storage options.
2470///
2471/// # Errors
2472///
2473/// Returns `ConnectorError` if catalog resolution fails.
2474#[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            // Resolve the table's actual storage location from Unity Catalog
2529            // via REST API, then return that direct path (s3://, az://, gs://)
2530            // instead of the uc:// URI. This bypasses delta-rs's built-in
2531            // uc:// handling which requires credential vending — a feature
2532            // that is denied outside Databricks compute environments.
2533            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// ============================================================================
2554// Integration tests (require delta-lake feature)
2555// ============================================================================
2556
2557#[cfg(all(test, feature = "delta-lake"))]
2558mod tests;