Skip to main content

laminar_connectors/lakehouse/
delta.rs

1//! Delta Lake sink connector implementation.
2//!
3//! [`DeltaLakeSink`] implements [`SinkConnector`], writing Arrow `RecordBatch`
4//! data to Delta Lake tables with ACID transactions and at-least-once delivery
5//! (exactly-once is selected once at the pipeline/runtime boundary).
6//!
7//! # Write Strategies
8//!
9//! - **Append mode**: Arrow-to-Parquet zero-copy writes for immutable streams
10//! - **Overwrite mode**: Replace partition contents for recomputation
11//! - **Upsert mode**: CDC MERGE via Z-set changelog integration
12//!
13//! Exactly-once append writes prepare immutable Parquet descriptors during the
14//! checkpoint and publish them with one namespaced, designated Delta commit.
15//! At-least-once append, overwrite, and upsert writes commit through `flush()`.
16//!
17//! # Ring Architecture
18//!
19//! - **Ring 0**: No sink code. Data arrives via SPSC channel (~5ns push).
20//! - **Ring 1**: Batch buffering, Parquet writes, Delta log commits.
21//! - **Ring 2**: Schema management, configuration, health checks.
22
23#[cfg(feature = "delta-lake")]
24use std::future::Future;
25use std::sync::Arc;
26use std::time::{Duration, Instant};
27
28use arrow_array::{Array, RecordBatch};
29use arrow_schema::SchemaRef;
30use async_trait::async_trait;
31use tracing::{debug, info, warn};
32
33#[cfg(feature = "delta-lake")]
34use deltalake::DeltaTable;
35
36#[cfg(feature = "delta-lake")]
37use deltalake::protocol::SaveMode;
38
39use crate::config::{ConnectorConfig, ConnectorState};
40#[cfg(feature = "delta-lake")]
41use crate::connector::{ConnectorTaskGuard, MAX_COORDINATED_COMMIT_PAYLOAD_BYTES};
42use crate::connector::{
43    ConnectorTaskOwner, ConnectorTaskTracker, SinkConnector, SinkConsistency, SinkContract,
44    SinkInputMode, SinkTopology, WriteResult,
45};
46use crate::error::ConnectorError;
47
48use super::delta_config::{DeltaLakeSinkConfig, DeltaWriteMode};
49use super::delta_metrics::DeltaLakeSinkMetrics;
50use crate::connector::DeliveryGuarantee;
51
52#[cfg(feature = "delta-lake")]
53async fn run_tracked_delta_task<F>(guard: ConnectorTaskGuard, task: F) -> F::Output
54where
55    F: Future,
56{
57    let _guard = guard;
58    task.await
59}
60
61#[cfg(feature = "delta-lake")]
62fn classify_delta_attempt_error(error: super::delta_io::DeltaWriteAttemptError) -> ConnectorError {
63    use super::delta_io::DeltaWriteAttemptError;
64
65    if error.is_definite_optimistic_conflict() {
66        return ConnectorError::WriteError(format!(
67            "Delta optimistic commit collision did not publish: {error}"
68        ));
69    }
70
71    match error {
72        DeltaWriteAttemptError::Local(error) => error,
73        DeltaWriteAttemptError::Delta(error) => {
74            // A storage failure may make progress in a fresh generation, but
75            // it still cannot prove whether the catalog accepted the commit.
76            // Structural/protocol errors are terminal and must not be turned
77            // into retries merely because their message says "conflict".
78            let retryable = super::delta_io::delta_error_has_retryable_transport(&error);
79            ConnectorError::outcome_unknown(
80                format!(
81                    "Delta write was dispatched but its catalog commit outcome is not known: {error}"
82                ),
83                retryable,
84            )
85        }
86    }
87}
88
89#[cfg(feature = "delta-lake")]
90struct DeltaWriteTaskSuccess {
91    table: DeltaTable,
92    merge_result: Option<super::delta_io::MergeResult>,
93}
94
95/// Counts `(upserts, deletes)` in a collapsed changelog batch's `_op` column.
96/// A row is a delete iff `_op == "D"`; everything else (including a missing or
97/// null op) counts as an upsert. Used only for collapse observability.
98#[cfg(feature = "delta-lake")]
99fn count_collapsed_ops(batch: &RecordBatch) -> (u64, u64) {
100    let Ok(idx) = batch.schema().index_of("_op") else {
101        return (0, 0);
102    };
103    let Some(ops) = batch
104        .column(idx)
105        .as_any()
106        .downcast_ref::<arrow_array::StringArray>()
107    else {
108        return (0, 0);
109    };
110    let deletes = (0..ops.len())
111        .filter(|&i| !ops.is_null(i) && ops.value(i) == "D")
112        .count() as u64;
113    let upserts = ops.len() as u64 - deletes;
114    (upserts, deletes)
115}
116
117#[cfg(feature = "delta-lake")]
118#[derive(Debug, Clone, PartialEq, Eq)]
119struct UnresolvedDeltaPublication {
120    external_key: String,
121    target: crate::connector::CoordinatedCommitCursor,
122    exact_batch_fingerprint: [u8; 32],
123}
124
125#[cfg(feature = "delta-lake")]
126impl UnresolvedDeltaPublication {
127    fn reconciled_by(&self, observed: Option<crate::connector::CoordinatedCommitCursor>) -> bool {
128        observed == Some(self.target)
129    }
130}
131
132#[cfg(feature = "delta-lake")]
133async fn publish_coordinated_delta_batch(
134    table_path: String,
135    storage_options: std::collections::HashMap<String, String>,
136    unresolved: Arc<parking_lot::Mutex<Option<UnresolvedDeltaPublication>>>,
137    pending: UnresolvedDeltaPublication,
138    batch: crate::connector::CoordinatedCommitBatch,
139    deadline: tokio::time::Instant,
140    publication_budget: Duration,
141) -> Result<(), ConnectorError> {
142    super::delta_io::validate_coordinated_storage_preflight(&table_path, &storage_options)?;
143    let storage_options = super::delta_io::bound_coordinated_storage_options(storage_options);
144    let result = async {
145        let table = tokio::time::timeout_at(
146            deadline,
147            super::delta_io::open_or_create_table(&table_path, storage_options, None),
148        )
149        .await
150        .map_err(|_| {
151            ConnectorError::TransactionError(
152                "Delta coordinated table open exceeded the publication deadline".into(),
153            )
154        })??;
155        let descriptor_count =
156            super::delta_io::commit_batch_coordinated(&table, &batch, deadline).await?;
157        info!(
158            epoch = batch.target.epoch,
159            checkpoint_id = batch.target.checkpoint_id,
160            descriptors = descriptor_count,
161            "delta coordinated commit"
162        );
163        Ok(())
164    }
165    .await;
166
167    if result.is_ok() && tokio::time::Instant::now() < deadline {
168        let mut unresolved = unresolved.lock();
169        if unresolved.as_ref() == Some(&pending) {
170            *unresolved = None;
171        }
172        return Ok(());
173    }
174    if result.is_ok() {
175        return Err(ConnectorError::outcome_unknown(
176            format!(
177                "Delta coordinated publication exceeded its {publication_budget:?} remaining \
178                 budget; reconcile the exact cursor before replay"
179            ),
180            true,
181        ));
182    }
183    result
184}
185
186/// Delta Lake sink connector.
187///
188/// Writes Arrow `RecordBatch` to Delta Lake tables with ACID transactions,
189/// at-least-once delivery (exactly-once opt-in), partitioning, and
190/// externally managed table maintenance.
191///
192/// # Exactly-Once Semantics
193///
194/// `pre_commit()` materializes immutable Parquet files and returns their Delta
195/// `Add` actions. The runtime durably seals the checkpoint before one
196/// designated committer calls `commit_aggregated()` with every participant's
197/// descriptor. `rollback_epoch()` discards in-memory state; unreferenced files
198/// are reclaimed later by retention-safe vacuum.
199pub struct DeltaLakeSink {
200    /// Sole admission authority for Delta tasks that may outlive a cancelled caller.
201    #[cfg(feature = "delta-lake")]
202    task_owner: ConnectorTaskOwner,
203    /// Stable terminal observer retained by the runtime after this sink is retired.
204    task_tracker: ConnectorTaskTracker,
205    /// Sink configuration.
206    config: DeltaLakeSinkConfig,
207    /// Arrow schema for input batches (set on first write or from existing table).
208    schema: Option<SchemaRef>,
209    /// Connector lifecycle state.
210    state: ConnectorState,
211    /// Current epoch being written.
212    current_epoch: u64,
213    /// `RecordBatch` buffer for the current epoch.
214    buffer: Vec<RecordBatch>,
215    /// Total rows buffered in current epoch.
216    buffered_rows: usize,
217    /// Total bytes buffered (estimated) in current epoch.
218    buffered_bytes: u64,
219    /// Current Delta Lake table version.
220    delta_version: u64,
221    /// Time when the current buffer started accumulating.
222    buffer_start_time: Option<Instant>,
223    /// Sink metrics.
224    metrics: DeltaLakeSinkMetrics,
225    /// Delta Lake table handle (present when `delta-lake` feature is enabled).
226    #[cfg(feature = "delta-lake")]
227    table: Option<DeltaTable>,
228    /// Staged batches ready for either coordinated descriptor creation or an
229    /// at-least-once flush. This separation lets `rollback_epoch()` discard
230    /// prepared in-memory data without publishing it to the Delta log.
231    staged_batches: Vec<RecordBatch>,
232    /// Rows staged for commit (mirrors `staged_batches`).
233    staged_rows: usize,
234    /// Estimated bytes staged for commit.
235    staged_bytes: u64,
236    /// Uncommitted `Add` actions for immutable Parquet files materialized in
237    /// the current coordinated epoch.
238    #[cfg(feature = "delta-lake")]
239    coordinated_adds: Vec<deltalake::kernel::Add>,
240    /// Canonical table incarnation and write-metadata fingerprint captured
241    /// from the exact immutable snapshot used to create `coordinated_adds`.
242    #[cfg(feature = "delta-lake")]
243    coordinated_binding: Option<super::commit_descriptor::DeltaTableBinding>,
244    /// Exact encoded descriptor size for `coordinated_adds`, or zero when the
245    /// vector is empty.
246    #[cfg(feature = "delta-lake")]
247    coordinated_descriptor_bytes: usize,
248    /// Exact publication that must be retried or observed at its target before
249    /// this instance stages a later cut. Absence cannot resolve a timed-out
250    /// remote catalog mutation because the server may still complete it.
251    #[cfg(feature = "delta-lake")]
252    coordinated_unresolved_publication: Arc<parking_lot::Mutex<Option<UnresolvedDeltaPublication>>>,
253    /// Resolved table path after catalog lookup (may differ from `config.table_path`
254    /// when using Unity/Glue catalogs). Used by `reopen_table()` so retries
255    /// target the same resolved path that `open()` connected to.
256    #[cfg(feature = "delta-lake")]
257    resolved_table_path: String,
258    /// Resolved storage options after catalog lookup.
259    #[cfg(feature = "delta-lake")]
260    resolved_storage_options: std::collections::HashMap<String, String>,
261    /// When true, Delta table init is deferred until the first `write_batch()`
262    /// provides a schema. This happens when Unity Catalog auto-create is
263    /// configured but the pipeline schema is not yet available at `open()` time.
264    #[cfg(feature = "delta-lake")]
265    needs_deferred_delta_init: bool,
266    /// Pre-built Parquet writer properties for hot-path writes. Built once
267    /// in `init_delta_table()` from `config.parquet`; cloning this is far
268    /// cheaper than rebuilding (string parsing, bloom-filter column setup)
269    /// from scratch on every commit.
270    #[cfg(feature = "delta-lake")]
271    cached_writer_properties: Option<deltalake::parquet::file::properties::WriterProperties>,
272    /// Shared `DataFusion` session for upsert/merge operations. Creating a
273    /// fresh `SessionContext` per merge allocated a runtime env, memory
274    /// pool, and object-store registry each commit; reusing one flattens
275    /// allocator churn under steady-state upsert load.
276    #[cfg(feature = "delta-lake")]
277    merge_session: Option<datafusion::prelude::SessionContext>,
278    /// A dispatched Delta operation lost its result, so this generation cannot
279    /// safely admit another write even after its detached task terminates.
280    #[cfg(feature = "delta-lake")]
281    unresolved_delta_write: bool,
282    /// Test hook that makes coordinated descriptor preparation remain pending.
283    /// This is compiled out of production builds.
284    #[cfg(all(test, feature = "delta-lake"))]
285    stall_descriptor_write: bool,
286}
287
288impl DeltaLakeSink {
289    /// Creates a new Delta Lake sink with the given configuration.
290    #[must_use]
291    pub fn new(config: DeltaLakeSinkConfig, registry: Option<&prometheus::Registry>) -> Self {
292        let (task_owner, task_tracker) = ConnectorTaskOwner::new();
293        #[cfg(not(feature = "delta-lake"))]
294        let _ = task_owner;
295        Self {
296            #[cfg(feature = "delta-lake")]
297            task_owner,
298            task_tracker,
299            config,
300            schema: None,
301            state: ConnectorState::Created,
302            current_epoch: 0,
303            buffer: Vec::with_capacity(16),
304            buffered_rows: 0,
305            buffered_bytes: 0,
306            delta_version: 0,
307            buffer_start_time: None,
308            metrics: DeltaLakeSinkMetrics::new(registry),
309            staged_batches: Vec::new(),
310            staged_rows: 0,
311            staged_bytes: 0,
312            #[cfg(feature = "delta-lake")]
313            coordinated_adds: Vec::new(),
314            #[cfg(feature = "delta-lake")]
315            coordinated_binding: None,
316            #[cfg(feature = "delta-lake")]
317            coordinated_descriptor_bytes: 0,
318            #[cfg(feature = "delta-lake")]
319            coordinated_unresolved_publication: Arc::new(parking_lot::Mutex::new(None)),
320            #[cfg(feature = "delta-lake")]
321            table: None,
322            #[cfg(feature = "delta-lake")]
323            resolved_table_path: String::new(),
324            #[cfg(feature = "delta-lake")]
325            resolved_storage_options: std::collections::HashMap::new(),
326            #[cfg(feature = "delta-lake")]
327            needs_deferred_delta_init: false,
328            #[cfg(feature = "delta-lake")]
329            cached_writer_properties: None,
330            #[cfg(feature = "delta-lake")]
331            merge_session: None,
332            #[cfg(feature = "delta-lake")]
333            unresolved_delta_write: false,
334            #[cfg(all(test, feature = "delta-lake"))]
335            stall_descriptor_write: false,
336        }
337    }
338
339    /// Creates a new Delta Lake sink with an explicit schema.
340    ///
341    /// In upsert mode the changelog metadata columns (`_op`, `_ts_ms`,
342    /// `__weight`) are stripped, matching the deferred first-write path, so the
343    /// target table holds only user data regardless of how the schema arrives.
344    #[must_use]
345    pub fn with_schema(config: DeltaLakeSinkConfig, schema: SchemaRef) -> Self {
346        let write_mode = config.write_mode;
347        let mut sink = Self::new(config, None);
348        sink.schema = Some(if write_mode == DeltaWriteMode::Upsert {
349            Self::target_schema(&schema, write_mode)
350        } else {
351            schema
352        });
353        sink
354    }
355
356    /// Initializes the Delta table: auto-creates in Unity Catalog if needed,
357    /// resolves the catalog path, and opens or creates the Delta table. Called
358    /// from `open()` or deferred to the first
359    /// `write_batch()` when the schema is not yet available at open time.
360    #[cfg(feature = "delta-lake")]
361    async fn init_delta_table(
362        &mut self,
363        deadline: tokio::time::Instant,
364    ) -> Result<(), ConnectorError> {
365        use super::delta_io;
366
367        // For uc:// tables, pre-create in Unity Catalog if needed.
368        // Must run before resolve_catalog_options which calls GET on the table.
369        #[cfg(feature = "delta-lake-unity")]
370        tokio::time::timeout_at(
371            deadline,
372            ensure_uc_table_exists(&self.config, self.schema.as_ref()),
373        )
374        .await
375        .map_err(|_| {
376            ConnectorError::ConnectionFailed(
377                "Delta Unity table initialization exceeded the write deadline".into(),
378            )
379        })??;
380
381        // Resolve catalog path: for Unity this calls GET to get the
382        // storage_location, bypassing delta-rs credential vending.
383        let (resolved_path, mut merged_options) = tokio::time::timeout_at(
384            deadline,
385            delta_io::resolve_catalog_options(
386                &self.config.catalog_type,
387                self.config.catalog_database.as_deref(),
388                self.config.catalog_name.as_deref(),
389                self.config.catalog_schema.as_deref(),
390                &self.config.table_path,
391                &self.config.storage_options,
392            ),
393        )
394        .await
395        .map_err(|_| {
396            ConnectorError::ConnectionFailed(
397                "Delta catalog resolution exceeded the write deadline".into(),
398            )
399        })??;
400
401        if self.config.delivery_guarantee == DeliveryGuarantee::ExactlyOnce {
402            delta_io::validate_coordinated_storage_preflight(&resolved_path, &merged_options)?;
403        }
404
405        // Inject default connection timeouts if not explicitly set.
406        // Azure load balancers close idle connections after ~4 minutes.
407        // Without these, a stale connection causes writes to hang forever.
408        merged_options
409            .entry("timeout".to_string())
410            .or_insert_with(|| "120s".to_string());
411        merged_options
412            .entry("connect_timeout".to_string())
413            .or_insert_with(|| "30s".to_string());
414        merged_options
415            .entry("pool_idle_timeout".to_string())
416            .or_insert_with(|| "60s".to_string());
417
418        // Persist resolved values for reopen_table() on conflict retry.
419        self.resolved_table_path.clone_from(&resolved_path);
420        self.resolved_storage_options.clone_from(&merged_options);
421
422        let table = tokio::time::timeout_at(
423            deadline,
424            delta_io::open_or_create_table(
425                &resolved_path,
426                merged_options.clone(),
427                self.schema.as_ref(),
428            ),
429        )
430        .await
431        .map_err(|_| {
432            ConnectorError::ConnectionFailed(format!(
433                "Delta table initialization exceeded the {:?} write deadline",
434                self.config.write_timeout
435            ))
436        })??;
437
438        // Read schema from existing table if we don't have one.
439        if self.schema.is_none() {
440            if let Ok(schema) = delta_io::get_table_schema(&table) {
441                self.schema = Some(schema);
442            }
443        }
444
445        self.delta_version = table
446            .version()
447            .and_then(|version| u64::try_from(version).ok())
448            .unwrap_or(0);
449        self.table = Some(table);
450
451        // Pre-build caches used on every commit. Rebuilding WriterProperties
452        // from config strings per commit was pure churn (HashMap<ColumnPath>
453        // cloned for bloom filters, string lowercase allocs); a cached value
454        // clones cheaply. Similarly, a shared SessionContext avoids
455        // allocating a new RuntimeEnv + MemoryPool + ObjectStoreRegistry
456        // per merge — a significant source of allocator fragmentation on
457        // long-running upsert streams.
458        self.cached_writer_properties = self.config.parquet.to_writer_properties().ok();
459        if self.config.write_mode == DeltaWriteMode::Upsert {
460            self.merge_session = Some(datafusion::prelude::SessionContext::new());
461        }
462
463        Ok(())
464    }
465
466    /// Returns the current connector state.
467    #[must_use]
468    pub fn state(&self) -> ConnectorState {
469        self.state
470    }
471
472    /// Returns the current epoch.
473    #[must_use]
474    pub fn current_epoch(&self) -> u64 {
475        self.current_epoch
476    }
477
478    /// Returns the number of buffered rows pending flush.
479    #[must_use]
480    pub fn buffered_rows(&self) -> usize {
481        self.buffered_rows
482    }
483
484    /// Returns the estimated buffered bytes.
485    #[must_use]
486    pub fn buffered_bytes(&self) -> u64 {
487        self.buffered_bytes
488    }
489
490    /// Returns the current Delta Lake table version.
491    #[must_use]
492    pub fn delta_version(&self) -> u64 {
493        self.delta_version
494    }
495
496    /// Returns a reference to the sink metrics.
497    #[must_use]
498    pub fn sink_metrics(&self) -> &DeltaLakeSinkMetrics {
499        &self.metrics
500    }
501
502    /// Returns the sink configuration.
503    #[must_use]
504    pub fn config(&self) -> &DeltaLakeSinkConfig {
505        &self.config
506    }
507
508    /// Checks if a buffer flush is needed based on size or time thresholds.
509    #[must_use]
510    pub fn should_flush(&self) -> bool {
511        if self.buffered_rows >= self.config.max_buffer_records {
512            return true;
513        }
514        if self.buffered_bytes >= self.config.target_file_size as u64 {
515            return true;
516        }
517        if let Some(start) = self.buffer_start_time {
518            if start.elapsed() >= self.config.max_buffer_duration {
519                return true;
520            }
521        }
522        false
523    }
524
525    /// Changelog metadata columns stripped from the target Delta table schema
526    /// in upsert mode, so the table holds only user data — not the CDC `_op`/
527    /// `_ts_ms` envelope or the Z-set `__weight` column. `collapse_changelog`
528    /// strips the same columns from the MERGE source, keeping the two in sync.
529    const CHANGELOG_METADATA_COLUMNS: &'static [&'static str] =
530        &["_op", "_ts_ms", laminar_core::changelog::WEIGHT_COLUMN];
531
532    fn target_schema(batch_schema: &SchemaRef, write_mode: DeltaWriteMode) -> SchemaRef {
533        if write_mode == DeltaWriteMode::Upsert {
534            let fields: Vec<_> = batch_schema
535                .fields()
536                .iter()
537                .filter(|f| !Self::CHANGELOG_METADATA_COLUMNS.contains(&f.name().as_str()))
538                .cloned()
539                .collect();
540            Arc::new(arrow_schema::Schema::new(fields))
541        } else {
542            batch_schema.clone()
543        }
544    }
545
546    /// Estimates the byte size of a `RecordBatch`.
547    #[must_use]
548    pub fn estimate_batch_size(batch: &RecordBatch) -> u64 {
549        batch
550            .columns()
551            .iter()
552            .map(|col| col.get_array_memory_size() as u64)
553            .sum()
554    }
555
556    #[cfg(feature = "delta-lake")]
557    fn operation_deadline(&self) -> tokio::time::Instant {
558        tokio::time::Instant::now() + self.config.write_timeout
559    }
560
561    #[cfg(feature = "delta-lake")]
562    fn ensure_write_generation_usable(&self) -> Result<(), ConnectorError> {
563        if self.unresolved_delta_write {
564            return Err(ConnectorError::InvalidState {
565                expected: "a fresh Delta sink generation after reconciliation".into(),
566                actual: "a prior dispatched Delta operation lost its result".into(),
567            });
568        }
569        Ok(())
570    }
571
572    #[cfg(feature = "delta-lake")]
573    fn spawn_tracked_delta_task<F>(
574        &self,
575        task: F,
576    ) -> Result<tokio::task::JoinHandle<F::Output>, ConnectorError>
577    where
578        F: Future + Send + 'static,
579        F::Output: Send + 'static,
580    {
581        let guard = self.task_owner.track().ok_or(ConnectorError::Closed)?;
582        Ok(tokio::spawn(run_tracked_delta_task(guard, task)))
583    }
584
585    /// Re-opens the Delta Lake table after a failed write consumes the handle.
586    /// Uses the resolved path/options from `open()`, not `config.*`, so that
587    /// catalog-resolved paths (Unity/Glue) are used correctly.
588    #[cfg(feature = "delta-lake")]
589    async fn reopen_table(&mut self) -> Result<(), ConnectorError> {
590        use super::delta_io;
591
592        let table = delta_io::open_or_create_table(
593            &self.resolved_table_path,
594            self.resolved_storage_options.clone(),
595            self.schema.as_ref(),
596        )
597        .await?;
598
599        self.delta_version = table
600            .version()
601            .and_then(|version| u64::try_from(version).ok())
602            .unwrap_or(0);
603        self.table = Some(table);
604        Ok(())
605    }
606
607    /// Attempts a single Delta write/merge and returns the updated table on success.
608    #[cfg(feature = "delta-lake")]
609    async fn attempt_delta_write(
610        table: DeltaTable,
611        batches: Vec<RecordBatch>,
612        write_mode: DeltaWriteMode,
613        merge_key_columns: Vec<String>,
614        partition_columns: Vec<String>,
615        schema_evolution: bool,
616        target_file_size: usize,
617        writer_properties: Option<deltalake::parquet::file::properties::WriterProperties>,
618        merge_session: Option<datafusion::prelude::SessionContext>,
619    ) -> Result<DeltaWriteTaskSuccess, super::delta_io::DeltaWriteAttemptError> {
620        if write_mode == DeltaWriteMode::Upsert {
621            // ── Upsert/Merge path ──
622            // flush_staged_to_delta pre-concats for upsert so retries don't
623            // pay a full O(rows × cols) copy each attempt. Handle len > 1
624            // defensively in case a future caller skips the pre-concat.
625            let combined = if batches.len() == 1 {
626                batches.into_iter().next().expect("len == 1 checked")
627            } else {
628                match arrow_select::concat::concat_batches(&batches[0].schema(), &batches) {
629                    Ok(c) => c,
630                    Err(e) => {
631                        return Err(ConnectorError::Internal(format!(
632                            "failed to concat batches: {e}"
633                        ))
634                        .into());
635                    }
636                }
637            };
638
639            let merge_session = merge_session
640                .as_ref()
641                .expect("merge_session built in init_delta_table for Upsert mode");
642
643            super::delta_io::merge_changelog(
644                table,
645                combined,
646                &merge_key_columns,
647                schema_evolution,
648                writer_properties,
649                merge_session,
650            )
651            .await
652            .map(|(table, merge_result)| DeltaWriteTaskSuccess {
653                table,
654                merge_result: Some(merge_result),
655            })
656        } else {
657            // ── Append/Overwrite path ──
658            let save_mode = match write_mode {
659                DeltaWriteMode::Append => SaveMode::Append,
660                DeltaWriteMode::Overwrite => SaveMode::Overwrite,
661                DeltaWriteMode::Upsert => unreachable!("handled by the upsert branch above"),
662            };
663
664            let partition_cols = if partition_columns.is_empty() {
665                None
666            } else {
667                Some(partition_columns.as_slice())
668            };
669
670            super::delta_io::write_batches(
671                table,
672                batches,
673                save_mode,
674                partition_cols,
675                schema_evolution,
676                Some(target_file_size),
677                writer_properties,
678            )
679            .await
680            .map(|(table, _version)| DeltaWriteTaskSuccess {
681                table,
682                merge_result: None,
683            })
684        }
685    }
686
687    /// Writes all staged data to Delta Lake as a single atomic transaction.
688    ///
689    /// Append + exactly-once decomposes into distributed write + one commit.
690    fn is_coordinated(&self) -> bool {
691        self.config.write_mode == DeltaWriteMode::Append
692            && self.config.delivery_guarantee == DeliveryGuarantee::ExactlyOnce
693    }
694
695    #[cfg(feature = "delta-lake")]
696    fn ensure_coordinated_reconciled(&self) -> Result<(), ConnectorError> {
697        if self.is_coordinated() && self.coordinated_unresolved_publication.lock().is_some() {
698            return Err(ConnectorError::TransactionError(
699                "Delta coordinated publication outcome is unresolved; reconcile or retry the exact cut before processing later work"
700                    .into(),
701            ));
702        }
703        Ok(())
704    }
705
706    /// Write staged batches to uniquely named Parquet files without making
707    /// them visible in the Delta log.
708    #[cfg(feature = "delta-lake")]
709    async fn materialize_staged_adds(
710        table: DeltaTable,
711        batches: Vec<RecordBatch>,
712        writer_properties: Option<deltalake::parquet::file::properties::WriterProperties>,
713        #[cfg(test)] stall: bool,
714    ) -> Result<Vec<deltalake::kernel::Add>, ConnectorError> {
715        use deltalake::writer::{DeltaWriter, RecordBatchWriter};
716
717        #[cfg(test)]
718        if stall {
719            std::future::pending::<()>().await;
720        }
721
722        let mut writer = RecordBatchWriter::for_table(&table)
723            .map_err(|e| ConnectorError::WriteError(format!("delta writer: {e}")))?;
724        // Mirror the non-coordinated path: honor configured Parquet properties
725        // (compression, row-group size, bloom filters) instead of the writer's
726        // hard-coded Snappy default.
727        if let Some(props) = writer_properties {
728            writer = writer.with_writer_properties(props);
729        }
730        for batch in batches {
731            writer
732                .write(batch)
733                .await
734                .map_err(|e| ConnectorError::WriteError(format!("delta write: {e}")))?;
735        }
736        let adds = writer
737            .flush()
738            .await
739            .map_err(|e| ConnectorError::WriteError(format!("delta flush: {e}")))?;
740        Ok(adds)
741    }
742
743    /// Materialize the current exact-mode staging buffer and retain only its
744    /// bounded Delta metadata. The Parquet objects remain invisible until
745    /// `commit_aggregated` publishes these Adds with the checkpoint cursor.
746    #[cfg(feature = "delta-lake")]
747    async fn materialize_coordinated_staged(
748        &mut self,
749        deadline: tokio::time::Instant,
750    ) -> Result<(), ConnectorError> {
751        if self.staged_batches.is_empty() {
752            return Ok(());
753        }
754        self.ensure_write_generation_usable()?;
755
756        let binding =
757            super::delta_io::coordinated_table_binding(self.table.as_ref().ok_or_else(|| {
758                ConnectorError::InvalidState {
759                    expected: "open".into(),
760                    actual: "delta table not loaded".into(),
761                }
762            })?)?;
763        if self
764            .coordinated_binding
765            .as_ref()
766            .is_some_and(|existing| existing != &binding)
767        {
768            return Err(ConnectorError::TransactionError(
769                "Delta table binding changed while materializing one coordinated checkpoint cut"
770                    .into(),
771            ));
772        }
773
774        let table = self
775            .table
776            .clone()
777            .ok_or_else(|| ConnectorError::InvalidState {
778                expected: "open".into(),
779                actual: "delta table not loaded".into(),
780            })?;
781        let batches = self.staged_batches.clone();
782        let writer_properties = self.cached_writer_properties.clone();
783        #[cfg(test)]
784        let stall = self.stall_descriptor_write;
785        let task = self.spawn_tracked_delta_task(Self::materialize_staged_adds(
786            table,
787            batches,
788            writer_properties,
789            #[cfg(test)]
790            stall,
791        ))?;
792        let adds = match tokio::time::timeout_at(deadline, task).await {
793            Ok(Ok(result)) => result?,
794            Ok(Err(error)) => {
795                self.unresolved_delta_write = true;
796                return Err(ConnectorError::outcome_unknown(
797                    format!("Delta descriptor writer task terminated unexpectedly: {error}"),
798                    true,
799                ));
800            }
801            Err(_) => {
802                self.unresolved_delta_write = true;
803                return Err(ConnectorError::outcome_unknown(
804                    format!(
805                        "Delta descriptor materialization timed out at its {:?} end-to-end deadline",
806                        self.config.write_timeout
807                    ),
808                    true,
809                ));
810            }
811        };
812        if adds.is_empty() {
813            self.staged_batches.clear();
814            self.staged_rows = 0;
815            self.staged_bytes = 0;
816            return Ok(());
817        }
818
819        let projected_add_count = self
820            .coordinated_adds
821            .len()
822            .checked_add(adds.len())
823            .ok_or_else(|| {
824                ConnectorError::WriteError("Delta coordinated Add count overflow".into())
825            })?;
826        if projected_add_count > super::delta_io::MAX_COORDINATED_ADD_ACTIONS {
827            return Err(ConnectorError::WriteError(format!(
828                "Delta coordinated checkpoint would exceed the fixed {} Add-action limit",
829                super::delta_io::MAX_COORDINATED_ADD_ACTIONS
830            )));
831        }
832
833        // Account only for the new Add array. Once the binding is encoded, appending another
834        // non-empty chunk replaces no envelope fields and inserts one comma between array items.
835        let chunk_add_array_bytes = super::delta_io::encoded_add_array_len(&adds)?;
836        let projected_descriptor_bytes = if self.coordinated_adds.is_empty() {
837            super::delta_io::encode_commit_descriptor(&binding, &adds)?.len()
838        } else {
839            self.coordinated_descriptor_bytes
840                .checked_add(chunk_add_array_bytes)
841                .and_then(|bytes| bytes.checked_sub(2))
842                .and_then(|bytes| bytes.checked_add(1))
843                .ok_or_else(|| {
844                    ConnectorError::WriteError("Delta coordinated descriptor size overflow".into())
845                })?
846        };
847        if projected_descriptor_bytes > MAX_COORDINATED_COMMIT_PAYLOAD_BYTES {
848            return Err(ConnectorError::WriteError(format!(
849                "Delta coordinated descriptor would exceed the fixed {MAX_COORDINATED_COMMIT_PAYLOAD_BYTES} byte control-plane \
850                 limit; the checkpoint cut produced too many files or partition values",
851            )));
852        }
853
854        self.coordinated_binding = Some(binding);
855        self.coordinated_adds.extend(adds);
856        self.coordinated_descriptor_bytes = projected_descriptor_bytes;
857        self.staged_batches.clear();
858        self.staged_rows = 0;
859        self.staged_bytes = 0;
860        Ok(())
861    }
862
863    /// Flush any prior retry buffer first, then move the current in-memory
864    /// exact-mode buffer into invisible Parquet staging.
865    #[cfg(feature = "delta-lake")]
866    async fn stage_coordinated_buffer(
867        &mut self,
868        deadline: tokio::time::Instant,
869    ) -> Result<(), ConnectorError> {
870        self.materialize_coordinated_staged(deadline).await?;
871        if self.buffer.is_empty() {
872            return Ok(());
873        }
874
875        self.staged_batches = std::mem::take(&mut self.buffer);
876        self.staged_rows = self.buffered_rows;
877        self.staged_bytes = self.buffered_bytes;
878        self.buffered_rows = 0;
879        self.buffered_bytes = 0;
880        self.buffer_start_time = None;
881        self.materialize_coordinated_staged(deadline).await
882    }
883
884    /// Writes staged data under one end-to-end deadline. delta-rs owns the
885    /// optimistic commit retry loop; this layer must not amplify that budget.
886    #[cfg(feature = "delta-lake")]
887    #[allow(clippy::too_many_lines)]
888    async fn flush_staged_to_delta(
889        &mut self,
890        deadline: tokio::time::Instant,
891    ) -> Result<WriteResult, ConnectorError> {
892        if self.staged_batches.is_empty() {
893            return Ok(WriteResult::new(0, 0));
894        }
895        self.ensure_write_generation_usable()?;
896        let write_timeout = self.config.write_timeout;
897
898        // For upsert, concat the whole epoch and collapse the changelog to one
899        // row per merge key BEFORE the MERGE. This (a) makes the MERGE
900        // cardinality-safe — delta-rs rejects multiple source rows matching one
901        // target row, which every aggregate retract+insert would otherwise
902        // trigger — and (b) strips the Z-set `__weight` column the MERGE does
903        // not understand. Pre-concatenating also means conflict retries don't
904        // redo the O(rows × cols) copy each attempt. Append/overwrite passes
905        // the Vec straight to delta-rs, which handles multi-batch internally.
906        if self.config.write_mode == DeltaWriteMode::Upsert {
907            let combined = if self.staged_batches.len() == 1 {
908                self.staged_batches[0].clone()
909            } else {
910                let schema = self.staged_batches[0].schema();
911                arrow_select::concat::concat_batches(&schema, &self.staged_batches).map_err(
912                    |e| ConnectorError::Internal(format!("failed to concat staged batches: {e}")),
913                )?
914            };
915            let rows_in = combined.num_rows() as u64;
916            let collapse_start = Instant::now();
917            let collapsed =
918                crate::changelog::collapse_changelog(&combined, &self.config.merge_key_columns)?;
919            let (upserts_out, deletes_out) = count_collapsed_ops(&collapsed);
920            self.metrics.observe_collapse(
921                rows_in,
922                upserts_out,
923                deletes_out,
924                collapse_start.elapsed().as_secs_f64(),
925            );
926            self.staged_batches.clear();
927            self.staged_batches.push(collapsed);
928        }
929
930        let total_rows = self.staged_rows;
931        let estimated_bytes = self.staged_bytes;
932        let flush_start = Instant::now();
933
934        // A failed/expired delta-rs write consumes the table handle. A later
935        // flush reopens it, but reopening and the new commit share this one
936        // operation deadline.
937        if self.table.is_none() {
938            tokio::time::timeout_at(deadline, self.reopen_table())
939                .await
940                .map_err(|_| {
941                    ConnectorError::ConnectionFailed(format!(
942                        "Delta table reopen exceeded the {write_timeout:?} write deadline"
943                    ))
944                })??;
945        }
946
947        let table = self
948            .table
949            .take()
950            .ok_or_else(|| ConnectorError::InvalidState {
951                expected: "table initialized".into(),
952                actual: "table not initialized".into(),
953            })?;
954
955        let write_task = self.spawn_tracked_delta_task(Self::attempt_delta_write(
956            table,
957            self.staged_batches.clone(),
958            self.config.write_mode,
959            self.config.merge_key_columns.clone(),
960            self.config.partition_columns.clone(),
961            self.config.schema_evolution,
962            self.config.target_file_size,
963            self.cached_writer_properties.clone(),
964            self.merge_session.clone(),
965        ))?;
966        let write = match tokio::time::timeout_at(deadline, write_task).await {
967            Ok(Ok(Ok(write))) => write,
968            Ok(Ok(Err(error))) => {
969                self.metrics
970                    .observe_flush_duration(flush_start.elapsed().as_secs_f64());
971                let error = classify_delta_attempt_error(error);
972                if error.is_outcome_unknown() {
973                    self.unresolved_delta_write = true;
974                }
975                return Err(error);
976            }
977            Ok(Err(error)) => {
978                self.unresolved_delta_write = true;
979                self.metrics
980                    .observe_flush_duration(flush_start.elapsed().as_secs_f64());
981                return Err(ConnectorError::outcome_unknown(
982                    format!("Delta writer task terminated unexpectedly: {error}"),
983                    true,
984                ));
985            }
986            Err(_) => {
987                self.unresolved_delta_write = true;
988                self.metrics
989                    .observe_flush_duration(flush_start.elapsed().as_secs_f64());
990                return Err(ConnectorError::outcome_unknown(
991                    format!("Delta write exceeded its {write_timeout:?} end-to-end deadline"),
992                    true,
993                ));
994            }
995        };
996        let table = write.table;
997        if let Some(result) = write.merge_result {
998            self.metrics.record_merge();
999            if result.rows_deleted > 0 {
1000                self.metrics.record_deletes(result.rows_deleted as u64);
1001            }
1002        }
1003
1004        self.delta_version = table
1005            .version()
1006            .and_then(|version| u64::try_from(version).ok())
1007            .unwrap_or(0);
1008        self.table = Some(table);
1009        self.staged_batches.clear();
1010        self.staged_rows = 0;
1011        self.staged_bytes = 0;
1012
1013        self.metrics
1014            .record_flush(total_rows as u64, estimated_bytes);
1015        self.metrics.record_commit(self.delta_version);
1016        self.metrics
1017            .observe_flush_duration(flush_start.elapsed().as_secs_f64());
1018
1019        debug!(
1020            rows = total_rows,
1021            bytes = estimated_bytes,
1022            delta_version = self.delta_version,
1023            "Delta Lake: committed staged data to Delta"
1024        );
1025
1026        Ok(WriteResult::new(total_rows, estimated_bytes))
1027    }
1028
1029    /// Splits a changelog `RecordBatch` into insert and delete batches.
1030    ///
1031    /// Uses the `_op` metadata column:
1032    /// - `"I"` (insert), `"U"` (update-after), `"r"` (snapshot read) -> insert
1033    /// - `"D"` (delete) -> delete
1034    ///
1035    /// The returned batches exclude metadata columns (those starting with `_`).
1036    ///
1037    /// # Errors
1038    ///
1039    /// Returns `ConnectorError::ConfigurationError` if the `_op` column is
1040    /// missing or not a string type.
1041    pub fn split_changelog_batch(
1042        batch: &RecordBatch,
1043    ) -> Result<(RecordBatch, RecordBatch), ConnectorError> {
1044        let op_idx = batch.schema().index_of("_op").map_err(|_| {
1045            ConnectorError::ConfigurationError(
1046                "upsert mode requires '_op' column in input schema".into(),
1047            )
1048        })?;
1049
1050        let op_array = batch
1051            .column(op_idx)
1052            .as_any()
1053            .downcast_ref::<arrow_array::StringArray>()
1054            .ok_or_else(|| {
1055                ConnectorError::ConfigurationError("'_op' column must be String (Utf8) type".into())
1056            })?;
1057
1058        // Build boolean masks for insert vs delete rows.
1059        let len = op_array.len();
1060        let mut insert_mask = Vec::with_capacity(len);
1061        let mut delete_mask = Vec::with_capacity(len);
1062
1063        for i in 0..len {
1064            if op_array.is_null(i) {
1065                insert_mask.push(false);
1066                delete_mask.push(false);
1067                continue;
1068            }
1069            match op_array.value(i) {
1070                "I" | "U" | "r" => {
1071                    insert_mask.push(true);
1072                    delete_mask.push(false);
1073                }
1074                "D" => {
1075                    insert_mask.push(false);
1076                    delete_mask.push(true);
1077                }
1078                _ => {
1079                    insert_mask.push(false);
1080                    delete_mask.push(false);
1081                }
1082            }
1083        }
1084
1085        // Compute user-column projection indices once (strip metadata columns).
1086        let user_col_indices: Vec<usize> = batch
1087            .schema()
1088            .fields()
1089            .iter()
1090            .enumerate()
1091            .filter(|(_, f)| !f.name().starts_with('_'))
1092            .map(|(i, _)| i)
1093            .collect();
1094
1095        let insert_batch = filter_and_project(batch, insert_mask, &user_col_indices)?;
1096        let delete_batch = filter_and_project(batch, delete_mask, &user_col_indices)?;
1097
1098        Ok((insert_batch, delete_batch))
1099    }
1100}
1101
1102/// Pre-creates a Unity Catalog external Delta table via the REST API if
1103/// `catalog.storage.location` is configured and a schema is available.
1104/// Idempotent: treats "already exists" (HTTP 409 / `ALREADY_EXISTS`) as success.
1105#[cfg(all(feature = "delta-lake", feature = "delta-lake-unity"))]
1106async fn ensure_uc_table_exists(
1107    config: &DeltaLakeSinkConfig,
1108    schema: Option<&SchemaRef>,
1109) -> Result<(), ConnectorError> {
1110    let super::delta_config::DeltaCatalogType::Unity {
1111        ref workspace_url,
1112        ref access_token,
1113    } = config.catalog_type
1114    else {
1115        return Ok(());
1116    };
1117
1118    let Some(ref storage_location) = config.catalog_storage_location else {
1119        return Ok(());
1120    };
1121
1122    let Some(arrow_schema) = schema else {
1123        warn!(
1124            "catalog.storage.location is set but no schema available — \
1125             skipping Unity Catalog auto-create"
1126        );
1127        return Ok(());
1128    };
1129
1130    let catalog = config.catalog_name.as_deref().unwrap_or_default();
1131    let schema_name = config.catalog_schema.as_deref().unwrap_or_default();
1132    let table_name = config
1133        .table_path
1134        .strip_prefix("uc://")
1135        .and_then(|s| s.rsplit('.').next())
1136        .unwrap_or(&config.table_path);
1137
1138    let columns = super::unity_catalog::arrow_to_uc_columns(arrow_schema);
1139    super::unity_catalog::create_uc_table(
1140        workspace_url,
1141        access_token,
1142        catalog,
1143        schema_name,
1144        table_name,
1145        storage_location,
1146        &columns,
1147    )
1148    .await
1149}
1150
1151#[async_trait]
1152impl SinkConnector for DeltaLakeSink {
1153    fn terminal_task_tracker(&self) -> Option<ConnectorTaskTracker> {
1154        Some(self.task_tracker.clone())
1155    }
1156
1157    fn contract(&self, config: &ConnectorConfig) -> Result<SinkContract, ConnectorError> {
1158        let cfg = if config.properties().is_empty() {
1159            self.config.clone()
1160        } else {
1161            DeltaLakeSinkConfig::from_config(config)?
1162        };
1163        #[cfg(feature = "delta-lake")]
1164        if cfg.delivery_guarantee == DeliveryGuarantee::ExactlyOnce {
1165            super::delta_io::validate_coordinated_storage_preflight(
1166                &cfg.table_path,
1167                &cfg.storage_options,
1168            )?;
1169        }
1170        if cfg.delivery_guarantee == DeliveryGuarantee::ExactlyOnce
1171            && cfg.write_mode != DeltaWriteMode::Append
1172        {
1173            return Err(ConnectorError::ConfigurationError(
1174                "Delta exactly-once requires coordinated append mode".into(),
1175            ));
1176        }
1177        let consistency = if cfg.delivery_guarantee == DeliveryGuarantee::ExactlyOnce
1178            && cfg.write_mode == DeltaWriteMode::Append
1179        {
1180            // Append mode emits durable data-file descriptors for one
1181            // designated catalog commit. Certification still requires an
1182            // exact external namespace/cursor, enforced by runtime admission.
1183            SinkConsistency::CheckpointCommittable
1184        } else {
1185            SinkConsistency::DurableAtLeastOnce
1186        };
1187        let target = cfg.table_path.to_ascii_lowercase();
1188        let shared_target = [
1189            "s3://", "s3a://", "gs://", "gcs://", "az://", "abfs://", "uc://",
1190        ]
1191        .iter()
1192        .any(|scheme| target.starts_with(scheme));
1193        let topology = if cfg.write_mode == DeltaWriteMode::Append && shared_target {
1194            SinkTopology::MultiWriter
1195        } else {
1196            // Node-local files plus overwrite/MERGE lack fenced distributed ownership.
1197            SinkTopology::Singleton
1198        };
1199        let input_mode = if cfg.write_mode == DeltaWriteMode::Upsert {
1200            SinkInputMode::FullChangelog
1201        } else {
1202            SinkInputMode::AppendOnly
1203        };
1204        Ok(SinkContract::new(consistency, topology, input_mode))
1205    }
1206
1207    async fn open(&mut self, config: &ConnectorConfig) -> Result<(), ConnectorError> {
1208        self.state = ConnectorState::Initializing;
1209
1210        // Re-parse config if properties provided.
1211        if !config.properties().is_empty() {
1212            self.config = DeltaLakeSinkConfig::from_config(config)?;
1213        }
1214        #[cfg(feature = "delta-lake")]
1215        if self.config.delivery_guarantee == DeliveryGuarantee::ExactlyOnce {
1216            super::delta_io::validate_coordinated_storage_preflight(
1217                &self.config.table_path,
1218                &self.config.storage_options,
1219            )?;
1220        }
1221        if self.config.delivery_guarantee == DeliveryGuarantee::ExactlyOnce
1222            && !self.is_coordinated()
1223        {
1224            self.state = ConnectorState::Failed;
1225            return Err(ConnectorError::ConfigurationError(
1226                "Delta exactly-once requires coordinated append mode".into(),
1227            ));
1228        }
1229
1230        info!(
1231            table_path = %self.config.table_path,
1232            mode = %self.config.write_mode,
1233            guarantee = %self.config.delivery_guarantee,
1234            "opening Delta Lake sink connector"
1235        );
1236
1237        // When delta-lake feature is enabled, open/create the actual table.
1238        // If Unity Catalog auto-create is configured but no schema is available
1239        // yet, defer initialization to the first write_batch() call.
1240        #[cfg(feature = "delta-lake")]
1241        {
1242            let should_defer = matches!(
1243                self.config.catalog_type,
1244                super::delta_config::DeltaCatalogType::Unity { .. }
1245            ) && self.config.catalog_storage_location.is_some()
1246                && self.schema.is_none();
1247
1248            if should_defer {
1249                info!(
1250                    "Unity Catalog auto-create configured but pipeline schema not yet \
1251                     available — deferring Delta table init to first begin_epoch"
1252                );
1253                self.needs_deferred_delta_init = true;
1254                self.state = ConnectorState::Initializing;
1255                return Ok(());
1256            }
1257
1258            let deadline = self.operation_deadline();
1259            self.init_delta_table(deadline).await?;
1260
1261            // If table still has no version after init (new table, no schema yet),
1262            // defer full creation to the first write_batch() when schema is available.
1263            if self.table.as_ref().is_some_and(|t| t.version().is_none()) && self.schema.is_none() {
1264                self.needs_deferred_delta_init = true;
1265                self.state = ConnectorState::Initializing;
1266                return Ok(());
1267            }
1268        }
1269
1270        #[cfg(not(feature = "delta-lake"))]
1271        {
1272            self.state = ConnectorState::Failed;
1273            return Err(ConnectorError::ConfigurationError(
1274                "Delta Lake sink requires the 'delta-lake' feature to be enabled. \
1275                 Build with: cargo build --features delta-lake"
1276                    .into(),
1277            ));
1278        }
1279
1280        #[cfg(feature = "delta-lake")]
1281        {
1282            self.state = ConnectorState::Running;
1283            info!("Delta Lake sink connector opened successfully");
1284            Ok(())
1285        }
1286    }
1287
1288    async fn write_batch(&mut self, batch: &RecordBatch) -> Result<WriteResult, ConnectorError> {
1289        // Accept both Running and Initializing (deferred init in progress).
1290        if self.state != ConnectorState::Running && self.state != ConnectorState::Initializing {
1291            return Err(ConnectorError::InvalidState {
1292                expected: "Running".into(),
1293                actual: self.state.to_string(),
1294            });
1295        }
1296
1297        #[cfg(feature = "delta-lake")]
1298        {
1299            self.ensure_coordinated_reconciled()?;
1300            self.ensure_write_generation_usable()?;
1301        }
1302        #[cfg(feature = "delta-lake")]
1303        let deadline = self.operation_deadline();
1304
1305        if batch.num_rows() == 0 {
1306            return Ok(WriteResult::new(0, 0));
1307        }
1308
1309        // Handle schema on first write. In upsert mode, strip metadata columns
1310        // (_op, _ts_ms) so the Delta table isn't created with changelog columns.
1311        if self.schema.is_none() {
1312            self.schema = Some(Self::target_schema(&batch.schema(), self.config.write_mode));
1313        }
1314
1315        // Fallback for deferred init: if begin_epoch() couldn't complete
1316        // init (schema was still None), complete it now that the first
1317        // batch provides a schema.
1318        #[cfg(feature = "delta-lake")]
1319        if self.needs_deferred_delta_init {
1320            info!("schema now available from first batch — completing deferred Delta table init");
1321            match self.init_delta_table(deadline).await {
1322                Ok(()) => {
1323                    self.needs_deferred_delta_init = false;
1324                    self.state = ConnectorState::Running;
1325                    info!("Delta Lake sink connector opened successfully (deferred)");
1326                }
1327                Err(e) => {
1328                    self.state = ConnectorState::Failed;
1329                    return Err(e);
1330                }
1331            }
1332        }
1333
1334        let num_rows = batch.num_rows();
1335        let estimated_bytes = Self::estimate_batch_size(batch);
1336
1337        // At-least-once retries retain failed staged data, so cap the combined
1338        // in-memory backlog. Coordinated append instead drains full buffers to
1339        // invisible Parquet files below and bounds only their retained Adds.
1340        if !self.is_coordinated() {
1341            let pending_rows = self.buffered_rows + self.staged_rows + num_rows;
1342            let pending_bytes = self.buffered_bytes + self.staged_bytes + estimated_bytes;
1343            let row_cap = self
1344                .config
1345                .max_buffer_records
1346                .saturating_mul(4)
1347                .max(num_rows);
1348            let byte_cap = (self.config.target_file_size as u64)
1349                .saturating_mul(4)
1350                .max(estimated_bytes);
1351            if pending_rows > row_cap || pending_bytes > byte_cap {
1352                return Err(ConnectorError::WriteError(format!(
1353                    "delta sink buffer full ({pending_rows} rows, \
1354                     {pending_bytes} bytes pending; cap {row_cap} rows, \
1355                     {byte_cap} bytes) — backpressure until next flush/commit"
1356                )));
1357            }
1358        }
1359
1360        // Buffer the batch.
1361        if self.buffer_start_time.is_none() {
1362            self.buffer_start_time = Some(Instant::now());
1363        }
1364        self.buffer.push(batch.clone());
1365        self.buffered_rows += num_rows;
1366        self.buffered_bytes += estimated_bytes;
1367
1368        #[cfg(feature = "delta-lake")]
1369        if self.is_coordinated() && self.should_flush() {
1370            self.stage_coordinated_buffer(deadline).await?;
1371        } else if self.config.delivery_guarantee != DeliveryGuarantee::ExactlyOnce
1372            && self.should_flush()
1373        {
1374            if !self.staged_batches.is_empty() {
1375                self.flush_staged_to_delta(deadline).await?;
1376            }
1377            self.staged_batches = std::mem::take(&mut self.buffer);
1378            self.staged_rows = self.buffered_rows;
1379            self.staged_bytes = self.buffered_bytes;
1380            self.buffered_rows = 0;
1381            self.buffered_bytes = 0;
1382            self.buffer_start_time = None;
1383            self.flush_staged_to_delta(deadline).await?;
1384        }
1385
1386        Ok(WriteResult::new(0, 0))
1387    }
1388
1389    fn schema(&self) -> SchemaRef {
1390        self.schema
1391            .clone()
1392            .unwrap_or_else(|| Arc::new(arrow_schema::Schema::empty()))
1393    }
1394
1395    async fn begin_epoch(&mut self, epoch: u64) -> Result<(), ConnectorError> {
1396        #[cfg(feature = "delta-lake")]
1397        {
1398            self.ensure_coordinated_reconciled()?;
1399            self.ensure_write_generation_usable()?;
1400        }
1401        #[cfg(feature = "delta-lake")]
1402        let deadline = self.operation_deadline();
1403
1404        // Complete deferred Delta table init on the first epoch.
1405        #[cfg(feature = "delta-lake")]
1406        if self.needs_deferred_delta_init {
1407            // Schema may not be available yet on the very first epoch.
1408            // If so, buffer the epoch — the write_batch will provide it.
1409            // But if the pipeline provided a schema via with_schema() or a
1410            // previous epoch's write_batch set it, complete init now.
1411            if self.schema.is_some() {
1412                info!("schema available — completing deferred Delta table init");
1413                match self.init_delta_table(deadline).await {
1414                    Ok(()) => {
1415                        self.needs_deferred_delta_init = false;
1416                        self.state = ConnectorState::Running;
1417                        info!("Delta Lake sink connector opened successfully (deferred)");
1418                    }
1419                    Err(e) => {
1420                        self.state = ConnectorState::Failed;
1421                        return Err(e);
1422                    }
1423                }
1424            }
1425        }
1426
1427        #[cfg(feature = "delta-lake")]
1428        if self.is_coordinated()
1429            && (!self.buffer.is_empty()
1430                || !self.staged_batches.is_empty()
1431                || !self.coordinated_adds.is_empty())
1432        {
1433            return Err(ConnectorError::InvalidState {
1434                expected: "the previous coordinated epoch to be prepared or rolled back".into(),
1435                actual: "unresolved Delta staging data remains".into(),
1436            });
1437        }
1438
1439        self.current_epoch = epoch;
1440        self.buffer.clear();
1441        self.buffered_rows = 0;
1442        self.buffered_bytes = 0;
1443        self.buffer_start_time = None;
1444
1445        debug!(epoch, "Delta Lake: began epoch");
1446        Ok(())
1447    }
1448
1449    async fn pre_commit(&mut self, epoch: u64) -> Result<Option<Vec<u8>>, ConnectorError> {
1450        // Coordinated append may already have materialized several invisible
1451        // Parquet chunks during writes. Flush only the remainder, then return
1452        // one bounded descriptor for the entire checkpoint cut.
1453        #[cfg(feature = "delta-lake")]
1454        if self.is_coordinated() {
1455            self.ensure_coordinated_reconciled()?;
1456            self.ensure_write_generation_usable()?;
1457            let deadline = self.operation_deadline();
1458            self.stage_coordinated_buffer(deadline).await?;
1459            if self.coordinated_adds.is_empty() {
1460                if self.coordinated_binding.is_some() {
1461                    return Err(ConnectorError::Internal(
1462                        "empty Delta coordinated cut retained a table binding".into(),
1463                    ));
1464                }
1465                return Ok(None);
1466            }
1467
1468            let binding = self.coordinated_binding.as_ref().ok_or_else(|| {
1469                ConnectorError::Internal(
1470                    "non-empty Delta coordinated cut has no table binding".into(),
1471                )
1472            })?;
1473            let descriptor =
1474                super::delta_io::encode_commit_descriptor(binding, &self.coordinated_adds)?;
1475            if descriptor.len() != self.coordinated_descriptor_bytes
1476                || descriptor.len() > MAX_COORDINATED_COMMIT_PAYLOAD_BYTES
1477            {
1478                return Err(ConnectorError::Internal(format!(
1479                    "Delta coordinated descriptor accounting mismatch: tracked {}, encoded {}",
1480                    self.coordinated_descriptor_bytes,
1481                    descriptor.len()
1482                )));
1483            }
1484            self.coordinated_adds.clear();
1485            self.coordinated_binding = None;
1486            self.coordinated_descriptor_bytes = 0;
1487            return Ok(Some(descriptor));
1488        }
1489
1490        // Non-coordinated callers retain the existing in-memory phase-one
1491        // behavior; the checkpoint runtime only invokes this path for sinks
1492        // whose contract admits it.
1493        if !self.buffer.is_empty() {
1494            self.staged_batches = std::mem::take(&mut self.buffer);
1495            self.staged_rows = self.buffered_rows;
1496            self.staged_bytes = self.buffered_bytes;
1497            self.buffered_rows = 0;
1498            self.buffered_bytes = 0;
1499            self.buffer_start_time = None;
1500        }
1501
1502        debug!(epoch, "Delta Lake: pre-committed (batches staged)");
1503        Ok(None)
1504    }
1505
1506    async fn rollback_epoch(&mut self, epoch: u64) -> Result<(), ConnectorError> {
1507        // Discard buffered/staged data. A coordinated attempt may already have
1508        // materialized an unreferenced immutable Parquet file; it must be reclaimed only by a
1509        // retention-safe vacuum after ambiguous catalog commits are impossible, never here.
1510        self.buffer.clear();
1511        self.buffered_rows = 0;
1512        self.buffered_bytes = 0;
1513        self.buffer_start_time = None;
1514        self.staged_batches.clear();
1515        self.staged_rows = 0;
1516        self.staged_bytes = 0;
1517        #[cfg(feature = "delta-lake")]
1518        {
1519            self.coordinated_adds.clear();
1520            self.coordinated_binding = None;
1521            self.coordinated_descriptor_bytes = 0;
1522        }
1523
1524        self.metrics.record_rollback();
1525        warn!(epoch, "Delta Lake: rolled back epoch");
1526        Ok(())
1527    }
1528
1529    fn suggested_write_timeout(&self) -> Duration {
1530        self.config.write_timeout
1531    }
1532
1533    async fn flush(&mut self) -> Result<(), ConnectorError> {
1534        #[cfg(feature = "delta-lake")]
1535        {
1536            self.ensure_coordinated_reconciled()?;
1537            self.ensure_write_generation_usable()?;
1538        }
1539        #[cfg(feature = "delta-lake")]
1540        let deadline = self.operation_deadline();
1541
1542        // For at-least-once delivery, flush() is the commit trigger. Write
1543        // directly to Delta without entering the coordinated protocol.
1544        #[cfg(feature = "delta-lake")]
1545        if self.config.delivery_guarantee != DeliveryGuarantee::ExactlyOnce {
1546            // Retry any orphaned staged data from a prior failed flush
1547            // before moving new data in, to prevent silent data loss.
1548            // flush_staged_to_delta handles table == None via reopen_table().
1549            if !self.staged_batches.is_empty() {
1550                self.flush_staged_to_delta(deadline).await?;
1551            }
1552
1553            // Stage new buffered data and flush to Delta.
1554            if !self.buffer.is_empty() {
1555                self.staged_batches = std::mem::take(&mut self.buffer);
1556                self.staged_rows = self.buffered_rows;
1557                self.staged_bytes = self.buffered_bytes;
1558                self.buffered_rows = 0;
1559                self.buffered_bytes = 0;
1560                self.buffer_start_time = None;
1561
1562                self.flush_staged_to_delta(deadline).await?;
1563            }
1564            return Ok(());
1565        }
1566
1567        #[cfg(feature = "delta-lake")]
1568        return self.stage_coordinated_buffer(deadline).await;
1569
1570        #[cfg(not(feature = "delta-lake"))]
1571        Ok(())
1572    }
1573
1574    async fn close(&mut self) -> Result<(), ConnectorError> {
1575        info!("closing Delta Lake sink connector");
1576
1577        // Only at-least-once may land buffered data during close. Exactly-once output without a
1578        // matching durable checkpoint must be discarded and replayed after recovery.
1579        #[cfg(feature = "delta-lake")]
1580        if self.config.delivery_guarantee == DeliveryGuarantee::ExactlyOnce
1581            || self.unresolved_delta_write
1582        {
1583            self.buffer.clear();
1584            self.staged_batches.clear();
1585            self.buffered_rows = 0;
1586            self.buffered_bytes = 0;
1587            self.staged_rows = 0;
1588            self.staged_bytes = 0;
1589            self.coordinated_adds.clear();
1590            self.coordinated_binding = None;
1591            self.coordinated_descriptor_bytes = 0;
1592        } else {
1593            self.flush().await?;
1594        }
1595
1596        // Drop the table handle when closing.
1597        #[cfg(feature = "delta-lake")]
1598        {
1599            self.table = None;
1600        }
1601
1602        self.state = ConnectorState::Closed;
1603
1604        info!(
1605            table_path = %self.config.table_path,
1606            delta_version = self.delta_version,
1607            "Delta Lake sink connector closed"
1608        );
1609
1610        Ok(())
1611    }
1612
1613    #[cfg(feature = "delta-lake")]
1614    fn as_coordinated_committer(&self) -> Option<&dyn crate::connector::CoordinatedCommitter> {
1615        self.is_coordinated()
1616            .then_some(self as &dyn crate::connector::CoordinatedCommitter)
1617    }
1618}
1619
1620#[cfg(feature = "delta-lake")]
1621#[async_trait]
1622impl crate::connector::CoordinatedCommitter for DeltaLakeSink {
1623    async fn commit_aggregated(
1624        &self,
1625        batch: crate::connector::CoordinatedCommitBatch,
1626        context: crate::connector::CoordinatedCommitContext,
1627    ) -> Result<(), ConnectorError> {
1628        let deadline = context.deadline();
1629        let publication_budget = context.remaining();
1630        if publication_budget.is_zero() {
1631            return Err(ConnectorError::TransactionError(
1632                "Delta coordinated publication deadline elapsed before I/O".into(),
1633            ));
1634        }
1635
1636        batch.validate_shape().map_err(|error| {
1637            ConnectorError::TransactionError(format!(
1638                "Delta coordinated batch validation failed: {error}"
1639            ))
1640        })?;
1641        let external_key = batch.namespace.external_key();
1642        let exact_batch_fingerprint = batch.exact_fingerprint();
1643        let target_cursor = crate::connector::CoordinatedCommitCursor {
1644            checkpoint_id: batch.target.checkpoint_id,
1645            fencing_token: batch.fencing_token,
1646        };
1647        let pending = UnresolvedDeltaPublication {
1648            external_key,
1649            target: target_cursor,
1650            exact_batch_fingerprint,
1651        };
1652        {
1653            let mut unresolved = self.coordinated_unresolved_publication.lock();
1654            if unresolved
1655                .as_ref()
1656                .is_some_and(|unresolved| unresolved != &pending)
1657            {
1658                return Err(ConnectorError::TransactionError(
1659                    "Delta has a different unresolved coordinated publication; only that exact cut may be retried"
1660                        .into(),
1661                ));
1662            }
1663            *unresolved = Some(pending.clone());
1664        }
1665
1666        let operation = publish_coordinated_delta_batch(
1667            self.resolved_table_path.clone(),
1668            self.resolved_storage_options.clone(),
1669            Arc::clone(&self.coordinated_unresolved_publication),
1670            pending.clone(),
1671            batch,
1672            deadline,
1673            publication_budget,
1674        );
1675        // Tokio task-local state is intentionally propagated only for the
1676        // deterministic delayed-catalog test hook.
1677        #[cfg(test)]
1678        let operation = {
1679            let delay = super::delta_io::DELAY_COORDINATED_CATALOG_COMMIT
1680                .try_with(Clone::clone)
1681                .ok();
1682            async move {
1683                if let Some(delay) = delay {
1684                    super::delta_io::DELAY_COORDINATED_CATALOG_COMMIT
1685                        .scope(delay, operation)
1686                        .await
1687                } else {
1688                    operation.await
1689                }
1690            }
1691        };
1692        let task = match self.spawn_tracked_delta_task(operation) {
1693            Ok(task) => task,
1694            Err(error) => {
1695                let mut unresolved = self.coordinated_unresolved_publication.lock();
1696                if unresolved.as_ref() == Some(&pending) {
1697                    *unresolved = None;
1698                }
1699                return Err(error);
1700            }
1701        };
1702
1703        match tokio::time::timeout_at(deadline, task).await {
1704            Ok(Ok(result)) => result,
1705            Ok(Err(error)) => Err(ConnectorError::outcome_unknown(
1706                format!("Delta coordinated publication task terminated unexpectedly: {error}"),
1707                true,
1708            )),
1709            Err(_) => Err(ConnectorError::outcome_unknown(
1710                format!(
1711                    "Delta coordinated publication exceeded its {publication_budget:?} remaining \
1712                     budget; reconcile the exact cursor before replay"
1713                ),
1714                true,
1715            )),
1716        }
1717    }
1718
1719    async fn committed_cursor(
1720        &self,
1721        namespace: &crate::connector::CoordinatedCommitNamespace,
1722    ) -> Result<Option<crate::connector::CoordinatedCommitCursor>, ConnectorError> {
1723        let table = super::delta_io::open_or_create_table(
1724            &self.resolved_table_path,
1725            self.resolved_storage_options.clone(),
1726            None,
1727        )
1728        .await?;
1729        let external_key = namespace.external_key();
1730        let observed = super::delta_io::get_coordinated_cursor(&table, &external_key).await?;
1731        let mut unresolved = self.coordinated_unresolved_publication.lock();
1732        if unresolved.as_ref().is_some_and(|pending| {
1733            pending.external_key == external_key && pending.reconciled_by(observed)
1734        }) {
1735            *unresolved = None;
1736        }
1737        Ok(observed)
1738    }
1739}
1740
1741impl std::fmt::Debug for DeltaLakeSink {
1742    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1743        f.debug_struct("DeltaLakeSink")
1744            .field("state", &self.state)
1745            .field("table_path", &self.config.table_path)
1746            .field("mode", &self.config.write_mode)
1747            .field("guarantee", &self.config.delivery_guarantee)
1748            .field("current_epoch", &self.current_epoch)
1749            .field("buffered_rows", &self.buffered_rows)
1750            .field("delta_version", &self.delta_version)
1751            .finish_non_exhaustive()
1752    }
1753}
1754
1755// ── Helper functions ────────────────────────────────────────────────
1756
1757/// Filters a `RecordBatch` using a boolean mask and projects to the given column indices.
1758///
1759/// Takes `mask` by value to hand it straight to `BooleanArray::from` without
1760/// an intermediate `Vec<bool>` copy. Projects before filtering so the SIMD
1761/// kernel only walks user columns, not the dropped metadata columns.
1762fn filter_and_project(
1763    batch: &RecordBatch,
1764    mask: Vec<bool>,
1765    col_indices: &[usize],
1766) -> Result<RecordBatch, ConnectorError> {
1767    use arrow_array::BooleanArray;
1768    use arrow_select::filter::filter_record_batch;
1769
1770    let bool_array = BooleanArray::from(mask);
1771
1772    let projected = batch
1773        .project(col_indices)
1774        .map_err(|e| ConnectorError::Internal(format!("batch projection failed: {e}")))?;
1775
1776    filter_record_batch(&projected, &bool_array)
1777        .map_err(|e| ConnectorError::Internal(format!("arrow filter failed: {e}")))
1778}
1779
1780#[cfg(test)]
1781#[allow(clippy::cast_possible_wrap)]
1782#[allow(clippy::cast_precision_loss)]
1783#[allow(clippy::float_cmp)]
1784mod tests;