Skip to main content

polars_io/cloud/
object_store_setup.rs

1use std::sync::{Arc, LazyLock};
2
3use object_store::ObjectStore;
4use object_store::local::LocalFileSystem;
5use polars_core::config::{self, verbose, verbose_print_sensitive};
6use polars_error::{PolarsError, PolarsResult, polars_bail, polars_err, to_compute_err};
7use polars_utils::aliases::PlHashMap;
8use polars_utils::cache::LruCache;
9use polars_utils::pl_path::{ALLOWED_EXT_SCHEMES, CloudScheme, PlPath, PlRefPath};
10use polars_utils::pl_str::PlSmallStr;
11use polars_utils::{format_pl_smallstr, pl_serialize};
12use tokio::sync::{OnceCell, RwLock};
13
14use super::{CloudLocation, CloudOptions, CloudType, PolarsObjectStore, split_http_url};
15use crate::cloud::http_rate_limit::{
16    DirectionalRateLimitConfig, InitPolicy, PacingBudget, RateLimiter,
17};
18use crate::cloud::{CloudConfig, CloudRateLimitConfig, CloudRetryConfig};
19
20/// Object stores must be cached. Every object-store will do DNS lookups and
21/// get rate limited when querying the DNS (can take up to 5s).
22/// Other reasons are connection pools that must be shared between as much as possible.
23#[allow(clippy::type_complexity)]
24static OBJECT_STORE_CACHE: LazyLock<RwLock<PlHashMap<Vec<u8>, PolarsObjectStore>>> =
25    LazyLock::new(Default::default);
26
27/// Per-origin HTTP stores; separate so that many origins don't evict the cloud stores.
28#[allow(clippy::type_complexity)]
29static HTTP_STORE_CACHE: LazyLock<
30    std::sync::Mutex<Option<LruCache<Vec<u8>, Arc<OnceCell<PolarsObjectStore>>>>>,
31> = LazyLock::new(Default::default);
32
33/// Trait for external ObjectStore builder (e.g., for HDFS). Unstable.
34pub trait ExtObjectStoreBuilder {
35    /// Build new object_store.
36    fn build(
37        &self,
38        url: &PlRefPath,
39        options: Option<&CloudOptions>,
40    ) -> PolarsResult<Arc<dyn ObjectStore + Send + Sync>>;
41
42    /// Return a stable cache key for this store.
43    /// Defaults to `None`, which uses the default key (URL authority + serialised CloudOptions).
44    fn stable_cache_key(
45        &self,
46        _url: &PlRefPath,
47        _options: Option<&CloudOptions>,
48    ) -> Option<Vec<u8>> {
49        None
50    }
51}
52
53static EXT_OBJECT_STORE_BUILDER_REGISTRY: LazyLock<
54    std::sync::RwLock<PlHashMap<PlSmallStr, Arc<dyn ExtObjectStoreBuilder + Send + Sync>>>,
55> = LazyLock::new(Default::default);
56
57/// Register custom object_store builder for a given cloud scheme.
58/// Example: for 'hdfs://', the scheme is "hdfs".
59/// Rejects native cloud schemes (e.g. "s3").
60pub fn register_object_store_builder(
61    scheme: &str,
62    builder: Arc<dyn ExtObjectStoreBuilder + Send + Sync>,
63) -> PolarsResult<()> {
64    // Reject schemes already handled natively.
65    // TODO: allow shadowing of existing schemes.
66    if CloudScheme::is_native_str(scheme) {
67        polars_bail!(
68            InvalidOperation:
69            "cannot register object_store_builder for scheme '{}': \
70             this scheme is handled natively",
71            scheme
72        );
73    }
74
75    if !polars_utils::pl_path::ext_scheme_allowed(scheme) {
76        polars_bail!(
77            InvalidOperation:
78            "cannot register object_store_builder for scheme '{}': \
79             allowed external schemes are: {:?}",
80            scheme,
81            ALLOWED_EXT_SCHEMES
82        );
83    }
84
85    if polars_config::config().verbose() {
86        eprintln!(
87            "[ObjectStoreBuilderRegistry]: register object_store_builder for scheme '{scheme}'"
88        )
89    }
90
91    EXT_OBJECT_STORE_BUILDER_REGISTRY
92        .write()
93        .unwrap()
94        .insert(scheme.into(), builder);
95    Ok(())
96}
97
98pub fn deregister_object_store_builder(scheme: &str) {
99    if polars_config::config().verbose() {
100        eprintln!(
101            "[ObjectStoreBuilderRegistry]: deregister object_store_builder for scheme '{scheme}'"
102        )
103    }
104
105    EXT_OBJECT_STORE_BUILDER_REGISTRY
106        .write()
107        .unwrap()
108        .remove(scheme);
109}
110
111#[allow(dead_code)]
112fn err_missing_feature(
113    feature: &str,
114    cloud_type: &CloudType,
115) -> PolarsResult<Arc<dyn ObjectStore>> {
116    polars_bail!(
117        ComputeError:
118        "feature '{}' must be enabled in order to use '{:?}' cloud urls",
119        feature,
120        cloud_type,
121    );
122}
123
124/// Get the key of a url for object store registration.
125fn path_and_creds_to_key(path: &PlPath, options: Option<&CloudOptions>) -> PolarsResult<Vec<u8>> {
126    // We include credentials as they can expire, so users will send new credentials for the same url.
127
128    #[cfg(feature = "cloud")]
129    let credential_cache_key = CacheKeyBytes(
130        options
131            .and_then(|o| o.credential_provider.as_ref())
132            .map(|x| x.stable_cache_key())
133            .transpose()?
134            .unwrap_or_default(),
135    );
136
137    let cloud_options = options
138        .map(
139            |CloudOptions {
140                 // Destructure to ensure this breaks if anything changes.
141                 #[cfg(feature = "file_cache")]
142                 file_cache_ttl,
143                 config,
144                 retry_config,
145                 rate_limit_config,
146                 #[cfg(feature = "cloud")]
147                     credential_provider: _,
148             }|
149             -> PolarsResult<CloudOptionsKey> {
150                Ok(CloudOptionsKey {
151                    #[cfg(feature = "file_cache")]
152                    file_cache_ttl: *file_cache_ttl,
153                    config: config.clone(),
154                    retry_config: *retry_config,
155                    rate_limit_config: *rate_limit_config,
156                    #[cfg(feature = "cloud")]
157                    credential_provider: credential_cache_key,
158                })
159            },
160        )
161        .transpose()?;
162
163    let cache_key = CacheKey {
164        url_base: format_pl_smallstr!("{}", &path.as_str()[..path.authority_end_position()]),
165        cloud_options,
166    };
167
168    verbose_print_sensitive(|| {
169        format!(
170            "object store cache key for path at '{}': {:?}",
171            path, cache_key
172        )
173    });
174
175    return pl_serialize::serialize_to_bytes::<_, false>(&cache_key);
176
177    #[derive(Clone, Debug, PartialEq, Hash, Eq)]
178    #[cfg_attr(feature = "serde", derive(serde::Serialize))]
179    struct CacheKey {
180        url_base: PlSmallStr,
181        cloud_options: Option<CloudOptionsKey>,
182    }
183
184    #[derive(Clone, PartialEq, Hash, Eq)]
185    #[cfg_attr(feature = "serde", derive(serde::Serialize))]
186    struct CacheKeyBytes(Vec<u8>);
187
188    impl std::fmt::Debug for CacheKeyBytes {
189        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
190            if self.0.is_empty() {
191                write!(f, "None")
192            } else {
193                for b in &self.0 {
194                    write!(f, "{:02x}", b)?;
195                }
196                Ok(())
197            }
198        }
199    }
200
201    /// Variant of CloudOptions for serializing to a cache key. The credential
202    /// provider is replaced by the function address.
203    #[derive(Clone, Debug, PartialEq, Hash, Eq)]
204    #[cfg_attr(feature = "serde", derive(serde::Serialize))]
205    struct CloudOptionsKey {
206        #[cfg(feature = "file_cache")]
207        file_cache_ttl: u64,
208        config: Option<CloudConfig>,
209        retry_config: CloudRetryConfig,
210        rate_limit_config: CloudRateLimitConfig,
211        #[cfg(feature = "cloud")]
212        credential_provider: CacheKeyBytes,
213    }
214}
215
216/// Construct an object_store `Path` from a string without any encoding/decoding.
217pub fn object_path_from_str(path: &str) -> PolarsResult<object_store::path::Path> {
218    object_store::path::Path::parse(path).map_err(to_compute_err)
219}
220
221#[derive(Debug, Clone)]
222pub(crate) struct PolarsObjectStoreBuilder {
223    path: PlRefPath,
224    cloud_type: CloudType,
225    options: Option<CloudOptions>,
226    // RateLimiter. Authoritative for config and read/write request rate cells (atomics).
227    // The rate-limiter, below the object store, updates them dynamically. The concurrency controller,
228    // above the object store, uses the request rate to limit the number of concurrent requests.
229    rate_limiter: Option<Arc<RateLimiter>>,
230}
231
232impl PolarsObjectStoreBuilder {
233    pub(super) fn path(&self) -> &PlRefPath {
234        &self.path
235    }
236
237    pub(crate) fn rate_limit_signal(&self) -> Option<PacingBudget> {
238        self.rate_limiter.as_ref().map(|r| r.read_budget())
239    }
240
241    pub(super) async fn build_impl(
242        &self,
243        // Whether to clear cached credentials for Python credential providers.
244        clear_cached_credentials: bool,
245    ) -> PolarsResult<Arc<dyn ObjectStore>> {
246        let options = self
247            .options
248            .as_ref()
249            .unwrap_or_else(|| CloudOptions::default_static_ref());
250
251        if let Some(options) = &self.options
252            && verbose()
253        {
254            eprintln!(
255                "build object-store: file_cache_ttl: {}",
256                options.file_cache_ttl
257            );
258
259            fn eprint_rate_config(direction: &str, config: &DirectionalRateLimitConfig) {
260                eprintln!(
261                    "object-store rate_limiter config({})]: init_rate: {:.0}rps, floor_rate: {:.0}rps, ceiling_rate: {:.0}rps, horizon: {}ms, max_wait: {}ms, init_policy: {}",
262                    direction,
263                    config.init_rate,
264                    config.floor_rate,
265                    config.ceiling_rate,
266                    config.horizon.as_millis(),
267                    config.max_wait.as_millis(),
268                    {
269                        match config.init_policy {
270                            InitPolicy::SetToInit => "to_init",
271                            InitPolicy::SetToFloor => "to_floor",
272                            InitPolicy::Inherit => "inherit",
273                        }
274                    }
275                )
276            }
277
278            if let Some(rate_limiter) = &self.rate_limiter {
279                eprint_rate_config("read", &rate_limiter.config.read);
280                eprint_rate_config("write", &rate_limiter.config.write);
281            }
282        }
283
284        let store = match self.cloud_type {
285            CloudType::Aws => {
286                #[cfg(feature = "aws")]
287                {
288                    let store = options
289                        .build_aws(
290                            self.path.clone(),
291                            clear_cached_credentials,
292                            self.rate_limiter.as_deref(),
293                        )
294                        .await?;
295                    Ok::<_, PolarsError>(Arc::new(store) as Arc<dyn ObjectStore>)
296                }
297                #[cfg(not(feature = "aws"))]
298                return err_missing_feature("aws", &self.cloud_type);
299            },
300            CloudType::Gcp => {
301                #[cfg(feature = "gcp")]
302                {
303                    let store = options.build_gcp(
304                        self.path.clone(),
305                        clear_cached_credentials,
306                        self.rate_limiter.as_deref(),
307                    )?;
308
309                    Ok::<_, PolarsError>(Arc::new(store) as Arc<dyn ObjectStore>)
310                }
311                #[cfg(not(feature = "gcp"))]
312                return err_missing_feature("gcp", &self.cloud_type);
313            },
314            CloudType::Azure => {
315                {
316                    #[cfg(feature = "azure")]
317                    {
318                        let store = options.build_azure(
319                            self.path.clone(),
320                            clear_cached_credentials,
321                            self.rate_limiter.as_deref(),
322                        )?;
323                        Ok::<_, PolarsError>(Arc::new(store) as Arc<dyn ObjectStore>)
324                    }
325                }
326                #[cfg(not(feature = "azure"))]
327                return err_missing_feature("azure", &self.cloud_type);
328            },
329            CloudType::File => {
330                let local = LocalFileSystem::new();
331                Ok::<_, PolarsError>(Arc::new(local) as Arc<dyn ObjectStore>)
332            },
333            CloudType::Http => {
334                {
335                    #[cfg(feature = "http")]
336                    {
337                        let store: Arc<dyn ObjectStore> = match split_http_url(&self.path) {
338                            Some((base_url, _)) => Arc::new(
339                                options.build_http_origin(self.path.sliced(0..base_url.len()))?,
340                            ),
341                            None => Arc::new(options.build_http(self.path.clone())?),
342                        };
343                        PolarsResult::Ok(store)
344                    }
345                }
346                #[cfg(not(feature = "http"))]
347                return err_missing_feature("http", &cloud_location.scheme);
348            },
349            CloudType::Hf => panic!("impl error: unresolved hf:// path"),
350            CloudType::Ext(scheme) => {
351                let prefix = &self.path.as_str()[..self.path.authority_end_position()];
352
353                verbose_print_sensitive(|| {
354                    format!(
355                        "build external object_store: scheme='{}', prefix='{}', options={:?}",
356                        scheme, prefix, self.options
357                    )
358                });
359
360                let store = EXT_OBJECT_STORE_BUILDER_REGISTRY
361                    .read()
362                    .unwrap()
363                    .get(scheme)
364                    .ok_or_else(|| {
365                        polars_err!(
366                            ComputeError:
367                            "no object_store_builder registered for prefix: {}; \
368                             call register_object_store_builder() before executing queries \
369                             against the scheme: {}",
370                            prefix, scheme
371                        )
372                    })?
373                    .build(&self.path, self.options.as_ref())?;
374
375                return Ok(store);
376            },
377        }?;
378
379        Ok(store)
380    }
381
382    /// Note: Use `build_impl` for a non-caching version.
383    pub(super) async fn build(self) -> PolarsResult<PolarsObjectStore> {
384        if self.cloud_type == CloudType::Http
385            && let Some((base_url, _)) = split_http_url(&self.path)
386        {
387            // The store serves the whole origin, so it must not keep this URL.
388            let path = self.path.sliced(0..base_url.len());
389            return Self { path, ..self }.build_http_cached().await;
390        }
391
392        let opt_cache_key = match self.cloud_type {
393            CloudType::Aws | CloudType::Gcp | CloudType::Azure => {
394                Some(path_and_creds_to_key(&self.path, self.options.as_ref())?)
395            },
396            CloudType::File | CloudType::Http | CloudType::Hf => None,
397            CloudType::Ext(scheme) => {
398                let registry = EXT_OBJECT_STORE_BUILDER_REGISTRY.read().unwrap();
399                let builder = registry.get(scheme).ok_or_else(|| {
400                    polars_err!(
401                        ComputeError:
402                        "no object_store_builder registered for scheme '{}'; \
403                         call register_object_store_builder() before executing queries \
404                         against this scheme",
405                        scheme
406                    )
407                })?;
408
409                let key = match builder.stable_cache_key(&self.path, self.options.as_ref()) {
410                    Some(key) => key,
411                    None => path_and_creds_to_key(&self.path, self.options.as_ref())?,
412                };
413
414                Some(key)
415            },
416        };
417
418        let opt_cache_write_guard = if let Some(cache_key) = opt_cache_key.as_deref() {
419            let cache = OBJECT_STORE_CACHE.read().await;
420
421            if let Some(store) = cache.get(cache_key) {
422                return Ok(store.clone());
423            }
424
425            drop(cache);
426
427            let cache = OBJECT_STORE_CACHE.write().await;
428
429            if let Some(store) = cache.get(cache_key) {
430                return Ok(store.clone());
431            }
432
433            Some(cache)
434        } else {
435            None
436        };
437
438        let store = self.build_impl(false).await?;
439        let store = PolarsObjectStore::new_from_inner(store, self);
440
441        if let Some(mut cache) = opt_cache_write_guard {
442            // Clear the cache if we surpass a certain amount of buckets.
443            if cache.len() >= 8 {
444                if config::verbose() {
445                    eprintln!(
446                        "build_object_store: clearing store cache (cache.len(): {})",
447                        cache.len()
448                    );
449                }
450                cache.clear()
451            }
452
453            cache.insert(opt_cache_key.unwrap(), store.clone());
454        }
455
456        Ok(store)
457    }
458
459    async fn build_http_cached(self) -> PolarsResult<PolarsObjectStore> {
460        // Clamped: the LRU allocates its capacity up front.
461        let capacity = polars_config::config().http_store_cache_size().min(1 << 16) as usize;
462
463        // An uncached cell builds a store that is dropped with its last user.
464        let cell = if capacity == 0 {
465            Default::default()
466        } else {
467            let cache_key = path_and_creds_to_key(&self.path, self.options.as_ref())?;
468            let mut cache = HTTP_STORE_CACHE.lock().unwrap();
469            let cache = match &mut *cache {
470                Some(cache) if cache.max_capacity() == capacity => cache,
471                // First use, or capacity changed at runtime.
472                cache => cache.insert(LruCache::with_capacity(capacity)),
473            };
474            let was_full = cache.len() == capacity;
475            let cell =
476                Arc::clone(cache.get_or_insert_with(cache_key.as_slice(), |_| Default::default()));
477            if !was_full && cache.len() == capacity && config::verbose() {
478                eprintln!(
479                    "build_object_store: http store cache full ({capacity} hosts), evicting \
480                     least recently used from now on (POLARS_HTTP_STORE_CACHE_SIZE)"
481                );
482            }
483            cell
484        };
485
486        cell.get_or_try_init(|| async {
487            let store = self.build_impl(false).await?;
488            PolarsResult::Ok(PolarsObjectStore::new_from_inner(store, self))
489        })
490        .await
491        .cloned()
492    }
493
494    pub(crate) fn is_azure(&self) -> bool {
495        matches!(&self.cloud_type, CloudType::Azure)
496    }
497
498    /// Http has no credentials to refresh; a rebuild would drop the connection pool.
499    pub(crate) fn rebuild_on_error(&self) -> bool {
500        !matches!(&self.cloud_type, CloudType::Http)
501    }
502}
503
504/// Build an [`ObjectStore`] based on the URL and passed in url. Return the cloud location and an implementation of the object store.
505pub async fn build_object_store(
506    path: PlRefPath,
507    #[cfg_attr(
508        not(any(feature = "aws", feature = "gcp", feature = "azure")),
509        allow(unused_variables)
510    )]
511    options: Option<&CloudOptions>,
512    glob: bool,
513) -> PolarsResult<(CloudLocation, PolarsObjectStore)> {
514    let path = path.to_absolute_path()?.into_owned();
515
516    let cloud_type = path
517        .scheme()
518        .map_or(CloudType::File, CloudType::from_cloud_scheme);
519    let cloud_location = CloudLocation::new(path.clone(), glob)?;
520
521    let disable_http_rate_limit = polars_config::config().disable_http_rate_limit();
522    let rate_limiter = match cloud_type {
523        CloudType::Aws | CloudType::Azure | CloudType::Gcp if !disable_http_rate_limit => {
524            Some(Arc::new(RateLimiter::new(
525                options
526                    .map(|options| options.rate_limit_config)
527                    .unwrap_or_default()
528                    .into(),
529            )))
530        },
531        _ => None,
532    };
533
534    let store = PolarsObjectStoreBuilder {
535        path,
536        cloud_type,
537        options: options.cloned(),
538        rate_limiter,
539    }
540    .build()
541    .await?;
542
543    Ok((cloud_location, store))
544}
545
546mod test {
547    #[test]
548    fn test_object_path_from_str() {
549        use super::object_path_from_str;
550
551        let path = "%25";
552        let out = object_path_from_str(path).unwrap();
553
554        assert_eq!(out.as_ref(), path);
555    }
556}
557
558#[cfg(all(test, feature = "cloud"))]
559mod ext_store_tests {
560    use std::sync::Arc;
561
562    use object_store::ObjectStore;
563    use object_store::memory::InMemory;
564    use polars_utils::pl_path::PlRefPath;
565    use polars_utils::relaxed_cell::RelaxedCell;
566
567    use super::*;
568
569    struct TestBuilder {
570        store: Arc<dyn ObjectStore + Send + Sync>,
571        build_count: RelaxedCell<usize>,
572    }
573
574    impl TestBuilder {
575        fn new() -> Arc<Self> {
576            Arc::new(Self {
577                store: Arc::new(InMemory::new()),
578                build_count: RelaxedCell::new_usize(0),
579            })
580        }
581
582        fn build_count(&self) -> usize {
583            self.build_count.load()
584        }
585
586        fn inc_build_count(&self) {
587            self.build_count.fetch_add(1);
588        }
589    }
590
591    impl ExtObjectStoreBuilder for TestBuilder {
592        fn build(
593            &self,
594            _url: &PlRefPath,
595            _options: Option<&CloudOptions>,
596        ) -> PolarsResult<Arc<dyn ObjectStore + Send + Sync>> {
597            self.inc_build_count();
598            Ok(self.store.clone())
599        }
600    }
601
602    #[tokio::test]
603    async fn test_register_and_resolve() -> PolarsResult<()> {
604        let builder = TestBuilder::new();
605        polars_utils::pl_path::_allow_ext_scheme("pl-test1")?;
606        register_object_store_builder("pl-test1", builder.clone()).unwrap();
607
608        let path = PlRefPath::new("pl-test1://host:1234/data/file.parquet");
609        let result = build_object_store(path, None, false).await;
610        assert!(result.is_ok());
611        assert_eq!(builder.build_count(), 1);
612
613        deregister_object_store_builder("pl-test1");
614        polars_utils::pl_path::_disallow_ext_scheme("pl-test1");
615        Ok(())
616    }
617
618    #[tokio::test]
619    async fn test_cache_hit_after_first_build() -> PolarsResult<()> {
620        let builder = TestBuilder::new();
621        polars_utils::pl_path::_allow_ext_scheme("pl-test2")?;
622        register_object_store_builder("pl-test2", builder.clone()).unwrap();
623
624        let path = PlRefPath::new("pl-test2://host:1234/data/file.parquet");
625
626        // First call — cache miss, build_impl called
627        build_object_store(path.clone(), None, false).await.unwrap();
628        assert_eq!(builder.build_count(), 1);
629
630        // Second call — cache hit, build_impl not called
631        build_object_store(path.clone(), None, false).await.unwrap();
632        assert_eq!(builder.build_count(), 1);
633
634        deregister_object_store_builder("pl-test2");
635        polars_utils::pl_path::_disallow_ext_scheme("pl-test2");
636        Ok(())
637    }
638
639    #[test]
640    fn test_native_scheme_rejected() {
641        let builder = TestBuilder::new();
642        let result = register_object_store_builder("s3", builder);
643        assert!(result.is_err());
644        assert!(result.unwrap_err().to_string().contains("handled natively"));
645    }
646
647    #[tokio::test]
648    async fn test_stable_cache_key_override() -> PolarsResult<()> {
649        #[derive(Clone)]
650        struct AuthorityOnlyBuilder {
651            store: Arc<dyn ObjectStore + Send + Sync>,
652            build_count: Arc<RelaxedCell<usize>>,
653        }
654
655        impl AuthorityOnlyBuilder {
656            fn new() -> Self {
657                Self {
658                    store: Arc::new(InMemory::new()),
659                    build_count: Arc::new(RelaxedCell::new_usize(0)),
660                }
661            }
662
663            fn build_count(&self) -> usize {
664                self.build_count.load()
665            }
666
667            fn inc_build_count(&self) -> usize {
668                self.build_count.fetch_add(1)
669            }
670        }
671
672        impl ExtObjectStoreBuilder for AuthorityOnlyBuilder {
673            fn build(
674                &self,
675                _url: &PlRefPath,
676                _options: Option<&CloudOptions>,
677            ) -> PolarsResult<Arc<dyn ObjectStore + Send + Sync>> {
678                self.inc_build_count();
679                Ok(self.store.clone())
680            }
681
682            fn stable_cache_key(
683                &self,
684                url: &PlRefPath,
685                _options: Option<&CloudOptions>,
686            ) -> Option<Vec<u8>> {
687                let authority = &url.as_str()[..url.authority_end_position()];
688                Some(authority.as_bytes().to_vec())
689            }
690        }
691
692        let builder = AuthorityOnlyBuilder::new();
693        polars_utils::pl_path::_allow_ext_scheme("pl-test3")?;
694        register_object_store_builder("pl-test3", Arc::new(builder.clone())).unwrap();
695
696        use crate::cloud::{CloudConfig, CloudOptions};
697
698        let options_a = CloudOptions {
699            config: Some(CloudConfig::Ext {
700                options: vec![("user".to_string(), "alice".to_string())],
701            }),
702            ..CloudOptions::default()
703        };
704
705        let options_b = CloudOptions {
706            config: Some(CloudConfig::Ext {
707                options: vec![("user".to_string(), "bob".to_string())],
708            }),
709            ..CloudOptions::default()
710        };
711
712        let path = PlRefPath::new("pl-test3://host:1234/data/file.parquet");
713
714        build_object_store(path.clone(), Some(&options_a), false)
715            .await
716            .unwrap();
717        build_object_store(path.clone(), Some(&options_b), false)
718            .await
719            .unwrap();
720
721        assert_eq!(builder.build_count(), 1);
722
723        deregister_object_store_builder("pl-test3");
724        polars_utils::pl_path::_disallow_ext_scheme("pl-test3");
725        Ok(())
726    }
727
728    #[tokio::test]
729    async fn test_storage_options_passed_to_builder() -> PolarsResult<()> {
730        use crate::cloud::{CloudConfig, CloudOptions};
731
732        #[allow(clippy::type_complexity)]
733        struct CapturingBuilder {
734            received_options: Arc<std::sync::Mutex<Option<Vec<(String, String)>>>>,
735            store: Arc<dyn ObjectStore + Send + Sync>,
736        }
737
738        impl ExtObjectStoreBuilder for CapturingBuilder {
739            fn build(
740                &self,
741                _url: &PlRefPath,
742                options: Option<&CloudOptions>,
743            ) -> PolarsResult<Arc<dyn ObjectStore + Send + Sync>> {
744                let captured = match options {
745                    Some(CloudOptions {
746                        config: Some(CloudConfig::Ext { options }),
747                        ..
748                    }) => Some(options.clone()),
749                    _ => None,
750                };
751                *self.received_options.lock().unwrap() = captured;
752                Ok(self.store.clone())
753            }
754        }
755
756        let received = Arc::new(std::sync::Mutex::new(None));
757
758        let builder = Arc::new(CapturingBuilder {
759            received_options: received.clone(),
760            store: Arc::new(InMemory::new()),
761        });
762
763        polars_utils::pl_path::_allow_ext_scheme("pl-test4")?;
764        register_object_store_builder("pl-test4", builder).unwrap();
765
766        let options = CloudOptions {
767            config: Some(CloudConfig::Ext {
768                options: vec![
769                    ("user".to_string(), "hadoop".to_string()),
770                    ("token".to_string(), "abc123".to_string()),
771                ],
772            }),
773            ..CloudOptions::default()
774        };
775
776        let path = PlRefPath::new("pl-test4://host:1234/data/file.parquet");
777        build_object_store(path, Some(&options), false)
778            .await
779            .unwrap();
780
781        let captured = received.lock().unwrap().clone().unwrap();
782        assert_eq!(captured.len(), 2);
783        assert!(captured.iter().any(|(k, v)| k == "user" && v == "hadoop"));
784        assert!(captured.iter().any(|(k, v)| k == "token" && v == "abc123"));
785
786        deregister_object_store_builder("pl-test4");
787        polars_utils::pl_path::_disallow_ext_scheme("pl-test4");
788        Ok(())
789    }
790}