Skip to main content

laminar_connectors/lakehouse/iceberg_io/
mod.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-core")]
7
8use std::collections::HashMap;
9use std::sync::Arc;
10
11use arrow_array::RecordBatch;
12use futures_util::StreamExt;
13use iceberg::table::Table;
14use iceberg::{Catalog, TableIdent};
15#[cfg(any(
16    feature = "iceberg-storage-s3",
17    feature = "iceberg-storage-gcs",
18    feature = "iceberg-storage-azure",
19    feature = "iceberg-storage-fs"
20))]
21#[cfg(any(test, feature = "iceberg-catalog-rest"))]
22use iceberg_storage_opendal::OpenDalResolvingStorageFactory;
23
24#[cfg(feature = "iceberg-catalog-rest")]
25use super::iceberg_config::IcebergStorageEncryption;
26#[cfg(any(test, feature = "iceberg-catalog-rest"))]
27use super::iceberg_config::IcebergStorageType;
28use super::iceberg_config::{IcebergCatalogConfig, IcebergCatalogType, IcebergStorageConfig};
29use crate::error::ConnectorError;
30#[cfg(any(test, feature = "iceberg-catalog-rest"))]
31use crate::storage::{StorageLocation, StorageProvider};
32
33const COMPAT_SCAN_MAX_BATCHES: usize = 65_536;
34const COMPAT_SCAN_MAX_BYTES: usize = 64 * 1024 * 1024;
35const COMPAT_SCAN_CONCURRENCY: usize = 4;
36const COMPAT_SCAN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
37const WRITE_DATA_PATH_PROPERTY: &str = "write.data.path";
38const WRITE_FOLDER_STORAGE_PATH_PROPERTY: &str = "write.folder-storage.path";
39
40pub(crate) fn checked_deadline(
41    timeout: std::time::Duration,
42    setting: &str,
43) -> Result<tokio::time::Instant, ConnectorError> {
44    tokio::time::Instant::now()
45        .checked_add(timeout)
46        .ok_or_else(|| {
47            ConnectorError::ConfigurationError(format!(
48                "[LDB-ICEBERG-DEADLINE-OVERFLOW] {setting} exceeds the platform clock range"
49            ))
50        })
51}
52
53#[derive(Debug, Clone, Copy)]
54pub(crate) enum CatalogAccess {
55    Read,
56    Write { auto_create: bool },
57}
58
59#[derive(Debug, Clone, Default)]
60pub(crate) struct CatalogCapabilities {
61    pub(crate) idempotency_key_lifetime: Option<std::time::Duration>,
62}
63
64#[derive(Clone, Default)]
65pub(crate) struct CatalogSession {
66    #[cfg(feature = "iceberg-catalog-rest")]
67    rest_authentication: Option<rest_catalog::RestAuthentication>,
68}
69
70pub(crate) struct BuiltCatalog {
71    pub(crate) catalog: Arc<dyn Catalog>,
72    pub(crate) capabilities: CatalogCapabilities,
73    pub(crate) session: CatalogSession,
74}
75
76#[derive(Debug, Clone, Copy)]
77pub(crate) struct AtomicTableRequirements {
78    #[cfg(feature = "iceberg-catalog-rest")]
79    pub(crate) table_uuid: uuid::Uuid,
80    #[cfg(feature = "iceberg-catalog-rest")]
81    pub(crate) schema_id: i32,
82    #[cfg(feature = "iceberg-catalog-rest")]
83    pub(crate) partition_spec_id: i32,
84    #[cfg(feature = "iceberg-catalog-rest")]
85    pub(crate) sort_order_id: i64,
86}
87
88impl AtomicTableRequirements {
89    #[cfg(feature = "iceberg-catalog-rest")]
90    pub(crate) fn from_table(table: &Table) -> Self {
91        Self {
92            table_uuid: table.metadata().uuid(),
93            schema_id: table.metadata().current_schema_id(),
94            partition_spec_id: table.metadata().default_partition_spec_id(),
95            sort_order_id: table.metadata().default_sort_order_id(),
96        }
97    }
98
99    #[cfg(not(feature = "iceberg-catalog-rest"))]
100    pub(crate) fn from_table(_table: &Table) -> Self {
101        Self {}
102    }
103}
104
105mod append;
106pub use append::commit_data_files_append;
107pub(crate) use append::{
108    commit_generated_data_files_append, iceberg_commit_error, GeneratedAppendError,
109};
110
111#[cfg(any(
112    feature = "iceberg-storage-s3",
113    feature = "iceberg-storage-gcs",
114    feature = "iceberg-storage-azure",
115    feature = "iceberg-storage-fs"
116))]
117mod bounded_storage;
118
119#[cfg(feature = "iceberg-catalog-rest")]
120mod rest_catalog;
121
122mod single_dispatch;
123pub(crate) use single_dispatch::SingleDispatchCatalog;
124
125#[cfg(any(
126    feature = "iceberg-storage-s3",
127    feature = "iceberg-storage-gcs",
128    feature = "iceberg-storage-azure",
129    feature = "iceberg-storage-fs"
130))]
131#[cfg(any(test, feature = "iceberg-catalog-rest"))]
132use bounded_storage::BoundedStorageFactory;
133
134pub(crate) fn external_error_summary(error: &iceberg::Error) -> String {
135    format!(
136        "{} ({})",
137        error.kind(),
138        if error.retryable() {
139            "retryable"
140        } else {
141            "terminal"
142        }
143    )
144}
145
146mod table_creation;
147
148/// Selects storage capabilities for the table-data URLs the catalog returns.
149/// The resolving factory dispatches on each returned URL, while the configured
150/// type keeps feature-disabled failures deterministic before catalog I/O.
151#[cfg(any(test, feature = "iceberg-catalog-rest"))]
152fn storage_factory(
153    warehouse: &str,
154    config: &IcebergStorageConfig,
155    configured_properties: &HashMap<String, String>,
156) -> Result<Arc<dyn iceberg::io::StorageFactory>, ConnectorError> {
157    let storage_type = config
158        .storage_type
159        .or_else(|| infer_storage_type(warehouse))
160        .ok_or_else(|| {
161            ConnectorError::ConfigurationError(
162                "[LDB-5100] cannot infer storage backend from catalog warehouse; set storage.type explicitly"
163                    .into(),
164            )
165        })?;
166
167    match storage_type {
168        IcebergStorageType::S3 => s3_storage_factory(config, configured_properties),
169        IcebergStorageType::Gcs => gcs_storage_factory(config, configured_properties),
170        IcebergStorageType::Azure => azure_storage_factory(config, configured_properties),
171        IcebergStorageType::Fs => fs_storage_factory(config, configured_properties),
172    }
173}
174
175#[cfg(any(test, feature = "iceberg-catalog-rest"))]
176fn infer_storage_type(warehouse: &str) -> Option<IcebergStorageType> {
177    match StorageLocation::parse(warehouse).ok()?.provider {
178        StorageProvider::AwsS3 => Some(IcebergStorageType::S3),
179        StorageProvider::Gcs => Some(IcebergStorageType::Gcs),
180        StorageProvider::AzureAdls => Some(IcebergStorageType::Azure),
181        StorageProvider::Local => Some(IcebergStorageType::Fs),
182    }
183}
184
185#[cfg(feature = "iceberg-storage-s3")]
186#[cfg(any(test, feature = "iceberg-catalog-rest"))]
187#[allow(clippy::unnecessary_wraps)] // Matches the fail-closed feature-disabled signature.
188fn s3_storage_factory(
189    storage: &IcebergStorageConfig,
190    configured_properties: &HashMap<String, String>,
191) -> Result<Arc<dyn iceberg::io::StorageFactory>, ConnectorError> {
192    Ok(Arc::new(BoundedStorageFactory::new(
193        OpenDalResolvingStorageFactory::new(),
194        storage.connect_timeout,
195        storage.request_timeout,
196        configured_properties,
197    )))
198}
199
200#[cfg(not(feature = "iceberg-storage-s3"))]
201#[cfg(any(test, feature = "iceberg-catalog-rest"))]
202fn s3_storage_factory(
203    _storage: &IcebergStorageConfig,
204    _configured_properties: &HashMap<String, String>,
205) -> Result<Arc<dyn iceberg::io::StorageFactory>, ConnectorError> {
206    Err(missing_storage_feature("s3", "iceberg-storage-s3"))
207}
208
209#[cfg(feature = "iceberg-storage-gcs")]
210#[cfg(any(test, feature = "iceberg-catalog-rest"))]
211#[allow(clippy::unnecessary_wraps)] // Matches the fail-closed feature-disabled signature.
212fn gcs_storage_factory(
213    storage: &IcebergStorageConfig,
214    configured_properties: &HashMap<String, String>,
215) -> Result<Arc<dyn iceberg::io::StorageFactory>, ConnectorError> {
216    Ok(Arc::new(BoundedStorageFactory::new(
217        OpenDalResolvingStorageFactory::new(),
218        storage.connect_timeout,
219        storage.request_timeout,
220        configured_properties,
221    )))
222}
223
224#[cfg(not(feature = "iceberg-storage-gcs"))]
225#[cfg(any(test, feature = "iceberg-catalog-rest"))]
226fn gcs_storage_factory(
227    _storage: &IcebergStorageConfig,
228    _configured_properties: &HashMap<String, String>,
229) -> Result<Arc<dyn iceberg::io::StorageFactory>, ConnectorError> {
230    Err(missing_storage_feature("gcs", "iceberg-gcs"))
231}
232
233#[cfg(feature = "iceberg-storage-azure")]
234#[cfg(any(test, feature = "iceberg-catalog-rest"))]
235#[allow(clippy::unnecessary_wraps)] // Matches the fail-closed feature-disabled signature.
236fn azure_storage_factory(
237    storage: &IcebergStorageConfig,
238    configured_properties: &HashMap<String, String>,
239) -> Result<Arc<dyn iceberg::io::StorageFactory>, ConnectorError> {
240    Ok(Arc::new(BoundedStorageFactory::new(
241        OpenDalResolvingStorageFactory::new(),
242        storage.connect_timeout,
243        storage.request_timeout,
244        configured_properties,
245    )))
246}
247
248#[cfg(not(feature = "iceberg-storage-azure"))]
249#[cfg(any(test, feature = "iceberg-catalog-rest"))]
250fn azure_storage_factory(
251    _storage: &IcebergStorageConfig,
252    _configured_properties: &HashMap<String, String>,
253) -> Result<Arc<dyn iceberg::io::StorageFactory>, ConnectorError> {
254    Err(missing_storage_feature("azure", "iceberg-azure"))
255}
256
257#[cfg(feature = "iceberg-storage-fs")]
258#[cfg(any(test, feature = "iceberg-catalog-rest"))]
259#[allow(clippy::unnecessary_wraps)] // Matches the fail-closed feature-disabled signature.
260fn fs_storage_factory(
261    storage: &IcebergStorageConfig,
262    configured_properties: &HashMap<String, String>,
263) -> Result<Arc<dyn iceberg::io::StorageFactory>, ConnectorError> {
264    Ok(Arc::new(BoundedStorageFactory::new(
265        OpenDalResolvingStorageFactory::new(),
266        storage.connect_timeout,
267        storage.request_timeout,
268        configured_properties,
269    )))
270}
271
272#[cfg(not(feature = "iceberg-storage-fs"))]
273#[cfg(any(test, feature = "iceberg-catalog-rest"))]
274fn fs_storage_factory(
275    _storage: &IcebergStorageConfig,
276    _configured_properties: &HashMap<String, String>,
277) -> Result<Arc<dyn iceberg::io::StorageFactory>, ConnectorError> {
278    Err(missing_storage_feature("fs", "iceberg-storage-fs"))
279}
280
281#[cfg(all(
282    any(test, feature = "iceberg-catalog-rest"),
283    any(
284        not(feature = "iceberg-storage-s3"),
285        not(feature = "iceberg-storage-gcs"),
286        not(feature = "iceberg-storage-azure"),
287        not(feature = "iceberg-storage-fs")
288    )
289))]
290fn missing_storage_feature(storage: &str, feature: &str) -> ConnectorError {
291    ConnectorError::FeatureUnsupported(format!(
292        "iceberg.storage.{storage}: build with the '{feature}' feature"
293    ))
294}
295
296/// Builds a REST catalog from configuration.
297///
298/// # Errors
299///
300/// Returns `ConnectorError::ConnectionFailed` if catalog initialization fails.
301#[cfg_attr(not(feature = "iceberg-catalog-rest"), allow(clippy::unused_async))]
302pub async fn build_catalog(
303    config: &IcebergCatalogConfig,
304    storage: &IcebergStorageConfig,
305) -> Result<Arc<dyn Catalog>, ConnectorError> {
306    Ok(
307        build_catalog_for_access(config, storage, CatalogAccess::Read)
308            .await?
309            .catalog,
310    )
311}
312
313pub(crate) async fn build_catalog_for_access(
314    config: &IcebergCatalogConfig,
315    storage: &IcebergStorageConfig,
316    access: CatalogAccess,
317) -> Result<BuiltCatalog, ConnectorError> {
318    build_catalog_for_access_with_metrics(config, storage, access, None).await
319}
320
321#[cfg(feature = "iceberg-catalog-rest")]
322pub(crate) async fn build_catalog_for_access_with_metrics(
323    config: &IcebergCatalogConfig,
324    storage: &IcebergStorageConfig,
325    access: CatalogAccess,
326    credential_refresh_failures: Option<prometheus::IntCounter>,
327) -> Result<BuiltCatalog, ConnectorError> {
328    match config.catalog_type {
329        IcebergCatalogType::Rest => {
330            rest_catalog::build(config, storage, access, credential_refresh_failures).await
331        }
332        other => Err(unsupported_catalog(other)),
333    }
334}
335
336#[cfg(not(feature = "iceberg-catalog-rest"))]
337pub(crate) fn build_catalog_for_access_with_metrics(
338    config: &IcebergCatalogConfig,
339    _storage: &IcebergStorageConfig,
340    access: CatalogAccess,
341    _credential_refresh_failures: Option<prometheus::IntCounter>,
342) -> std::future::Ready<Result<BuiltCatalog, ConnectorError>> {
343    if let CatalogAccess::Write { auto_create } = access {
344        let _ = auto_create;
345    }
346    std::future::ready(Err(unsupported_catalog(config.catalog_type)))
347}
348
349fn unsupported_catalog(catalog_type: IcebergCatalogType) -> ConnectorError {
350    let message = match catalog_type {
351        IcebergCatalogType::Rest => {
352            "iceberg.catalog.rest: build with the 'iceberg-catalog-rest' feature"
353        }
354        IcebergCatalogType::Glue => {
355            "iceberg.catalog.glue: released catalog APIs are not enabled in this build"
356        }
357        IcebergCatalogType::Hms => {
358            "iceberg.catalog.hms: released catalog APIs are not enabled in this build"
359        }
360        IcebergCatalogType::S3Tables => {
361            "iceberg.catalog.s3tables: released catalog APIs are not enabled in this build"
362        }
363        IcebergCatalogType::Sql => {
364            "iceberg.catalog.sql: released catalog APIs are not enabled in this build"
365        }
366    };
367    ConnectorError::FeatureUnsupported(message.into())
368}
369
370#[cfg(feature = "iceberg-catalog-rest")]
371pub(crate) async fn build_publication_catalog(
372    catalog: Arc<dyn Catalog>,
373    config: &IcebergCatalogConfig,
374    storage: &IcebergStorageConfig,
375    capabilities: &CatalogCapabilities,
376    session: &CatalogSession,
377    idempotency_key: Option<uuid::Uuid>,
378    requirements: AtomicTableRequirements,
379) -> Result<Option<Arc<dyn Catalog>>, ConnectorError> {
380    if session.rest_authentication.is_none() {
381        return Ok(None);
382    }
383    rest_catalog::build_publication(
384        catalog,
385        config,
386        storage,
387        session,
388        capabilities,
389        idempotency_key,
390        requirements,
391    )
392    .await
393    .map(Some)
394}
395
396#[cfg(not(feature = "iceberg-catalog-rest"))]
397pub(crate) async fn build_publication_catalog(
398    _catalog: Arc<dyn Catalog>,
399    _config: &IcebergCatalogConfig,
400    _storage: &IcebergStorageConfig,
401    _capabilities: &CatalogCapabilities,
402    _session: &CatalogSession,
403    _idempotency_key: Option<uuid::Uuid>,
404    _requirements: AtomicTableRequirements,
405) -> Result<Option<Arc<dyn Catalog>>, ConnectorError> {
406    Ok(None)
407}
408
409#[cfg(feature = "iceberg-catalog-rest")]
410fn rest_properties(
411    config: &IcebergCatalogConfig,
412    storage: &IcebergStorageConfig,
413) -> Result<HashMap<String, String>, ConnectorError> {
414    for key in storage.properties.keys() {
415        let normalized = key.to_ascii_lowercase();
416        if config.properties.contains_key(key)
417            || matches!(
418                normalized.as_str(),
419                "token"
420                    | "credential"
421                    | "oauth2-server-uri"
422                    | "scope"
423                    | "audience"
424                    | "resource"
425                    | "prefix"
426                    | "uri"
427                    | "warehouse"
428                    | "disable-header-redaction"
429            )
430            || normalized.starts_with("header.")
431        {
432            return Err(ConnectorError::ConfigurationError(format!(
433                "storage.property.{key} overlaps Iceberg REST catalog configuration"
434            )));
435        }
436    }
437    let mut properties = config.properties.clone();
438    properties.extend(storage.properties.clone());
439    if let Some(prefix) = &config.prefix {
440        properties.insert("prefix".into(), prefix.clone());
441    }
442    if let Some(uri) = &config.oauth2_server_uri {
443        properties.insert("oauth2-server-uri".into(), uri.clone());
444    }
445    if let Some(scope) = &config.oauth2_scope {
446        properties.insert("scope".into(), scope.clone());
447    }
448    apply_storage_properties(
449        &mut properties,
450        storage,
451        storage
452            .storage_type
453            .or_else(|| infer_storage_type(&config.warehouse)),
454    );
455    Ok(properties)
456}
457
458#[cfg(feature = "iceberg-catalog-rest")]
459fn validate_storage_options(
460    warehouse: &str,
461    storage: &IcebergStorageConfig,
462) -> Result<(), ConnectorError> {
463    let storage_type = storage
464        .storage_type
465        .or_else(|| infer_storage_type(warehouse));
466    if storage_type == Some(IcebergStorageType::Azure) && storage.endpoint.is_some() {
467        return Err(ConnectorError::FeatureUnsupported(
468            "iceberg.storage.azure.endpoint: iceberg-storage-opendal 0.10.1 derives the ADLS endpoint from the table URL and exposes no endpoint property"
469                .into(),
470        ));
471    }
472    if storage_type != Some(IcebergStorageType::S3)
473        && (storage.region.is_some()
474            || storage.path_style
475            || storage.encryption != IcebergStorageEncryption::None)
476    {
477        return Err(ConnectorError::ConfigurationError(
478            "storage.region, storage.path_style, and storage.encryption are valid only for Iceberg S3 storage"
479                .into(),
480        ));
481    }
482    if storage_type == Some(IcebergStorageType::Fs) && storage.endpoint.is_some() {
483        return Err(ConnectorError::ConfigurationError(
484            "storage.endpoint is not valid for Iceberg filesystem storage".into(),
485        ));
486    }
487    Ok(())
488}
489
490#[cfg(feature = "iceberg-catalog-rest")]
491fn apply_storage_properties(
492    properties: &mut HashMap<String, String>,
493    storage: &IcebergStorageConfig,
494    storage_type: Option<IcebergStorageType>,
495) {
496    if let Some(endpoint) = &storage.endpoint {
497        match storage_type {
498            Some(IcebergStorageType::S3) => {
499                properties.insert("s3.endpoint".into(), endpoint.clone());
500            }
501            Some(IcebergStorageType::Gcs) => {
502                properties.insert("gcs.service.path".into(), endpoint.clone());
503            }
504            Some(IcebergStorageType::Azure | IcebergStorageType::Fs) | None => {}
505        }
506    }
507    if storage_type == Some(IcebergStorageType::S3) {
508        if let Some(region) = &storage.region {
509            properties.insert("s3.region".into(), region.clone());
510        }
511        properties.insert(
512            "s3.path-style-access".into(),
513            storage.path_style.to_string(),
514        );
515        match storage.encryption {
516            IcebergStorageEncryption::None => {}
517            IcebergStorageEncryption::Sse => {
518                properties.insert("s3.sse.type".into(), "s3".into());
519            }
520            IcebergStorageEncryption::Kms => {
521                properties.insert("s3.sse.type".into(), "kms".into());
522                if let Some(key) = &storage.kms_key {
523                    properties.insert("s3.sse.key".into(), key.clone());
524                }
525            }
526        }
527    }
528}
529
530/// Loads an Iceberg table from the catalog.
531///
532/// # Errors
533///
534/// Returns `ConnectorError::ReadError` if the table cannot be loaded, or
535/// `ConnectorError::FeatureUnsupported` when loading requires an unavailable capability.
536pub async fn load_table(
537    catalog: &dyn Catalog,
538    namespace: &str,
539    table_name: &str,
540) -> Result<Table, ConnectorError> {
541    load_table_with_timeout(
542        catalog,
543        namespace,
544        table_name,
545        std::time::Duration::from_secs(30),
546    )
547    .await
548}
549
550/// Loads an Iceberg table within a bounded catalog operation.
551///
552/// # Errors
553///
554/// Returns `ConnectorError::ReadError` if the table cannot be loaded before the deadline, or
555/// `ConnectorError::FeatureUnsupported` when loading requires an unavailable capability.
556pub async fn load_table_with_timeout(
557    catalog: &dyn Catalog,
558    namespace: &str,
559    table_name: &str,
560    timeout: std::time::Duration,
561) -> Result<Table, ConnectorError> {
562    let ns = iceberg::NamespaceIdent::from_strs(namespace.split('.').collect::<Vec<_>>())
563        .map_err(|e| ConnectorError::ConfigurationError(format!("invalid namespace: {e}")))?;
564
565    let ident = TableIdent::new(ns, table_name.to_string());
566
567    let table = tokio::time::timeout(timeout, catalog.load_table(&ident))
568        .await
569        .map_err(|_| {
570            ConnectorError::ReadError(format!(
571                "[LDB-ICEBERG-CATALOG-TIMEOUT] load table '{table_name}' exceeded {timeout:?}"
572            ))
573        })?
574        .map_err(|error| catalog_load_error(table_name, &error))?;
575    validate_loaded_table_locations(&table)?;
576    Ok(table)
577}
578
579fn catalog_load_error(table_name: &str, error: &iceberg::Error) -> ConnectorError {
580    if error.kind() == iceberg::ErrorKind::FeatureUnsupported {
581        return ConnectorError::FeatureUnsupported(
582            "[LDB-ICEBERG-TABLE-LOAD-UNSUPPORTED] Iceberg table load requires a catalog or storage capability unavailable in this build"
583                .into(),
584        );
585    }
586    ConnectorError::ReadError(format!(
587        "[LDB-ICEBERG-CATALOG-LOAD] load table '{table_name}' failed ({})",
588        error.kind()
589    ))
590}
591
592pub(crate) fn validate_loaded_table_locations(table: &Table) -> Result<(), ConnectorError> {
593    validate_credential_free_location("table location", table.metadata().location())?;
594    let metadata_location = table
595        .metadata_location()
596        .filter(|location| !location.is_empty())
597        .ok_or_else(|| {
598            ConnectorError::ReadError(
599                "[LDB-ICEBERG-METADATA-LOCATION-MISSING] loaded table has no durable metadata location"
600                    .into(),
601            )
602        })?;
603    validate_credential_free_location("metadata location", metadata_location)?;
604    for (label, property) in [
605        ("data location", WRITE_DATA_PATH_PROPERTY),
606        (
607            "folder storage location",
608            WRITE_FOLDER_STORAGE_PATH_PROPERTY,
609        ),
610    ] {
611        if let Some(location) = table.metadata().properties().get(property) {
612            validate_credential_free_location(label, location)?;
613        }
614    }
615    Ok(())
616}
617
618pub(crate) fn effective_data_location(table: &Table) -> String {
619    effective_data_location_from_metadata(table.metadata())
620}
621
622pub(crate) fn effective_data_location_from_metadata(
623    metadata: &iceberg::spec::TableMetadata,
624) -> String {
625    // COMPAT: this is the precedence used by Iceberg's DefaultLocationGenerator.
626    metadata
627        .properties()
628        .get(WRITE_DATA_PATH_PROPERTY)
629        .or_else(|| {
630            metadata
631                .properties()
632                .get(WRITE_FOLDER_STORAGE_PATH_PROPERTY)
633        })
634        .cloned()
635        .unwrap_or_else(|| format!("{}/data", metadata.location()))
636}
637
638pub(crate) fn validate_credential_free_location(
639    label: &str,
640    location: &str,
641) -> Result<(), ConnectorError> {
642    if crate::security::value_contains_uri_secret(location, false) {
643        return Err(ConnectorError::ReadError(format!(
644            "[LDB-ICEBERG-CREDENTIAL-LOCATION] catalog {label} must not embed credentials"
645        )));
646    }
647    Ok(())
648}
649
650/// Scans a table into a compatibility buffer with fixed file, batch, byte, and time bounds.
651///
652/// # Errors
653///
654/// Returns `ConnectorError::ReadError` when the snapshot is absent, a bound is exceeded, or the
655/// scan fails.
656#[deprecated(since = "0.30.0", note = "use IcebergSource for streaming reads")]
657pub async fn scan_table(
658    table: &Table,
659    snapshot_id: Option<i64>,
660    select_columns: &[String],
661) -> Result<Vec<RecordBatch>, ConnectorError> {
662    let snapshot = match snapshot_id {
663        Some(snapshot_id) => table
664            .metadata()
665            .snapshot_by_id(snapshot_id)
666            .ok_or_else(|| {
667                ConnectorError::ReadError(format!(
668                    "[LDB-ICEBERG-SNAPSHOT-MISSING] snapshot {snapshot_id} does not exist"
669                ))
670            })?,
671        None => match table.metadata().current_snapshot() {
672            Some(snapshot) => snapshot,
673            None => return Ok(Vec::new()),
674        },
675    };
676    let mut builder = table
677        .scan()
678        .snapshot_id(snapshot.snapshot_id())
679        .with_batch_size(Some(8_192))
680        .with_concurrency_limit(COMPAT_SCAN_CONCURRENCY);
681    builder = if select_columns.is_empty() {
682        builder.select_all()
683    } else {
684        builder.select(select_columns.iter().map(String::as_str))
685    };
686    let scan = builder.build().map_err(|error| {
687        super::iceberg_scan::connector_scan_error("build compatibility Iceberg scan", &error)
688    })?;
689    let deadline = tokio::time::Instant::now() + COMPAT_SCAN_TIMEOUT;
690    super::iceberg_scan::preflight_snapshot(
691        table,
692        snapshot,
693        super::iceberg_scan::ManifestReadLimits::fixed(),
694        deadline,
695    )
696    .await?;
697    let tasks = super::iceberg_scan::plan_files(
698        &scan,
699        super::iceberg_scan::DEFAULT_MAX_PLANNED_FILES,
700        deadline,
701    )
702    .await?;
703    let reader = table
704        .reader_builder()
705        .with_batch_size(8_192)
706        .with_data_file_concurrency_limit(COMPAT_SCAN_CONCURRENCY)
707        .build()
708        .read(tasks)
709        .map_err(|error| {
710            super::iceberg_scan::connector_scan_error("create compatibility Iceberg reader", &error)
711        })?;
712    collect_compat_scan(reader.stream(), deadline).await
713}
714
715async fn collect_compat_scan(
716    mut stream: iceberg::scan::ArrowRecordBatchStream,
717    deadline: tokio::time::Instant,
718) -> Result<Vec<RecordBatch>, ConnectorError> {
719    let mut batches = Vec::new();
720    let mut bytes = 0_usize;
721    while let Some(result) = tokio::time::timeout_at(deadline, stream.next())
722        .await
723        .map_err(|_| {
724            ConnectorError::ReadError(
725                "[LDB-ICEBERG-COMPAT-SCAN-TIMEOUT] compatibility scan exceeded 30 seconds".into(),
726            )
727        })?
728    {
729        let batch = result.map_err(|error| {
730            super::iceberg_scan::connector_scan_error("compatibility Iceberg read failed", &error)
731        })?;
732        if batches.len() == COMPAT_SCAN_MAX_BATCHES {
733            return Err(compat_scan_limit_error());
734        }
735        bytes = batch.columns().iter().try_fold(bytes, |total, column| {
736            total
737                .checked_add(column.get_array_memory_size())
738                .ok_or_else(compat_scan_limit_error)
739        })?;
740        if bytes > COMPAT_SCAN_MAX_BYTES {
741            return Err(compat_scan_limit_error());
742        }
743        batches.push(batch);
744    }
745    Ok(batches)
746}
747
748fn compat_scan_limit_error() -> ConnectorError {
749    ConnectorError::ReadError(
750        "[LDB-ICEBERG-COMPAT-SCAN-LIMIT] compatibility scan exceeded its fixed buffer bounds"
751            .into(),
752    )
753}
754
755/// Returns the current snapshot ID of a table, if any.
756#[must_use]
757pub fn current_snapshot_id(table: &Table) -> Option<i64> {
758    table.metadata().current_snapshot().map(|s| s.snapshot_id())
759}
760
761/// Creates an Iceberg table (and namespace) if it does not already exist.
762///
763/// # Errors
764///
765/// Returns `ConnectorError` on creation failure.
766pub async fn ensure_table_exists(
767    catalog: &dyn Catalog,
768    config: &super::iceberg_config::IcebergSinkConfig,
769    arrow_schema: &arrow_schema::SchemaRef,
770) -> Result<(), ConnectorError> {
771    let namespace = &config.catalog.namespace;
772    let table_name = &config.catalog.table_name;
773    let ns = iceberg::NamespaceIdent::from_strs(namespace.split('.').collect::<Vec<_>>())
774        .map_err(|e| ConnectorError::ConfigurationError(format!("invalid namespace: {e}")))?;
775    let ident = TableIdent::new(ns.clone(), table_name.clone());
776    let creation = table_creation::build_table_creation(config, arrow_schema)?;
777
778    // Ensure the namespace exists before probing the table: a HEAD on a table in
779    // a missing namespace returns 400 (not 404) on some REST catalogs. Creation
780    // tolerates a concurrent creator (N writers may auto-create the
781    // same namespace at once) by re-checking existence on failure.
782    if !catalog.namespace_exists(&ns).await.map_err(|error| {
783        ConnectorError::ReadError(format!(
784            "inspect Iceberg namespace ({})",
785            external_error_summary(&error)
786        ))
787    })? {
788        if let Err(error) = catalog.create_namespace(&ns, HashMap::new()).await {
789            if !catalog.namespace_exists(&ns).await.unwrap_or(false) {
790                return Err(ConnectorError::WriteError(format!(
791                    "create Iceberg namespace ({})",
792                    external_error_summary(&error)
793                )));
794            }
795        }
796    }
797
798    if catalog.table_exists(&ident).await.map_err(|error| {
799        ConnectorError::ReadError(format!(
800            "inspect Iceberg table ({})",
801            external_error_summary(&error)
802        ))
803    })? {
804        return Ok(());
805    }
806
807    // Same race tolerance for the table itself.
808    if let Err(error) = catalog.create_table(&ns, creation).await {
809        if !catalog.table_exists(&ident).await.unwrap_or(false) {
810            return Err(ConnectorError::WriteError(format!(
811                "create Iceberg table ({})",
812                external_error_summary(&error)
813            )));
814        }
815    }
816
817    Ok(())
818}
819
820#[cfg(test)]
821mod tests;