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, 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 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 #[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 #[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 #[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 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 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 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 #[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 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 #[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 #[must_use]
278 pub fn state(&self) -> ConnectorState {
279 self.state
280 }
281
282 #[must_use]
284 pub fn current_epoch(&self) -> u64 {
285 self.current_epoch
286 }
287
288 #[must_use]
290 pub fn buffered_rows(&self) -> usize {
291 self.buffered_rows
292 }
293
294 #[must_use]
296 pub fn buffered_bytes(&self) -> u64 {
297 self.buffered_bytes
298 }
299
300 #[must_use]
302 pub fn delta_version(&self) -> u64 {
303 self.delta_version
304 }
305
306 #[must_use]
308 pub fn sink_metrics(&self) -> &DeltaLakeSinkMetrics {
309 &self.metrics
310 }
311
312 #[must_use]
314 pub fn config(&self) -> &DeltaLakeSinkConfig {
315 &self.config
316 }
317
318 #[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 #[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 #[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 #[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 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 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 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 #[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 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 #[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 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 #[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 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 #[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 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 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 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 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}