Skip to main content

laminar_connectors/lakehouse/
mod.rs

1//! Lakehouse connectors (Delta Lake, Apache Iceberg).
2
3// Versioned envelope for Delta coordinated-commit descriptors.
4#[cfg(feature = "delta-lake")]
5mod commit_descriptor;
6
7// Delta Lake modules
8pub mod delta;
9pub mod delta_config;
10#[cfg(feature = "delta-lake")]
11pub mod delta_io;
12#[cfg(feature = "delta-lake")]
13pub mod delta_lookup;
14pub mod delta_metrics;
15#[cfg(feature = "delta-lake")]
16pub mod delta_reference;
17pub mod delta_source;
18pub mod delta_source_config;
19#[cfg(feature = "delta-lake")]
20pub mod delta_table_provider;
21#[cfg(feature = "delta-lake-unity")]
22pub(crate) mod unity_catalog;
23
24// Apache Iceberg modules
25pub mod iceberg;
26pub mod iceberg_config;
27#[cfg(feature = "iceberg-core")]
28pub mod iceberg_io;
29#[cfg(feature = "iceberg-core")]
30pub mod iceberg_lookup;
31#[cfg(feature = "iceberg-core")]
32pub mod iceberg_reference;
33#[cfg(feature = "iceberg-core")]
34mod iceberg_scan;
35pub mod iceberg_source;
36
37// Common metrics
38pub mod metrics;
39#[cfg(any(test, feature = "delta-lake", feature = "iceberg-core"))]
40mod snapshot_schema;
41
42// Re-export Delta Lake types at module level.
43pub use delta::DeltaLakeSink;
44pub use delta_config::{DeltaCatalogType, DeltaLakeSinkConfig, DeltaWriteMode};
45#[cfg(feature = "delta-lake")]
46pub use delta_lookup::{DeltaLookupSource, DeltaLookupSourceConfig};
47pub use delta_metrics::DeltaLakeSinkMetrics;
48#[cfg(feature = "delta-lake")]
49pub use delta_reference::DeltaReferenceTableSource;
50pub use delta_source::DeltaSource;
51pub use delta_source_config::DeltaSourceConfig;
52pub use metrics::LakehouseSinkMetrics;
53
54// Re-export Iceberg types at module level.
55pub use iceberg::IcebergSink;
56use iceberg_config::{
57    sink_config_keys as iceberg_sink_config_keys, source_config_keys as iceberg_source_config_keys,
58};
59pub use iceberg_config::{
60    IcebergCatalogAuthType, IcebergCatalogConfig, IcebergCatalogType, IcebergNullOrder,
61    IcebergPartitionField, IcebergReadBootstrap, IcebergReadMode, IcebergSchemaEvolutionMode,
62    IcebergSinkConfig, IcebergSortDirection, IcebergSortField, IcebergSourceConfig,
63    IcebergStorageConfig, IcebergStorageEncryption, IcebergStorageType, IcebergTransform,
64    IcebergWriteDistributionMode, IcebergWriteMode,
65};
66#[cfg(feature = "iceberg-core")]
67pub use iceberg_lookup::{IcebergLookupSource, IcebergLookupSourceConfig};
68#[cfg(feature = "iceberg-core")]
69pub use iceberg_reference::IcebergReferenceTableSource;
70pub use iceberg_source::IcebergSource;
71
72use std::sync::Arc;
73
74use crate::config::{ConfigKeySpec, ConnectorInfo};
75use crate::registry::ConnectorRegistry;
76
77/// Registers the Delta Lake sink connector with the given registry.
78///
79/// # Errors
80///
81/// Returns the registry error when a name is already registered or the registry is frozen.
82pub fn register_delta_lake_sink(
83    registry: &ConnectorRegistry,
84) -> Result<(), crate::error::ConnectorError> {
85    let info = ConnectorInfo {
86        name: "delta-lake".to_string(),
87        display_name: "Delta Lake Sink".to_string(),
88        version: env!("CARGO_PKG_VERSION").to_string(),
89        is_source: false,
90        is_sink: true,
91        config_keys: delta_lake_config_keys(),
92    };
93
94    registry.register_sink(
95        "delta-lake",
96        info,
97        Arc::new(|config, registry: Option<&Arc<prometheus::Registry>>| {
98            Ok(Box::new(DeltaLakeSink::new(
99                DeltaLakeSinkConfig::from_config(config)?,
100                registry.map(Arc::as_ref),
101            )))
102        }),
103    )
104}
105
106/// Registers the Delta Lake source connector with the given registry.
107///
108/// # Errors
109///
110/// Returns the registry error when a name is already registered or the registry is frozen.
111pub fn register_delta_lake_source(
112    registry: &ConnectorRegistry,
113) -> Result<(), crate::error::ConnectorError> {
114    let info = ConnectorInfo {
115        name: "delta-lake".to_string(),
116        display_name: "Delta Lake Source".to_string(),
117        version: env!("CARGO_PKG_VERSION").to_string(),
118        is_source: true,
119        is_sink: false,
120        config_keys: delta_lake_source_config_keys(),
121    };
122
123    registry.register_source(
124        "delta-lake",
125        info.clone(),
126        Arc::new(|registry: Option<&Arc<prometheus::Registry>>| {
127            Ok(Box::new(DeltaSource::new(
128                DeltaSourceConfig::default(),
129                registry.map(Arc::as_ref),
130            )))
131        }),
132    )?;
133
134    // Register finite startup snapshots for replicated reference tables.
135    #[cfg(feature = "delta-lake")]
136    registry.register_table_source(
137        "delta-lake",
138        info.clone(),
139        Arc::new(|config, declared_schema| {
140            Ok(Box::new(DeltaReferenceTableSource::from_connector_config(
141                config,
142                declared_schema,
143            )?))
144        }),
145    )?;
146
147    // Register lookup source factory for on-demand/partial cache mode.
148    #[cfg(feature = "delta-lake")]
149    registry.register_lookup_source("delta-lake", info, Arc::new(DeltaLookupFactory))?;
150
151    Ok(())
152}
153
154#[cfg(feature = "delta-lake")]
155struct DeltaLookupFactory;
156
157#[cfg(feature = "delta-lake")]
158#[async_trait::async_trait]
159impl crate::registry::LookupSourceFactory for DeltaLookupFactory {
160    async fn build(
161        &self,
162        config: crate::config::ConnectorConfig,
163        _declared_schema: Option<arrow_schema::SchemaRef>,
164    ) -> Result<Arc<dyn laminar_core::lookup::source::LookupSourceDyn>, crate::error::ConnectorError>
165    {
166        use crate::lakehouse::delta_source_config::DeltaSourceConfig;
167        let pk_columns: Vec<String> = config
168            .get("_primary_key_columns")
169            .unwrap_or("")
170            .split(',')
171            .map(|s| s.trim().to_string())
172            .filter(|s| !s.is_empty())
173            .collect();
174
175        if pk_columns.is_empty() {
176            return Err(crate::error::ConnectorError::ConfigurationError(
177                "delta-lake lookup source requires primary key columns".into(),
178            ));
179        }
180
181        let src_config = DeltaSourceConfig::from_config(&config)?;
182
183        let (resolved_path, resolved_opts) = crate::lakehouse::delta_io::resolve_catalog_options(
184            &src_config.catalog_type,
185            src_config.catalog_database.as_deref(),
186            src_config.catalog_name.as_deref(),
187            src_config.catalog_schema.as_deref(),
188            &src_config.table_path,
189            &src_config.storage_options,
190        )
191        .await?;
192
193        let lookup_config = DeltaLookupSourceConfig {
194            table_path: resolved_path,
195            storage_options: resolved_opts,
196            primary_key_columns: pk_columns,
197            table_name: "delta_lookup".to_string(),
198        };
199
200        // `From<LookupError>` preserves transient/non-transient class.
201        let source = DeltaLookupSource::open(lookup_config).await?;
202
203        Ok(Arc::new(source) as Arc<dyn laminar_core::lookup::source::LookupSourceDyn>)
204    }
205}
206
207/// Registers the Iceberg sink connector with the given registry.
208///
209/// # Errors
210///
211/// Returns the registry error when a name is already registered or the registry is frozen.
212pub fn register_iceberg_sink(
213    registry: &ConnectorRegistry,
214) -> Result<(), crate::error::ConnectorError> {
215    let info = ConnectorInfo {
216        name: "iceberg".to_string(),
217        display_name: "Apache Iceberg Sink".to_string(),
218        version: env!("CARGO_PKG_VERSION").to_string(),
219        is_source: false,
220        is_sink: true,
221        config_keys: iceberg_sink_config_keys(),
222    };
223
224    registry.register_sink(
225        "iceberg",
226        info,
227        Arc::new(|config, registry: Option<&Arc<prometheus::Registry>>| {
228            Ok(Box::new(IcebergSink::new(
229                IcebergSinkConfig::from_config(config)?,
230                registry.map(Arc::as_ref),
231            )))
232        }),
233    )
234}
235
236/// Registers the Iceberg source connector with the given registry.
237///
238/// # Errors
239///
240/// Returns the registry error when a name is already registered or the registry is frozen.
241pub fn register_iceberg_source(
242    registry: &ConnectorRegistry,
243) -> Result<(), crate::error::ConnectorError> {
244    let info = ConnectorInfo {
245        name: "iceberg".to_string(),
246        display_name: "Apache Iceberg Source".to_string(),
247        version: env!("CARGO_PKG_VERSION").to_string(),
248        is_source: true,
249        is_sink: false,
250        config_keys: iceberg_source_config_keys(),
251    };
252
253    registry.register_source(
254        "iceberg",
255        info.clone(),
256        Arc::new(|registry: Option<&Arc<prometheus::Registry>>| {
257            let mut config = crate::config::ConnectorConfig::new("iceberg");
258            config.set("catalog.uri", "http://localhost:8181");
259            config.set("catalog.warehouse", "s3://default/wh");
260            config.set("namespace", "default");
261            config.set("table.name", "default");
262            Ok(Box::new(IcebergSource::new(
263                IcebergSourceConfig::from_config(&config)?,
264                registry.map(Arc::as_ref),
265            )))
266        }),
267    )?;
268
269    // Register finite startup snapshots for replicated reference tables.
270    #[cfg(feature = "iceberg-core")]
271    registry.register_table_source(
272        "iceberg",
273        info.clone(),
274        Arc::new(|config, declared_schema| {
275            Ok(Box::new(
276                IcebergReferenceTableSource::from_connector_config(config, declared_schema)?,
277            ))
278        }),
279    )?;
280
281    // Register lookup source factory for on-demand/partial cache mode.
282    #[cfg(feature = "iceberg-core")]
283    registry.register_lookup_source("iceberg", info, Arc::new(IcebergLookupFactory))?;
284
285    Ok(())
286}
287
288#[cfg(feature = "iceberg-core")]
289struct IcebergLookupFactory;
290
291#[cfg(feature = "iceberg-core")]
292#[async_trait::async_trait]
293impl crate::registry::LookupSourceFactory for IcebergLookupFactory {
294    async fn build(
295        &self,
296        config: crate::config::ConnectorConfig,
297        _declared_schema: Option<arrow_schema::SchemaRef>,
298    ) -> Result<Arc<dyn laminar_core::lookup::source::LookupSourceDyn>, crate::error::ConnectorError>
299    {
300        use crate::lakehouse::iceberg_config::{IcebergCatalogConfig, IcebergStorageConfig};
301        let pk_columns: Vec<String> = config
302            .get("_primary_key_columns")
303            .unwrap_or("")
304            .split(',')
305            .map(|s| s.trim().to_string())
306            .filter(|s| !s.is_empty())
307            .collect();
308
309        if pk_columns.is_empty() {
310            return Err(crate::error::ConnectorError::ConfigurationError(
311                "iceberg lookup source requires primary key columns".into(),
312            ));
313        }
314
315        let catalog = IcebergCatalogConfig::from_config(&config)?;
316        let storage = IcebergStorageConfig::from_config(&config)?;
317        let lookup_config = IcebergLookupSourceConfig {
318            catalog,
319            storage,
320            primary_key_columns: pk_columns,
321        };
322
323        let source = IcebergLookupSource::open(lookup_config).await?;
324        Ok(Arc::new(source) as Arc<dyn laminar_core::lookup::source::LookupSourceDyn>)
325    }
326}
327
328/// Registers all lakehouse sink connectors (Delta Lake, Iceberg).
329///
330/// # Errors
331///
332/// Returns the first registry error.
333pub fn register_lakehouse_sinks(
334    registry: &ConnectorRegistry,
335) -> Result<(), crate::error::ConnectorError> {
336    register_delta_lake_sink(registry)?;
337    register_iceberg_sink(registry)
338}
339
340/// Registers all lakehouse source connectors.
341///
342/// # Errors
343///
344/// Returns the first registry error.
345pub fn register_lakehouse_sources(
346    registry: &ConnectorRegistry,
347) -> Result<(), crate::error::ConnectorError> {
348    register_delta_lake_source(registry)?;
349    register_iceberg_source(registry)
350}
351
352#[allow(clippy::too_many_lines)]
353fn delta_lake_config_keys() -> Vec<ConfigKeySpec> {
354    vec![
355        ConfigKeySpec::required(
356            "table.path",
357            "Path to Delta Lake table (local, s3://, az://, gs://)",
358        ),
359        ConfigKeySpec::optional(
360            "partition.columns",
361            "Comma-separated partition column names",
362            "",
363        ),
364        ConfigKeySpec::optional(
365            "target.file.size",
366            "Target Parquet file size in bytes",
367            "134217728",
368        ),
369        ConfigKeySpec::optional(
370            "max.buffer.records",
371            "Maximum records to buffer before flushing",
372            "100000",
373        ),
374        ConfigKeySpec::optional(
375            "max.buffer.duration.ms",
376            "Maximum time to buffer before flushing (ms)",
377            "60000",
378        ),
379        ConfigKeySpec::optional(
380            "schema.evolution",
381            "Enable automatic schema evolution (additive columns)",
382            "false",
383        ),
384        ConfigKeySpec::optional(
385            "write.mode",
386            "Write mode: append, overwrite, upsert",
387            "append",
388        ),
389        ConfigKeySpec::optional(
390            "merge.key.columns",
391            "Key columns for upsert MERGE (required for upsert mode)",
392            "",
393        ),
394        // ── Catalog configuration ──
395        ConfigKeySpec::optional("catalog.type", "Catalog type: none, glue, unity", "none"),
396        ConfigKeySpec::optional(
397            "catalog.database",
398            "Catalog database name (required for Glue)",
399            "",
400        ),
401        ConfigKeySpec::optional("catalog.name", "Catalog name (required for Unity)", ""),
402        ConfigKeySpec::optional(
403            "catalog.schema",
404            "Catalog schema name (required for Unity)",
405            "",
406        ),
407        ConfigKeySpec::optional(
408            "catalog.workspace_url",
409            "Databricks workspace URL (required for Unity)",
410            "",
411        ),
412        ConfigKeySpec::optional(
413            "catalog.access_token",
414            "Databricks access token (required for Unity)",
415            "",
416        ),
417        ConfigKeySpec::optional(
418            "catalog.storage.location",
419            "Storage location for auto-created UC external tables (e.g. s3://bucket/path)",
420            "",
421        ),
422        // ── LogStore configuration ──
423        ConfigKeySpec::optional(
424            "storage.s3_locking_provider",
425            "S3 locking provider: 'dynamodb' for DynamoDB-backed log store",
426            "",
427        ),
428        ConfigKeySpec::optional(
429            "storage.dynamodb_table_name",
430            "DynamoDB table name for S3 locking (default: delta_log)",
431            "",
432        ),
433        // ── Cloud storage credentials (resolved via StorageCredentialResolver) ──
434        ConfigKeySpec::optional(
435            "storage.aws_access_key_id",
436            "AWS access key ID (falls back to AWS_ACCESS_KEY_ID env var)",
437            "",
438        ),
439        ConfigKeySpec::optional(
440            "storage.aws_secret_access_key",
441            "AWS secret access key (falls back to AWS_SECRET_ACCESS_KEY env var)",
442            "",
443        ),
444        ConfigKeySpec::optional(
445            "storage.aws_region",
446            "AWS region for S3 paths (falls back to AWS_REGION env var)",
447            "",
448        ),
449        ConfigKeySpec::optional(
450            "storage.aws_session_token",
451            "AWS session token for temporary credentials (falls back to AWS_SESSION_TOKEN)",
452            "",
453        ),
454        ConfigKeySpec::optional(
455            "storage.aws_endpoint",
456            "Custom S3 endpoint (MinIO, LocalStack; falls back to AWS_ENDPOINT_URL)",
457            "",
458        ),
459        ConfigKeySpec::optional(
460            "storage.aws_profile",
461            "AWS profile name (falls back to AWS_PROFILE env var)",
462            "",
463        ),
464        ConfigKeySpec::optional(
465            "storage.azure_storage_account_name",
466            "Azure storage account name (falls back to AZURE_STORAGE_ACCOUNT_NAME)",
467            "",
468        ),
469        ConfigKeySpec::optional(
470            "storage.azure_storage_account_key",
471            "Azure storage account key (falls back to AZURE_STORAGE_ACCOUNT_KEY)",
472            "",
473        ),
474        ConfigKeySpec::optional(
475            "storage.azure_storage_sas_token",
476            "Azure SAS token (falls back to AZURE_STORAGE_SAS_TOKEN)",
477            "",
478        ),
479        ConfigKeySpec::optional(
480            "storage.azure_storage_client_id",
481            "Azure client ID for service principal auth (falls back to AZURE_CLIENT_ID)",
482            "",
483        ),
484        ConfigKeySpec::optional(
485            "storage.google_service_account_path",
486            "Path to GCS service account JSON (falls back to GOOGLE_APPLICATION_CREDENTIALS)",
487            "",
488        ),
489        ConfigKeySpec::optional(
490            "storage.google_service_account_key",
491            "Inline GCS service account JSON (falls back to GOOGLE_SERVICE_ACCOUNT_KEY)",
492            "",
493        ),
494    ]
495}
496
497fn delta_lake_source_config_keys() -> Vec<ConfigKeySpec> {
498    vec![
499        ConfigKeySpec::required(
500            "table.path",
501            "Path to Delta Lake table (local, s3://, az://, gs://)",
502        ),
503        ConfigKeySpec::optional(
504            "starting.version",
505            "First version to read (default: only versions committed after startup)",
506            "",
507        ),
508        ConfigKeySpec::optional(
509            "poll.interval.ms",
510            "How often to poll for new versions (ms)",
511            "1000",
512        ),
513        // ── Catalog configuration ──
514        ConfigKeySpec::optional("catalog.type", "Catalog type: none, glue, unity", "none"),
515        ConfigKeySpec::optional(
516            "catalog.database",
517            "Catalog database name (required for Glue)",
518            "",
519        ),
520        ConfigKeySpec::optional("catalog.name", "Catalog name (required for Unity)", ""),
521        ConfigKeySpec::optional(
522            "catalog.schema",
523            "Catalog schema name (required for Unity)",
524            "",
525        ),
526        ConfigKeySpec::optional(
527            "catalog.workspace_url",
528            "Databricks workspace URL (required for Unity)",
529            "",
530        ),
531        ConfigKeySpec::optional(
532            "catalog.access_token",
533            "Databricks access token (required for Unity)",
534            "",
535        ),
536        // ── LogStore configuration ──
537        ConfigKeySpec::optional(
538            "storage.s3_locking_provider",
539            "S3 locking provider: 'dynamodb' for DynamoDB-backed log store",
540            "",
541        ),
542        ConfigKeySpec::optional(
543            "storage.dynamodb_table_name",
544            "DynamoDB table name for S3 locking (default: delta_log)",
545            "",
546        ),
547        // ── Cloud storage credentials ──
548        ConfigKeySpec::optional("storage.aws_access_key_id", "AWS access key ID", ""),
549        ConfigKeySpec::optional("storage.aws_secret_access_key", "AWS secret access key", ""),
550        ConfigKeySpec::optional("storage.aws_region", "AWS region for S3 paths", ""),
551        ConfigKeySpec::optional(
552            "storage.azure_storage_account_name",
553            "Azure storage account name",
554            "",
555        ),
556        ConfigKeySpec::optional(
557            "storage.azure_storage_account_key",
558            "Azure storage account key",
559            "",
560        ),
561        ConfigKeySpec::optional(
562            "storage.google_service_account_path",
563            "Path to GCS service account JSON",
564            "",
565        ),
566    ]
567}
568
569#[cfg(test)]
570mod tests;