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