laminar_connectors/lakehouse/
iceberg_io.rs1#![allow(clippy::disallowed_types)] #![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
22fn 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 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
66pub 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
101pub 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
122pub 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#[must_use]
165pub fn current_snapshot_id(table: &Table) -> Option<i64> {
166 table.metadata().current_snapshot().map(|s| s.snapshot_id())
167}
168
169pub 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
221pub 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 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 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 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 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 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}