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