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 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 build_object_store(path.clone(), None, false).await.unwrap();
568 assert_eq!(builder.build_count(), 1);
569
570 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}