laminar_connectors/lakehouse/delta/mod.rs
1//! Delta Lake sink connector implementation.
2//!
3//! [`DeltaLakeSink`] implements [`SinkConnector`], writing Arrow `RecordBatch`
4//! data to Delta Lake tables with ACID transactions and at-least-once delivery
5//! (exactly-once is selected once at the pipeline/runtime boundary).
6//!
7//! # Write Strategies
8//!
9//! - **Append mode**: Arrow-to-Parquet zero-copy writes for immutable streams
10//! - **Overwrite mode**: Replace partition contents for recomputation
11//! - **Upsert mode**: CDC MERGE via Z-set changelog integration
12//!
13//! Exactly-once append writes prepare immutable Parquet descriptors during the
14//! checkpoint and publish them with one namespaced, designated Delta commit.
15//! At-least-once append, overwrite, and upsert writes commit through `flush()`.
16//!
17//! # Ring Architecture
18//!
19//! - **Ring 0**: No sink code. Data arrives via SPSC channel (~5ns push).
20//! - **Ring 1**: Batch buffering, Parquet writes, Delta log commits.
21//! - **Ring 2**: Schema management, configuration, health checks.
22
23mod lifecycle;
24mod operations;
25mod publication;
26
27#[cfg(feature = "delta-lake")]
28use std::future::Future;
29use std::sync::Arc;
30use std::time::{Duration, Instant};
31
32use arrow_array::{Array, RecordBatch};
33#[cfg(feature = "delta-lake")]
34use arrow_schema::DataType;
35use arrow_schema::SchemaRef;
36use async_trait::async_trait;
37use tracing::{debug, info, warn};
38
39#[cfg(feature = "delta-lake")]
40use deltalake::DeltaTable;
41
42#[cfg(feature = "delta-lake")]
43use deltalake::protocol::SaveMode;
44
45use crate::config::{ConnectorConfig, ConnectorState};
46#[cfg(feature = "delta-lake")]
47use crate::connector::{ConnectorTaskGuard, MAX_COORDINATED_COMMIT_PAYLOAD_BYTES};
48use crate::connector::{
49 ConnectorTaskOwner, ConnectorTaskTracker, SinkConnector, SinkConsistency, SinkContract,
50 SinkInputMode, SinkTopology, WriteResult,
51};
52use crate::error::ConnectorError;
53use crate::storage::{StorageCredentialResolver, StorageProvider};
54
55use super::delta_config::{DeltaLakeSinkConfig, DeltaWriteMode};
56use super::delta_metrics::DeltaLakeSinkMetrics;
57use crate::connector::DeliveryGuarantee;
58
59#[cfg(feature = "delta-lake")]
60use publication::{
61 classify_delta_attempt_error, count_collapsed_ops, publish_coordinated_delta_batch,
62 retry_delta_metadata_until, run_tracked_delta_task, DeltaWriteTaskSuccess,
63 UnresolvedDeltaPublication,
64};
65
66/// Delta Lake sink connector.
67///
68/// Writes Arrow `RecordBatch` to Delta Lake tables with ACID transactions,
69/// at-least-once delivery (exactly-once opt-in), partitioning, and
70/// externally managed table maintenance.
71///
72/// # Exactly-Once Semantics
73///
74/// `pre_commit()` materializes immutable Parquet files and returns their Delta
75/// `Add` actions. The runtime durably seals the checkpoint before one
76/// designated committer calls `commit_aggregated()` with every participant's
77/// descriptor. `rollback_epoch()` discards in-memory state; unreferenced files
78/// are reclaimed later by retention-safe vacuum.
79pub struct DeltaLakeSink {
80 /// Sole admission authority for Delta tasks that may outlive a cancelled caller.
81 #[cfg(feature = "delta-lake")]
82 task_owner: ConnectorTaskOwner,
83 /// Stable terminal observer retained by the runtime after this sink is retired.
84 task_tracker: ConnectorTaskTracker,
85 /// Sink configuration.
86 config: DeltaLakeSinkConfig,
87 /// Arrow schema for input batches (set on first write or from existing table).
88 schema: Option<SchemaRef>,
89 /// Connector lifecycle state.
90 state: ConnectorState,
91 /// Current epoch being written.
92 current_epoch: u64,
93 /// `RecordBatch` buffer for the current epoch.
94 buffer: Vec<RecordBatch>,
95 /// Total rows buffered in current epoch.
96 buffered_rows: usize,
97 /// Total bytes buffered (estimated) in current epoch.
98 buffered_bytes: u64,
99 /// Current Delta Lake table version.
100 delta_version: u64,
101 /// Time when the current buffer started accumulating.
102 buffer_start_time: Option<Instant>,
103 /// Sink metrics.
104 metrics: DeltaLakeSinkMetrics,
105 /// Delta Lake table handle (present when `delta-lake` feature is enabled).
106 #[cfg(feature = "delta-lake")]
107 table: Option<DeltaTable>,
108 /// Staged batches ready for either coordinated descriptor creation or an
109 /// at-least-once flush. This separation lets `rollback_epoch()` discard
110 /// prepared in-memory data without publishing it to the Delta log.
111 staged_batches: Vec<RecordBatch>,
112 /// Rows staged for commit (mirrors `staged_batches`).
113 staged_rows: usize,
114 /// Estimated bytes staged for commit.
115 staged_bytes: u64,
116 /// Uncommitted `Add` actions for immutable Parquet files materialized in
117 /// the current coordinated epoch.
118 #[cfg(feature = "delta-lake")]
119 coordinated_adds: Vec<deltalake::kernel::Add>,
120 /// Canonical table incarnation and write-metadata fingerprint captured
121 /// from the exact immutable snapshot used to create `coordinated_adds`.
122 #[cfg(feature = "delta-lake")]
123 coordinated_binding: Option<super::commit_descriptor::DeltaTableBinding>,
124 /// Exact encoded descriptor size for `coordinated_adds`, or zero when the
125 /// vector is empty.
126 #[cfg(feature = "delta-lake")]
127 coordinated_descriptor_bytes: usize,
128 /// Exact publication that must be retried or observed at its target before
129 /// this instance stages a later cut. Absence cannot resolve a timed-out
130 /// remote catalog mutation because the server may still complete it.
131 #[cfg(feature = "delta-lake")]
132 coordinated_unresolved_publication: Arc<parking_lot::Mutex<Option<UnresolvedDeltaPublication>>>,
133 /// Resolved table path after catalog lookup (may differ from `config.table_path`
134 /// when using Unity/Glue catalogs). Used by `reopen_table()` so retries
135 /// target the same resolved path that `open()` connected to.
136 #[cfg(feature = "delta-lake")]
137 resolved_table_path: String,
138 /// Resolved storage options after catalog lookup.
139 #[cfg(feature = "delta-lake")]
140 resolved_storage_options: std::collections::HashMap<String, String>,
141 /// When true, Delta table init is deferred until the first `write_batch()`
142 /// provides a schema. This happens when Unity Catalog auto-create is
143 /// configured but the pipeline schema is not yet available at `open()` time.
144 #[cfg(feature = "delta-lake")]
145 needs_deferred_delta_init: bool,
146 /// Pre-built Parquet writer properties for hot-path writes. Built once
147 /// in `init_delta_table()` from `config.parquet`; cloning this is far
148 /// cheaper than rebuilding (string parsing, bloom-filter column setup)
149 /// from scratch on every commit.
150 #[cfg(feature = "delta-lake")]
151 cached_writer_properties: Option<deltalake::parquet::file::properties::WriterProperties>,
152 /// Shared `DataFusion` session for upsert/merge operations. Creating a
153 /// fresh `SessionContext` per merge allocated a runtime env, memory
154 /// pool, and object-store registry each commit; reusing one flattens
155 /// allocator churn under steady-state upsert load.
156 #[cfg(feature = "delta-lake")]
157 merge_session: Option<datafusion::prelude::SessionContext>,
158 /// A dispatched Delta operation lost its result, so this generation cannot
159 /// safely admit another write even after its detached task terminates.
160 #[cfg(feature = "delta-lake")]
161 unresolved_delta_write: bool,
162 /// Test hook that makes coordinated descriptor preparation remain pending.
163 /// This is compiled out of production builds.
164 #[cfg(all(test, feature = "delta-lake"))]
165 stall_descriptor_write: bool,
166}
167
168/// Pre-creates a Unity Catalog external Delta table via the REST API if
169/// `catalog.storage.location` is configured and a schema is available.
170/// Idempotent: treats "already exists" (HTTP 409 / `ALREADY_EXISTS`) as success.
171#[cfg(all(feature = "delta-lake", feature = "delta-lake-unity"))]
172async fn ensure_uc_table_exists(
173 config: &DeltaLakeSinkConfig,
174 schema: Option<&SchemaRef>,
175) -> Result<(), ConnectorError> {
176 let super::delta_config::DeltaCatalogType::Unity {
177 ref workspace_url,
178 ref access_token,
179 } = config.catalog_type
180 else {
181 return Ok(());
182 };
183
184 let Some(ref storage_location) = config.catalog_storage_location else {
185 return Ok(());
186 };
187
188 let Some(arrow_schema) = schema else {
189 warn!(
190 "catalog.storage.location is set but no schema available — \
191 skipping Unity Catalog auto-create"
192 );
193 return Ok(());
194 };
195
196 let catalog = config.catalog_name.as_deref().unwrap_or_default();
197 let schema_name = config.catalog_schema.as_deref().unwrap_or_default();
198 let table_name = config
199 .table_path
200 .strip_prefix("uc://")
201 .and_then(|s| s.rsplit('.').next())
202 .unwrap_or(&config.table_path);
203
204 let columns = super::unity_catalog::arrow_to_uc_columns(arrow_schema);
205 super::unity_catalog::create_uc_table(
206 workspace_url,
207 access_token,
208 catalog,
209 schema_name,
210 table_name,
211 storage_location,
212 &columns,
213 )
214 .await
215}
216
217impl std::fmt::Debug for DeltaLakeSink {
218 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
219 f.debug_struct("DeltaLakeSink")
220 .field("state", &self.state)
221 .field("table_path", &"<configured>")
222 .field("mode", &self.config.write_mode)
223 .field("guarantee", &self.config.delivery_guarantee)
224 .field("current_epoch", &self.current_epoch)
225 .field("buffered_rows", &self.buffered_rows)
226 .field("delta_version", &self.delta_version)
227 .finish_non_exhaustive()
228 }
229}
230
231// ── Helper functions ────────────────────────────────────────────────
232
233/// Filters a `RecordBatch` using a boolean mask and projects to the given column indices.
234///
235/// Takes `mask` by value to hand it straight to `BooleanArray::from` without
236/// an intermediate `Vec<bool>` copy. Projects before filtering so the SIMD
237/// kernel only walks user columns, not the dropped metadata columns.
238fn filter_and_project(
239 batch: &RecordBatch,
240 mask: Vec<bool>,
241 col_indices: &[usize],
242) -> Result<RecordBatch, ConnectorError> {
243 use arrow_array::BooleanArray;
244 use arrow_select::filter::filter_record_batch;
245
246 let bool_array = BooleanArray::from(mask);
247
248 let projected = batch
249 .project(col_indices)
250 .map_err(|e| ConnectorError::Internal(format!("batch projection failed: {e}")))?;
251
252 filter_record_batch(&projected, &bool_array)
253 .map_err(|e| ConnectorError::Internal(format!("arrow filter failed: {e}")))
254}
255
256#[cfg(test)]
257#[allow(clippy::cast_possible_wrap)]
258#[allow(clippy::cast_precision_loss)]
259#[allow(clippy::float_cmp)]
260mod tests;