Skip to main content

lance_io/object_store/providers/
azure.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright The Lance Authors
3
4use std::{
5    collections::HashMap,
6    str::FromStr,
7    sync::{Arc, LazyLock},
8    time::Duration,
9};
10
11use object_store::ObjectStore as OSObjectStore;
12use opendal::{Operator, services::Azblob, services::Azdls};
13
14use object_store::{
15    RetryConfig,
16    azure::{AzureConfigKey, AzureCredential, MicrosoftAzureBuilder},
17};
18use url::Url;
19
20use crate::object_store::opendal_store::OpendalStore;
21use crate::object_store::{
22    DEFAULT_CLOUD_BLOCK_SIZE, DEFAULT_CLOUD_IO_PARALLELISM, DEFAULT_MAX_IOP_SIZE, ObjectStore,
23    ObjectStoreParams, ObjectStoreProvider, StorageOptions, StorageOptionsAccessor,
24    dynamic_credentials::build_dynamic_credential_provider,
25    throttle::{AimdThrottleConfig, AimdThrottleState, AimdThrottledStore, cloud_http_connector},
26};
27use lance_core::error::{Error, Result};
28
29#[derive(Default, Debug)]
30pub struct AzureBlobStoreProvider;
31
32impl AzureBlobStoreProvider {
33    /// Normalize Azure storage options for OpenDAL, resolving aliases for
34    /// well-known keys while passing through all other options (e.g.
35    /// `client_id`, `tenant_id`, `encryption_key`, etc.) so that OpenDAL
36    /// can use them directly.
37    fn normalize_opendal_azure_options(
38        options: &HashMap<String, String>,
39    ) -> HashMap<String, String> {
40        // Start with all options so unknown keys are forwarded to OpenDAL.
41        let mut config_map = options.clone();
42
43        // Normalize well-known aliases into canonical OpenDAL key names.
44        // Remove the alias after resolving to avoid duplicate/conflicting entries.
45        let alias_groups: &[(&str, &[&str])] = &[
46            ("account_name", &["azure_storage_account_name"]),
47            ("endpoint", &["azure_storage_endpoint", "azure_endpoint"]),
48            (
49                "account_key",
50                &[
51                    "azure_storage_account_key",
52                    "azure_storage_access_key",
53                    "azure_storage_master_key",
54                    "access_key",
55                    "master_key",
56                ],
57            ),
58            (
59                "sas_token",
60                &[
61                    "azure_storage_sas_token",
62                    "azure_storage_sas_key",
63                    "sas_key",
64                ],
65            ),
66        ];
67
68        for (canonical, aliases) in alias_groups {
69            if !config_map.contains_key(*canonical) {
70                for alias in *aliases {
71                    if let Some(value) = config_map.remove(*alias) {
72                        config_map.insert(canonical.to_string(), value);
73                        break;
74                    }
75                }
76            } else {
77                // Canonical key exists; remove aliases to avoid conflicts.
78                for alias in *aliases {
79                    config_map.remove(*alias);
80                }
81            }
82        }
83
84        config_map
85    }
86
87    fn build_opendal_operator(
88        base_path: &Url,
89        storage_options: &StorageOptions,
90    ) -> Result<Operator> {
91        // Start with all storage options as the config map
92        // OpenDAL will handle environment variables through its default credentials chain
93        let mut config_map = Self::normalize_opendal_azure_options(&storage_options.0);
94
95        match base_path.scheme() {
96            "az" => {
97                let container = base_path
98                    .host_str()
99                    .ok_or_else(|| Error::invalid_input("Azure URL must contain container name"))?
100                    .to_string();
101
102                config_map.insert("container".to_string(), container);
103
104                let prefix = base_path.path().trim_start_matches('/');
105                if !prefix.is_empty() {
106                    config_map.insert("root".to_string(), format!("/{}", prefix));
107                }
108
109                Operator::from_iter::<Azblob>(config_map).map_err(|e| {
110                    Error::invalid_input(format!("Failed to create Azure Blob operator: {:?}", e))
111                })
112            }
113            "abfss" => {
114                let filesystem = base_path.username();
115                if filesystem.is_empty() {
116                    return Err(Error::invalid_input(
117                        "abfss:// URL must include account: abfss://<filesystem>@<account>.dfs.core.windows.net/path",
118                    ));
119                }
120                let host = base_path.host_str().ok_or_else(|| {
121                    Error::invalid_input(
122                        "abfss:// URL must include account: abfss://<filesystem>@<account>.dfs.core.windows.net/path"
123                    )
124                })?;
125
126                config_map.insert("filesystem".to_string(), filesystem.to_string());
127                config_map.insert("endpoint".to_string(), format!("https://{}", host));
128                config_map
129                    .entry("account_name".to_string())
130                    .or_insert_with(|| host.split('.').next().unwrap_or(host).to_string());
131
132                let root_path = base_path.path().trim_start_matches('/');
133                if !root_path.is_empty() {
134                    config_map.insert("root".to_string(), format!("/{}", root_path));
135                }
136
137                Operator::from_iter::<Azdls>(config_map).map_err(|e| {
138                    Error::invalid_input(format!(
139                        "Failed to create Azure DFS (ADLS Gen2) operator: {:?}",
140                        e
141                    ))
142                })
143            }
144            _ => Err(Error::invalid_input(format!(
145                "Unsupported Azure scheme: {}",
146                base_path.scheme()
147            ))),
148        }
149    }
150
151    async fn build_opendal_azure_store(
152        &self,
153        base_path: &Url,
154        storage_options: &StorageOptions,
155    ) -> Result<Arc<dyn OSObjectStore>> {
156        let operator = Self::build_opendal_operator(base_path, storage_options)?;
157        Ok(Arc::new(OpendalStore::new(operator)))
158    }
159
160    async fn build_microsoft_azure_store(
161        &self,
162        base_path: &Url,
163        storage_options: &StorageOptions,
164        accessor: Option<Arc<StorageOptionsAccessor>>,
165        throttle_state: Option<&AimdThrottleState>,
166    ) -> Result<Arc<dyn OSObjectStore>> {
167        // Use a low retry count since the AIMD throttle layer handles
168        // throttle recovery with its own retry loop.
169        let retry_config = RetryConfig {
170            backoff: Default::default(),
171            max_retries: storage_options.client_max_retries(),
172            retry_timeout: Duration::from_secs(storage_options.client_retry_timeout()),
173        };
174
175        let mut builder = MicrosoftAzureBuilder::new()
176            .with_url(base_path.as_ref())
177            .with_retry(retry_config)
178            .with_client_options(storage_options.client_options()?);
179        for (key, value) in storage_options.as_azure_options() {
180            builder = builder.with_config(key, value);
181        }
182
183        if let Some(credentials) =
184            build_dynamic_credential_provider::<AzureCredential>(accessor).await?
185        {
186            builder = builder.with_credentials(credentials);
187        }
188
189        let store_prefix =
190            self.calculate_object_store_prefix(base_path, Some(&storage_options.0))?;
191        builder = builder.with_http_connector(cloud_http_connector(throttle_state, store_prefix));
192
193        Ok(Arc::new(builder.build()?) as Arc<dyn OSObjectStore>)
194    }
195
196    fn calculate_object_store_prefix_with_env(
197        url: &Url,
198        storage_options: Option<&HashMap<String, String>>,
199        env_options: &HashMap<String, String>,
200    ) -> Result<String> {
201        let authority = url.authority();
202        let (container, account) = match authority.find("@") {
203            Some(at_index) => {
204                // The URI has an:
205                // - az:// schema type and is similar to 'az://container@account.dfs.core.windows.net/path-part/file
206                //         or possibly 'az://container@account/path-part/file' (the short version).
207                // - abfss:// schema type and is similar to 'abfss://filesystem@account.dfs.core.windows.net/path-part/file'.
208                let container = &authority[..at_index];
209                let account = &authority[at_index + 1..];
210                (
211                    container,
212                    account.split(".").next().unwrap_or_default().to_string(),
213                )
214            }
215            None => {
216                // The URI looks like 'az://container/path-part/file'.
217                // We must look at the storage options to find the account.
218                let mut account = match storage_options {
219                    Some(opts) => StorageOptions::find_configured_storage_account(opts),
220                    None => None,
221                };
222                if account.is_none() {
223                    account = StorageOptions::find_configured_storage_account(env_options);
224                }
225                let account = account.ok_or(Error::invalid_input("Unable to find object store prefix: no Azure account name in URI, and no storage account configured."))?;
226                (authority, account)
227            }
228        };
229        Ok(format!("{}${}@{}", url.scheme(), container, account))
230    }
231}
232
233#[async_trait::async_trait]
234impl ObjectStoreProvider for AzureBlobStoreProvider {
235    async fn new_store(&self, base_path: Url, params: &ObjectStoreParams) -> Result<ObjectStore> {
236        let scheme = base_path.scheme().to_string();
237        if scheme != "az" && scheme != "abfss" {
238            return Err(Error::invalid_input(format!(
239                "Unsupported Azure scheme '{}', expected 'az' or 'abfss'",
240                scheme
241            )));
242        }
243
244        let block_size = params.block_size.unwrap_or(DEFAULT_CLOUD_BLOCK_SIZE);
245        let mut storage_options =
246            StorageOptions::new(params.storage_options().cloned().unwrap_or_default());
247        storage_options.with_env_azure();
248        let download_retry_count = storage_options.download_retry_count();
249
250        let use_opendal = storage_options
251            .0
252            .get("use_opendal")
253            .map(|v| v.as_str() == "true")
254            .unwrap_or(false);
255
256        let accessor = params.get_accessor();
257
258        let throttle_config = AimdThrottleConfig::from_storage_options(params.storage_options())?;
259        let throttle_state = if throttle_config.is_disabled() {
260            None
261        } else {
262            Some(AimdThrottleState::new(throttle_config)?)
263        };
264
265        let inner: Arc<dyn OSObjectStore> = if use_opendal {
266            // OpenDAL Azure intentionally uses static/environment-backed configuration only.
267            // Namespace-vended dynamic credentials are supported on the native object_store path.
268            self.build_opendal_azure_store(&base_path, &storage_options)
269                .await?
270        } else {
271            self.build_microsoft_azure_store(
272                &base_path,
273                &storage_options,
274                accessor,
275                throttle_state.as_ref(),
276            )
277            .await?
278        };
279        let inner = if let Some(throttle_state) = throttle_state {
280            Arc::new(AimdThrottledStore::new_with_state(
281                inner,
282                throttle_state,
283                !use_opendal,
284            )) as Arc<dyn OSObjectStore>
285        } else {
286            inner
287        };
288
289        Ok(ObjectStore {
290            inner,
291            local_dir_operations: None,
292            scheme,
293            block_size,
294            max_iop_size: *DEFAULT_MAX_IOP_SIZE,
295            use_constant_size_upload_parts: false,
296            list_is_lexically_ordered: true,
297            io_parallelism: DEFAULT_CLOUD_IO_PARALLELISM,
298            download_retry_count,
299            io_tracker: Default::default(),
300            store_prefix: self
301                .calculate_object_store_prefix(&base_path, params.storage_options())?,
302        })
303    }
304
305    fn calculate_object_store_prefix(
306        &self,
307        url: &Url,
308        storage_options: Option<&HashMap<String, String>>,
309    ) -> Result<String> {
310        Self::calculate_object_store_prefix_with_env(url, storage_options, &ENV_OPTIONS.0)
311    }
312}
313
314static ENV_OPTIONS: LazyLock<StorageOptions> = LazyLock::new(StorageOptions::from_env);
315
316impl StorageOptions {
317    /// Iterate over all environment variables, looking for anything related to Azure.
318    fn from_env() -> Self {
319        let mut opts = HashMap::<String, String>::new();
320        for (os_key, os_value) in std::env::vars_os() {
321            if let (Some(key), Some(value)) = (os_key.to_str(), os_value.to_str())
322                && let Ok(config_key) = AzureConfigKey::from_str(&key.to_ascii_lowercase())
323            {
324                opts.insert(config_key.as_ref().to_string(), value.to_string());
325            }
326        }
327        Self(opts)
328    }
329
330    /// Add values from the environment to storage options
331    pub fn with_env_azure(&mut self) {
332        for (os_key, os_value) in &ENV_OPTIONS.0 {
333            if !self.0.contains_key(os_key) {
334                self.0.insert(os_key.clone(), os_value.clone());
335            }
336        }
337    }
338
339    /// Subset of options relevant for azure storage
340    pub fn as_azure_options(&self) -> HashMap<AzureConfigKey, String> {
341        self.0
342            .iter()
343            .filter_map(|(key, value)| {
344                let az_key = AzureConfigKey::from_str(&key.to_ascii_lowercase()).ok()?;
345                Some((az_key, value.clone()))
346            })
347            .collect()
348    }
349
350    #[allow(clippy::manual_map)]
351    fn find_configured_storage_account(map: &HashMap<String, String>) -> Option<String> {
352        if let Some(account) = map.get("azure_storage_account_name") {
353            Some(account.clone())
354        } else if let Some(account) = map.get("account_name") {
355            Some(account.clone())
356        } else {
357            None
358        }
359    }
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365    use std::sync::Arc;
366
367    use crate::object_store::test_utils::StaticMockStorageOptionsProvider;
368    use crate::object_store::{ObjectStoreParams, StorageOptionsAccessor};
369    use std::collections::HashMap;
370
371    #[test]
372    fn test_azure_store_path() {
373        let provider = AzureBlobStoreProvider;
374
375        let url = Url::parse("az://bucket/path/to/file").unwrap();
376        let path = provider.extract_path(&url).unwrap();
377        let expected_path = object_store::path::Path::from("path/to/file");
378        assert_eq!(path, expected_path);
379    }
380
381    #[tokio::test]
382    async fn test_use_opendal_flag() {
383        let provider = AzureBlobStoreProvider;
384        let url = Url::parse("az://test-container/path").unwrap();
385        let params_with_flag = ObjectStoreParams {
386            storage_options_accessor: Some(Arc::new(StorageOptionsAccessor::with_static_options(
387                HashMap::from([
388                    ("use_opendal".to_string(), "true".to_string()),
389                    ("account_name".to_string(), "test_account".to_string()),
390                    (
391                        "endpoint".to_string(),
392                        "https://test_account.blob.core.windows.net".to_string(),
393                    ),
394                    (
395                        "account_key".to_string(),
396                        "dGVzdF9hY2NvdW50X2tleQ==".to_string(),
397                    ),
398                ]),
399            ))),
400            ..Default::default()
401        };
402
403        let store = provider
404            .new_store(url.clone(), &params_with_flag)
405            .await
406            .unwrap();
407        assert_eq!(store.scheme, "az");
408        let inner_desc = store.inner.to_string();
409        assert!(
410            inner_desc.contains("Opendal") && inner_desc.contains("azblob"),
411            "az:// with use_opendal=true should use OpenDAL Azblob, got: {}",
412            inner_desc
413        );
414    }
415
416    #[tokio::test]
417    async fn test_dynamic_azure_credentials_provider() {
418        let accessor = Arc::new(StorageOptionsAccessor::with_provider(Arc::new(
419            StaticMockStorageOptionsProvider {
420                options: HashMap::from([(
421                    "azure_storage_sas_token".to_string(),
422                    "?sv=2022-11-02&sp=rl&sig=test".to_string(),
423                )]),
424            },
425        )));
426
427        let credentials = build_dynamic_credential_provider::<AzureCredential>(Some(accessor))
428            .await
429            .expect("dynamic azure credentials should build")
430            .expect("expected credential provider")
431            .get_credential()
432            .await
433            .expect("expected azure credential");
434
435        match credentials.as_ref() {
436            AzureCredential::SASToken(pairs) => {
437                assert!(
438                    pairs
439                        .iter()
440                        .any(|(key, value)| key == "sig" && value == "test")
441                );
442            }
443            other => panic!("expected SAS token, got {other:?}"),
444        }
445    }
446
447    #[test]
448    fn test_find_configured_storage_account() {
449        assert_eq!(
450            Some("myaccount".to_string()),
451            StorageOptions::find_configured_storage_account(&HashMap::from_iter(
452                [
453                    ("access_key".to_string(), "myaccesskey".to_string()),
454                    (
455                        "azure_storage_account_name".to_string(),
456                        "myaccount".to_string()
457                    )
458                ]
459                .into_iter()
460            ))
461        );
462    }
463
464    #[test]
465    fn test_calculate_object_store_prefix_from_url_and_options() {
466        let provider = AzureBlobStoreProvider;
467        let options = HashMap::from_iter([("account_name".to_string(), "bob".to_string())]);
468        assert_eq!(
469            "az$container@bob",
470            provider
471                .calculate_object_store_prefix(
472                    &Url::parse("az://container/path").unwrap(),
473                    Some(&options)
474                )
475                .unwrap()
476        );
477    }
478
479    #[test]
480    fn test_calculate_object_store_prefix_from_url_and_ignored_options() {
481        let provider = AzureBlobStoreProvider;
482        let options = HashMap::from_iter([("account_name".to_string(), "bob".to_string())]);
483        assert_eq!(
484            "az$container@account",
485            provider
486                .calculate_object_store_prefix(
487                    &Url::parse("az://container@account.dfs.core.windows.net/path").unwrap(),
488                    Some(&options)
489                )
490                .unwrap()
491        );
492    }
493
494    #[test]
495    fn test_calculate_object_store_prefix_from_url_short_account() {
496        let provider = AzureBlobStoreProvider;
497        let options = HashMap::from_iter([("account_name".to_string(), "bob".to_string())]);
498        assert_eq!(
499            "az$container@account",
500            provider
501                .calculate_object_store_prefix(
502                    &Url::parse("az://container@account/path").unwrap(),
503                    Some(&options)
504                )
505                .unwrap()
506        );
507    }
508
509    #[test]
510    fn test_fail_to_calculate_object_store_prefix_from_url() {
511        let options = HashMap::from_iter([("access_key".to_string(), "myaccesskey".to_string())]);
512        let expected = "Invalid user input: Unable to find object store prefix: no Azure account name in URI, and no storage account configured.";
513        let result = AzureBlobStoreProvider::calculate_object_store_prefix_with_env(
514            &Url::parse("az://container/path").unwrap(),
515            Some(&options),
516            &HashMap::new(),
517        )
518        .expect_err("expected error")
519        .to_string();
520        assert_eq!(expected, &result[..expected.len()]);
521    }
522
523    // --- abfss:// tests ---
524
525    #[test]
526    fn test_abfss_extract_path() {
527        let provider = AzureBlobStoreProvider;
528        let url = Url::parse("abfss://myfs@myaccount.dfs.core.windows.net/path/to/dataset.lance")
529            .unwrap();
530        let path = provider.extract_path(&url).unwrap();
531        assert_eq!(
532            path,
533            object_store::path::Path::from("path/to/dataset.lance")
534        );
535    }
536
537    #[test]
538    fn test_calculate_abfss_prefix() {
539        let provider = AzureBlobStoreProvider;
540        let url = Url::parse("abfss://myfs@myaccount.dfs.core.windows.net/path/to/data").unwrap();
541        let prefix = provider.calculate_object_store_prefix(&url, None).unwrap();
542        assert_eq!(prefix, "abfss$myfs@myaccount");
543    }
544
545    #[test]
546    fn test_calculate_abfss_prefix_ignores_storage_options() {
547        let provider = AzureBlobStoreProvider;
548        let options =
549            HashMap::from_iter([("account_name".to_string(), "other_account".to_string())]);
550        let url = Url::parse("abfss://myfs@myaccount.dfs.core.windows.net/path").unwrap();
551        let prefix = provider
552            .calculate_object_store_prefix(&url, Some(&options))
553            .unwrap();
554        assert_eq!(prefix, "abfss$myfs@myaccount");
555    }
556
557    #[tokio::test]
558    async fn test_abfss_default_uses_microsoft_builder() {
559        use crate::object_store::StorageOptionsAccessor;
560        let provider = AzureBlobStoreProvider;
561        let url = Url::parse("abfss://testfs@testaccount.dfs.core.windows.net/data").unwrap();
562        let params = ObjectStoreParams {
563            storage_options_accessor: Some(Arc::new(StorageOptionsAccessor::with_static_options(
564                HashMap::from([
565                    ("account_name".to_string(), "testaccount".to_string()),
566                    ("account_key".to_string(), "dGVzdA==".to_string()),
567                ]),
568            ))),
569            ..Default::default()
570        };
571
572        let store = provider.new_store(url, &params).await.unwrap();
573        assert_eq!(store.scheme, "abfss");
574        assert!(!store.is_local());
575        assert!(store.is_cloud());
576        let inner_desc = store.inner.to_string();
577        assert!(
578            inner_desc.contains("MicrosoftAzure"),
579            "abfss:// without use_opendal should use MicrosoftAzureBuilder, got: {}",
580            inner_desc
581        );
582    }
583
584    #[tokio::test]
585    async fn test_unsupported_scheme_rejected() {
586        use crate::object_store::StorageOptionsAccessor;
587        let provider = AzureBlobStoreProvider;
588        let url = Url::parse("wasbs://container@myaccount.blob.core.windows.net/path").unwrap();
589        let params = ObjectStoreParams {
590            storage_options_accessor: Some(Arc::new(StorageOptionsAccessor::with_static_options(
591                HashMap::from([
592                    ("account_name".to_string(), "myaccount".to_string()),
593                    ("account_key".to_string(), "dGVzdA==".to_string()),
594                ]),
595            ))),
596            ..Default::default()
597        };
598
599        let err = provider
600            .new_store(url, &params)
601            .await
602            .expect_err("expected error for unsupported scheme");
603        assert!(
604            err.to_string().contains("Unsupported Azure scheme"),
605            "unexpected error: {}",
606            err
607        );
608    }
609
610    #[tokio::test]
611    async fn test_abfss_with_opendal_uses_azdls() {
612        use crate::object_store::StorageOptionsAccessor;
613        let provider = AzureBlobStoreProvider;
614        let url = Url::parse("abfss://testfs@testaccount.dfs.core.windows.net/data").unwrap();
615        let params = ObjectStoreParams {
616            storage_options_accessor: Some(Arc::new(StorageOptionsAccessor::with_static_options(
617                HashMap::from([
618                    ("use_opendal".to_string(), "true".to_string()),
619                    ("account_name".to_string(), "testaccount".to_string()),
620                    ("account_key".to_string(), "dGVzdA==".to_string()),
621                ]),
622            ))),
623            ..Default::default()
624        };
625
626        let store = provider.new_store(url, &params).await.unwrap();
627        assert_eq!(store.scheme, "abfss");
628        assert!(!store.is_local());
629        assert!(store.is_cloud());
630        let inner_desc = store.inner.to_string();
631        assert!(
632            inner_desc.contains("Opendal") && inner_desc.contains("azdls"),
633            "abfss:// with use_opendal=true should use OpenDAL Azdls, got: {}",
634            inner_desc
635        );
636    }
637
638    #[test]
639    fn test_azdls_capabilities_differ_from_azblob() {
640        let common_opts = StorageOptions(HashMap::from([
641            ("account_name".to_string(), "testaccount".to_string()),
642            ("account_key".to_string(), "dGVzdA==".to_string()),
643            (
644                "endpoint".to_string(),
645                "https://testaccount.blob.core.windows.net".to_string(),
646            ),
647        ]));
648
649        // Build az:// operator (uses Azblob backend)
650        let az_url = Url::parse("az://test-container/path").unwrap();
651        let az_operator =
652            AzureBlobStoreProvider::build_opendal_operator(&az_url, &common_opts).unwrap();
653
654        // Build abfss:// operator (uses Azdls backend)
655        let abfss_url = Url::parse("abfss://testfs@testaccount.dfs.core.windows.net/data").unwrap();
656        let abfss_operator =
657            AzureBlobStoreProvider::build_opendal_operator(&abfss_url, &common_opts).unwrap();
658
659        let azblob_cap = az_operator.info().capability();
660        let azdls_cap = abfss_operator.info().capability();
661
662        // Both support basic operations
663        assert!(azblob_cap.read);
664        assert!(azdls_cap.read);
665        assert!(azblob_cap.write);
666        assert!(azdls_cap.write);
667        assert!(azblob_cap.list);
668        assert!(azdls_cap.list);
669
670        // Azdls supports rename and create_dir (HNS features); Azblob does not
671        assert!(azdls_cap.rename, "Azdls should support rename");
672        assert!(azdls_cap.create_dir, "Azdls should support create_dir");
673        assert!(!azblob_cap.rename, "Azblob should not support rename");
674    }
675}