laminar_connectors/lakehouse/
iceberg.rs1use std::time::Duration;
4
5use arrow_array::RecordBatch;
6use arrow_schema::SchemaRef;
7use async_trait::async_trait;
8#[cfg(feature = "iceberg")]
9use tracing::info;
10
11use crate::config::{ConnectorConfig, ConnectorState};
12use crate::connector::{
13 SinkConnector, SinkConsistency, SinkContract, SinkInputMode, SinkTopology, WriteResult,
14};
15use crate::error::ConnectorError;
16
17use super::iceberg_config::IcebergSinkConfig;
18
19pub struct IcebergSink {
24 config: IcebergSinkConfig,
25 schema: Option<SchemaRef>,
26 state: ConnectorState,
27 buffer: Vec<RecordBatch>,
28 buffered_rows: usize,
29 staged_batches: Vec<RecordBatch>,
30 #[cfg(feature = "iceberg")]
31 catalog: Option<std::sync::Arc<dyn iceberg::Catalog>>,
32 #[cfg(feature = "iceberg")]
33 table: Option<iceberg::table::Table>,
34 #[cfg(feature = "iceberg")]
37 iceberg_arrow_schema: Option<SchemaRef>,
38}
39
40impl IcebergSink {
41 #[must_use]
43 pub fn new(config: IcebergSinkConfig, _registry: Option<&prometheus::Registry>) -> Self {
44 Self {
45 config,
46 schema: None,
47 state: ConnectorState::Created,
48 buffer: Vec::new(),
49 buffered_rows: 0,
50 staged_batches: Vec::new(),
51 #[cfg(feature = "iceberg")]
52 catalog: None,
53 #[cfg(feature = "iceberg")]
54 table: None,
55 #[cfg(feature = "iceberg")]
56 iceberg_arrow_schema: None,
57 }
58 }
59
60 fn clear_buffer(&mut self) {
61 self.buffer.clear();
62 self.buffered_rows = 0;
63 }
64
65 #[cfg(feature = "iceberg")]
66 fn clear_staged(&mut self) {
67 self.staged_batches.clear();
68 }
69
70 #[cfg(feature = "iceberg")]
73 fn align_batch_to_iceberg_schema(
74 &self,
75 batch: &RecordBatch,
76 ) -> Result<RecordBatch, ConnectorError> {
77 let target_schema =
78 self.iceberg_arrow_schema
79 .as_ref()
80 .ok_or_else(|| ConnectorError::InvalidState {
81 expected: "open".into(),
82 actual: "iceberg arrow schema not initialized".into(),
83 })?;
84
85 let batch_schema = batch.schema();
88 if batch_schema.fields().len() == target_schema.fields().len()
89 && batch_schema
90 .fields()
91 .iter()
92 .zip(target_schema.fields().iter())
93 .all(|(a, b)| a.name() == b.name() && a.data_type() == b.data_type())
94 {
95 return RecordBatch::try_new(target_schema.clone(), batch.columns().to_vec()).map_err(
96 |e| ConnectorError::WriteError(format!("align batch to iceberg schema: {e}")),
97 );
98 }
99
100 let mut columns = Vec::with_capacity(target_schema.fields().len());
102
103 for field in target_schema.fields() {
104 if let Ok(col_idx) = batch_schema.index_of(field.name()) {
105 let col = batch.column(col_idx);
106 if col.data_type() == field.data_type() {
107 columns.push(col.clone());
108 } else {
109 columns.push(arrow_cast::cast(col, field.data_type()).map_err(|e| {
110 ConnectorError::WriteError(format!(
111 "cast field '{}' from {} to {}: {e}",
112 field.name(),
113 col.data_type(),
114 field.data_type(),
115 ))
116 })?);
117 }
118 } else if field.is_nullable() {
119 columns.push(arrow_array::new_null_array(
121 field.data_type(),
122 batch.num_rows(),
123 ));
124 } else {
125 return Err(ConnectorError::SchemaMismatch(format!(
126 "Iceberg column '{}' is NOT NULL but missing from pipeline",
127 field.name(),
128 )));
129 }
130 }
131
132 for field in batch_schema.fields() {
135 if target_schema.field_with_name(field.name()).is_err() {
136 return Err(ConnectorError::SchemaMismatch(format!(
137 "pipeline column '{}' has no matching field in Iceberg table schema \
138 (schema evolved since open?)",
139 field.name(),
140 )));
141 }
142 }
143
144 RecordBatch::try_new(target_schema.clone(), columns)
145 .map_err(|e| ConnectorError::WriteError(format!("align batch to iceberg schema: {e}")))
146 }
147
148 #[cfg(feature = "iceberg")]
150 fn parquet_compression(name: &str) -> parquet::basic::Compression {
151 match name.to_lowercase().as_str() {
152 "snappy" => parquet::basic::Compression::SNAPPY,
153 "none" | "uncompressed" => parquet::basic::Compression::UNCOMPRESSED,
154 "lz4" => parquet::basic::Compression::LZ4,
155 _ => parquet::basic::Compression::ZSTD(
157 parquet::basic::ZstdLevel::try_new(3).unwrap_or_default(),
158 ),
159 }
160 }
161
162 #[cfg(feature = "iceberg")]
165 fn validate_schema_not_drifted(&self) -> Result<(), ConnectorError> {
166 if let (Some(pipeline_schema), Some(target_schema)) =
167 (&self.schema, &self.iceberg_arrow_schema)
168 {
169 for field in pipeline_schema.fields() {
170 if target_schema.field_with_name(field.name()).is_err() {
171 return Err(ConnectorError::SchemaMismatch(format!(
172 "pipeline field '{}' no longer exists in Iceberg table schema \
173 (concurrent schema evolution?)",
174 field.name(),
175 )));
176 }
177 }
178 }
179 Ok(())
180 }
181
182 #[cfg(feature = "iceberg")]
184 async fn write_staged_data_files(
185 &self,
186 ) -> Result<Vec<iceberg::spec::DataFile>, ConnectorError> {
187 use iceberg::writer::file_writer::{FileWriter, FileWriterBuilder, ParquetWriterBuilder};
188
189 let table = self
190 .table
191 .as_ref()
192 .ok_or_else(|| ConnectorError::InvalidState {
193 expected: "open".into(),
194 actual: "table not loaded".into(),
195 })?;
196
197 let file_io = table.file_io().clone();
198 let location = table.metadata().location().to_string();
199 let schema = table.current_schema_ref();
200
201 self.validate_schema_not_drifted()?;
202
203 let props = parquet::file::properties::WriterProperties::builder()
204 .set_compression(Self::parquet_compression(&self.config.compression))
205 .build();
206 let writer_builder = ParquetWriterBuilder::new(props, schema);
207
208 let mut writer = None;
213 for batch in &self.staged_batches {
214 if batch.num_rows() == 0 {
215 continue;
216 }
217
218 let aligned = self.align_batch_to_iceberg_schema(batch)?;
222
223 if writer.is_none() {
226 let file_path = format!("{location}/data/ldb-{}.parquet", uuid::Uuid::new_v4());
227 let output_file = file_io
228 .new_output(&file_path)
229 .map_err(|e| ConnectorError::WriteError(format!("create output: {e}")))?;
230 writer = Some(
231 writer_builder
232 .clone()
233 .build(output_file)
234 .await
235 .map_err(|e| {
236 ConnectorError::WriteError(format!("build parquet writer: {e}"))
237 })?,
238 );
239 }
240
241 writer
242 .as_mut()
243 .expect("writer opened above")
244 .write(&aligned)
245 .await
246 .map_err(|e| ConnectorError::WriteError(format!("parquet write: {e}")))?;
247 }
248
249 let mut all_data_files = Vec::new();
250 if let Some(writer) = writer {
251 let data_file_builders = writer
252 .close()
253 .await
254 .map_err(|e| ConnectorError::WriteError(format!("close parquet writer: {e}")))?;
255 for dfb in data_file_builders {
256 let data_file = dfb
257 .build()
258 .map_err(|e| ConnectorError::WriteError(format!("data file build: {e}")))?;
259 all_data_files.push(data_file);
260 }
261 }
262
263 Ok(all_data_files)
264 }
265}
266
267#[async_trait]
268impl SinkConnector for IcebergSink {
269 fn contract(&self, config: &ConnectorConfig) -> Result<SinkContract, ConnectorError> {
270 let config = IcebergSinkConfig::from_config(config)?;
271 let warehouse = config.catalog.warehouse.to_ascii_lowercase();
272 let shared_warehouse = ["s3://", "s3a://"]
273 .iter()
274 .any(|scheme| warehouse.starts_with(scheme))
275 || config
276 .catalog
277 .storage_type
278 .as_deref()
279 .is_some_and(|storage| matches!(storage, "s3" | "s3a"));
280 Ok(SinkContract::new(
281 SinkConsistency::DurableAtLeastOnce,
282 if shared_warehouse {
283 SinkTopology::MultiWriter
284 } else {
285 SinkTopology::Singleton
286 },
287 SinkInputMode::AppendOnly,
288 ))
289 }
290
291 async fn open(&mut self, config: &ConnectorConfig) -> Result<(), ConnectorError> {
292 if !config.properties().is_empty() {
294 self.config = IcebergSinkConfig::from_config(config)?;
295 }
296
297 #[cfg(feature = "iceberg")]
298 {
299 let catalog = super::iceberg_io::build_catalog(&self.config.catalog).await?;
300 let ns = &self.config.catalog.namespace;
301 let tbl = &self.config.catalog.table_name;
302
303 if self.config.auto_create {
304 if let Some(schema) = config.arrow_schema() {
305 super::iceberg_io::ensure_table_exists(catalog.as_ref(), ns, tbl, &schema)
306 .await?;
307 }
308 }
309
310 let table = super::iceberg_io::load_table(catalog.as_ref(), ns, tbl).await?;
311
312 let iceberg_schema = table.current_schema_ref();
314 let table_schema = std::sync::Arc::new(
315 iceberg::arrow::schema_to_arrow_schema(&iceberg_schema).map_err(|e| {
316 ConnectorError::SchemaMismatch(format!("iceberg→arrow schema: {e}"))
317 })?,
318 );
319
320 self.iceberg_arrow_schema = Some(table_schema.clone());
323
324 if self.schema.is_none() {
325 self.schema = Some(table_schema.clone());
326 }
327
328 if let Some(pipeline_schema) = config.arrow_schema() {
331 super::iceberg_config::validate_sink_schema(&pipeline_schema, &table_schema)?;
332 self.schema = Some(pipeline_schema);
333 }
334
335 self.catalog = Some(catalog);
336 self.table = Some(table);
337 self.state = ConnectorState::Running;
338
339 info!(table = tbl, namespace = ns, "iceberg sink connected");
340 return Ok(());
341 }
342
343 #[cfg(not(feature = "iceberg"))]
344 {
345 self.state = ConnectorState::Failed;
346 Err(ConnectorError::ConfigurationError(
347 "Apache Iceberg requires the 'iceberg' feature".into(),
348 ))
349 }
350 }
351
352 async fn write_batch(&mut self, batch: &RecordBatch) -> Result<WriteResult, ConnectorError> {
353 if batch.num_rows() == 0 {
354 return Ok(WriteResult::new(0, 0));
355 }
356
357 if self.schema.is_none() {
358 self.schema = Some(batch.schema());
359 }
360
361 let rows = batch.num_rows();
362 self.buffer.push(batch.clone());
363 self.buffered_rows += rows;
364
365 Ok(WriteResult::new(rows, 0))
366 }
367
368 fn schema(&self) -> SchemaRef {
369 self.schema
370 .clone()
371 .unwrap_or_else(|| std::sync::Arc::new(arrow_schema::Schema::empty()))
372 }
373
374 fn suggested_write_timeout(&self) -> Duration {
375 Duration::from_secs(300)
377 }
378
379 async fn flush(&mut self) -> Result<(), ConnectorError> {
380 if self.staged_batches.is_empty() {
381 if self.buffer.is_empty() {
382 return Ok(());
383 }
384 std::mem::swap(&mut self.staged_batches, &mut self.buffer);
385 self.clear_buffer();
386 }
387
388 #[cfg(feature = "iceberg")]
389 {
390 let data_files = self.write_staged_data_files().await?;
391 if data_files.is_empty() {
392 self.clear_staged();
393 return Ok(());
394 }
395 let catalog = self
396 .catalog
397 .as_ref()
398 .ok_or_else(|| ConnectorError::InvalidState {
399 expected: "open".into(),
400 actual: "catalog not initialized".into(),
401 })?;
402 let table = self
403 .table
404 .as_ref()
405 .ok_or_else(|| ConnectorError::InvalidState {
406 expected: "open".into(),
407 actual: "table not loaded".into(),
408 })?;
409 let updated =
410 super::iceberg_io::commit_data_files_append(table, catalog.as_ref(), data_files)
411 .await?;
412 self.table = Some(updated);
413 self.clear_staged();
414 return Ok(());
415 }
416
417 #[cfg(not(feature = "iceberg"))]
418 Err(ConnectorError::ConfigurationError(
419 "Apache Iceberg requires the 'iceberg' feature".into(),
420 ))
421 }
422
423 async fn close(&mut self) -> Result<(), ConnectorError> {
424 while !self.staged_batches.is_empty() || !self.buffer.is_empty() {
425 self.flush().await?;
426 }
427 #[cfg(feature = "iceberg")]
428 {
429 self.catalog = None;
430 self.table = None;
431 self.iceberg_arrow_schema = None;
432 }
433 self.state = ConnectorState::Closed;
434 Ok(())
435 }
436}
437
438#[cfg(test)]
439mod tests {
440 use super::*;
441 use arrow_array::Int64Array;
442 use arrow_schema::{DataType, Field, Schema};
443 use std::sync::Arc;
444
445 fn test_schema() -> SchemaRef {
446 Arc::new(Schema::new(vec![Field::new("id", DataType::Int64, false)]))
447 }
448
449 fn test_batch(n: usize) -> RecordBatch {
450 let ids: Vec<i64> = (0..n as i64).collect();
451 RecordBatch::try_new(test_schema(), vec![Arc::new(Int64Array::from(ids))]).unwrap()
452 }
453
454 fn test_connector_config() -> ConnectorConfig {
455 let mut config = ConnectorConfig::new("iceberg");
456 config.set("catalog.uri", "http://localhost:8181");
457 config.set("warehouse", "s3://test/wh");
458 config.set("namespace", "test");
459 config.set("table.name", "events");
460 config
461 }
462
463 fn test_config() -> IcebergSinkConfig {
464 IcebergSinkConfig::from_config(&test_connector_config()).unwrap()
465 }
466
467 #[test]
468 fn test_new_sink() {
469 let sink = IcebergSink::new(test_config(), None);
470 assert!(sink.schema.is_none());
471 assert_eq!(sink.buffered_rows, 0);
472 }
473
474 #[tokio::test]
475 async fn test_write_buffers_batches() {
476 let mut sink = IcebergSink::new(test_config(), None);
477
478 let result = sink.write_batch(&test_batch(100)).await.unwrap();
479 assert_eq!(result.records_written, 100);
480 assert_eq!(sink.buffered_rows, 100);
481 assert_eq!(sink.buffer.len(), 1);
482
483 let result = sink.write_batch(&test_batch(50)).await.unwrap();
484 assert_eq!(result.records_written, 50);
485 assert_eq!(sink.buffered_rows, 150);
486 assert_eq!(sink.buffer.len(), 2);
487 }
488
489 #[test]
490 fn test_contract() {
491 let sink = IcebergSink::new(test_config(), None);
492 let contract = sink.contract(&test_connector_config()).unwrap();
493 assert_eq!(contract.consistency, SinkConsistency::DurableAtLeastOnce);
494 assert_eq!(contract.topology, SinkTopology::MultiWriter);
495 assert_eq!(contract.input_mode, SinkInputMode::AppendOnly);
496 assert!(sink.as_coordinated_committer().is_none());
497 assert_eq!(sink.suggested_write_timeout(), Duration::from_secs(300));
498 }
499
500 #[test]
501 fn test_contract_uses_singleton_for_wired_local_storage() {
502 let sink = IcebergSink::new(test_config(), None);
503 let mut config = test_connector_config();
504 config.set("warehouse", "file:///tmp/iceberg");
505 config.set("storage.type", "fs");
506
507 let contract = sink.contract(&config).unwrap();
508 assert_eq!(contract.topology, SinkTopology::Singleton);
509 }
510
511 #[test]
512 fn test_contract_uses_multi_writer_for_named_s3_warehouse() {
513 let sink = IcebergSink::new(test_config(), None);
514 let mut config = test_connector_config();
515 config.set("warehouse", "production");
516 config.set("storage.type", "s3");
517
518 let contract = sink.contract(&config).unwrap();
519 assert_eq!(contract.topology, SinkTopology::MultiWriter);
520 }
521}