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