Skip to main content

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;