1#[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 #[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 #[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 #[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 #[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 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 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 self.resolved_table_path.clone_from(&resolved_path);
153 self.resolved_storage_options.clone_from(&merged_options);
154
155 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 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 #[must_use]
214 pub fn state(&self) -> ConnectorState {
215 self.state
216 }
217
218 #[must_use]
220 pub fn current_epoch(&self) -> u64 {
221 self.current_epoch
222 }
223
224 #[must_use]
226 pub fn buffered_rows(&self) -> usize {
227 self.buffered_rows
228 }
229
230 #[must_use]
232 pub fn buffered_bytes(&self) -> u64 {
233 self.buffered_bytes
234 }
235
236 #[must_use]
238 pub fn delta_version(&self) -> u64 {
239 self.delta_version
240 }
241
242 #[must_use]
244 pub fn sink_metrics(&self) -> &DeltaLakeSinkMetrics {
245 &self.metrics
246 }
247
248 #[must_use]
250 pub fn config(&self) -> &DeltaLakeSinkConfig {
251 &self.config
252 }
253
254 #[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 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 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 #[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 #[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 #[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 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 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 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 #[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 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 #[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 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 #[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 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 #[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 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 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 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 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}