laminar_connectors/lakehouse/delta/
lifecycle.rs1use super::{
4 async_trait, debug, info, warn, Arc, ConnectorConfig, ConnectorError, ConnectorState,
5 ConnectorTaskTracker, DeliveryGuarantee, DeltaLakeSink, DeltaLakeSinkConfig, DeltaWriteMode,
6 Duration, Instant, RecordBatch, SchemaRef, SinkConnector, SinkConsistency, SinkContract,
7 SinkInputMode, SinkTopology, StorageCredentialResolver, StorageProvider, WriteResult,
8};
9#[cfg(feature = "delta-lake")]
10use super::{
11 publish_coordinated_delta_batch, retry_delta_metadata_until, UnresolvedDeltaPublication,
12 MAX_COORDINATED_COMMIT_PAYLOAD_BYTES,
13};
14
15#[async_trait]
16impl SinkConnector for DeltaLakeSink {
17 fn terminal_task_tracker(&self) -> Option<ConnectorTaskTracker> {
18 Some(self.task_tracker.clone())
19 }
20
21 fn contract(&self, config: &ConnectorConfig) -> Result<SinkContract, ConnectorError> {
22 let cfg = if config.properties().is_empty() {
23 self.config.clone()
24 } else {
25 DeltaLakeSinkConfig::from_config(config)?
26 };
27 #[cfg(feature = "delta-lake")]
28 if cfg.delivery_guarantee == DeliveryGuarantee::ExactlyOnce {
29 super::super::delta_io::validate_coordinated_storage_preflight(
30 &cfg.table_path,
31 &cfg.storage_options,
32 )?;
33 }
34 if cfg.delivery_guarantee == DeliveryGuarantee::ExactlyOnce
35 && cfg.write_mode != DeltaWriteMode::Append
36 {
37 return Err(ConnectorError::ConfigurationError(
38 "Delta exactly-once requires coordinated append mode".into(),
39 ));
40 }
41 let consistency = if cfg.delivery_guarantee == DeliveryGuarantee::ExactlyOnce
42 && cfg.write_mode == DeltaWriteMode::Append
43 {
44 SinkConsistency::CheckpointCommittable
48 } else {
49 SinkConsistency::DurableAtLeastOnce
50 };
51 let unity_target = cfg
52 .table_path
53 .split_once("://")
54 .is_some_and(|(scheme, _)| scheme.eq_ignore_ascii_case("uc"));
55 let shared_target = StorageProvider::is_shared_uri(&cfg.table_path) || unity_target;
56 let topology = if cfg.write_mode == DeltaWriteMode::Append && shared_target {
57 SinkTopology::MultiWriter
58 } else {
59 SinkTopology::Singleton
61 };
62 let input_mode = match cfg.write_mode {
63 DeltaWriteMode::Append | DeltaWriteMode::Upsert => SinkInputMode::FullChangelog,
64 DeltaWriteMode::Overwrite => SinkInputMode::AppendOnly,
65 };
66 let contract = SinkContract::new(consistency, topology, input_mode);
67 let cluster_exact_target = StorageProvider::is_direct_s3_uri(&cfg.table_path);
68 Ok(
69 if consistency == SinkConsistency::CheckpointCommittable && cluster_exact_target {
70 contract.with_cluster_exact_delivery_certification()
71 } else {
72 contract
73 },
74 )
75 }
76
77 async fn open(&mut self, config: &ConnectorConfig) -> Result<(), ConnectorError> {
78 self.state = ConnectorState::Initializing;
79
80 if !config.properties().is_empty() {
82 self.config = DeltaLakeSinkConfig::from_config(config)?;
83 }
84 if config.get("_arrow_schema").is_some() {
85 let schema = config.arrow_schema().ok_or_else(|| {
86 ConnectorError::ConfigurationError(
87 "invalid Delta sink '_arrow_schema' encoding".into(),
88 )
89 })?;
90 self.schema = Some(Self::target_schema(&schema, self.config.write_mode));
91 }
92 #[cfg(feature = "delta-lake")]
93 if self.config.delivery_guarantee == DeliveryGuarantee::ExactlyOnce {
94 super::super::delta_io::validate_coordinated_storage_preflight(
95 &self.config.table_path,
96 &self.config.storage_options,
97 )?;
98 }
99 if self.config.delivery_guarantee == DeliveryGuarantee::ExactlyOnce
100 && !self.is_coordinated()
101 {
102 self.state = ConnectorState::Failed;
103 return Err(ConnectorError::ConfigurationError(
104 "Delta exactly-once requires coordinated append mode".into(),
105 ));
106 }
107
108 if self.config.catalog_type == super::super::delta_config::DeltaCatalogType::None {
109 let explicit_storage = if config.properties().is_empty() {
110 self.config.storage_options.clone()
111 } else {
112 config.properties_with_prefix("storage.")
113 };
114 let diagnostics =
115 StorageCredentialResolver::resolve(&self.config.table_path, &explicit_storage);
116 info!(
117 operation_class = "connector-open",
118 storage_provider = %diagnostics.provider,
119 storage_endpoint_class = %diagnostics.endpoint_class(),
120 storage_auth_source = %diagnostics.auth_source,
121 mode = %self.config.write_mode,
122 guarantee = %self.config.delivery_guarantee,
123 "opening Delta Lake sink connector"
124 );
125 } else {
126 info!(
127 operation_class = "connector-open",
128 storage_provider = "catalog-resolved",
129 storage_endpoint_class = "catalog-resolved",
130 storage_auth_source = "catalog-resolved",
131 mode = %self.config.write_mode,
132 guarantee = %self.config.delivery_guarantee,
133 "opening Delta Lake sink connector"
134 );
135 }
136
137 #[cfg(feature = "delta-lake")]
141 {
142 let should_defer = matches!(
143 self.config.catalog_type,
144 super::super::delta_config::DeltaCatalogType::Unity { .. }
145 ) && self.config.catalog_storage_location.is_some()
146 && self.schema.is_none();
147
148 if should_defer {
149 info!(
150 "Unity Catalog auto-create configured but pipeline schema not yet \
151 available — deferring Delta table init to first begin_epoch"
152 );
153 self.needs_deferred_delta_init = true;
154 self.state = ConnectorState::Initializing;
155 return Ok(());
156 }
157
158 let deadline = self.operation_deadline();
159 self.init_delta_table(deadline).await?;
160
161 if self.table.as_ref().is_some_and(|t| t.version().is_none()) && self.schema.is_none() {
164 self.needs_deferred_delta_init = true;
165 self.state = ConnectorState::Initializing;
166 return Ok(());
167 }
168 }
169
170 #[cfg(not(feature = "delta-lake"))]
171 {
172 self.state = ConnectorState::Failed;
173 return Err(ConnectorError::ConfigurationError(
174 "Delta Lake sink requires the 'delta-lake' feature to be enabled. \
175 Build with: cargo build --features delta-lake"
176 .into(),
177 ));
178 }
179
180 #[cfg(feature = "delta-lake")]
181 {
182 self.state = ConnectorState::Running;
183 info!("Delta Lake sink connector opened successfully");
184 Ok(())
185 }
186 }
187
188 async fn write_batch(&mut self, batch: &RecordBatch) -> Result<WriteResult, ConnectorError> {
189 if self.state != ConnectorState::Running && self.state != ConnectorState::Initializing {
191 return Err(ConnectorError::InvalidState {
192 expected: "Running".into(),
193 actual: self.state.to_string(),
194 });
195 }
196
197 #[cfg(feature = "delta-lake")]
198 {
199 self.ensure_coordinated_reconciled()?;
200 self.ensure_write_generation_usable()?;
201 }
202 #[cfg(feature = "delta-lake")]
203 let deadline = self.operation_deadline();
204
205 if batch.num_rows() == 0 {
206 return Ok(WriteResult::new(0, 0));
207 }
208
209 if self.schema.is_none() {
212 self.schema = Some(Self::target_schema(&batch.schema(), self.config.write_mode));
213 }
214
215 #[cfg(feature = "delta-lake")]
219 if self.needs_deferred_delta_init {
220 info!("schema now available from first batch — completing deferred Delta table init");
221 match self.init_delta_table(deadline).await {
222 Ok(()) => {
223 self.needs_deferred_delta_init = false;
224 self.state = ConnectorState::Running;
225 info!("Delta Lake sink connector opened successfully (deferred)");
226 }
227 Err(e) => {
228 self.state = ConnectorState::Failed;
229 return Err(e);
230 }
231 }
232 }
233
234 let num_rows = batch.num_rows();
235 let estimated_bytes = Self::estimate_batch_size(batch);
236
237 if !self.is_coordinated() {
241 let pending_rows = self.buffered_rows + self.staged_rows + num_rows;
242 let pending_bytes = self.buffered_bytes + self.staged_bytes + estimated_bytes;
243 let row_cap = self
244 .config
245 .max_buffer_records
246 .saturating_mul(4)
247 .max(num_rows);
248 let byte_cap = (self.config.target_file_size as u64)
249 .saturating_mul(4)
250 .max(estimated_bytes);
251 if pending_rows > row_cap || pending_bytes > byte_cap {
252 return Err(ConnectorError::WriteError(format!(
253 "delta sink buffer full ({pending_rows} rows, \
254 {pending_bytes} bytes pending; cap {row_cap} rows, \
255 {byte_cap} bytes) — backpressure until next flush/commit"
256 )));
257 }
258 }
259
260 if self.buffer_start_time.is_none() {
262 self.buffer_start_time = Some(Instant::now());
263 }
264 self.buffer.push(batch.clone());
265 self.buffered_rows += num_rows;
266 self.buffered_bytes += estimated_bytes;
267
268 #[cfg(feature = "delta-lake")]
269 if self.is_coordinated() && self.should_flush() {
270 self.stage_coordinated_buffer(deadline).await?;
271 } else if self.config.delivery_guarantee != DeliveryGuarantee::ExactlyOnce
272 && self.should_flush()
273 {
274 if !self.staged_batches.is_empty() {
275 self.flush_staged_to_delta(deadline).await?;
276 }
277 self.staged_batches = std::mem::take(&mut self.buffer);
278 self.staged_rows = self.buffered_rows;
279 self.staged_bytes = self.buffered_bytes;
280 self.buffered_rows = 0;
281 self.buffered_bytes = 0;
282 self.buffer_start_time = None;
283 self.flush_staged_to_delta(deadline).await?;
284 }
285
286 Ok(WriteResult::new(0, 0))
287 }
288
289 fn schema(&self) -> SchemaRef {
290 self.schema
291 .clone()
292 .unwrap_or_else(|| Arc::new(arrow_schema::Schema::empty()))
293 }
294
295 async fn checkpoint_artifact_intent(
296 &mut self,
297 _epoch: u64,
298 ) -> Result<Option<Vec<u8>>, ConnectorError> {
299 Ok(None)
302 }
303
304 async fn begin_epoch(&mut self, epoch: u64) -> Result<(), ConnectorError> {
305 #[cfg(feature = "delta-lake")]
306 {
307 self.ensure_coordinated_reconciled()?;
308 self.ensure_write_generation_usable()?;
309 }
310 #[cfg(feature = "delta-lake")]
311 let deadline = self.operation_deadline();
312
313 #[cfg(feature = "delta-lake")]
315 if self.needs_deferred_delta_init {
316 if self.schema.is_some() {
321 info!("schema available — completing deferred Delta table init");
322 match self.init_delta_table(deadline).await {
323 Ok(()) => {
324 self.needs_deferred_delta_init = false;
325 self.state = ConnectorState::Running;
326 info!("Delta Lake sink connector opened successfully (deferred)");
327 }
328 Err(e) => {
329 self.state = ConnectorState::Failed;
330 return Err(e);
331 }
332 }
333 }
334 }
335
336 #[cfg(feature = "delta-lake")]
337 if self.is_coordinated()
338 && (!self.buffer.is_empty()
339 || !self.staged_batches.is_empty()
340 || !self.coordinated_adds.is_empty())
341 {
342 return Err(ConnectorError::InvalidState {
343 expected: "the previous coordinated epoch to be prepared or rolled back".into(),
344 actual: "unresolved Delta staging data remains".into(),
345 });
346 }
347
348 self.current_epoch = epoch;
349 self.buffer.clear();
350 self.buffered_rows = 0;
351 self.buffered_bytes = 0;
352 self.buffer_start_time = None;
353
354 debug!(epoch, "Delta Lake: began epoch");
355 Ok(())
356 }
357
358 async fn pre_commit(&mut self, epoch: u64) -> Result<Option<Vec<u8>>, ConnectorError> {
359 #[cfg(feature = "delta-lake")]
363 if self.is_coordinated() {
364 self.ensure_coordinated_reconciled()?;
365 self.ensure_write_generation_usable()?;
366 let deadline = self.operation_deadline();
367 self.stage_coordinated_buffer(deadline).await?;
368 if self.coordinated_adds.is_empty() {
369 if self.coordinated_binding.is_some() {
370 return Err(ConnectorError::Internal(
371 "empty Delta coordinated cut retained a table binding".into(),
372 ));
373 }
374 return Ok(None);
375 }
376
377 let binding = self.coordinated_binding.as_ref().ok_or_else(|| {
378 ConnectorError::Internal(
379 "non-empty Delta coordinated cut has no table binding".into(),
380 )
381 })?;
382 let descriptor =
383 super::super::delta_io::encode_commit_descriptor(binding, &self.coordinated_adds)?;
384 if descriptor.len() != self.coordinated_descriptor_bytes
385 || descriptor.len() > MAX_COORDINATED_COMMIT_PAYLOAD_BYTES
386 {
387 return Err(ConnectorError::Internal(format!(
388 "Delta coordinated descriptor accounting mismatch: tracked {}, encoded {}",
389 self.coordinated_descriptor_bytes,
390 descriptor.len()
391 )));
392 }
393 self.coordinated_adds.clear();
394 self.coordinated_binding = None;
395 self.coordinated_descriptor_bytes = 0;
396 return Ok(Some(descriptor));
397 }
398
399 if !self.buffer.is_empty() {
403 self.staged_batches = std::mem::take(&mut self.buffer);
404 self.staged_rows = self.buffered_rows;
405 self.staged_bytes = self.buffered_bytes;
406 self.buffered_rows = 0;
407 self.buffered_bytes = 0;
408 self.buffer_start_time = None;
409 }
410
411 debug!(epoch, "Delta Lake: pre-committed (batches staged)");
412 Ok(None)
413 }
414
415 async fn rollback_epoch(&mut self, epoch: u64) -> Result<(), ConnectorError> {
416 self.buffer.clear();
420 self.buffered_rows = 0;
421 self.buffered_bytes = 0;
422 self.buffer_start_time = None;
423 self.staged_batches.clear();
424 self.staged_rows = 0;
425 self.staged_bytes = 0;
426 #[cfg(feature = "delta-lake")]
427 {
428 self.coordinated_adds.clear();
429 self.coordinated_binding = None;
430 self.coordinated_descriptor_bytes = 0;
431 }
432
433 self.metrics.record_rollback();
434 warn!(epoch, "Delta Lake: rolled back epoch");
435 Ok(())
436 }
437
438 fn suggested_write_timeout(&self) -> Duration {
439 self.config.write_timeout
440 }
441
442 async fn flush(&mut self) -> Result<(), ConnectorError> {
443 #[cfg(feature = "delta-lake")]
444 {
445 self.ensure_coordinated_reconciled()?;
446 self.ensure_write_generation_usable()?;
447 }
448 #[cfg(feature = "delta-lake")]
449 let deadline = self.operation_deadline();
450
451 #[cfg(feature = "delta-lake")]
454 if self.config.delivery_guarantee != DeliveryGuarantee::ExactlyOnce {
455 if !self.staged_batches.is_empty() {
459 self.flush_staged_to_delta(deadline).await?;
460 }
461
462 if !self.buffer.is_empty() {
464 self.staged_batches = std::mem::take(&mut self.buffer);
465 self.staged_rows = self.buffered_rows;
466 self.staged_bytes = self.buffered_bytes;
467 self.buffered_rows = 0;
468 self.buffered_bytes = 0;
469 self.buffer_start_time = None;
470
471 self.flush_staged_to_delta(deadline).await?;
472 }
473 return Ok(());
474 }
475
476 #[cfg(feature = "delta-lake")]
477 return self.stage_coordinated_buffer(deadline).await;
478
479 #[cfg(not(feature = "delta-lake"))]
480 Ok(())
481 }
482
483 async fn close(&mut self) -> Result<(), ConnectorError> {
484 info!("closing Delta Lake sink connector");
485
486 #[cfg(feature = "delta-lake")]
489 if self.config.delivery_guarantee == DeliveryGuarantee::ExactlyOnce
490 || self.unresolved_delta_write
491 {
492 self.buffer.clear();
493 self.staged_batches.clear();
494 self.buffered_rows = 0;
495 self.buffered_bytes = 0;
496 self.staged_rows = 0;
497 self.staged_bytes = 0;
498 self.coordinated_adds.clear();
499 self.coordinated_binding = None;
500 self.coordinated_descriptor_bytes = 0;
501 } else {
502 self.flush().await?;
503 }
504
505 #[cfg(feature = "delta-lake")]
507 {
508 self.table = None;
509 }
510
511 self.state = ConnectorState::Closed;
512
513 info!(
514 delta_version = self.delta_version,
515 "Delta Lake sink connector closed"
516 );
517
518 Ok(())
519 }
520
521 #[cfg(feature = "delta-lake")]
522 fn as_coordinated_committer(&self) -> Option<&dyn crate::connector::CoordinatedCommitter> {
523 self.is_coordinated()
524 .then_some(self as &dyn crate::connector::CoordinatedCommitter)
525 }
526
527 fn coordinated_abort_cleaner(
528 &self,
529 ) -> Option<Arc<dyn crate::connector::CoordinatedAbortCleaner>> {
530 None
531 }
532}
533
534#[cfg(feature = "delta-lake")]
535#[async_trait]
536impl crate::connector::CoordinatedCommitter for DeltaLakeSink {
537 async fn commit_aggregated(
538 &self,
539 batch: crate::connector::CoordinatedCommitBatch,
540 context: crate::connector::CoordinatedCommitContext,
541 ) -> Result<(), ConnectorError> {
542 let deadline = context.deadline();
543 let publication_budget = context.remaining();
544 if publication_budget.is_zero() {
545 return Err(ConnectorError::TransactionError(
546 "Delta coordinated publication deadline elapsed before I/O".into(),
547 ));
548 }
549
550 batch.validate_shape().map_err(|error| {
551 ConnectorError::TransactionError(format!(
552 "Delta coordinated batch validation failed: {error}"
553 ))
554 })?;
555 let external_key = batch.namespace.external_key();
556 let exact_batch_fingerprint = batch.exact_fingerprint();
557 let target_cursor = crate::connector::CoordinatedCommitCursor {
558 checkpoint_id: batch.target.checkpoint_id,
559 fencing_token: batch.fencing_token,
560 };
561 let pending = UnresolvedDeltaPublication {
562 external_key,
563 target: target_cursor,
564 exact_batch_fingerprint,
565 };
566 {
567 let mut unresolved = self.coordinated_unresolved_publication.lock();
568 if unresolved
569 .as_ref()
570 .is_some_and(|unresolved| unresolved != &pending)
571 {
572 return Err(ConnectorError::TransactionError(
573 "Delta has a different unresolved coordinated publication; only that exact cut may be retried"
574 .into(),
575 ));
576 }
577 *unresolved = Some(pending.clone());
578 }
579
580 let operation = publish_coordinated_delta_batch(
581 self.resolved_table_path.clone(),
582 self.resolved_storage_options.clone(),
583 Arc::clone(&self.coordinated_unresolved_publication),
584 pending.clone(),
585 batch,
586 deadline,
587 publication_budget,
588 );
589 #[cfg(test)]
592 let operation = {
593 let delay = super::super::delta_io::DELAY_COORDINATED_CATALOG_COMMIT
594 .try_with(Clone::clone)
595 .ok();
596 async move {
597 if let Some(delay) = delay {
598 super::super::delta_io::DELAY_COORDINATED_CATALOG_COMMIT
599 .scope(delay, operation)
600 .await
601 } else {
602 operation.await
603 }
604 }
605 };
606 let task = match self.spawn_tracked_delta_task(operation) {
607 Ok(task) => task,
608 Err(error) => {
609 let mut unresolved = self.coordinated_unresolved_publication.lock();
610 if unresolved.as_ref() == Some(&pending) {
611 *unresolved = None;
612 }
613 return Err(error);
614 }
615 };
616
617 match tokio::time::timeout_at(deadline, task).await {
618 Ok(Ok(result)) => result,
619 Ok(Err(error)) => Err(ConnectorError::outcome_unknown(
620 format!("Delta coordinated publication task terminated unexpectedly: {error}"),
621 true,
622 )),
623 Err(_) => Err(ConnectorError::outcome_unknown(
624 format!(
625 "Delta coordinated publication exceeded its {publication_budget:?} remaining \
626 budget; reconcile the exact cursor before replay"
627 ),
628 true,
629 )),
630 }
631 }
632
633 async fn committed_cursor(
634 &self,
635 namespace: &crate::connector::CoordinatedCommitNamespace,
636 ) -> Result<Option<crate::connector::CoordinatedCommitCursor>, ConnectorError> {
637 namespace.validate()?;
638 let external_key = namespace.external_key();
639 let deadline = self.operation_deadline();
640 let observed = retry_delta_metadata_until(deadline, "coordinated cursor read", || async {
644 let table = super::super::delta_io::open_or_create_table(
645 &self.resolved_table_path,
646 self.resolved_storage_options.clone(),
647 None,
648 )
649 .await?;
650 super::super::delta_io::get_coordinated_cursor(&table, &external_key).await
651 })
652 .await?;
653 let mut unresolved = self.coordinated_unresolved_publication.lock();
654 if unresolved.as_ref().is_some_and(|pending| {
655 pending.external_key == external_key && pending.reconciled_by(observed)
656 }) {
657 *unresolved = None;
658 }
659 Ok(observed)
660 }
661}