Skip to main content

laminar_connectors/lakehouse/delta/
operations.rs

1//! Stateful table initialization, staging, materialization, and write operations.
2
3#[cfg(all(feature = "delta-lake", feature = "delta-lake-unity"))]
4use super::ensure_uc_table_exists;
5#[cfg(feature = "delta-lake")]
6use super::{
7    classify_delta_attempt_error, count_collapsed_ops, debug, retry_delta_metadata_until,
8    run_tracked_delta_task, DataType, DeltaTable, DeltaWriteTaskSuccess, Future, Instant, SaveMode,
9    WriteResult, MAX_COORDINATED_COMMIT_PAYLOAD_BYTES,
10};
11use super::{
12    filter_and_project, Arc, Array, ConnectorError, ConnectorState, ConnectorTaskOwner,
13    DeliveryGuarantee, DeltaLakeSink, DeltaLakeSinkConfig, DeltaLakeSinkMetrics, DeltaWriteMode,
14    RecordBatch, SchemaRef,
15};
16
17impl DeltaLakeSink {
18    /// Creates a new Delta Lake sink with the given configuration.
19    #[must_use]
20    pub fn new(config: DeltaLakeSinkConfig, registry: Option<&prometheus::Registry>) -> Self {
21        let (task_owner, task_tracker) = ConnectorTaskOwner::new();
22        #[cfg(not(feature = "delta-lake"))]
23        let _ = task_owner;
24        Self {
25            #[cfg(feature = "delta-lake")]
26            task_owner,
27            task_tracker,
28            config,
29            schema: None,
30            state: ConnectorState::Created,
31            current_epoch: 0,
32            buffer: Vec::with_capacity(16),
33            buffered_rows: 0,
34            buffered_bytes: 0,
35            delta_version: 0,
36            buffer_start_time: None,
37            metrics: DeltaLakeSinkMetrics::new(registry),
38            staged_batches: Vec::new(),
39            staged_rows: 0,
40            staged_bytes: 0,
41            #[cfg(feature = "delta-lake")]
42            coordinated_adds: Vec::new(),
43            #[cfg(feature = "delta-lake")]
44            coordinated_binding: None,
45            #[cfg(feature = "delta-lake")]
46            coordinated_descriptor_bytes: 0,
47            #[cfg(feature = "delta-lake")]
48            coordinated_unresolved_publication: Arc::new(parking_lot::Mutex::new(None)),
49            #[cfg(feature = "delta-lake")]
50            table: None,
51            #[cfg(feature = "delta-lake")]
52            resolved_table_path: String::new(),
53            #[cfg(feature = "delta-lake")]
54            resolved_storage_options: std::collections::HashMap::new(),
55            #[cfg(feature = "delta-lake")]
56            needs_deferred_delta_init: false,
57            #[cfg(feature = "delta-lake")]
58            cached_writer_properties: None,
59            #[cfg(feature = "delta-lake")]
60            merge_session: None,
61            #[cfg(feature = "delta-lake")]
62            unresolved_delta_write: false,
63            #[cfg(all(test, feature = "delta-lake"))]
64            stall_descriptor_write: false,
65        }
66    }
67
68    /// Creates a new Delta Lake sink with an explicit schema.
69    ///
70    /// In upsert mode the changelog metadata columns (`_op`, `_ts_ms`,
71    /// `__weight`) are stripped, matching the deferred first-write path, so the
72    /// target table holds only user data regardless of how the schema arrives.
73    #[must_use]
74    pub fn with_schema(config: DeltaLakeSinkConfig, schema: SchemaRef) -> Self {
75        let write_mode = config.write_mode;
76        let mut sink = Self::new(config, None);
77        sink.schema = Some(if write_mode == DeltaWriteMode::Upsert {
78            Self::target_schema(&schema, write_mode)
79        } else {
80            schema
81        });
82        sink
83    }
84
85    /// Initializes the Delta table: auto-creates in Unity Catalog if needed,
86    /// resolves the catalog path, and opens or creates the Delta table. Called
87    /// from `open()` or deferred to the first
88    /// `write_batch()` when the schema is not yet available at open time.
89    #[cfg(feature = "delta-lake")]
90    pub(super) async fn init_delta_table(
91        &mut self,
92        deadline: tokio::time::Instant,
93    ) -> Result<(), ConnectorError> {
94        use super::super::delta_io;
95
96        if let Some(schema) = self.schema.as_ref() {
97            Self::validate_weight_column(schema, "pipeline")?;
98        }
99
100        // For uc:// tables, pre-create in Unity Catalog if needed.
101        // Must run before resolve_catalog_options which calls GET on the table.
102        #[cfg(feature = "delta-lake-unity")]
103        tokio::time::timeout_at(
104            deadline,
105            ensure_uc_table_exists(&self.config, self.schema.as_ref()),
106        )
107        .await
108        .map_err(|_| {
109            ConnectorError::ConnectionFailed(
110                "Delta Unity table initialization exceeded the write deadline".into(),
111            )
112        })??;
113
114        // Resolve catalog path: for Unity this calls GET to get the
115        // storage_location, bypassing delta-rs credential vending.
116        let (resolved_path, mut merged_options) = tokio::time::timeout_at(
117            deadline,
118            delta_io::resolve_catalog_options(
119                &self.config.catalog_type,
120                self.config.catalog_database.as_deref(),
121                self.config.catalog_name.as_deref(),
122                self.config.catalog_schema.as_deref(),
123                &self.config.table_path,
124                &self.config.storage_options,
125            ),
126        )
127        .await
128        .map_err(|_| {
129            ConnectorError::ConnectionFailed(
130                "Delta catalog resolution exceeded the write deadline".into(),
131            )
132        })??;
133
134        if self.config.delivery_guarantee == DeliveryGuarantee::ExactlyOnce {
135            delta_io::validate_coordinated_storage_preflight(&resolved_path, &merged_options)?;
136        }
137
138        // Inject default connection timeouts if not explicitly set.
139        // Azure load balancers close idle connections after ~4 minutes.
140        // Without these, a stale connection causes writes to hang forever.
141        merged_options
142            .entry("timeout".to_string())
143            .or_insert_with(|| "120s".to_string());
144        merged_options
145            .entry("connect_timeout".to_string())
146            .or_insert_with(|| "30s".to_string());
147        merged_options
148            .entry("pool_idle_timeout".to_string())
149            .or_insert_with(|| "60s".to_string());
150
151        // Persist resolved values for reopen_table() on conflict retry.
152        self.resolved_table_path.clone_from(&resolved_path);
153        self.resolved_storage_options.clone_from(&merged_options);
154
155        // RECOVERY: retry only the read-only open. Table creation is a mutation and remains a
156        // single dispatch so an uncertain create outcome is never duplicated by this layer.
157        let mut table = retry_delta_metadata_until(deadline, "table open", || {
158            delta_io::open_or_create_table(&resolved_path, merged_options.clone(), None)
159        })
160        .await?;
161        if table.version().is_none() && self.schema.is_some() {
162            table = tokio::time::timeout_at(
163                deadline,
164                delta_io::open_or_create_table(
165                    &resolved_path,
166                    merged_options.clone(),
167                    self.schema.as_ref(),
168                ),
169            )
170            .await
171            .map_err(|_| {
172                ConnectorError::ConnectionFailed(format!(
173                    "Delta table creation exceeded the {:?} write deadline",
174                    self.config.write_timeout
175                ))
176            })??;
177        }
178
179        if table.version().is_some() {
180            let table_schema = delta_io::get_table_schema(&table)?;
181            if let Some(pipeline_schema) = self.schema.as_ref() {
182                Self::validate_existing_table_schema(
183                    pipeline_schema,
184                    &table_schema,
185                    self.config.schema_evolution,
186                    self.is_coordinated(),
187                )?;
188            } else {
189                Self::validate_weight_column(&table_schema, "Delta table")?;
190                self.schema = Some(table_schema);
191            }
192        }
193
194        self.delta_version = table.version().unwrap_or(0);
195        self.table = Some(table);
196
197        // Pre-build caches used on every commit. Rebuilding WriterProperties
198        // from config strings per commit was pure churn (HashMap<ColumnPath>
199        // cloned for bloom filters, string lowercase allocs); a cached value
200        // clones cheaply. Similarly, a shared SessionContext avoids
201        // allocating a new RuntimeEnv + MemoryPool + ObjectStoreRegistry
202        // per merge — a significant source of allocator fragmentation on
203        // long-running upsert streams.
204        self.cached_writer_properties = self.config.parquet.to_writer_properties().ok();
205        if self.config.write_mode == DeltaWriteMode::Upsert {
206            self.merge_session = Some(datafusion::prelude::SessionContext::new());
207        }
208
209        Ok(())
210    }
211
212    /// Returns the current connector state.
213    #[must_use]
214    pub fn state(&self) -> ConnectorState {
215        self.state
216    }
217
218    /// Returns the current epoch.
219    #[must_use]
220    pub fn current_epoch(&self) -> u64 {
221        self.current_epoch
222    }
223
224    /// Returns the number of buffered rows pending flush.
225    #[must_use]
226    pub fn buffered_rows(&self) -> usize {
227        self.buffered_rows
228    }
229
230    /// Returns the estimated buffered bytes.
231    #[must_use]
232    pub fn buffered_bytes(&self) -> u64 {
233        self.buffered_bytes
234    }
235
236    /// Returns the current Delta Lake table version.
237    #[must_use]
238    pub fn delta_version(&self) -> u64 {
239        self.delta_version
240    }
241
242    /// Returns a reference to the sink metrics.
243    #[must_use]
244    pub fn sink_metrics(&self) -> &DeltaLakeSinkMetrics {
245        &self.metrics
246    }
247
248    /// Returns the sink configuration.
249    #[must_use]
250    pub fn config(&self) -> &DeltaLakeSinkConfig {
251        &self.config
252    }
253
254    /// Checks if a buffer flush is needed based on size or time thresholds.
255    #[must_use]
256    pub fn should_flush(&self) -> bool {
257        if self.buffered_rows >= self.config.max_buffer_records {
258            return true;
259        }
260        if self.buffered_bytes >= self.config.target_file_size as u64 {
261            return true;
262        }
263        if let Some(start) = self.buffer_start_time {
264            if start.elapsed() >= self.config.max_buffer_duration {
265                return true;
266            }
267        }
268        false
269    }
270
271    /// Changelog metadata columns stripped from the target Delta table schema
272    /// in upsert mode, so the table holds only user data — not the CDC `_op`/
273    /// `_ts_ms` envelope or the Z-set `__weight` column. `collapse_changelog`
274    /// strips the same columns from the MERGE source, keeping the two in sync.
275    const CHANGELOG_METADATA_COLUMNS: &'static [&'static str] =
276        &["_op", "_ts_ms", laminar_core::changelog::WEIGHT_COLUMN];
277
278    pub(super) fn target_schema(batch_schema: &SchemaRef, write_mode: DeltaWriteMode) -> SchemaRef {
279        if write_mode == DeltaWriteMode::Upsert {
280            let fields: Vec<_> = batch_schema
281                .fields()
282                .iter()
283                .filter(|f| !Self::CHANGELOG_METADATA_COLUMNS.contains(&f.name().as_str()))
284                .cloned()
285                .collect();
286            Arc::new(arrow_schema::Schema::new(fields))
287        } else {
288            batch_schema.clone()
289        }
290    }
291
292    #[cfg(feature = "delta-lake")]
293    fn validate_weight_column(schema: &SchemaRef, owner: &str) -> Result<(), ConnectorError> {
294        let Ok(field) = schema.field_with_name(laminar_core::changelog::WEIGHT_COLUMN) else {
295            return Ok(());
296        };
297        if field.data_type() != &DataType::Int64 || field.is_nullable() {
298            return Err(ConnectorError::SchemaMismatch(format!(
299                "{owner} '{}' column must be non-null Int64",
300                laminar_core::changelog::WEIGHT_COLUMN
301            )));
302        }
303        Ok(())
304    }
305
306    #[cfg(feature = "delta-lake")]
307    fn validate_existing_table_schema(
308        pipeline: &SchemaRef,
309        table: &SchemaRef,
310        schema_evolution: bool,
311        exact_schema_required: bool,
312    ) -> Result<(), ConnectorError> {
313        Self::validate_weight_column(pipeline, "pipeline")?;
314        Self::validate_weight_column(table, "Delta table")?;
315
316        let weight = laminar_core::changelog::WEIGHT_COLUMN;
317        if pipeline.field_with_name(weight).is_ok() != table.field_with_name(weight).is_ok() {
318            return Err(ConnectorError::SchemaMismatch(format!(
319                "pipeline and existing Delta table must both contain '{weight}' for a weighted changelog"
320            )));
321        }
322
323        // INVARIANT: identity is checked in Delta storage form (ms -> us).
324        let pipeline = super::super::delta_io::widen_millisecond_timestamps(pipeline);
325
326        if exact_schema_required {
327            if pipeline.as_ref() != table.as_ref() {
328                return Err(ConnectorError::SchemaMismatch(
329                    "pipeline schema must exactly match the existing Delta table for coordinated append"
330                        .into(),
331                ));
332            }
333            return Ok(());
334        }
335
336        let allow_evolution = schema_evolution;
337        for input_field in pipeline.fields() {
338            let Ok(table_field) = table.field_with_name(input_field.name()) else {
339                if allow_evolution {
340                    continue;
341                }
342                return Err(ConnectorError::SchemaMismatch(format!(
343                    "pipeline column '{}' is missing from the existing Delta table",
344                    input_field.name()
345                )));
346            };
347            if !arrow_cast::can_cast_types(input_field.data_type(), table_field.data_type()) {
348                return Err(ConnectorError::SchemaMismatch(format!(
349                    "pipeline column '{}' cannot be written as Delta type {}",
350                    input_field.name(),
351                    table_field.data_type()
352                )));
353            }
354            if input_field.is_nullable() && !table_field.is_nullable() {
355                return Err(ConnectorError::SchemaMismatch(format!(
356                    "nullable pipeline column '{}' cannot target a non-null Delta column",
357                    input_field.name()
358                )));
359            }
360        }
361
362        for table_field in table.fields() {
363            if pipeline.field_with_name(table_field.name()).is_err()
364                && (!allow_evolution || !table_field.is_nullable())
365            {
366                return Err(ConnectorError::SchemaMismatch(format!(
367                    "existing Delta column '{}' is missing from the pipeline schema",
368                    table_field.name()
369                )));
370            }
371        }
372        Ok(())
373    }
374
375    /// Estimates the byte size of a `RecordBatch`.
376    #[must_use]
377    pub fn estimate_batch_size(batch: &RecordBatch) -> u64 {
378        batch
379            .columns()
380            .iter()
381            .map(|col| col.get_array_memory_size() as u64)
382            .sum()
383    }
384
385    #[cfg(feature = "delta-lake")]
386    pub(super) fn operation_deadline(&self) -> tokio::time::Instant {
387        tokio::time::Instant::now() + self.config.write_timeout
388    }
389
390    #[cfg(feature = "delta-lake")]
391    pub(super) fn ensure_write_generation_usable(&self) -> Result<(), ConnectorError> {
392        if self.unresolved_delta_write {
393            return Err(ConnectorError::InvalidState {
394                expected: "a fresh Delta sink generation after reconciliation".into(),
395                actual: "a prior dispatched Delta operation lost its result".into(),
396            });
397        }
398        Ok(())
399    }
400
401    #[cfg(feature = "delta-lake")]
402    pub(super) fn spawn_tracked_delta_task<F>(
403        &self,
404        task: F,
405    ) -> Result<tokio::task::JoinHandle<F::Output>, ConnectorError>
406    where
407        F: Future + Send + 'static,
408        F::Output: Send + 'static,
409    {
410        let guard = self.task_owner.track().ok_or(ConnectorError::Closed)?;
411        Ok(tokio::spawn(run_tracked_delta_task(guard, task)))
412    }
413
414    /// Re-opens the Delta Lake table after a failed write consumes the handle.
415    /// Uses the resolved path/options from `open()`, not `config.*`, so that
416    /// catalog-resolved paths (Unity/Glue) are used correctly.
417    #[cfg(feature = "delta-lake")]
418    async fn reopen_table(&mut self) -> Result<(), ConnectorError> {
419        use super::super::delta_io;
420
421        let table = delta_io::open_or_create_table(
422            &self.resolved_table_path,
423            self.resolved_storage_options.clone(),
424            self.schema.as_ref(),
425        )
426        .await?;
427
428        self.delta_version = table.version().unwrap_or(0);
429        self.table = Some(table);
430        Ok(())
431    }
432
433    /// Attempts a single Delta write/merge and returns the updated table on success.
434    #[cfg(feature = "delta-lake")]
435    async fn attempt_delta_write(
436        table: DeltaTable,
437        batches: Vec<RecordBatch>,
438        write_mode: DeltaWriteMode,
439        merge_key_columns: Vec<String>,
440        partition_columns: Vec<String>,
441        schema_evolution: bool,
442        target_file_size: usize,
443        writer_properties: Option<deltalake::parquet::file::properties::WriterProperties>,
444        merge_session: Option<datafusion::prelude::SessionContext>,
445    ) -> Result<DeltaWriteTaskSuccess, super::super::delta_io::DeltaWriteAttemptError> {
446        if write_mode == DeltaWriteMode::Upsert {
447            // ── Upsert/Merge path ──
448            // flush_staged_to_delta pre-concats for upsert so retries don't
449            // pay a full O(rows × cols) copy each attempt. Handle len > 1
450            // defensively in case a future caller skips the pre-concat.
451            let combined = if batches.len() == 1 {
452                batches.into_iter().next().expect("len == 1 checked")
453            } else {
454                match arrow_select::concat::concat_batches(&batches[0].schema(), &batches) {
455                    Ok(c) => c,
456                    Err(e) => {
457                        return Err(ConnectorError::Internal(format!(
458                            "failed to concat batches: {e}"
459                        ))
460                        .into());
461                    }
462                }
463            };
464
465            let merge_session = merge_session
466                .as_ref()
467                .expect("merge_session built in init_delta_table for Upsert mode");
468
469            super::super::delta_io::merge_changelog(
470                table,
471                combined,
472                &merge_key_columns,
473                schema_evolution,
474                writer_properties,
475                merge_session,
476            )
477            .await
478            .map(|(table, merge_result)| DeltaWriteTaskSuccess {
479                table,
480                merge_result: Some(merge_result),
481            })
482        } else {
483            // ── Append/Overwrite path ──
484            let save_mode = match write_mode {
485                DeltaWriteMode::Append => SaveMode::Append,
486                DeltaWriteMode::Overwrite => SaveMode::Overwrite,
487                DeltaWriteMode::Upsert => unreachable!("handled by the upsert branch above"),
488            };
489
490            let partition_cols = if partition_columns.is_empty() {
491                None
492            } else {
493                Some(partition_columns.as_slice())
494            };
495
496            super::super::delta_io::write_batches(
497                table,
498                batches,
499                save_mode,
500                partition_cols,
501                schema_evolution,
502                Some(target_file_size),
503                writer_properties,
504            )
505            .await
506            .map(|(table, _version)| DeltaWriteTaskSuccess {
507                table,
508                merge_result: None,
509            })
510        }
511    }
512
513    /// Writes all staged data to Delta Lake as a single atomic transaction.
514    ///
515    /// Append + exactly-once decomposes into distributed write + one commit.
516    pub(super) fn is_coordinated(&self) -> bool {
517        self.config.write_mode == DeltaWriteMode::Append
518            && self.config.delivery_guarantee == DeliveryGuarantee::ExactlyOnce
519    }
520
521    #[cfg(feature = "delta-lake")]
522    pub(super) fn ensure_coordinated_reconciled(&self) -> Result<(), ConnectorError> {
523        if self.is_coordinated() && self.coordinated_unresolved_publication.lock().is_some() {
524            return Err(ConnectorError::TransactionError(
525                "Delta coordinated publication outcome is unresolved; reconcile or retry the exact cut before processing later work"
526                    .into(),
527            ));
528        }
529        Ok(())
530    }
531
532    /// Write staged batches to uniquely named Parquet files without making
533    /// them visible in the Delta log.
534    #[cfg(feature = "delta-lake")]
535    async fn materialize_staged_adds(
536        table: DeltaTable,
537        batches: Vec<RecordBatch>,
538        writer_properties: Option<deltalake::parquet::file::properties::WriterProperties>,
539        #[cfg(test)] stall: bool,
540    ) -> Result<Vec<deltalake::kernel::Add>, ConnectorError> {
541        use deltalake::writer::{DeltaWriter, RecordBatchWriter};
542
543        #[cfg(test)]
544        if stall {
545            std::future::pending::<()>().await;
546        }
547
548        let mut writer = RecordBatchWriter::for_table(&table)
549            .map_err(|e| ConnectorError::WriteError(format!("delta writer: {e}")))?;
550        // Mirror the non-coordinated path: honor configured Parquet properties
551        // (compression, row-group size, bloom filters) instead of the writer's
552        // hard-coded Snappy default.
553        if let Some(props) = writer_properties {
554            writer = writer.with_writer_properties(props);
555        }
556        for batch in batches {
557            let batch = super::super::delta_io::widen_batch_millisecond_timestamps(batch)?;
558            writer
559                .write(batch)
560                .await
561                .map_err(|e| ConnectorError::WriteError(format!("delta write: {e}")))?;
562        }
563        let adds = writer
564            .flush()
565            .await
566            .map_err(|e| ConnectorError::WriteError(format!("delta flush: {e}")))?;
567        Ok(adds)
568    }
569
570    /// Materialize the current exact-mode staging buffer and retain only its
571    /// bounded Delta metadata. The Parquet objects remain invisible until
572    /// `commit_aggregated` publishes these Adds with the checkpoint cursor.
573    #[cfg(feature = "delta-lake")]
574    async fn materialize_coordinated_staged(
575        &mut self,
576        deadline: tokio::time::Instant,
577    ) -> Result<(), ConnectorError> {
578        if self.staged_batches.is_empty() {
579            return Ok(());
580        }
581        self.ensure_write_generation_usable()?;
582
583        let binding =
584            super::super::delta_io::coordinated_table_binding(self.table.as_ref().ok_or_else(
585                || ConnectorError::InvalidState {
586                    expected: "open".into(),
587                    actual: "delta table not loaded".into(),
588                },
589            )?)?;
590        if self
591            .coordinated_binding
592            .as_ref()
593            .is_some_and(|existing| existing != &binding)
594        {
595            return Err(ConnectorError::TransactionError(
596                "Delta table binding changed while materializing one coordinated checkpoint cut"
597                    .into(),
598            ));
599        }
600
601        let table = self
602            .table
603            .clone()
604            .ok_or_else(|| ConnectorError::InvalidState {
605                expected: "open".into(),
606                actual: "delta table not loaded".into(),
607            })?;
608        let batches = self.staged_batches.clone();
609        let writer_properties = self.cached_writer_properties.clone();
610        #[cfg(test)]
611        let stall = self.stall_descriptor_write;
612        let task = self.spawn_tracked_delta_task(Self::materialize_staged_adds(
613            table,
614            batches,
615            writer_properties,
616            #[cfg(test)]
617            stall,
618        ))?;
619        let adds = match tokio::time::timeout_at(deadline, task).await {
620            Ok(Ok(result)) => result?,
621            Ok(Err(error)) => {
622                self.unresolved_delta_write = true;
623                return Err(ConnectorError::outcome_unknown(
624                    format!("Delta descriptor writer task terminated unexpectedly: {error}"),
625                    true,
626                ));
627            }
628            Err(_) => {
629                self.unresolved_delta_write = true;
630                return Err(ConnectorError::outcome_unknown(
631                    format!(
632                        "Delta descriptor materialization timed out at its {:?} end-to-end deadline",
633                        self.config.write_timeout
634                    ),
635                    true,
636                ));
637            }
638        };
639        if adds.is_empty() {
640            self.staged_batches.clear();
641            self.staged_rows = 0;
642            self.staged_bytes = 0;
643            return Ok(());
644        }
645
646        let projected_add_count = self
647            .coordinated_adds
648            .len()
649            .checked_add(adds.len())
650            .ok_or_else(|| {
651                ConnectorError::WriteError("Delta coordinated Add count overflow".into())
652            })?;
653        if projected_add_count > super::super::delta_io::MAX_COORDINATED_ADD_ACTIONS {
654            return Err(ConnectorError::WriteError(format!(
655                "Delta coordinated checkpoint would exceed the fixed {} Add-action limit",
656                super::super::delta_io::MAX_COORDINATED_ADD_ACTIONS
657            )));
658        }
659
660        // Account only for the new Add array. Once the binding is encoded, appending another
661        // non-empty chunk replaces no envelope fields and inserts one comma between array items.
662        let chunk_add_array_bytes = super::super::delta_io::encoded_add_array_len(&adds)?;
663        let projected_descriptor_bytes = if self.coordinated_adds.is_empty() {
664            super::super::delta_io::encode_commit_descriptor(&binding, &adds)?.len()
665        } else {
666            self.coordinated_descriptor_bytes
667                .checked_add(chunk_add_array_bytes)
668                .and_then(|bytes| bytes.checked_sub(2))
669                .and_then(|bytes| bytes.checked_add(1))
670                .ok_or_else(|| {
671                    ConnectorError::WriteError("Delta coordinated descriptor size overflow".into())
672                })?
673        };
674        if projected_descriptor_bytes > MAX_COORDINATED_COMMIT_PAYLOAD_BYTES {
675            return Err(ConnectorError::WriteError(format!(
676                "Delta coordinated descriptor would exceed the fixed {MAX_COORDINATED_COMMIT_PAYLOAD_BYTES} byte control-plane \
677                 limit; the checkpoint cut produced too many files or partition values",
678            )));
679        }
680
681        self.coordinated_binding = Some(binding);
682        self.coordinated_adds.extend(adds);
683        self.coordinated_descriptor_bytes = projected_descriptor_bytes;
684        self.staged_batches.clear();
685        self.staged_rows = 0;
686        self.staged_bytes = 0;
687        Ok(())
688    }
689
690    /// Flush any prior retry buffer first, then move the current in-memory
691    /// exact-mode buffer into invisible Parquet staging.
692    #[cfg(feature = "delta-lake")]
693    pub(super) async fn stage_coordinated_buffer(
694        &mut self,
695        deadline: tokio::time::Instant,
696    ) -> Result<(), ConnectorError> {
697        self.materialize_coordinated_staged(deadline).await?;
698        if self.buffer.is_empty() {
699            return Ok(());
700        }
701
702        self.staged_batches = std::mem::take(&mut self.buffer);
703        self.staged_rows = self.buffered_rows;
704        self.staged_bytes = self.buffered_bytes;
705        self.buffered_rows = 0;
706        self.buffered_bytes = 0;
707        self.buffer_start_time = None;
708        self.materialize_coordinated_staged(deadline).await
709    }
710
711    #[cfg(feature = "delta-lake")]
712    fn collapse_staged_upsert_changelog(&mut self) -> Result<(), ConnectorError> {
713        if self.config.write_mode != DeltaWriteMode::Upsert {
714            return Ok(());
715        }
716
717        // Collapse the epoch before MERGE so each key appears once and retries reuse the result.
718        let combined = if self.staged_batches.len() == 1 {
719            self.staged_batches[0].clone()
720        } else {
721            let schema = self.staged_batches[0].schema();
722            arrow_select::concat::concat_batches(&schema, &self.staged_batches).map_err(
723                |error| {
724                    ConnectorError::Internal(format!("failed to concat staged batches: {error}"))
725                },
726            )?
727        };
728        let rows_in = combined.num_rows() as u64;
729        let collapse_start = Instant::now();
730        let collapsed =
731            crate::changelog::collapse_changelog(&combined, &self.config.merge_key_columns)?;
732        let (upserts_out, deletes_out) = count_collapsed_ops(&collapsed);
733        self.metrics.observe_collapse(
734            rows_in,
735            upserts_out,
736            deletes_out,
737            collapse_start.elapsed().as_secs_f64(),
738        );
739        self.staged_batches.clear();
740        self.staged_batches.push(collapsed);
741        Ok(())
742    }
743
744    /// Writes staged data under one end-to-end deadline. delta-rs owns the
745    /// optimistic commit retry loop; this layer must not amplify that budget.
746    #[cfg(feature = "delta-lake")]
747    pub(super) async fn flush_staged_to_delta(
748        &mut self,
749        deadline: tokio::time::Instant,
750    ) -> Result<WriteResult, ConnectorError> {
751        if self.staged_batches.is_empty() {
752            return Ok(WriteResult::new(0, 0));
753        }
754        self.ensure_write_generation_usable()?;
755        let write_timeout = self.config.write_timeout;
756        self.collapse_staged_upsert_changelog()?;
757
758        let total_rows = self.staged_rows;
759        let estimated_bytes = self.staged_bytes;
760        let flush_start = Instant::now();
761
762        // A failed/expired delta-rs write consumes the table handle. A later
763        // flush reopens it, but reopening and the new commit share this one
764        // operation deadline.
765        if self.table.is_none() {
766            tokio::time::timeout_at(deadline, self.reopen_table())
767                .await
768                .map_err(|_| {
769                    ConnectorError::ConnectionFailed(format!(
770                        "Delta table reopen exceeded the {write_timeout:?} write deadline"
771                    ))
772                })??;
773        }
774
775        let table = self
776            .table
777            .take()
778            .ok_or_else(|| ConnectorError::InvalidState {
779                expected: "table initialized".into(),
780                actual: "table not initialized".into(),
781            })?;
782
783        let write_task = self.spawn_tracked_delta_task(Self::attempt_delta_write(
784            table,
785            self.staged_batches.clone(),
786            self.config.write_mode,
787            self.config.merge_key_columns.clone(),
788            self.config.partition_columns.clone(),
789            self.config.schema_evolution,
790            self.config.target_file_size,
791            self.cached_writer_properties.clone(),
792            self.merge_session.clone(),
793        ))?;
794        let write = match tokio::time::timeout_at(deadline, write_task).await {
795            Ok(Ok(Ok(write))) => write,
796            Ok(Ok(Err(error))) => {
797                self.metrics
798                    .observe_flush_duration(flush_start.elapsed().as_secs_f64());
799                let error = classify_delta_attempt_error(error);
800                if error.is_outcome_unknown() {
801                    self.unresolved_delta_write = true;
802                }
803                return Err(error);
804            }
805            Ok(Err(error)) => {
806                self.unresolved_delta_write = true;
807                self.metrics
808                    .observe_flush_duration(flush_start.elapsed().as_secs_f64());
809                return Err(ConnectorError::outcome_unknown(
810                    format!("Delta writer task terminated unexpectedly: {error}"),
811                    true,
812                ));
813            }
814            Err(_) => {
815                self.unresolved_delta_write = true;
816                self.metrics
817                    .observe_flush_duration(flush_start.elapsed().as_secs_f64());
818                return Err(ConnectorError::outcome_unknown(
819                    format!("Delta write exceeded its {write_timeout:?} end-to-end deadline"),
820                    true,
821                ));
822            }
823        };
824        let table = write.table;
825        if let Some(result) = write.merge_result {
826            self.metrics.record_merge();
827            if result.rows_deleted > 0 {
828                self.metrics.record_deletes(result.rows_deleted as u64);
829            }
830        }
831
832        self.delta_version = table.version().unwrap_or(0);
833        self.table = Some(table);
834        self.staged_batches.clear();
835        self.staged_rows = 0;
836        self.staged_bytes = 0;
837
838        self.metrics
839            .record_flush(total_rows as u64, estimated_bytes);
840        self.metrics.record_commit(self.delta_version);
841        self.metrics
842            .observe_flush_duration(flush_start.elapsed().as_secs_f64());
843
844        debug!(
845            rows = total_rows,
846            bytes = estimated_bytes,
847            delta_version = self.delta_version,
848            "Delta Lake: committed staged data to Delta"
849        );
850
851        Ok(WriteResult::new(total_rows, estimated_bytes))
852    }
853
854    /// Splits a changelog `RecordBatch` into insert and delete batches.
855    ///
856    /// Uses the `_op` metadata column:
857    /// - `"I"` (insert), `"U"` (update-after), `"r"` (snapshot read) -> insert
858    /// - `"D"` (delete) -> delete
859    ///
860    /// The returned batches exclude metadata columns (those starting with `_`).
861    ///
862    /// # Errors
863    ///
864    /// Returns `ConnectorError::ConfigurationError` if the `_op` column is
865    /// missing or not a string type.
866    pub fn split_changelog_batch(
867        batch: &RecordBatch,
868    ) -> Result<(RecordBatch, RecordBatch), ConnectorError> {
869        let op_idx = batch.schema().index_of("_op").map_err(|_| {
870            ConnectorError::ConfigurationError(
871                "upsert mode requires '_op' column in input schema".into(),
872            )
873        })?;
874
875        let op_array = batch
876            .column(op_idx)
877            .as_any()
878            .downcast_ref::<arrow_array::StringArray>()
879            .ok_or_else(|| {
880                ConnectorError::ConfigurationError("'_op' column must be String (Utf8) type".into())
881            })?;
882
883        // Build boolean masks for insert vs delete rows.
884        let len = op_array.len();
885        let mut insert_mask = Vec::with_capacity(len);
886        let mut delete_mask = Vec::with_capacity(len);
887
888        for i in 0..len {
889            if op_array.is_null(i) {
890                insert_mask.push(false);
891                delete_mask.push(false);
892                continue;
893            }
894            match op_array.value(i) {
895                "I" | "U" | "r" => {
896                    insert_mask.push(true);
897                    delete_mask.push(false);
898                }
899                "D" => {
900                    insert_mask.push(false);
901                    delete_mask.push(true);
902                }
903                _ => {
904                    insert_mask.push(false);
905                    delete_mask.push(false);
906                }
907            }
908        }
909
910        // Compute user-column projection indices once (strip metadata columns).
911        let user_col_indices: Vec<usize> = batch
912            .schema()
913            .fields()
914            .iter()
915            .enumerate()
916            .filter(|(_, f)| !f.name().starts_with('_'))
917            .map(|(i, _)| i)
918            .collect();
919
920        let insert_batch = filter_and_project(batch, insert_mask, &user_col_indices)?;
921        let delete_batch = filter_and_project(batch, delete_mask, &user_col_indices)?;
922
923        Ok((insert_batch, delete_batch))
924    }
925}