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#[allow(clippy::type_complexity)]
23static OBJECT_STORE_CACHE: LazyLock<RwLock<PlHashMap<Vec<u8>, PolarsObjectStore>>> =
24 LazyLock::new(Default::default);
25
26pub trait ExtObjectStoreBuilder {
28 fn build(
30 &self,
31 url: &PlRefPath,
32 options: Option<&CloudOptions>,
33 ) -> PolarsResult<Arc<dyn ObjectStore + Send + Sync>>;
34
35 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
50pub fn register_object_store_builder(
54 scheme: &str,
55 builder: Arc<dyn ExtObjectStoreBuilder + Send + Sync>,
56) -> PolarsResult<()> {
57 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
117fn path_and_creds_to_key(path: &PlPath, options: Option<&CloudOptions>) -> PolarsResult<Vec<u8>> {
119 #[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 #[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 #[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
209pub 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 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 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 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 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
444pub 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 rate_limiter = match cloud_type {
462 CloudType::Aws | CloudType::Azure | CloudType::Gcp => Some(Arc::new(RateLimiter::new(
463 options
464 .map(|options| options.rate_limit_config)
465 .unwrap_or_default()
466 .into(),
467 ))),
468 _ => None,
469 };
470
471 let store = PolarsObjectStoreBuilder {
472 path,
473 cloud_type,
474 options: options.cloned(),
475 rate_limiter,
476 }
477 .build()
478 .await?;
479
480 Ok((cloud_location, store))
481}
482
483mod test {
484 #[test]
485 fn test_object_path_from_str() {
486 use super::object_path_from_str;
487
488 let path = "%25";
489 let out = object_path_from_str(path).unwrap();
490
491 assert_eq!(out.as_ref(), path);
492 }
493}
494
495#[cfg(all(test, feature = "cloud"))]
496mod ext_store_tests {
497 use std::sync::Arc;
498
499 use object_store::ObjectStore;
500 use object_store::memory::InMemory;
501 use polars_utils::pl_path::PlRefPath;
502 use polars_utils::relaxed_cell::RelaxedCell;
503
504 use super::*;
505
506 struct TestBuilder {
507 store: Arc<dyn ObjectStore + Send + Sync>,
508 build_count: RelaxedCell<usize>,
509 }
510
511 impl TestBuilder {
512 fn new() -> Arc<Self> {
513 Arc::new(Self {
514 store: Arc::new(InMemory::new()),
515 build_count: RelaxedCell::new_usize(0),
516 })
517 }
518
519 fn build_count(&self) -> usize {
520 self.build_count.load()
521 }
522
523 fn inc_build_count(&self) {
524 self.build_count.fetch_add(1);
525 }
526 }
527
528 impl ExtObjectStoreBuilder for TestBuilder {
529 fn build(
530 &self,
531 _url: &PlRefPath,
532 _options: Option<&CloudOptions>,
533 ) -> PolarsResult<Arc<dyn ObjectStore + Send + Sync>> {
534 self.inc_build_count();
535 Ok(self.store.clone())
536 }
537 }
538
539 #[tokio::test]
540 async fn test_register_and_resolve() -> PolarsResult<()> {
541 let builder = TestBuilder::new();
542 polars_utils::pl_path::_allow_ext_scheme("pl-test1")?;
543 register_object_store_builder("pl-test1", builder.clone()).unwrap();
544
545 let path = PlRefPath::new("pl-test1://host:1234/data/file.parquet");
546 let result = build_object_store(path, None, false).await;
547 assert!(result.is_ok());
548 assert_eq!(builder.build_count(), 1);
549
550 deregister_object_store_builder("pl-test1");
551 polars_utils::pl_path::_disallow_ext_scheme("pl-test1");
552 Ok(())
553 }
554
555 #[tokio::test]
556 async fn test_cache_hit_after_first_build() -> PolarsResult<()> {
557 let builder = TestBuilder::new();
558 polars_utils::pl_path::_allow_ext_scheme("pl-test2")?;
559 register_object_store_builder("pl-test2", builder.clone()).unwrap();
560
561 let path = PlRefPath::new("pl-test2://host:1234/data/file.parquet");
562
563 build_object_store(path.clone(), None, false).await.unwrap();
565 assert_eq!(builder.build_count(), 1);
566
567 build_object_store(path.clone(), None, false).await.unwrap();
569 assert_eq!(builder.build_count(), 1);
570
571 deregister_object_store_builder("pl-test2");
572 polars_utils::pl_path::_disallow_ext_scheme("pl-test2");
573 Ok(())
574 }
575
576 #[test]
577 fn test_native_scheme_rejected() {
578 let builder = TestBuilder::new();
579 let result = register_object_store_builder("s3", builder);
580 assert!(result.is_err());
581 assert!(result.unwrap_err().to_string().contains("handled natively"));
582 }
583
584 #[tokio::test]
585 async fn test_stable_cache_key_override() -> PolarsResult<()> {
586 #[derive(Clone)]
587 struct AuthorityOnlyBuilder {
588 store: Arc<dyn ObjectStore + Send + Sync>,
589 build_count: Arc<RelaxedCell<usize>>,
590 }
591
592 impl AuthorityOnlyBuilder {
593 fn new() -> Self {
594 Self {
595 store: Arc::new(InMemory::new()),
596 build_count: Arc::new(RelaxedCell::new_usize(0)),
597 }
598 }
599
600 fn build_count(&self) -> usize {
601 self.build_count.load()
602 }
603
604 fn inc_build_count(&self) -> usize {
605 self.build_count.fetch_add(1)
606 }
607 }
608
609 impl ExtObjectStoreBuilder for AuthorityOnlyBuilder {
610 fn build(
611 &self,
612 _url: &PlRefPath,
613 _options: Option<&CloudOptions>,
614 ) -> PolarsResult<Arc<dyn ObjectStore + Send + Sync>> {
615 self.inc_build_count();
616 Ok(self.store.clone())
617 }
618
619 fn stable_cache_key(
620 &self,
621 url: &PlRefPath,
622 _options: Option<&CloudOptions>,
623 ) -> Option<Vec<u8>> {
624 let authority = &url.as_str()[..url.authority_end_position()];
625 Some(authority.as_bytes().to_vec())
626 }
627 }
628
629 let builder = AuthorityOnlyBuilder::new();
630 polars_utils::pl_path::_allow_ext_scheme("pl-test3")?;
631 register_object_store_builder("pl-test3", Arc::new(builder.clone())).unwrap();
632
633 use crate::cloud::{CloudConfig, CloudOptions};
634
635 let options_a = CloudOptions {
636 config: Some(CloudConfig::Ext {
637 options: vec![("user".to_string(), "alice".to_string())],
638 }),
639 ..CloudOptions::default()
640 };
641
642 let options_b = CloudOptions {
643 config: Some(CloudConfig::Ext {
644 options: vec![("user".to_string(), "bob".to_string())],
645 }),
646 ..CloudOptions::default()
647 };
648
649 let path = PlRefPath::new("pl-test3://host:1234/data/file.parquet");
650
651 build_object_store(path.clone(), Some(&options_a), false)
652 .await
653 .unwrap();
654 build_object_store(path.clone(), Some(&options_b), false)
655 .await
656 .unwrap();
657
658 assert_eq!(builder.build_count(), 1);
659
660 deregister_object_store_builder("pl-test3");
661 polars_utils::pl_path::_disallow_ext_scheme("pl-test3");
662 Ok(())
663 }
664
665 #[tokio::test]
666 async fn test_storage_options_passed_to_builder() -> PolarsResult<()> {
667 use crate::cloud::{CloudConfig, CloudOptions};
668
669 #[allow(clippy::type_complexity)]
670 struct CapturingBuilder {
671 received_options: Arc<std::sync::Mutex<Option<Vec<(String, String)>>>>,
672 store: Arc<dyn ObjectStore + Send + Sync>,
673 }
674
675 impl ExtObjectStoreBuilder for CapturingBuilder {
676 fn build(
677 &self,
678 _url: &PlRefPath,
679 options: Option<&CloudOptions>,
680 ) -> PolarsResult<Arc<dyn ObjectStore + Send + Sync>> {
681 let captured = match options {
682 Some(CloudOptions {
683 config: Some(CloudConfig::Ext { options }),
684 ..
685 }) => Some(options.clone()),
686 _ => None,
687 };
688 *self.received_options.lock().unwrap() = captured;
689 Ok(self.store.clone())
690 }
691 }
692
693 let received = Arc::new(std::sync::Mutex::new(None));
694
695 let builder = Arc::new(CapturingBuilder {
696 received_options: received.clone(),
697 store: Arc::new(InMemory::new()),
698 });
699
700 polars_utils::pl_path::_allow_ext_scheme("pl-test4")?;
701 register_object_store_builder("pl-test4", builder).unwrap();
702
703 let options = CloudOptions {
704 config: Some(CloudConfig::Ext {
705 options: vec![
706 ("user".to_string(), "hadoop".to_string()),
707 ("token".to_string(), "abc123".to_string()),
708 ],
709 }),
710 ..CloudOptions::default()
711 };
712
713 let path = PlRefPath::new("pl-test4://host:1234/data/file.parquet");
714 build_object_store(path, Some(&options), false)
715 .await
716 .unwrap();
717
718 let captured = received.lock().unwrap().clone().unwrap();
719 assert_eq!(captured.len(), 2);
720 assert!(captured.iter().any(|(k, v)| k == "user" && v == "hadoop"));
721 assert!(captured.iter().any(|(k, v)| k == "token" && v == "abc123"));
722
723 deregister_object_store_builder("pl-test4");
724 polars_utils::pl_path::_disallow_ext_scheme("pl-test4");
725 Ok(())
726 }
727}