Skip to main content

laminar_connectors/lakehouse/
iceberg.rs

1//! Apache Iceberg append sink connector.
2
3use 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
19/// Apache Iceberg sink connector.
20///
21/// Buffers `RecordBatch` data and publishes each flush with an Iceberg
22/// `fast_append` transaction.
23pub 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    /// Arrow schema derived from the Iceberg table schema (carries
35    /// `PARQUET:field_id` metadata required by the Iceberg Parquet writer).
36    #[cfg(feature = "iceberg")]
37    iceberg_arrow_schema: Option<SchemaRef>,
38}
39
40impl IcebergSink {
41    /// Creates a new Iceberg sink with the given configuration.
42    #[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    /// Reproject a batch onto the Iceberg-derived Arrow schema so every field
71    /// carries the `PARQUET:field_id` metadata the Iceberg writer requires.
72    #[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        // Fast path: field names, types, and count match — only metadata differs.
86        // Avoids per-column name lookup and Vec construction.
87        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        // Slow path: column reordering, type casting, or null-filling needed.
101        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                // Nullable Iceberg column not in pipeline — fill with nulls.
120                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        // Detect batch columns that would be silently dropped — every field
133        // in the source batch must map to a field in the target schema.
134        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    /// Parses compression config string to parquet Compression.
149    #[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            // Default to zstd(3) for anything else including "zstd".
156            _ => parquet::basic::Compression::ZSTD(
157                parquet::basic::ZstdLevel::try_new(3).unwrap_or_default(),
158            ),
159        }
160    }
161
162    /// Checks that every pipeline field still exists in the refreshed
163    /// Iceberg Arrow schema. Returns `SchemaMismatch` on drift.
164    #[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    /// Write staged batches to Parquet data files (no catalog commit).
183    #[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        // Stream every staged batch into ONE Parquet file (one S3 upload) per
209        // epoch. The source hands the sink many small batches (e.g. 1k-row Kafka
210        // polls); a file-per-batch loop would pay a full S3 multipart lifecycle
211        // for each, serializing the pipeline behind that I/O.
212        let mut writer = None;
213        for batch in &self.staged_batches {
214            if batch.num_rows() == 0 {
215                continue;
216            }
217
218            // Reproject onto the Iceberg-derived Arrow schema so every field
219            // carries PARQUET:field_id metadata; without it the writer can't
220            // map Arrow fields to Iceberg field IDs ("Field id N not found").
221            let aligned = self.align_batch_to_iceberg_schema(batch)?;
222
223            // Open lazily on the first non-empty batch so an all-empty epoch
224            // leaves no zero-row file behind.
225            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        // Re-parse config from the runtime ConnectorConfig (not factory defaults).
293        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            // Always derive the canonical schema from the Iceberg table.
313            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            // Store the Iceberg-derived Arrow schema (with PARQUET:field_id
321            // metadata) for use during Parquet writes.
322            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            // Validate pipeline schema against table schema, then use the
329            // pipeline schema as self.schema (it's what write_batch receives).
330            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        // Iceberg catalog writes can be slow under contention.
376        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}