Skip to main content

laminar_connectors/lakehouse/
iceberg_io.rs

1//! Feature-gated I/O operations for Apache Iceberg.
2//!
3//! Contains catalog construction, table loading, scanning, and writing
4//! functions. All code requires the `iceberg` feature.
5#![allow(clippy::disallowed_types)] // cold path: lakehouse I/O
6#![cfg(feature = "iceberg")]
7
8use std::collections::HashMap;
9use std::sync::Arc;
10
11use arrow_array::RecordBatch;
12use iceberg::table::Table;
13use iceberg::transaction::{ApplyTransactionAction, Transaction};
14use iceberg::{Catalog, CatalogBuilder, TableIdent};
15use iceberg_catalog_rest::RestCatalogBuilder;
16use iceberg_storage_opendal::OpenDalStorageFactory;
17use tokio_stream::StreamExt;
18
19use super::iceberg_config::{IcebergCatalogConfig, IcebergCatalogType};
20use crate::error::ConnectorError;
21
22/// Selects the `OpenDalStorageFactory` for the table-data URLs the catalog
23/// will return. Explicit `storage.type` wins; otherwise inferred from the
24/// `s3://` / `s3a://` / `file://` warehouse URL.
25fn storage_factory(
26    warehouse: &str,
27    storage_type: Option<&str>,
28) -> Result<Arc<dyn iceberg::io::StorageFactory>, ConnectorError> {
29    let scheme = storage_type
30        .map(str::to_lowercase)
31        .or_else(|| {
32            if warehouse.starts_with("s3a://") {
33                Some("s3a".to_string())
34            } else if warehouse.starts_with("s3://") {
35                Some("s3".to_string())
36            } else if warehouse.starts_with("file://") {
37                Some("fs".to_string())
38            } else {
39                None
40            }
41        })
42        .ok_or_else(|| {
43            ConnectorError::ConfigurationError(format!(
44                "[LDB-5100] cannot infer storage backend from warehouse '{warehouse}'; \
45                 set storage.type = 's3' | 's3a' | 'fs'"
46            ))
47        })?;
48
49    // configured_scheme is the bare scheme ("s3"), NOT "s3://" —
50    // iceberg-storage-opendal formats the prefix as `{scheme}://{bucket}/`.
51    let factory: Arc<dyn iceberg::io::StorageFactory> = match scheme.as_str() {
52        "s3" | "s3a" => Arc::new(OpenDalStorageFactory::S3 {
53            configured_scheme: scheme,
54            customized_credential_load: None,
55        }),
56        "fs" => Arc::new(OpenDalStorageFactory::Fs),
57        other => {
58            return Err(ConnectorError::ConfigurationError(format!(
59                "[LDB-5101] unsupported storage.type '{other}'; expected s3 | s3a | fs"
60            )));
61        }
62    };
63    Ok(factory)
64}
65
66/// Builds a REST catalog from configuration.
67///
68/// # Errors
69///
70/// Returns `ConnectorError::ConnectionFailed` if catalog initialization fails.
71pub async fn build_catalog(
72    config: &IcebergCatalogConfig,
73) -> Result<Arc<dyn Catalog>, ConnectorError> {
74    match config.catalog_type {
75        IcebergCatalogType::Rest => build_rest_catalog(config).await,
76    }
77}
78
79async fn build_rest_catalog(
80    config: &IcebergCatalogConfig,
81) -> Result<Arc<dyn Catalog>, ConnectorError> {
82    let storage_factory = storage_factory(&config.warehouse, config.storage_type.as_deref())?;
83
84    let mut props = HashMap::new();
85    props.insert("uri".to_string(), config.catalog_uri.clone());
86    props.insert("warehouse".to_string(), config.warehouse.clone());
87
88    for (k, v) in &config.properties {
89        props.insert(k.clone(), v.clone());
90    }
91
92    let catalog = RestCatalogBuilder::default()
93        .with_storage_factory(storage_factory)
94        .load("laminardb", props)
95        .await
96        .map_err(|e| ConnectorError::ConnectionFailed(format!("iceberg catalog: {e}")))?;
97
98    Ok(Arc::new(catalog))
99}
100
101/// Loads an Iceberg table from the catalog.
102///
103/// # Errors
104///
105/// Returns `ConnectorError::ReadError` if the table cannot be loaded.
106pub async fn load_table(
107    catalog: &dyn Catalog,
108    namespace: &str,
109    table_name: &str,
110) -> Result<Table, ConnectorError> {
111    let ns = iceberg::NamespaceIdent::from_strs(namespace.split('.').collect::<Vec<_>>())
112        .map_err(|e| ConnectorError::ConfigurationError(format!("invalid namespace: {e}")))?;
113
114    let ident = TableIdent::new(ns, table_name.to_string());
115
116    catalog
117        .load_table(&ident)
118        .await
119        .map_err(|e| ConnectorError::ReadError(format!("load table '{table_name}': {e}")))
120}
121
122/// Scans a table and returns all record batches for the current snapshot.
123///
124/// # Errors
125///
126/// Returns `ConnectorError::ReadError` on scan failure.
127pub async fn scan_table(
128    table: &Table,
129    snapshot_id: Option<i64>,
130    select_columns: &[String],
131) -> Result<Vec<RecordBatch>, ConnectorError> {
132    let mut scan_builder = table.scan();
133
134    if let Some(sid) = snapshot_id {
135        scan_builder = scan_builder.snapshot_id(sid);
136    }
137
138    if select_columns.is_empty() {
139        scan_builder = scan_builder.select_all();
140    } else {
141        scan_builder = scan_builder.select(select_columns.iter().map(String::as_str));
142    }
143
144    let scan = scan_builder
145        .build()
146        .map_err(|e| ConnectorError::ReadError(format!("build scan: {e}")))?;
147
148    let stream = scan
149        .to_arrow()
150        .await
151        .map_err(|e| ConnectorError::ReadError(format!("scan to arrow: {e}")))?;
152
153    let mut batches = Vec::new();
154    let mut stream = std::pin::pin!(stream);
155    while let Some(result) = stream.next().await {
156        let batch = result.map_err(|e| ConnectorError::ReadError(format!("read batch: {e}")))?;
157        batches.push(batch);
158    }
159
160    Ok(batches)
161}
162
163/// Returns the current snapshot ID of a table, if any.
164#[must_use]
165pub fn current_snapshot_id(table: &Table) -> Option<i64> {
166    table.metadata().current_snapshot().map(|s| s.snapshot_id())
167}
168
169/// Append `data_files` in one Iceberg transaction.
170///
171/// # Errors
172/// Returns [`ConnectorError::OutcomeUnknown`] when the catalog does not acknowledge the commit.
173pub async fn commit_data_files_append(
174    table: &Table,
175    catalog: &dyn Catalog,
176    data_files: Vec<iceberg::spec::DataFile>,
177) -> Result<Table, ConnectorError> {
178    let tx = Transaction::new(table);
179    let tx = if data_files.is_empty() {
180        tx
181    } else {
182        tx.fast_append()
183            .add_data_files(data_files)
184            .apply(tx)
185            .map_err(|e| ConnectorError::TransactionError(format!("apply fast_append: {e}")))?
186    };
187    tx.commit(catalog)
188        .await
189        .map_err(|error| iceberg_commit_error(&error))
190}
191
192fn iceberg_commit_error(error: &iceberg::Error) -> ConnectorError {
193    use iceberg::ErrorKind;
194
195    let kind = error.kind();
196    let retryable = error.retryable();
197    match kind {
198        ErrorKind::CatalogCommitConflicts => {
199            ConnectorError::WriteError(format!("Iceberg catalog commit conflict: {error}"))
200        }
201        ErrorKind::PreconditionFailed
202        | ErrorKind::DataInvalid
203        | ErrorKind::NamespaceAlreadyExists
204        | ErrorKind::TableAlreadyExists
205        | ErrorKind::NamespaceNotFound
206        | ErrorKind::TableNotFound
207        | ErrorKind::FeatureUnsupported => ConnectorError::TransactionError(format!(
208            "Iceberg catalog rejected commit ({kind}): {error}"
209        )),
210        ErrorKind::Unexpected => ConnectorError::outcome_unknown(
211            format!("Iceberg catalog commit failed after dispatch and may have applied: {error}"),
212            retryable,
213        ),
214        _ => ConnectorError::outcome_unknown(
215            format!("Iceberg catalog returned an unclassified commit failure: {error}"),
216            retryable,
217        ),
218    }
219}
220
221/// Creates an Iceberg table (and namespace) if it does not already exist.
222///
223/// # Errors
224///
225/// Returns `ConnectorError` on creation failure.
226pub async fn ensure_table_exists(
227    catalog: &dyn Catalog,
228    namespace: &str,
229    table_name: &str,
230    arrow_schema: &arrow_schema::SchemaRef,
231) -> Result<(), ConnectorError> {
232    let ns = iceberg::NamespaceIdent::from_strs(namespace.split('.').collect::<Vec<_>>())
233        .map_err(|e| ConnectorError::ConfigurationError(format!("invalid namespace: {e}")))?;
234
235    let ident = TableIdent::new(ns.clone(), table_name.to_string());
236
237    // Ensure the namespace exists before probing the table: a HEAD on a table in
238    // a missing namespace returns 400 (not 404) on some REST catalogs. Creation
239    // tolerates a concurrent creator (N writers may auto-create the
240    // same namespace at once) by re-checking existence on failure.
241    if !catalog
242        .namespace_exists(&ns)
243        .await
244        .map_err(|e| ConnectorError::ReadError(format!("namespace_exists: {e}")))?
245    {
246        if let Err(e) = catalog.create_namespace(&ns, HashMap::new()).await {
247            if !catalog.namespace_exists(&ns).await.unwrap_or(false) {
248                return Err(ConnectorError::WriteError(format!("create namespace: {e}")));
249            }
250        }
251    }
252
253    if catalog
254        .table_exists(&ident)
255        .await
256        .map_err(|e| ConnectorError::ReadError(format!("table_exists: {e}")))?
257    {
258        return Ok(());
259    }
260
261    // Pipeline-derived Arrow schemas don't carry `PARQUET:field_id`
262    // metadata; let iceberg-rust assign sequential IDs.
263    let iceberg_schema = iceberg::arrow::arrow_schema_to_schema_auto_assign_ids(arrow_schema)
264        .map_err(|e| {
265            ConnectorError::SchemaMismatch(format!("arrow→iceberg schema conversion: {e}"))
266        })?;
267
268    let creation = iceberg::TableCreation::builder()
269        .name(table_name.to_string())
270        .schema(iceberg_schema)
271        .build();
272
273    // Same race tolerance for the table itself.
274    if let Err(e) = catalog.create_table(&ns, creation).await {
275        if !catalog.table_exists(&ident).await.unwrap_or(false) {
276            return Err(ConnectorError::WriteError(format!("create table: {e}")));
277        }
278    }
279
280    Ok(())
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    #[test]
288    fn catalog_commit_failure_has_unknown_outcome() {
289        let source = iceberg::Error::new(iceberg::ErrorKind::Unexpected, "response lost")
290            .with_retryable(true);
291        let error = iceberg_commit_error(&source);
292        assert!(error.is_outcome_unknown());
293        assert!(error.is_transient());
294        assert!(error.to_string().contains("may have applied"));
295    }
296
297    #[test]
298    fn catalog_commit_conflict_is_a_definite_retryable_rejection() {
299        let source = iceberg::Error::new(
300            iceberg::ErrorKind::CatalogCommitConflicts,
301            "base metadata changed",
302        );
303        let error = iceberg_commit_error(&source);
304        assert!(!error.is_outcome_unknown());
305        assert!(error.is_transient());
306    }
307
308    #[test]
309    fn test_storage_factory_infers_s3_from_warehouse_url() {
310        let f = storage_factory("s3://bucket/warehouse", None).unwrap();
311        assert!(format!("{f:?}").contains("S3"));
312    }
313
314    #[test]
315    fn test_storage_factory_infers_s3a_from_warehouse_url() {
316        let f = storage_factory("s3a://bucket/warehouse", None).unwrap();
317        assert!(format!("{f:?}").contains("S3"));
318    }
319
320    #[test]
321    fn test_storage_factory_infers_fs_from_file_url() {
322        let f = storage_factory("file:///tmp/warehouse", None).unwrap();
323        assert!(format!("{f:?}").contains("Fs"));
324    }
325
326    #[test]
327    fn test_storage_factory_bare_path_requires_explicit_storage_type() {
328        // Trimmed `/` and `./` inference: REST catalogs use logical names
329        // and we don't want a silent default to local fs.
330        let err = storage_factory("/tmp/warehouse", None)
331            .unwrap_err()
332            .to_string();
333        assert!(err.contains("LDB-5100"), "got: {err}");
334    }
335
336    #[test]
337    fn test_storage_factory_explicit_overrides_inference() {
338        // Lakekeeper-style: warehouse is a name, storage backend is S3.
339        let f = storage_factory("demo", Some("s3")).unwrap();
340        assert!(format!("{f:?}").contains("S3"));
341    }
342
343    #[test]
344    fn test_storage_factory_unknown_warehouse_without_storage_type_errors() {
345        let err = storage_factory("demo", None).unwrap_err().to_string();
346        assert!(err.contains("LDB-5100"), "got: {err}");
347    }
348
349    #[test]
350    fn test_storage_factory_rejects_unknown_storage_type() {
351        let err = storage_factory("demo", Some("hdfs"))
352            .unwrap_err()
353            .to_string();
354        assert!(err.contains("LDB-5101"), "got: {err}");
355    }
356
357    fn empty_fixture_table() -> Table {
358        let schema = iceberg::spec::Schema::builder()
359            .with_fields(vec![])
360            .build()
361            .unwrap();
362
363        let creation = iceberg::TableCreation::builder()
364            .name("test_table".to_string())
365            .schema(schema)
366            .location("s3://test/location".to_string())
367            .build();
368
369        let metadata = iceberg::spec::TableMetadataBuilder::from_table_creation(creation)
370            .unwrap()
371            .build()
372            .unwrap();
373
374        Table::builder()
375            .metadata(metadata.metadata)
376            .identifier(TableIdent::new(
377                iceberg::NamespaceIdent::new("test".to_string()),
378                "t".to_string(),
379            ))
380            .file_io(iceberg::io::FileIO::new_with_memory())
381            .build()
382            .unwrap()
383    }
384
385    #[test]
386    fn test_current_snapshot_id_empty_table() {
387        assert!(current_snapshot_id(&empty_fixture_table()).is_none());
388    }
389}