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
101        if state.table.is_none() {
102            let table = self.open_or_create(&schema).await?;
103            state.table = Some(table);
104        }
105        if state.writer.is_none() {
106            let table = state.table.as_ref().expect("table set above");
107            let writer = RecordBatchWriter::for_table(table).map_err(|e| {
108                FaucetError::Sink(format!("delta: could not build record-batch writer: {e}"))
109            })?;
110            state.writer = Some(writer);
111        }
112        Ok(())
113    }
114
115    /// Open the existing table or create it from the inferred schema.
116    async fn open_or_create(&self, schema: &SchemaRef) -> Result<DeltaTable, FaucetError> {
117        if let Some(table) = self.config.connection.open_optional().await? {
118            return Ok(table);
119        }
120        if !self.config.create_if_not_missing {
121            return Err(FaucetError::Sink(format!(
122                "delta: table '{}' does not exist and create_if_not_missing is false",
123                self.config.connection.redacted_uri()
124            )));
125        }
126        self.create_table(schema).await
127    }
128
129    /// Create a new Delta table from the inferred Arrow schema + partitioning.
130    async fn create_table(&self, schema: &SchemaRef) -> Result<DeltaTable, FaucetError> {
131        // Every partition column must exist in the record schema.
132        for col in &self.config.partition_by {
133            if schema.field_with_name(col).is_err() {
134                return Err(FaucetError::Sink(format!(
135                    "delta: partition column '{col}' not present in the inferred record schema"
136                )));
137            }
138        }
139
140        let delta_schema: StructType = schema.as_ref().try_into_kernel().map_err(|e| {
141            FaucetError::Sink(format!(
142                "delta: could not convert Arrow schema to Delta: {e}"
143            ))
144        })?;
145
146        let mut builder = CreateBuilder::new()
147            .with_location(self.config.connection.location_string()?)
148            .with_storage_options(self.config.connection.merged_storage_options())
149            .with_columns(delta_schema.fields().cloned());
150        if !self.config.partition_by.is_empty() {
151            builder = builder.with_partition_columns(self.config.partition_by.clone());
152        }
153
154        builder.await.map_err(|e| {
155            FaucetError::Sink(format!(
156                "delta: could not create table '{}': {e}",
157                self.config.connection.redacted_uri()
158            ))
159        })
160    }
161
162    /// Decode a chunk of JSON records into a `RecordBatch` against the locked
163    /// schema, warning once per dropped unknown field.
164    fn encode_batch(
165        &self,
166        warned_fields: &mut HashSet<String>,
167        schema: SchemaRef,
168        records: &[Value],
169    ) -> Result<RecordBatch, FaucetError> {
170        warn_on_unknown_fields(warned_fields, &schema, records);
171
172        let mut decoder = arrow_json::ReaderBuilder::new(schema.clone())
173            .build_decoder()
174            .map_err(|e| FaucetError::Sink(format!("delta: could not build json decoder: {e}")))?;
175        decoder.serialize(records).map_err(|e| {
176            FaucetError::Sink(format!("delta: record does not match table schema: {e}"))
177        })?;
178        decoder
179            .flush()
180            .map_err(|e| FaucetError::Sink(format!("delta: json decode error: {e}")))?
181            .ok_or_else(|| FaucetError::Sink("delta: json decoder produced no batch".to_string()))
182    }
183
184    /// Write one chunk of records into the buffered writer.
185    async fn write_chunk(
186        &self,
187        state: &mut SinkState,
188        records: &[Value],
189    ) -> Result<usize, FaucetError> {
190        if records.is_empty() {
191            return Ok(0);
192        }
193        self.ensure_open(state, records).await?;
194        let schema = state.schema.clone().expect("schema set");
195        let batch = self.encode_batch(&mut state.warned_fields, schema, records)?;
196        let rows = batch.num_rows();
197        let writer = state.writer.as_mut().expect("writer set");
198        writer
199            .write(batch)
200            .await
201            .map_err(|e| FaucetError::Sink(format!("delta: write failed: {e}")))?;
202        state.pending = true;
203        Ok(rows)
204    }
205}
206
207#[async_trait]
208impl faucet_core::Sink for DeltaSink {
209    fn config_schema(&self) -> Value {
210        serde_json::to_value(faucet_core::schema_for!(DeltaSinkConfig))
211            .expect("schema serialization")
212    }
213
214    fn connector_name(&self) -> &'static str {
215        "delta"
216    }
217
218    fn dataset_uri(&self) -> String {
219        self.config.connection.redacted_uri()
220    }
221
222    fn supported_write_modes(&self) -> &'static [WriteMode] {
223        // Append-only in v1 (mirrors the Iceberg sink). MERGE/upsert is a
224        // version-gated follow-up (#317 "Out of scope").
225        &[WriteMode::Append]
226    }
227
228    async fn check(
229        &self,
230        ctx: &faucet_core::check::CheckContext,
231    ) -> Result<faucet_core::check::CheckReport, FaucetError> {
232        use faucet_core::check::{CheckReport, Probe};
233        let started = std::time::Instant::now();
234        // Metadata-only open (no data scan). A reachable store passes whether
235        // or not the table exists yet — `create_if_not_missing` handles an
236        // absent table at write time.
237        let probe =
238            match tokio::time::timeout(ctx.timeout, self.config.connection.open_optional()).await {
239                Ok(Ok(_)) => Probe::pass("table", started.elapsed()),
240                Ok(Err(e)) => Probe::fail_hint(
241                    "table",
242                    started.elapsed(),
243                    format!("delta sink probe failed: {e}"),
244                    "Verify table_uri, credentials, and object-store reachability.",
245                ),
246                Err(_) => Probe::fail_hint(
247                    "table",
248                    started.elapsed(),
249                    format!("delta sink probe timed out after {:?}", ctx.timeout),
250                    "Check object-store network reachability.",
251                ),
252            };
253        Ok(CheckReport::single(probe))
254    }
255
256    async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
257        if records.is_empty() {
258            return Ok(0);
259        }
260        let mut state = self.state.lock().await;
261        let bs = self.config.batch_size;
262        let mut total = 0;
263        if bs == 0 || records.len() <= bs {
264            total += self.write_chunk(&mut state, records).await?;
265        } else {
266            for chunk in records.chunks(bs) {
267                total += self.write_chunk(&mut state, chunk).await?;
268            }
269        }
270        Ok(total)
271    }
272
273    async fn flush(&self) -> Result<(), FaucetError> {
274        let mut state = self.state.lock().await;
275        if !state.pending {
276            return Ok(());
277        }
278        // Take the writer + table out to satisfy the borrow checker, commit,
279        // then put the table back and drop the (now-flushed) writer so the next
280        // page rebuilds a fresh writer against the advanced table.
281        let mut writer = match state.writer.take() {
282            Some(w) => w,
283            None => return Ok(()),
284        };
285        let mut table = state
286            .table
287            .take()
288            .ok_or_else(|| FaucetError::Sink("delta: flush without an open table".to_string()))?;
289        let version = writer
290            .flush_and_commit(&mut table)
291            .await
292            .map_err(|e| FaucetError::Sink(format!("delta: commit failed: {e}")))?;
293        tracing::debug!(version, uri = %self.config.connection.redacted_uri(), "delta commit");
294        state.table = Some(table);
295        state.pending = false;
296        Ok(())
297    }
298}
299
300/// Emit a one-shot warning for each field present in `records` but absent from
301/// the locked `schema` (such fields are dropped by the JSON decoder).
302fn warn_on_unknown_fields(
303    warned_fields: &mut HashSet<String>,
304    schema: &SchemaRef,
305    records: &[Value],
306) {
307    for rec in records {
308        if let Value::Object(map) = rec {
309            for key in map.keys() {
310                if schema.field_with_name(key).is_err() && warned_fields.insert(key.clone()) {
311                    tracing::warn!(
312                        field = %key,
313                        "delta sink: dropping field not present in the table schema"
314                    );
315                }
316            }
317        }
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324    use faucet_core::Sink;
325
326    #[tokio::test]
327    async fn trait_metadata_methods() {
328        let sink = DeltaSink::new(DeltaSinkConfig::new("file:///tmp/delta_meta"))
329            .await
330            .unwrap();
331        assert_eq!(sink.connector_name(), "delta");
332        assert_eq!(sink.dataset_uri(), "file:///tmp/delta_meta");
333        assert_eq!(sink.supported_write_modes(), &[WriteMode::Append]);
334        assert!(sink.config_schema().is_object());
335    }
336
337    #[tokio::test]
338    async fn create_table_rejects_missing_partition_column() {
339        let dir = tempfile::tempdir().unwrap();
340        let uri = dir.path().join("p").to_string_lossy().into_owned();
341        let mut cfg = DeltaSinkConfig::new(&uri);
342        cfg.partition_by = vec!["nope".into()];
343        let sink = DeltaSink::new(cfg).await.unwrap();
344        let err = sink
345            .write_batch(&[serde_json::json!({"id": 1})])
346            .await
347            .unwrap_err();
348        assert!(err.to_string().contains("partition column"), "{err}");
349    }
350}