Skip to main content

faucet_sink_delta/
sink.rs

1//! Delta Lake sink executor.
2//!
3//! Lazily opens (or creates) the target Delta table on the first `write_batch`
4//! so the schema can be inferred from real records, then appends via
5//! delta-rs's low-level [`RecordBatchWriter`] — one Delta commit per
6//! [`flush`](faucet_core::Sink::flush). No datafusion is pulled in.
7//!
8//! ## Flush / commit contract
9//!
10//! `RecordBatchWriter` buffers written batches into parquet in memory; the
11//! Delta transaction is only committed by `flush_and_commit`. The pipeline
12//! calls [`flush`](faucet_core::Sink::flush) after every bookmark-carrying
13//! page, so each page becomes its own atomic Delta commit. **A dropped sink
14//! that never `flush`es loses the buffered, uncommitted batch** — the same
15//! contract as the Parquet sink.
16
17use std::collections::HashSet;
18
19use arrow::datatypes::SchemaRef;
20use arrow::record_batch::RecordBatch;
21use async_trait::async_trait;
22use deltalake::DeltaTable;
23use deltalake::kernel::StructType;
24use deltalake::kernel::engine::arrow_conversion::TryIntoKernel;
25use deltalake::operations::create::CreateBuilder;
26use deltalake::writer::{DeltaWriter, RecordBatchWriter};
27use faucet_common_delta::convert::infer_arrow_schema;
28use faucet_core::{FaucetError, WriteMode};
29use serde_json::Value;
30use tokio::sync::Mutex;
31
32use crate::config::DeltaSinkConfig;
33
34/// A sink that appends JSON records to an Apache Delta Lake table.
35pub struct DeltaSink {
36    config: DeltaSinkConfig,
37    state: Mutex<SinkState>,
38}
39
40/// Mutable per-run state, guarded by a `Mutex` so `write_batch(&self, …)` can
41/// mutate the open table / writer.
42struct SinkState {
43    /// The open Delta table, established on first write. Advanced in place by
44    /// each `flush_and_commit`.
45    table: Option<DeltaTable>,
46    /// The record-batch writer bound to `table`. Rebuilt after a table (re)open.
47    writer: Option<RecordBatchWriter>,
48    /// The Arrow schema locked in on first write; every subsequent batch is
49    /// decoded against it. A record that diverges fails the batch (v1: no
50    /// schema evolution).
51    schema: Option<SchemaRef>,
52    /// Fields warned-about as dropped (present in a record, absent from the
53    /// locked schema). Deduped to one line per field per run.
54    warned_fields: HashSet<String>,
55    /// Whether any batch has been buffered since the last commit (so `flush`
56    /// can skip a no-op commit).
57    pending: bool,
58}
59
60impl SinkState {
61    fn new() -> Self {
62        Self {
63            table: None,
64            writer: None,
65            schema: None,
66            warned_fields: HashSet::new(),
67            pending: false,
68        }
69    }
70}
71
72impl DeltaSink {
73    /// Build a new Delta sink. Validates config eagerly; the table is opened
74    /// lazily on the first write.
75    pub async fn new(config: DeltaSinkConfig) -> Result<Self, FaucetError> {
76        config
77            .validate()
78            .map_err(|e| FaucetError::Config(format!("invalid delta sink config: {e}")))?;
79        // Register cloud object-store handlers up front so a bad scheme fails
80        // predictably rather than on first write.
81        config.connection.register_handlers();
82        Ok(Self {
83            config,
84            state: Mutex::new(SinkState::new()),
85        })
86    }
87
88    /// Ensure the table + writer + schema are established, inferring the schema
89    /// from `records` on the very first call.
90    async fn ensure_open(
91        &self,
92        state: &mut SinkState,
93        records: &[Value],
94    ) -> Result<(), FaucetError> {
95        if state.schema.is_none() {
96            let schema = infer_arrow_schema(records, self.config.effective_sample_size())?;
97            state.schema = Some(schema);
98        }
99        let schema = state.schema.clone().expect("schema set above");
100        self.ensure_table_writer(state, &schema).await
101    }
102
103    /// Establish the table + record-batch writer for an already-locked `schema`.
104    /// Shared by the JSON path ([`ensure_open`](Self::ensure_open), which infers
105    /// the schema from records) and the columnar path
106    /// ([`write_batch_columnar`](faucet_core::Sink::write_batch_columnar), which
107    /// takes it from the incoming `RecordBatch`).
108    async fn ensure_table_writer(
109        &self,
110        state: &mut SinkState,
111        schema: &SchemaRef,
112    ) -> Result<(), FaucetError> {
113        if state.table.is_none() {
114            let table = self.open_or_create(schema).await?;
115            state.table = Some(table);
116        }
117        if state.writer.is_none() {
118            let table = state.table.as_ref().expect("table set above");
119            let writer = RecordBatchWriter::for_table(table).map_err(|e| {
120                FaucetError::Sink(format!("delta: could not build record-batch writer: {e}"))
121            })?;
122            state.writer = Some(writer);
123        }
124        Ok(())
125    }
126
127    /// Open the existing table or create it from the inferred schema.
128    async fn open_or_create(&self, schema: &SchemaRef) -> Result<DeltaTable, FaucetError> {
129        if let Some(table) = self.config.connection.open_optional().await? {
130            return Ok(table);
131        }
132        if !self.config.create_if_not_missing {
133            return Err(FaucetError::Sink(format!(
134                "delta: table '{}' does not exist and create_if_not_missing is false",
135                self.config.connection.redacted_uri()
136            )));
137        }
138        self.create_table(schema).await
139    }
140
141    /// Create a new Delta table from the inferred Arrow schema + partitioning.
142    async fn create_table(&self, schema: &SchemaRef) -> Result<DeltaTable, FaucetError> {
143        // Every partition column must exist in the record schema.
144        for col in &self.config.partition_by {
145            if schema.field_with_name(col).is_err() {
146                return Err(FaucetError::Sink(format!(
147                    "delta: partition column '{col}' not present in the inferred record schema"
148                )));
149            }
150        }
151
152        let delta_schema: StructType = schema.as_ref().try_into_kernel().map_err(|e| {
153            FaucetError::Sink(format!(
154                "delta: could not convert Arrow schema to Delta: {e}"
155            ))
156        })?;
157
158        let mut builder = CreateBuilder::new()
159            .with_location(self.config.connection.location_string()?)
160            .with_storage_options(self.config.connection.merged_storage_options())
161            .with_columns(delta_schema.fields().cloned());
162        if !self.config.partition_by.is_empty() {
163            builder = builder.with_partition_columns(self.config.partition_by.clone());
164        }
165
166        builder.await.map_err(|e| {
167            FaucetError::Sink(format!(
168                "delta: could not create table '{}': {e}",
169                self.config.connection.redacted_uri()
170            ))
171        })
172    }
173
174    /// Decode a chunk of JSON records into a `RecordBatch` against the locked
175    /// schema, warning once per dropped unknown field.
176    fn encode_batch(
177        &self,
178        warned_fields: &mut HashSet<String>,
179        schema: SchemaRef,
180        records: &[Value],
181    ) -> Result<RecordBatch, FaucetError> {
182        warn_on_unknown_fields(warned_fields, &schema, records);
183
184        let mut decoder = arrow_json::ReaderBuilder::new(schema.clone())
185            .build_decoder()
186            .map_err(|e| FaucetError::Sink(format!("delta: could not build json decoder: {e}")))?;
187        decoder.serialize(records).map_err(|e| {
188            FaucetError::Sink(format!("delta: record does not match table schema: {e}"))
189        })?;
190        decoder
191            .flush()
192            .map_err(|e| FaucetError::Sink(format!("delta: json decode error: {e}")))?
193            .ok_or_else(|| FaucetError::Sink("delta: json decoder produced no batch".to_string()))
194    }
195
196    /// Write one chunk of records into the buffered writer.
197    async fn write_chunk(
198        &self,
199        state: &mut SinkState,
200        records: &[Value],
201    ) -> Result<usize, FaucetError> {
202        if records.is_empty() {
203            return Ok(0);
204        }
205        self.ensure_open(state, records).await?;
206        let schema = state.schema.clone().expect("schema set");
207        let batch = self.encode_batch(&mut state.warned_fields, schema, records)?;
208        let rows = batch.num_rows();
209        let writer = state.writer.as_mut().expect("writer set");
210        writer
211            .write(batch)
212            .await
213            .map_err(|e| FaucetError::Sink(format!("delta: write failed: {e}")))?;
214        state.pending = true;
215        Ok(rows)
216    }
217}
218
219#[async_trait]
220impl faucet_core::Sink for DeltaSink {
221    fn config_schema(&self) -> Value {
222        serde_json::to_value(faucet_core::schema_for!(DeltaSinkConfig))
223            .expect("schema serialization")
224    }
225
226    fn connector_name(&self) -> &'static str {
227        "delta"
228    }
229
230    fn dataset_uri(&self) -> String {
231        self.config.connection.redacted_uri()
232    }
233
234    fn supported_write_modes(&self) -> &'static [WriteMode] {
235        // Append-only in v1 (mirrors the Iceberg sink). MERGE/upsert is a
236        // version-gated follow-up (#317 "Out of scope").
237        &[WriteMode::Append]
238    }
239
240    async fn check(
241        &self,
242        ctx: &faucet_core::check::CheckContext,
243    ) -> Result<faucet_core::check::CheckReport, FaucetError> {
244        use faucet_core::check::{CheckReport, Probe};
245        let started = std::time::Instant::now();
246        // Metadata-only open (no data scan). A reachable store passes whether
247        // or not the table exists yet — `create_if_not_missing` handles an
248        // absent table at write time.
249        let probe =
250            match tokio::time::timeout(ctx.timeout, self.config.connection.open_optional()).await {
251                Ok(Ok(_)) => Probe::pass("table", started.elapsed()),
252                Ok(Err(e)) => Probe::fail_hint(
253                    "table",
254                    started.elapsed(),
255                    format!("delta sink probe failed: {e}"),
256                    "Verify table_uri, credentials, and object-store reachability.",
257                ),
258                Err(_) => Probe::fail_hint(
259                    "table",
260                    started.elapsed(),
261                    format!("delta sink probe timed out after {:?}", ctx.timeout),
262                    "Check object-store network reachability.",
263                ),
264            };
265        Ok(CheckReport::single(probe))
266    }
267
268    async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
269        if records.is_empty() {
270            return Ok(0);
271        }
272        let mut state = self.state.lock().await;
273        let bs = self.config.batch_size;
274        let mut total = 0;
275        if bs == 0 || records.len() <= bs {
276            total += self.write_chunk(&mut state, records).await?;
277        } else {
278            for chunk in records.chunks(bs) {
279                total += self.write_chunk(&mut state, chunk).await?;
280            }
281        }
282        Ok(total)
283    }
284
285    /// delta-rs writes via `RecordBatchWriter`, so the sink consumes Arrow
286    /// batches natively (#375): a `parquet → delta` / `delta → delta` chain
287    /// never materializes `Value`.
288    #[cfg(feature = "arrow")]
289    fn supports_columnar(&self) -> bool {
290        true
291    }
292
293    /// Write an Arrow batch straight into the buffered `RecordBatchWriter`,
294    /// skipping the `Value → RecordBatch` encode. The schema is locked from the
295    /// first batch's own schema (the columnar analogue of inferring it from the
296    /// first records); partition columns must be present in the batch (the Delta
297    /// source appends them), matching the JSON path's create-table contract.
298    #[cfg(feature = "arrow")]
299    async fn write_batch_columnar(&self, batch: &RecordBatch) -> Result<usize, FaucetError> {
300        if batch.num_rows() == 0 {
301            return Ok(0);
302        }
303        let mut state = self.state.lock().await;
304        if state.schema.is_none() {
305            state.schema = Some(batch.schema());
306        }
307        let schema = state.schema.clone().expect("schema set above");
308        self.ensure_table_writer(&mut state, &schema).await?;
309        let rows = batch.num_rows();
310        let writer = state.writer.as_mut().expect("writer set");
311        writer
312            .write(batch.clone())
313            .await
314            .map_err(|e| FaucetError::Sink(format!("delta: columnar write failed: {e}")))?;
315        state.pending = true;
316        Ok(rows)
317    }
318
319    async fn flush(&self) -> Result<(), FaucetError> {
320        let mut state = self.state.lock().await;
321        if !state.pending {
322            return Ok(());
323        }
324        // Take the writer + table out to satisfy the borrow checker, commit,
325        // then put the table back and drop the (now-flushed) writer so the next
326        // page rebuilds a fresh writer against the advanced table.
327        let mut writer = match state.writer.take() {
328            Some(w) => w,
329            None => return Ok(()),
330        };
331        let mut table = state
332            .table
333            .take()
334            .ok_or_else(|| FaucetError::Sink("delta: flush without an open table".to_string()))?;
335        let version = writer
336            .flush_and_commit(&mut table)
337            .await
338            .map_err(|e| FaucetError::Sink(format!("delta: commit failed: {e}")))?;
339        tracing::debug!(version, uri = %self.config.connection.redacted_uri(), "delta commit");
340        state.table = Some(table);
341        state.pending = false;
342        Ok(())
343    }
344}
345
346/// Emit a one-shot warning for each field present in `records` but absent from
347/// the locked `schema` (such fields are dropped by the JSON decoder).
348fn warn_on_unknown_fields(
349    warned_fields: &mut HashSet<String>,
350    schema: &SchemaRef,
351    records: &[Value],
352) {
353    for rec in records {
354        if let Value::Object(map) = rec {
355            for key in map.keys() {
356                if schema.field_with_name(key).is_err() && warned_fields.insert(key.clone()) {
357                    tracing::warn!(
358                        field = %key,
359                        "delta sink: dropping field not present in the table schema"
360                    );
361                }
362            }
363        }
364    }
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370    use faucet_core::Sink;
371
372    #[tokio::test]
373    async fn trait_metadata_methods() {
374        let sink = DeltaSink::new(DeltaSinkConfig::new("file:///tmp/delta_meta"))
375            .await
376            .unwrap();
377        assert_eq!(sink.connector_name(), "delta");
378        assert_eq!(sink.dataset_uri(), "file:///tmp/delta_meta");
379        assert_eq!(sink.supported_write_modes(), &[WriteMode::Append]);
380        assert!(sink.config_schema().is_object());
381    }
382
383    #[tokio::test]
384    async fn create_table_rejects_missing_partition_column() {
385        let dir = tempfile::tempdir().unwrap();
386        let uri = dir.path().join("p").to_string_lossy().into_owned();
387        let mut cfg = DeltaSinkConfig::new(&uri);
388        cfg.partition_by = vec!["nope".into()];
389        let sink = DeltaSink::new(cfg).await.unwrap();
390        let err = sink
391            .write_batch(&[serde_json::json!({"id": 1})])
392            .await
393            .unwrap_err();
394        assert!(err.to_string().contains("partition column"), "{err}");
395    }
396}