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 fn keyed_mutation_key(
78 &self,
79 config: &ConnectorConfig,
80 ) -> Result<Option<Vec<String>>, ConnectorError> {
81 let cfg = if config.properties().is_empty() {
82 self.config.clone()
83 } else {
84 DeltaLakeSinkConfig::from_config(config)?
85 };
86 Ok((cfg.write_mode == DeltaWriteMode::Upsert).then_some(cfg.merge_key_columns))
89 }
90
91 async fn resolve_schema(
92 &mut self,
93 config: &ConnectorConfig,
94 input: SchemaRef,
95 ) -> Result<crate::schema::resolution::SchemaBinding, ConnectorError> {
96 #[cfg(feature = "delta-lake")]
97 {
98 super::super::schema_resolution::delta_sink(config, input).await
99 }
100 #[cfg(not(feature = "delta-lake"))]
101 {
102 let _ = (config, input);
103 Err(ConnectorError::FeatureUnsupported(
104 "Delta schema resolution requires the delta-lake feature".into(),
105 ))
106 }
107 }
108
109 async fn prepare_schema(
110 &mut self,
111 config: &ConnectorConfig,
112 binding: &mut crate::schema::resolution::SchemaBinding,
113 ) -> Result<(), ConnectorError> {
114 #[cfg(feature = "delta-lake")]
115 {
116 super::super::schema_resolution::prepare_delta(config, binding).await
117 }
118 #[cfg(not(feature = "delta-lake"))]
119 {
120 let _ = (config, binding);
121 Err(ConnectorError::FeatureUnsupported(
122 "Delta preparation requires the delta-lake feature".into(),
123 ))
124 }
125 }
126
127 async fn open(&mut self, config: &ConnectorConfig) -> Result<(), ConnectorError> {
128 self.state = ConnectorState::Initializing;
129
130 if !config.properties().is_empty() {
132 self.config = DeltaLakeSinkConfig::from_config(config)?;
133 }
134 if config.get("_arrow_schema").is_some() {
135 let schema = config.arrow_schema().ok_or_else(|| {
136 ConnectorError::ConfigurationError(
137 "invalid Delta sink '_arrow_schema' encoding".into(),
138 )
139 })?;
140 self.query_schema = Some(Arc::clone(&schema));
141 self.schema = Some(Self::target_schema(&schema, self.config.write_mode));
142 }
143 if self.query_schema.is_none() {
144 return Err(ConnectorError::ConfigurationError("Delta sink needs its bound input schema before open; supply _arrow_schema or with_schema".into()));
145 }
146 #[cfg(feature = "delta-lake")]
147 if self.config.delivery_guarantee == DeliveryGuarantee::ExactlyOnce {
148 super::super::delta_io::validate_coordinated_storage_preflight(
149 &self.config.table_path,
150 &self.config.storage_options,
151 )?;
152 }
153 if self.config.delivery_guarantee == DeliveryGuarantee::ExactlyOnce
154 && !self.is_coordinated()
155 {
156 self.state = ConnectorState::Failed;
157 return Err(ConnectorError::ConfigurationError(
158 "Delta exactly-once requires coordinated append mode".into(),
159 ));
160 }
161
162 self.bind_writer_contract(config).await?;
163
164 if self.config.catalog_type == super::super::delta_config::DeltaCatalogType::None {
165 let explicit_storage = if config.properties().is_empty() {
166 self.config.storage_options.clone()
167 } else {
168 config.properties_with_prefix("storage.")
169 };
170 let diagnostics =
171 StorageCredentialResolver::resolve(&self.config.table_path, &explicit_storage);
172 info!(
173 operation_class = "connector-open",
174 storage_provider = %diagnostics.provider,
175 storage_endpoint_class = %diagnostics.endpoint_class(),
176 storage_auth_source = %diagnostics.auth_source,
177 mode = %self.config.write_mode,
178 guarantee = %self.config.delivery_guarantee,
179 "opening Delta Lake sink connector"
180 );
181 } else {
182 info!(
183 operation_class = "connector-open",
184 storage_provider = "catalog-resolved",
185 storage_endpoint_class = "catalog-resolved",
186 storage_auth_source = "catalog-resolved",
187 mode = %self.config.write_mode,
188 guarantee = %self.config.delivery_guarantee,
189 "opening Delta Lake sink connector"
190 );
191 }
192
193 #[cfg(feature = "delta-lake")]
195 {
196 let deadline = self.operation_deadline();
197 self.init_delta_table(deadline).await?;
198 if let Some(table) = &self.table {
199 super::super::schema_resolution::verify_identity(
200 self.schema_binding.as_ref(),
201 &super::super::schema_resolution::delta_native(table)?,
202 )?;
203 }
204 }
205
206 #[cfg(not(feature = "delta-lake"))]
207 {
208 self.state = ConnectorState::Failed;
209 return Err(ConnectorError::ConfigurationError(
210 "Delta Lake sink requires the 'delta-lake' feature to be enabled. \
211 Build with: cargo build --features delta-lake"
212 .into(),
213 ));
214 }
215
216 #[cfg(feature = "delta-lake")]
217 {
218 self.state = ConnectorState::Running;
219 info!("Delta Lake sink connector opened successfully");
220 Ok(())
221 }
222 }
223
224 async fn write_batch(&mut self, batch: &RecordBatch) -> Result<WriteResult, ConnectorError> {
225 if self.state != ConnectorState::Running {
226 return Err(ConnectorError::InvalidState {
227 expected: "Running".into(),
228 actual: self.state.to_string(),
229 });
230 }
231
232 #[cfg(feature = "delta-lake")]
233 {
234 self.ensure_coordinated_reconciled()?;
235 self.ensure_write_generation_usable()?;
236 }
237 #[cfg(feature = "delta-lake")]
238 let deadline = self.operation_deadline();
239
240 if batch.num_rows() == 0 {
241 return Ok(WriteResult::new(0, 0));
242 }
243
244 if self
245 .query_schema
246 .as_ref()
247 .is_none_or(|schema| schema.as_ref() != batch.schema().as_ref())
248 {
249 return Err(ConnectorError::SchemaMismatch(
250 "Delta batch changed the frozen query-output schema".into(),
251 ));
252 }
253 let projected;
254 let batch = if let Some(indices) = &self.writer_projection {
255 projected = batch
256 .project(indices)
257 .map_err(|error| ConnectorError::SchemaMismatch(error.to_string()))?;
258 &projected
259 } else {
260 batch
261 };
262 let num_rows = batch.num_rows();
263 let estimated_bytes = Self::estimate_batch_size(batch);
264
265 if !self.is_coordinated() {
269 let pending_rows = self.buffered_rows + self.staged_rows + num_rows;
270 let pending_bytes = self.buffered_bytes + self.staged_bytes + estimated_bytes;
271 let row_cap = self
272 .config
273 .max_buffer_records
274 .saturating_mul(4)
275 .max(num_rows);
276 let byte_cap = (self.config.target_file_size as u64)
277 .saturating_mul(4)
278 .max(estimated_bytes);
279 if pending_rows > row_cap || pending_bytes > byte_cap {
280 return Err(ConnectorError::WriteError(format!(
281 "delta sink buffer full ({pending_rows} rows, \
282 {pending_bytes} bytes pending; cap {row_cap} rows, \
283 {byte_cap} bytes) — backpressure until next flush/commit"
284 )));
285 }
286 }
287
288 if self.buffer_start_time.is_none() {
290 self.buffer_start_time = Some(Instant::now());
291 }
292 self.buffer.push(batch.clone());
293 self.buffered_rows += num_rows;
294 self.buffered_bytes += estimated_bytes;
295
296 #[cfg(feature = "delta-lake")]
297 if self.is_coordinated() && self.should_flush() {
298 self.stage_coordinated_buffer(deadline).await?;
299 } else if self.config.delivery_guarantee != DeliveryGuarantee::ExactlyOnce
300 && self.should_flush()
301 {
302 if !self.staged_batches.is_empty() {
303 self.flush_staged_to_delta(deadline).await?;
304 }
305 self.staged_batches = std::mem::take(&mut self.buffer);
306 self.staged_rows = self.buffered_rows;
307 self.staged_bytes = self.buffered_bytes;
308 self.buffered_rows = 0;
309 self.buffered_bytes = 0;
310 self.buffer_start_time = None;
311 self.flush_staged_to_delta(deadline).await?;
312 }
313
314 Ok(WriteResult::new(0, 0))
315 }
316
317 fn schema(&self) -> SchemaRef {
318 self.schema
319 .clone()
320 .unwrap_or_else(|| Arc::new(arrow_schema::Schema::empty()))
321 }
322
323 async fn checkpoint_artifact_intent(
324 &mut self,
325 _epoch: u64,
326 ) -> Result<Option<Vec<u8>>, ConnectorError> {
327 Ok(None)
330 }
331
332 async fn begin_epoch(&mut self, epoch: u64) -> Result<(), ConnectorError> {
333 #[cfg(feature = "delta-lake")]
334 {
335 self.ensure_coordinated_reconciled()?;
336 self.ensure_write_generation_usable()?;
337 }
338 #[cfg(feature = "delta-lake")]
339 if self.is_coordinated()
340 && (!self.buffer.is_empty()
341 || !self.staged_batches.is_empty()
342 || !self.coordinated_adds.is_empty())
343 {
344 return Err(ConnectorError::InvalidState {
345 expected: "the previous coordinated epoch to be prepared or rolled back".into(),
346 actual: "unresolved Delta staging data remains".into(),
347 });
348 }
349
350 self.current_epoch = epoch;
351 self.buffer.clear();
352 self.buffered_rows = 0;
353 self.buffered_bytes = 0;
354 self.buffer_start_time = None;
355
356 debug!(epoch, "Delta Lake: began epoch");
357 Ok(())
358 }
359
360 async fn pre_commit(&mut self, epoch: u64) -> Result<Option<Vec<u8>>, ConnectorError> {
361 #[cfg(feature = "delta-lake")]
365 if self.is_coordinated() {
366 self.ensure_coordinated_reconciled()?;
367 self.ensure_write_generation_usable()?;
368 let deadline = self.operation_deadline();
369 self.renew_credentials_if_expiring(deadline).await?;
370 self.stage_coordinated_buffer(deadline).await?;
371 if self.coordinated_adds.is_empty() {
372 if self.coordinated_binding.is_some() {
373 return Err(ConnectorError::Internal(
374 "empty Delta coordinated cut retained a table binding".into(),
375 ));
376 }
377 return Ok(None);
378 }
379
380 let binding = self.coordinated_binding.as_ref().ok_or_else(|| {
381 ConnectorError::Internal(
382 "non-empty Delta coordinated cut has no table binding".into(),
383 )
384 })?;
385 let descriptor =
386 super::super::delta_io::encode_commit_descriptor(binding, &self.coordinated_adds)?;
387 if descriptor.len() != self.coordinated_descriptor_bytes
388 || descriptor.len() > MAX_COORDINATED_COMMIT_PAYLOAD_BYTES
389 {
390 return Err(ConnectorError::Internal(format!(
391 "Delta coordinated descriptor accounting mismatch: tracked {}, encoded {}",
392 self.coordinated_descriptor_bytes,
393 descriptor.len()
394 )));
395 }
396 self.coordinated_adds.clear();
397 self.coordinated_binding = None;
398 self.coordinated_descriptor_bytes = 0;
399 return Ok(Some(descriptor));
400 }
401
402 if !self.buffer.is_empty() {
406 self.staged_batches = std::mem::take(&mut self.buffer);
407 self.staged_rows = self.buffered_rows;
408 self.staged_bytes = self.buffered_bytes;
409 self.buffered_rows = 0;
410 self.buffered_bytes = 0;
411 self.buffer_start_time = None;
412 }
413
414 debug!(epoch, "Delta Lake: pre-committed (batches staged)");
415 Ok(None)
416 }
417
418 async fn rollback_epoch(&mut self, epoch: u64) -> Result<(), ConnectorError> {
419 self.buffer.clear();
423 self.buffered_rows = 0;
424 self.buffered_bytes = 0;
425 self.buffer_start_time = None;
426 self.staged_batches.clear();
427 self.staged_rows = 0;
428 self.staged_bytes = 0;
429 #[cfg(feature = "delta-lake")]
430 {
431 self.coordinated_adds.clear();
432 self.coordinated_binding = None;
433 self.coordinated_descriptor_bytes = 0;
434 }
435
436 self.metrics.record_rollback();
437 warn!(epoch, "Delta Lake: rolled back epoch");
438 Ok(())
439 }
440
441 fn suggested_write_timeout(&self) -> Duration {
442 self.config.write_timeout
443 }
444
445 async fn flush(&mut self) -> Result<(), ConnectorError> {
446 #[cfg(feature = "delta-lake")]
447 {
448 self.ensure_coordinated_reconciled()?;
449 self.ensure_write_generation_usable()?;
450 }
451 #[cfg(feature = "delta-lake")]
452 let deadline = self.operation_deadline();
453
454 #[cfg(feature = "delta-lake")]
457 if self.config.delivery_guarantee != DeliveryGuarantee::ExactlyOnce {
458 if !self.staged_batches.is_empty() {
462 self.flush_staged_to_delta(deadline).await?;
463 }
464
465 if !self.buffer.is_empty() {
467 self.staged_batches = std::mem::take(&mut self.buffer);
468 self.staged_rows = self.buffered_rows;
469 self.staged_bytes = self.buffered_bytes;
470 self.buffered_rows = 0;
471 self.buffered_bytes = 0;
472 self.buffer_start_time = None;
473
474 self.flush_staged_to_delta(deadline).await?;
475 }
476 return Ok(());
477 }
478
479 #[cfg(feature = "delta-lake")]
480 return self.stage_coordinated_buffer(deadline).await;
481
482 #[cfg(not(feature = "delta-lake"))]
483 Ok(())
484 }
485
486 async fn close(&mut self) -> Result<(), ConnectorError> {
487 info!("closing Delta Lake sink connector");
488
489 #[cfg(feature = "delta-lake")]
492 if self.config.delivery_guarantee == DeliveryGuarantee::ExactlyOnce
493 || self.unresolved_delta_write
494 {
495 self.buffer.clear();
496 self.staged_batches.clear();
497 self.buffered_rows = 0;
498 self.buffered_bytes = 0;
499 self.staged_rows = 0;
500 self.staged_bytes = 0;
501 self.coordinated_adds.clear();
502 self.coordinated_binding = None;
503 self.coordinated_descriptor_bytes = 0;
504 } else {
505 self.flush().await?;
506 }
507
508 #[cfg(feature = "delta-lake")]
510 {
511 self.table = None;
512 }
513
514 self.state = ConnectorState::Closed;
515
516 info!(
517 delta_version = self.delta_version,
518 "Delta Lake sink connector closed"
519 );
520
521 Ok(())
522 }
523
524 #[cfg(feature = "delta-lake")]
525 fn as_coordinated_committer(&self) -> Option<&dyn crate::connector::CoordinatedCommitter> {
526 self.is_coordinated()
527 .then_some(self as &dyn crate::connector::CoordinatedCommitter)
528 }
529
530 fn coordinated_abort_cleaner(
531 &self,
532 ) -> Option<Arc<dyn crate::connector::CoordinatedAbortCleaner>> {
533 None
534 }
535}
536
537#[cfg(feature = "delta-lake")]
538#[async_trait]
539impl crate::connector::CoordinatedCommitter for DeltaLakeSink {
540 async fn commit_aggregated(
541 &self,
542 batch: crate::connector::CoordinatedCommitBatch,
543 context: crate::connector::CoordinatedCommitContext,
544 ) -> Result<(), ConnectorError> {
545 let deadline = context.deadline();
546 let publication_budget = context.remaining();
547 if publication_budget.is_zero() {
548 return Err(ConnectorError::TransactionError(
549 "Delta coordinated publication deadline elapsed before I/O".into(),
550 ));
551 }
552
553 batch.validate_shape().map_err(|error| {
554 ConnectorError::TransactionError(format!(
555 "Delta coordinated batch validation failed: {error}"
556 ))
557 })?;
558 let external_key = batch.namespace.external_key();
559 let exact_batch_fingerprint = batch.exact_fingerprint();
560 let target_cursor = crate::connector::CoordinatedCommitCursor {
561 checkpoint_id: batch.target.checkpoint_id,
562 fencing_token: batch.fencing_token,
563 };
564 let pending = UnresolvedDeltaPublication {
565 external_key,
566 target: target_cursor,
567 exact_batch_fingerprint,
568 };
569 {
570 let mut unresolved = self.coordinated_unresolved_publication.lock();
571 if unresolved
572 .as_ref()
573 .is_some_and(|unresolved| unresolved != &pending)
574 {
575 return Err(ConnectorError::TransactionError(
576 "Delta has a different unresolved coordinated publication; only that exact cut may be retried"
577 .into(),
578 ));
579 }
580 *unresolved = Some(pending.clone());
581 }
582
583 let operation = publish_coordinated_delta_batch(
584 self.resolved_table_path.clone(),
585 self.resolved_storage_options.clone(),
586 Arc::clone(&self.coordinated_unresolved_publication),
587 pending.clone(),
588 batch,
589 deadline,
590 publication_budget,
591 );
592 #[cfg(test)]
595 let operation = {
596 let delay = super::super::delta_io::DELAY_COORDINATED_CATALOG_COMMIT
597 .try_with(Clone::clone)
598 .ok();
599 async move {
600 if let Some(delay) = delay {
601 super::super::delta_io::DELAY_COORDINATED_CATALOG_COMMIT
602 .scope(delay, operation)
603 .await
604 } else {
605 operation.await
606 }
607 }
608 };
609 let task = match self.spawn_tracked_delta_task(operation) {
610 Ok(task) => task,
611 Err(error) => {
612 let mut unresolved = self.coordinated_unresolved_publication.lock();
613 if unresolved.as_ref() == Some(&pending) {
614 *unresolved = None;
615 }
616 return Err(error);
617 }
618 };
619
620 match tokio::time::timeout_at(deadline, task).await {
621 Ok(Ok(result)) => result,
622 Ok(Err(error)) => Err(ConnectorError::outcome_unknown(
623 format!("Delta coordinated publication task terminated unexpectedly: {error}"),
624 true,
625 )),
626 Err(_) => Err(ConnectorError::outcome_unknown(
627 format!(
628 "Delta coordinated publication exceeded its {publication_budget:?} remaining \
629 budget; reconcile the exact cursor before replay"
630 ),
631 true,
632 )),
633 }
634 }
635
636 async fn committed_cursor(
637 &self,
638 namespace: &crate::connector::CoordinatedCommitNamespace,
639 ) -> Result<Option<crate::connector::CoordinatedCommitCursor>, ConnectorError> {
640 namespace.validate()?;
641 let external_key = namespace.external_key();
642 let deadline = self.operation_deadline();
643 let observed = retry_delta_metadata_until(deadline, "coordinated cursor read", || async {
647 let table = super::super::delta_io::open_or_create_table(
648 &self.resolved_table_path,
649 self.resolved_storage_options.clone(),
650 None,
651 )
652 .await?;
653 super::super::schema_resolution::verify_identity(
656 self.schema_binding.as_ref(),
657 &super::super::schema_resolution::delta_native(&table)?,
658 )?;
659 super::super::delta_io::get_coordinated_cursor(&table, &external_key).await
660 })
661 .await?;
662 let mut unresolved = self.coordinated_unresolved_publication.lock();
663 if unresolved.as_ref().is_some_and(|pending| {
664 pending.external_key == external_key && pending.reconciled_by(observed)
665 }) {
666 *unresolved = None;
667 }
668 Ok(observed)
669 }
670}