Skip to main content

lance_io/object_store/
providers.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright The Lance Authors
3
4use std::{
5    collections::HashMap,
6    sync::{
7        Arc, RwLock, Weak,
8        atomic::{AtomicU64, Ordering},
9    },
10};
11
12use object_store::path::Path;
13use url::Url;
14
15use crate::object_store::WrappingObjectStore;
16use crate::object_store::uri_to_url;
17
18use super::{ObjectStore, ObjectStoreParams, tracing::ObjectStoreTracingExt};
19use lance_core::error::{Error, LanceOptionExt, Result};
20
21#[cfg(feature = "aws")]
22pub mod aws;
23#[cfg(feature = "azure")]
24pub mod azure;
25#[cfg(feature = "gcp")]
26pub mod gcp;
27#[cfg(feature = "goosefs")]
28pub mod goosefs;
29#[cfg(feature = "huggingface")]
30pub mod huggingface;
31pub mod local;
32pub mod memory;
33#[cfg(feature = "oss")]
34pub mod oss;
35pub mod shared_memory;
36#[cfg(feature = "tencent")]
37pub mod tencent;
38#[cfg(feature = "tos")]
39pub mod tos;
40
41#[async_trait::async_trait]
42pub trait ObjectStoreProvider: std::fmt::Debug + Sync + Send {
43    async fn new_store(&self, base_path: Url, params: &ObjectStoreParams) -> Result<ObjectStore>;
44
45    /// Extract the path relative to the base of the store.
46    ///
47    /// For example, in S3 the path is relative to the bucket. So a URL of
48    /// `s3://bucket/path/to/file` would return `path/to/file`.
49    ///
50    /// Meanwhile, for a file store, the path is relative to the filesystem root.
51    /// So a URL of `file:///path/to/file` would return `/path/to/file`.
52    fn extract_path(&self, url: &Url) -> Result<Path> {
53        // url.path() returns a percent-encoded string (per the WHATWG URL spec).
54        // Path::from_url_path decodes it first so the Path internal representation
55        // holds the raw UTF-8 string. This prevents double-encoding when the
56        // object store client later percent-encodes the path for HTTP requests.
57        Path::from_url_path(url.path()).map_err(|e| {
58            Error::invalid_input(format!("Invalid path in URL '{}': {}", url.path(), e))
59        })
60    }
61
62    /// Calculate the unique prefix that should be used for this object store.
63    ///
64    /// For object stores that don't have the concept of buckets, this will just be something like
65    /// 'file' or 'memory'.
66    ///
67    /// In object stores where all bucket names are unique, like s3, this will be
68    /// simply 's3$my_bucket_name' or similar.
69    ///
70    /// In Azure, only the combination of (account name, container name) is unique, so
71    /// this will be something like 'az$account_name@container'
72    ///
73    /// Providers should override this if they have special requirements like Azure's.
74    fn calculate_object_store_prefix(
75        &self,
76        url: &Url,
77        _storage_options: Option<&HashMap<String, String>>,
78    ) -> Result<String> {
79        Ok(format!("{}${}", url.scheme(), url.authority()))
80    }
81}
82
83/// Statistics for the object store registry cache.
84#[derive(Debug, Clone, Default)]
85pub struct ObjectStoreRegistryStats {
86    /// Number of cache hits (store was already cached and reused).
87    pub hits: u64,
88    /// Number of cache misses (new store had to be created).
89    pub misses: u64,
90    /// Number of currently active object stores in the cache.
91    pub active_stores: usize,
92}
93
94/// A registry of object store providers.
95///
96/// Use [`Self::default()`] to create one with the available default providers.
97/// This includes (depending on features enabled):
98/// - `memory`: An in-memory object store.
99/// - `file`: A local file object store, with optimized code paths.
100/// - `file-object-store`: A local file object store that uses the ObjectStore API,
101///   for all operations. Used for testing with ObjectStore wrappers.
102/// - `file+uring`: A local file object store using io_uring (Linux only).
103/// - `s3`: An S3 object store.
104/// - `s3+ddb`: An S3 object store with DynamoDB for metadata.
105/// - `az`: An Azure Blob Storage object store.
106/// - `gs`: A Google Cloud Storage object store.
107/// - `tos`: A Volcengine TOS object store.
108///
109/// Use [`Self::empty()`] to create an empty registry, with no providers registered.
110///
111/// The registry also caches object stores that are currently in use. It holds
112/// weak references to the object stores, so they are not held onto. If an object
113/// store is no longer in use, it will be removed from the cache on the next
114/// call to either [`Self::active_stores()`] or [`Self::get_store()`].
115#[derive(Debug)]
116pub struct ObjectStoreRegistry {
117    providers: RwLock<HashMap<String, Arc<dyn ObjectStoreProvider>>>,
118    // Cache of object stores currently in use. We use a weak reference so the
119    // cache itself doesn't keep them alive if no object store is actually using
120    // it.
121    active_stores: RwLock<HashMap<(String, ObjectStoreParams), Weak<ObjectStore>>>,
122    // Cache statistics
123    hits: AtomicU64,
124    misses: AtomicU64,
125}
126
127impl ObjectStoreRegistry {
128    /// Create a new registry with no providers registered.
129    ///
130    /// Typically, you want to use [`Self::default()`] instead, so you get the
131    /// default providers.
132    pub fn empty() -> Self {
133        Self {
134            providers: RwLock::new(HashMap::new()),
135            active_stores: RwLock::new(HashMap::new()),
136            hits: AtomicU64::new(0),
137            misses: AtomicU64::new(0),
138        }
139    }
140
141    /// Get the object store provider for a given scheme.
142    pub fn get_provider(&self, scheme: &str) -> Option<Arc<dyn ObjectStoreProvider>> {
143        self.providers
144            .read()
145            .expect("ObjectStoreRegistry lock poisoned")
146            .get(scheme)
147            .cloned()
148    }
149
150    /// Get a list of all active object stores.
151    ///
152    /// Calling this will also clean up any weak references to object stores that
153    /// are no longer valid.
154    pub fn active_stores(&self) -> Vec<Arc<ObjectStore>> {
155        let mut found_inactive = false;
156        let output = self
157            .active_stores
158            .read()
159            .expect("ObjectStoreRegistry lock poisoned")
160            .values()
161            .filter_map(|weak| match weak.upgrade() {
162                Some(store) => Some(store),
163                None => {
164                    found_inactive = true;
165                    None
166                }
167            })
168            .collect();
169
170        if found_inactive {
171            // Clean up the cache by removing any weak references that are no longer valid
172            let mut cache_lock = self
173                .active_stores
174                .write()
175                .expect("ObjectStoreRegistry lock poisoned");
176            cache_lock.retain(|_, weak| weak.upgrade().is_some());
177        }
178        output
179    }
180
181    /// Get cache statistics for monitoring and debugging.
182    ///
183    /// Returns the number of cache hits, misses, and currently active stores.
184    /// This is useful for detecting configuration issues that cause excessive
185    /// cache misses (e.g., storage options that vary per-request).
186    pub fn stats(&self) -> ObjectStoreRegistryStats {
187        let active_stores = self
188            .active_stores
189            .read()
190            .map(|s| s.values().filter(|w| w.strong_count() > 0).count())
191            .unwrap_or(0);
192        ObjectStoreRegistryStats {
193            hits: self.hits.load(Ordering::Relaxed),
194            misses: self.misses.load(Ordering::Relaxed),
195            active_stores,
196        }
197    }
198
199    fn scheme_not_found_error(&self, scheme: &str) -> Error {
200        let mut message = format!("No object store provider found for scheme: '{}'", scheme);
201        if let Ok(providers) = self.providers.read() {
202            let valid_schemes = providers.keys().cloned().collect::<Vec<_>>().join(", ");
203            message.push_str(&format!("\nValid schemes: {}", valid_schemes));
204        }
205        Error::invalid_input(message)
206    }
207
208    async fn build_store(
209        &self,
210        provider: Arc<dyn ObjectStoreProvider>,
211        base_path: Url,
212        params: &ObjectStoreParams,
213        metrics_base: &str,
214    ) -> Result<Arc<ObjectStore>> {
215        let mut store = provider.new_store(base_path, params).await?;
216        // Providers only know the explicit parameter; the storage option is
217        // applied here so every store honours it the same way.
218        if let Some(block_size) = params.resolved_block_size()? {
219            store.block_size = block_size;
220        }
221
222        store.inner = store.inner.traced();
223
224        // Label metrics by the store's unique prefix (e.g. `s3$bucket`,
225        // `az$container@account`) so multiple stores on one cloud differ, or by
226        // the dataset URI when the label mode asks for it.
227        crate::object_store::meter_store(&mut store.inner, &mut store.io_tracker, metrics_base);
228
229        if let Some(wrapper) = &params.object_store_wrapper {
230            store.apply_wrapper(wrapper.as_ref());
231        }
232
233        // Always wrap with IO tracking
234        store.inner = store.io_tracker.wrap("", store.inner);
235
236        Ok(Arc::new(store))
237    }
238
239    /// Build a fresh object store without consulting or populating the cache.
240    ///
241    /// Callers should retain the returned [`Arc`] for as long as they want to
242    /// reuse provider-local state such as HTTP clients and rate limiters.
243    #[doc(hidden)]
244    pub async fn new_store(
245        &self,
246        base_path: Url,
247        params: &ObjectStoreParams,
248    ) -> Result<Arc<ObjectStore>> {
249        // Base-scoped storage options (`base_<id>.<key>`) are directives for
250        // other registered base paths; resolve them away before building a
251        // store for this location.
252        let params = params.scoped_to_base(None);
253        let params = params.as_ref();
254        let scheme = base_path.scheme();
255        let Some(provider) = self.get_provider(scheme) else {
256            return Err(self.scheme_not_found_error(scheme));
257        };
258        let store_prefix =
259            provider.calculate_object_store_prefix(&base_path, params.storage_options())?;
260        let metrics_base = crate::object_store::metrics_base(&store_prefix, &base_path);
261
262        self.build_store(provider, base_path, params, &metrics_base)
263            .await
264    }
265
266    /// Get an object store for a given base path and parameters.
267    ///
268    /// If the object store is already in use, it will return a strong reference
269    /// to the object store. If the object store is not in use, it will create a
270    /// new object store and return a strong reference to it.
271    pub async fn get_store(
272        &self,
273        base_path: Url,
274        params: &ObjectStoreParams,
275    ) -> Result<Arc<ObjectStore>> {
276        // Base-scoped storage options (`base_<id>.<key>`) are directives for
277        // other registered base paths; resolve them away before building or
278        // caching a store for this location. Params already resolved for a
279        // base contain no scoped entries, so this is a no-op for them.
280        let params = params.scoped_to_base(None);
281        let params = params.as_ref();
282        let scheme = base_path.scheme();
283        let Some(provider) = self.get_provider(scheme) else {
284            return Err(self.scheme_not_found_error(scheme));
285        };
286
287        // Keyed by the metrics identity rather than the bare prefix: in
288        // `dataset` label mode that is the dataset URI, so datasets sharing a
289        // bucket get separate stores and their metrics stay attributed.
290        let store_prefix =
291            provider.calculate_object_store_prefix(&base_path, params.storage_options())?;
292        let cache_path = crate::object_store::metrics_base(&store_prefix, &base_path);
293        let cache_key = (cache_path.clone(), params.clone());
294
295        // Check if we have a cached store for this base path and params
296        {
297            let maybe_store = self
298                .active_stores
299                .read()
300                .ok()
301                .expect_ok()?
302                .get(&cache_key)
303                .cloned();
304            if let Some(store) = maybe_store {
305                if let Some(store) = store.upgrade() {
306                    self.hits.fetch_add(1, Ordering::Relaxed);
307                    return Ok(store);
308                } else {
309                    // Remove the weak reference if it is no longer valid
310                    let mut cache_lock = self
311                        .active_stores
312                        .write()
313                        .expect("ObjectStoreRegistry lock poisoned");
314                    if let Some(store) = cache_lock.get(&cache_key)
315                        && store.upgrade().is_none()
316                    {
317                        // Remove the weak reference if it is no longer valid
318                        cache_lock.remove(&cache_key);
319                    }
320                }
321            }
322        }
323
324        self.misses.fetch_add(1, Ordering::Relaxed);
325
326        let store = self
327            .build_store(provider, base_path, params, &cache_path)
328            .await?;
329
330        {
331            // Insert the store into the cache
332            let mut cache_lock = self.active_stores.write().ok().expect_ok()?;
333            cache_lock.insert(cache_key, Arc::downgrade(&store));
334        }
335
336        Ok(store)
337    }
338
339    /// Calculate the datastore prefix based on the URI and the storage options.
340    /// The data store prefix should uniquely identify the datastore.
341    pub fn calculate_object_store_prefix(
342        &self,
343        uri: &str,
344        storage_options: Option<&HashMap<String, String>>,
345    ) -> Result<String> {
346        let url = uri_to_url(uri)?;
347        match self.get_provider(url.scheme()) {
348            None => {
349                if url.scheme() == "file" || url.scheme().len() == 1 {
350                    Ok("file".to_string())
351                } else {
352                    Err(self.scheme_not_found_error(url.scheme()))
353                }
354            }
355            Some(provider) => provider.calculate_object_store_prefix(&url, storage_options),
356        }
357    }
358}
359
360impl Default for ObjectStoreRegistry {
361    fn default() -> Self {
362        let mut providers: HashMap<String, Arc<dyn ObjectStoreProvider>> = HashMap::new();
363
364        providers.insert("memory".into(), Arc::new(memory::MemoryStoreProvider));
365        providers.insert(
366            "shared-memory".into(),
367            Arc::new(shared_memory::SharedMemoryStoreProvider::default()),
368        );
369        providers.insert("file".into(), Arc::new(local::FileStoreProvider));
370        // The "file" scheme has special optimized code paths that bypass
371        // the ObjectStore API for better performance. However, this can make it
372        // hard to test when using ObjectStore wrappers, such as IOTrackingStore.
373        // So we provide a "file-object-store" scheme that uses the ObjectStore API.
374        // The specialized code paths are differentiated by the scheme name.
375        providers.insert(
376            "file-object-store".into(),
377            Arc::new(local::FileStoreProvider),
378        );
379        #[cfg(target_os = "linux")]
380        providers.insert("file+uring".into(), Arc::new(local::FileStoreProvider));
381
382        #[cfg(feature = "aws")]
383        {
384            let aws = Arc::new(aws::AwsStoreProvider);
385            providers.insert("s3".into(), aws.clone());
386            providers.insert("s3+ddb".into(), aws);
387        }
388        #[cfg(feature = "azure")]
389        {
390            let azure = Arc::new(azure::AzureBlobStoreProvider);
391            providers.insert("az".into(), azure.clone());
392            providers.insert("abfss".into(), azure);
393        }
394        #[cfg(feature = "gcp")]
395        providers.insert("gs".into(), Arc::new(gcp::GcsStoreProvider));
396        #[cfg(feature = "goosefs")]
397        providers.insert("goosefs".into(), Arc::new(goosefs::GooseFsStoreProvider));
398        #[cfg(feature = "oss")]
399        providers.insert("oss".into(), Arc::new(oss::OssStoreProvider));
400        #[cfg(feature = "tencent")]
401        providers.insert("cos".into(), Arc::new(tencent::TencentStoreProvider));
402        #[cfg(feature = "huggingface")]
403        providers.insert("hf".into(), Arc::new(huggingface::HuggingfaceStoreProvider));
404        #[cfg(feature = "tos")]
405        providers.insert("tos".into(), Arc::new(tos::TosStoreProvider));
406        Self {
407            providers: RwLock::new(providers),
408            active_stores: RwLock::new(HashMap::new()),
409            hits: AtomicU64::new(0),
410            misses: AtomicU64::new(0),
411        }
412    }
413}
414
415impl ObjectStoreRegistry {
416    /// Add a new object store provider to the registry. The provider will be used
417    /// in [`Self::get_store()`] when a URL is passed with a matching scheme.
418    pub fn insert(&self, scheme: &str, provider: Arc<dyn ObjectStoreProvider>) {
419        self.providers
420            .write()
421            .expect("ObjectStoreRegistry lock poisoned")
422            .insert(scheme.into(), provider);
423    }
424}
425
426#[cfg(test)]
427mod tests {
428    use std::collections::HashMap;
429    use std::sync::Mutex;
430
431    use super::*;
432    use object_store::ObjectStore as OSObjectStore;
433
434    use crate::object_store::providers::memory::MemoryStoreProvider;
435    use object_store::list::{PaginatedListOptions, PaginatedListResult, PaginatedListStore};
436    use rstest::rstest;
437
438    #[derive(Debug)]
439    struct DummyProvider;
440
441    #[async_trait::async_trait]
442    impl ObjectStoreProvider for DummyProvider {
443        async fn new_store(
444            &self,
445            _base_path: Url,
446            _params: &ObjectStoreParams,
447        ) -> Result<ObjectStore> {
448            unreachable!("This test doesn't create stores")
449        }
450    }
451
452    /// A lister that exists only to be handed to a wrapper.
453    struct StubLister;
454
455    #[async_trait::async_trait]
456    impl PaginatedListStore for StubLister {
457        async fn list_paginated(
458            &self,
459            _prefix: Option<&str>,
460            _opts: PaginatedListOptions,
461        ) -> object_store::Result<PaginatedListResult> {
462            unimplemented!("this lister exists to be wrapped, not to list")
463        }
464    }
465
466    /// A provider whose stores come with a paginated lister, which the memory store does not.
467    #[derive(Debug)]
468    struct PaginatedProvider;
469
470    #[async_trait::async_trait]
471    impl ObjectStoreProvider for PaginatedProvider {
472        async fn new_store(
473            &self,
474            base_path: Url,
475            params: &ObjectStoreParams,
476        ) -> Result<ObjectStore> {
477            let mut store = MemoryStoreProvider.new_store(base_path, params).await?;
478            store.paginated_lister = Some(Arc::new(StubLister));
479            Ok(store)
480        }
481
482        fn calculate_object_store_prefix(
483            &self,
484            _url: &Url,
485            _storage_options: Option<&HashMap<String, String>>,
486        ) -> Result<String> {
487            Ok("memory".to_string())
488        }
489    }
490
491    /// Swaps the store out for an empty one, the way a wrapper enforcing visibility would, and
492    /// records the prefix each call was labelled with. `keep_pushdown` is what it answers when
493    /// asked about the lister.
494    #[derive(Debug)]
495    struct RecordingWrapper {
496        keep_pushdown: bool,
497        prefixes: Mutex<Vec<String>>,
498    }
499
500    impl WrappingObjectStore for RecordingWrapper {
501        fn wrap(
502            &self,
503            store_prefix: &str,
504            _original: Arc<dyn OSObjectStore>,
505        ) -> Arc<dyn OSObjectStore> {
506            self.prefixes
507                .lock()
508                .unwrap()
509                .push(format!("wrap@{store_prefix}"));
510            Arc::new(object_store::memory::InMemory::new())
511        }
512
513        fn wrap_paginated(
514            &self,
515            store_prefix: &str,
516            original: Arc<dyn PaginatedListStore>,
517        ) -> Option<Arc<dyn PaginatedListStore>> {
518            self.prefixes
519                .lock()
520                .unwrap()
521                .push(format!("wrap_paginated@{store_prefix}"));
522            self.keep_pushdown.then_some(original)
523        }
524    }
525
526    /// A decorator supplied through [`ObjectStoreParams`] has to reach the paginated lister
527    /// too, or `read_dir_page` would talk to the backend behind its back — and a decorator
528    /// that gives the pushdown up gets a store with no lister, so its listings go through the
529    /// wrapped `inner` and see what the wrapper allows rather than what the backend holds.
530    #[rstest]
531    #[case::keeps_the_pushdown(true)]
532    #[case::gives_up_the_pushdown(false)]
533    #[tokio::test]
534    async fn test_the_registry_hands_the_lister_to_the_wrapper(#[case] keep_pushdown: bool) {
535        let wrapper = Arc::new(RecordingWrapper {
536            keep_pushdown,
537            prefixes: Mutex::new(Vec::new()),
538        });
539        let registry = ObjectStoreRegistry::default();
540        registry.insert("pagmem", Arc::new(PaginatedProvider));
541
542        let store = registry
543            .get_store(
544                Url::parse("pagmem:///").unwrap(),
545                &ObjectStoreParams {
546                    object_store_wrapper: Some(wrapper.clone()),
547                    ..Default::default()
548                },
549            )
550            .await
551            .unwrap();
552
553        assert_eq!(store.paginated_lister.is_some(), keep_pushdown);
554        // Both halves of the store are labelled with the same prefix.
555        assert_eq!(
556            *wrapper.prefixes.lock().unwrap(),
557            vec!["wrap@memory", "wrap_paginated@memory"]
558        );
559        if !keep_pushdown {
560            // `StubLister` panics if it is ever asked to list, so reaching a page at all is
561            // the other half of the assertion.
562            let page = store
563                .read_dir_page(Path::from(""), Default::default())
564                .await
565                .unwrap();
566            assert!(page.result.common_prefixes.is_empty());
567            assert!(page.result.objects.is_empty());
568        }
569    }
570
571    #[test]
572    fn test_calculate_object_store_prefix() {
573        let provider = DummyProvider;
574        let url = Url::parse("dummy://blah/path").unwrap();
575        assert_eq!(
576            "dummy$blah",
577            provider.calculate_object_store_prefix(&url, None).unwrap()
578        );
579    }
580
581    #[tokio::test]
582    async fn test_get_store_resolves_base_scoped_options() {
583        use crate::object_store::StorageOptionsAccessor;
584
585        let registry = ObjectStoreRegistry::default();
586        let url = Url::parse("memory://test").unwrap();
587
588        let with_scoped = ObjectStoreParams {
589            storage_options_accessor: Some(Arc::new(StorageOptionsAccessor::with_static_options(
590                HashMap::from([
591                    ("shared".to_string(), "value".to_string()),
592                    ("base_1.account_key".to_string(), "base1-key".to_string()),
593                ]),
594            ))),
595            ..Default::default()
596        };
597        let without_scoped = ObjectStoreParams {
598            storage_options_accessor: Some(Arc::new(StorageOptionsAccessor::with_static_options(
599                HashMap::from([("shared".to_string(), "value".to_string())]),
600            ))),
601            ..Default::default()
602        };
603
604        // Base-scoped entries are resolved away before the store is built and
605        // cached, so params with and without them yield the same cached store.
606        let store_scoped = registry.get_store(url.clone(), &with_scoped).await.unwrap();
607        let store_plain = registry.get_store(url, &without_scoped).await.unwrap();
608        assert!(Arc::ptr_eq(&store_scoped, &store_plain));
609    }
610
611    #[test]
612    fn test_calculate_object_store_scheme_not_found() {
613        let registry = ObjectStoreRegistry::empty();
614        registry.insert("dummy", Arc::new(DummyProvider));
615        let s = "Invalid user input: No object store provider found for scheme: 'dummy2'\nValid schemes: dummy";
616        let result = registry
617            .calculate_object_store_prefix("dummy2://mybucket/my/long/path", None)
618            .expect_err("expected error")
619            .to_string();
620        assert_eq!(s, &result[..s.len()]);
621    }
622
623    // Test that paths without a scheme get treated as local paths.
624    #[test]
625    fn test_calculate_object_store_prefix_for_local() {
626        let registry = ObjectStoreRegistry::empty();
627        assert_eq!(
628            "file",
629            registry
630                .calculate_object_store_prefix("/tmp/foobar", None)
631                .unwrap()
632        );
633    }
634
635    // Test that paths with a single-letter scheme that is not registered for anything get treated as local paths.
636    #[test]
637    fn test_calculate_object_store_prefix_for_local_windows_path() {
638        let registry = ObjectStoreRegistry::empty();
639        assert_eq!(
640            "file",
641            registry
642                .calculate_object_store_prefix("c://dos/path", None)
643                .unwrap()
644        );
645    }
646
647    // Test that paths with a given scheme get mapped to that storage provider.
648    #[test]
649    fn test_calculate_object_store_prefix_for_dummy_path() {
650        let registry = ObjectStoreRegistry::empty();
651        registry.insert("dummy", Arc::new(DummyProvider));
652        assert_eq!(
653            "dummy$mybucket",
654            registry
655                .calculate_object_store_prefix("dummy://mybucket/my/long/path", None)
656                .unwrap()
657        );
658    }
659
660    #[tokio::test]
661    async fn test_stats_hit_miss_tracking() {
662        use crate::object_store::StorageOptionsAccessor;
663        let registry = ObjectStoreRegistry::default();
664        let url = Url::parse("memory://test").unwrap();
665
666        let params1 = ObjectStoreParams::default();
667        let params2 = ObjectStoreParams {
668            storage_options_accessor: Some(Arc::new(StorageOptionsAccessor::with_static_options(
669                HashMap::from([("k".into(), "v".into())]),
670            ))),
671            ..Default::default()
672        };
673
674        // (hits, misses, active)
675        let cases: &[(&ObjectStoreParams, (u64, u64, usize))] = &[
676            (&params1, (0, 1, 1)), // miss: new params
677            (&params1, (1, 1, 1)), // hit: same params
678            (&params2, (1, 2, 2)), // miss: different storage_options
679        ];
680
681        let mut stores = vec![]; // retain the stores
682        for (params, (hits, misses, active)) in cases {
683            stores.push(registry.get_store(url.clone(), params).await.unwrap());
684            let s = registry.stats();
685            assert_eq!(
686                (s.hits, s.misses, s.active_stores),
687                (*hits, *misses, *active)
688            );
689        }
690
691        // Same params returns same instance
692        assert!(Arc::ptr_eq(&stores[0], &stores[1]));
693    }
694
695    #[tokio::test]
696    async fn test_new_store_bypasses_cache() {
697        let registry = ObjectStoreRegistry::default();
698        let url = Url::parse("memory://test").unwrap();
699        let params = ObjectStoreParams::default();
700
701        let first = registry.new_store(url.clone(), &params).await.unwrap();
702        let second = registry.new_store(url, &params).await.unwrap();
703
704        assert!(!Arc::ptr_eq(&first, &second));
705        let stats = registry.stats();
706        assert_eq!((stats.hits, stats.misses, stats.active_stores), (0, 0, 0));
707    }
708}