1use 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 fn normalize_opendal_azure_options(
38 options: &HashMap<String, String>,
39 ) -> HashMap<String, String> {
40 let mut config_map = options.clone();
42
43 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 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 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 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 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 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 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 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 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 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(), ¶ms_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 #[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, ¶ms).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, ¶ms)
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, ¶ms).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 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 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 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 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}