1#![allow(clippy::disallowed_types)] #![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#[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)] fn 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)] fn 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)] fn 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)] fn 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#[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
530pub 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
550pub 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 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#[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#[must_use]
757pub fn current_snapshot_id(table: &Table) -> Option<i64> {
758 table.metadata().current_snapshot().map(|s| s.snapshot_id())
759}
760
761pub 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 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 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;