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