faucet_sink_delta/
sink.rs1use 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
34pub struct DeltaSink {
36 config: DeltaSinkConfig,
37 state: Mutex<SinkState>,
38}
39
40struct SinkState {
43 table: Option<DeltaTable>,
46 writer: Option<RecordBatchWriter>,
48 schema: Option<SchemaRef>,
52 warned_fields: HashSet<String>,
55 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 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 config.connection.register_handlers();
82 Ok(Self {
83 config,
84 state: Mutex::new(SinkState::new()),
85 })
86 }
87
88 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 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 async fn create_table(&self, schema: &SchemaRef) -> Result<DeltaTable, FaucetError> {
131 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 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 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 &[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 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 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
300fn 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}