Skip to main content

lance_io/
object_store.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright The Lance Authors
3
4//! Extend [object_store::ObjectStore] functionalities
5
6use std::borrow::Cow;
7use std::collections::HashMap;
8use std::ops::Range;
9use std::pin::Pin;
10use std::str::FromStr;
11use std::sync::Arc;
12use std::time::Duration;
13
14use async_trait::async_trait;
15use bytes::Bytes;
16use chrono::{DateTime, Utc};
17use futures::{FutureExt, Stream};
18use futures::{StreamExt, TryStreamExt, future, stream::BoxStream};
19use lance_core::deepsize::DeepSizeOf;
20use lance_core::error::LanceOptionExt;
21use lance_core::utils::parse::str_is_truthy;
22use list_retry::ListRetryStream;
23use object_store::DynObjectStore;
24use object_store::ObjectStoreExt as OSObjectStoreExt;
25#[cfg(feature = "aws")]
26use object_store::aws::AwsCredentialProvider;
27#[cfg(any(feature = "aws", feature = "azure", feature = "gcp"))]
28use object_store::{ClientOptions, HeaderMap, HeaderValue};
29use object_store::{ListResult, ObjectMeta, ObjectStore as OSObjectStore, path::Path};
30use providers::local::FileStoreProvider;
31use providers::memory::MemoryStoreProvider;
32use tokio::io::AsyncWriteExt;
33use url::Url;
34
35use super::local::LocalObjectReader;
36#[cfg(target_os = "linux")]
37use crate::uring::{UringCurrentThreadReader, UringReader};
38#[cfg(any(feature = "aws", feature = "azure", feature = "gcp"))]
39pub(crate) mod dynamic_credentials;
40#[cfg(any(feature = "oss", feature = "huggingface", feature = "tos"))]
41pub(crate) mod dynamic_opendal;
42mod list_retry;
43#[cfg(feature = "metrics")]
44pub mod metrics;
45pub mod providers;
46pub mod storage_options;
47#[cfg(test)]
48pub(crate) mod test_utils;
49pub mod throttle;
50mod tracing;
51use crate::object_reader::SmallReader;
52use crate::object_writer::{LocalWriter, WriteResult};
53use crate::traits::{WriteExt, Writer};
54use crate::utils::tracking_store::{IOTracker, IoStats};
55use crate::{object_reader::CloudObjectReader, object_writer::ObjectWriter, traits::Reader};
56use lance_core::{Error, Result};
57
58// Local disks tend to do fine with a few threads
59// Note: the number of threads here also impacts the number of files
60// we need to read in some situations.  So keeping this at 8 keeps the
61// RAM on our scanner down.
62pub const DEFAULT_LOCAL_IO_PARALLELISM: usize = 8;
63// Cloud disks often need many many threads to saturate the network
64pub const DEFAULT_CLOUD_IO_PARALLELISM: usize = 64;
65
66const DEFAULT_LOCAL_BLOCK_SIZE: usize = 4 * 1024; // 4KB block size
67#[cfg(any(
68    feature = "aws",
69    feature = "gcp",
70    feature = "azure",
71    feature = "oss",
72    feature = "tencent",
73    feature = "huggingface",
74    feature = "tos",
75    feature = "goosefs",
76))]
77const DEFAULT_CLOUD_BLOCK_SIZE: usize = 64 * 1024; // 64KB block size
78
79pub static DEFAULT_MAX_IOP_SIZE: std::sync::LazyLock<u64> = std::sync::LazyLock::new(|| {
80    std::env::var("LANCE_MAX_IOP_SIZE")
81        .map(|val| val.parse().unwrap())
82        .unwrap_or(16 * 1024 * 1024)
83});
84
85pub const DEFAULT_DOWNLOAD_RETRY_COUNT: usize = 3;
86
87pub use providers::{ObjectStoreProvider, ObjectStoreRegistry};
88pub use storage_options::{
89    BASE_SCOPED_OPTION_PREFIX, BaseScopedStorageOptionsProvider, EXPIRES_AT_MILLIS_KEY,
90    LanceNamespaceStorageOptionsProvider, REFRESH_OFFSET_MILLIS_KEY, StorageOptionsAccessor,
91    StorageOptionsProvider, has_base_scoped_options, parse_base_scoped_key,
92    resolve_base_scoped_options,
93};
94
95#[async_trait]
96pub trait ObjectStoreExt {
97    /// Returns true if the file exists.
98    async fn exists(&self, path: &Path) -> Result<bool>;
99
100    /// Read all files (start from base directory) recursively
101    ///
102    /// unmodified_since can be specified to only return files that have not been modified since the given time.
103    fn read_dir_all<'a, 'b>(
104        &'a self,
105        dir_path: impl Into<&'b Path> + Send,
106        unmodified_since: Option<DateTime<Utc>>,
107    ) -> BoxStream<'a, Result<ObjectMeta>>;
108}
109
110#[async_trait]
111impl<O: OSObjectStore + ?Sized> ObjectStoreExt for O {
112    fn read_dir_all<'a, 'b>(
113        &'a self,
114        dir_path: impl Into<&'b Path> + Send,
115        unmodified_since: Option<DateTime<Utc>>,
116    ) -> BoxStream<'a, Result<ObjectMeta>> {
117        let output = self.list(Some(dir_path.into())).map_err(|e| e.into());
118        if let Some(unmodified_since_val) = unmodified_since {
119            output
120                .try_filter(move |file| future::ready(file.last_modified <= unmodified_since_val))
121                .boxed()
122        } else {
123            output.boxed()
124        }
125    }
126
127    async fn exists(&self, path: &Path) -> Result<bool> {
128        match self.head(path).await {
129            Ok(_) => Ok(true),
130            Err(object_store::Error::NotFound { path: _, source: _ }) => Ok(false),
131            Err(e) => Err(e.into()),
132        }
133    }
134}
135
136/// Wraps [ObjectStore](object_store::ObjectStore)
137#[derive(Debug, Clone)]
138pub struct ObjectStore {
139    // Inner object store
140    pub inner: Arc<dyn OSObjectStore>,
141    scheme: String,
142    block_size: usize,
143    max_iop_size: u64,
144    /// Whether to use constant size upload parts for multipart uploads. This
145    /// is only necessary for Cloudflare R2.
146    pub use_constant_size_upload_parts: bool,
147    /// Whether we can assume that the list of files is lexically ordered. This
148    /// is true for object stores, but not for local filesystems.
149    pub list_is_lexically_ordered: bool,
150    io_parallelism: usize,
151    /// Number of times to retry a failed download
152    download_retry_count: usize,
153    /// IO tracker for monitoring read/write operations
154    io_tracker: IOTracker,
155    /// The datastore prefix that uniquely identifies this object store. It encodes information
156    /// which usually cannot be found in the URL such as Azure account name. The prefix plus the
157    /// path uniquely identifies any object inside the store.
158    pub store_prefix: String,
159}
160
161impl DeepSizeOf for ObjectStore {
162    fn deep_size_of_children(&self, context: &mut lance_core::deepsize::Context) -> usize {
163        // We aren't counting `inner` here which is problematic but an ObjectStore
164        // shouldn't be too big.  The only exception might be the write cache but, if
165        // the writer cache has data, it means we're using it somewhere else that isn't
166        // a cache and so that doesn't really count.
167        self.scheme.deep_size_of_children(context) + self.block_size.deep_size_of_children(context)
168    }
169}
170
171impl std::fmt::Display for ObjectStore {
172    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
173        write!(f, "ObjectStore({})", self.scheme)
174    }
175}
176
177pub trait WrappingObjectStore: std::fmt::Debug + Send + Sync {
178    /// Wrap an object store with additional functionality
179    ///
180    /// The store_prefix is a string which uniquely identifies the object
181    /// store being wrapped.
182    fn wrap(&self, store_prefix: &str, original: Arc<dyn OSObjectStore>) -> Arc<dyn OSObjectStore>;
183}
184
185#[derive(Debug, Clone)]
186pub struct ChainedWrappingObjectStore {
187    wrappers: Vec<Arc<dyn WrappingObjectStore>>,
188}
189
190impl ChainedWrappingObjectStore {
191    pub fn new(wrappers: Vec<Arc<dyn WrappingObjectStore>>) -> Self {
192        Self { wrappers }
193    }
194
195    pub fn add_wrapper(&mut self, wrapper: Arc<dyn WrappingObjectStore>) {
196        self.wrappers.push(wrapper);
197    }
198}
199
200impl WrappingObjectStore for ChainedWrappingObjectStore {
201    fn wrap(&self, store_prefix: &str, original: Arc<dyn OSObjectStore>) -> Arc<dyn OSObjectStore> {
202        self.wrappers
203            .iter()
204            .fold(original, |acc, wrapper| wrapper.wrap(store_prefix, acc))
205    }
206}
207
208/// Parameters to create an [ObjectStore]
209///
210#[derive(Debug, Clone)]
211pub struct ObjectStoreParams {
212    pub block_size: Option<usize>,
213    #[deprecated(note = "Implement an ObjectStoreProvider instead")]
214    pub object_store: Option<(Arc<DynObjectStore>, Url)>,
215    /// Refresh offset for AWS credentials when using the legacy AWS credentials path.
216    /// For StorageOptionsAccessor, use `refresh_offset_millis` storage option instead.
217    pub s3_credentials_refresh_offset: Duration,
218    #[cfg(feature = "aws")]
219    pub aws_credentials: Option<AwsCredentialProvider>,
220    pub object_store_wrapper: Option<Arc<dyn WrappingObjectStore>>,
221    /// Unified storage options accessor with caching and automatic refresh
222    ///
223    /// Provides storage options and optionally a dynamic provider for automatic
224    /// credential refresh. Use `StorageOptionsAccessor::with_static_options()` for static
225    /// options or `StorageOptionsAccessor::with_initial_and_provider()` for dynamic refresh.
226    pub storage_options_accessor: Option<Arc<StorageOptionsAccessor>>,
227    /// Use constant size upload parts for multipart uploads. Only necessary
228    /// for Cloudflare R2, which doesn't support variable size parts. When this
229    /// is false, max upload size is 2.5TB. When this is true, the max size is
230    /// 50GB.
231    pub use_constant_size_upload_parts: bool,
232    pub list_is_lexically_ordered: Option<bool>,
233}
234
235impl Default for ObjectStoreParams {
236    fn default() -> Self {
237        #[allow(deprecated)]
238        Self {
239            object_store: None,
240            block_size: None,
241            s3_credentials_refresh_offset: Duration::from_secs(60),
242            #[cfg(feature = "aws")]
243            aws_credentials: None,
244            object_store_wrapper: None,
245            storage_options_accessor: None,
246            use_constant_size_upload_parts: false,
247            list_is_lexically_ordered: None,
248        }
249    }
250}
251
252impl ObjectStoreParams {
253    /// Get the StorageOptionsAccessor from the params
254    pub fn get_accessor(&self) -> Option<Arc<StorageOptionsAccessor>> {
255        self.storage_options_accessor.clone()
256    }
257
258    /// Get storage options from the accessor, if any
259    ///
260    /// Returns the initial storage options from the accessor without triggering refresh.
261    pub fn storage_options(&self) -> Option<&HashMap<String, String>> {
262        self.storage_options_accessor
263            .as_ref()
264            .and_then(|a| a.initial_storage_options())
265    }
266
267    /// Resolve these params for a single base path scope.
268    ///
269    /// Storage options may carry base-scoped entries (`base_<id>.<key>`) that
270    /// apply only to one registered base path; see
271    /// [`StorageOptionsAccessor::scoped_to_base`]. Returns the params unchanged
272    /// when the storage options contain no base-scoped entries.
273    pub fn scoped_to_base(&self, base_id: Option<u32>) -> Cow<'_, Self> {
274        let Some(accessor) = &self.storage_options_accessor else {
275            return Cow::Borrowed(self);
276        };
277        let scoped = accessor.scoped_to_base(base_id);
278        if Arc::ptr_eq(&scoped, accessor) {
279            Cow::Borrowed(self)
280        } else {
281            Cow::Owned(Self {
282                storage_options_accessor: Some(scoped),
283                ..self.clone()
284            })
285        }
286    }
287}
288
289fn wrapper_allocation_ptr(wrapper: &Arc<dyn WrappingObjectStore>) -> *const () {
290    // Trait object pointers include vtable metadata, which is not stable across codegen units.
291    // Cache identity must follow the Arc allocation instead.
292    Arc::as_ptr(wrapper) as *const ()
293}
294
295// We implement hash for caching
296impl std::hash::Hash for ObjectStoreParams {
297    #[allow(deprecated)]
298    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
299        // For hashing, we use pointer values for ObjectStore, S3 credentials, wrapper
300        self.block_size.hash(state);
301        if let Some((store, url)) = &self.object_store {
302            Arc::as_ptr(store).hash(state);
303            url.hash(state);
304        }
305        self.s3_credentials_refresh_offset.hash(state);
306        #[cfg(feature = "aws")]
307        if let Some(aws_credentials) = &self.aws_credentials {
308            Arc::as_ptr(aws_credentials).hash(state);
309        }
310        if let Some(wrapper) = &self.object_store_wrapper {
311            wrapper_allocation_ptr(wrapper).hash(state);
312        }
313        if let Some(accessor) = &self.storage_options_accessor {
314            accessor.accessor_id().hash(state);
315        }
316        self.use_constant_size_upload_parts.hash(state);
317        self.list_is_lexically_ordered.hash(state);
318    }
319}
320
321// We implement eq for caching
322impl Eq for ObjectStoreParams {}
323impl PartialEq for ObjectStoreParams {
324    #[allow(deprecated)]
325    fn eq(&self, other: &Self) -> bool {
326        #[cfg(feature = "aws")]
327        if self.aws_credentials.is_some() != other.aws_credentials.is_some() {
328            return false;
329        }
330
331        // For equality, we use pointer comparison for ObjectStore, S3 credentials, wrapper
332        // For accessor, we use accessor_id() for semantic equality
333        self.block_size == other.block_size
334            && self
335                .object_store
336                .as_ref()
337                .map(|(store, url)| (Arc::as_ptr(store), url))
338                == other
339                    .object_store
340                    .as_ref()
341                    .map(|(store, url)| (Arc::as_ptr(store), url))
342            && self.s3_credentials_refresh_offset == other.s3_credentials_refresh_offset
343            && self
344                .object_store_wrapper
345                .as_ref()
346                .map(wrapper_allocation_ptr)
347                == other
348                    .object_store_wrapper
349                    .as_ref()
350                    .map(wrapper_allocation_ptr)
351            && self
352                .storage_options_accessor
353                .as_ref()
354                .map(|a| a.accessor_id())
355                == other
356                    .storage_options_accessor
357                    .as_ref()
358                    .map(|a| a.accessor_id())
359            && self.use_constant_size_upload_parts == other.use_constant_size_upload_parts
360            && self.list_is_lexically_ordered == other.list_is_lexically_ordered
361    }
362}
363
364/// Convert a URI string or local path to a URL
365///
366/// This function handles both proper URIs (with schemes like `file://`, `s3://`, etc.)
367/// and plain local filesystem paths. On Windows, it correctly handles drive letters
368/// that might be parsed as URL schemes.
369///
370/// # Examples
371///
372/// ```
373/// # use lance_io::object_store::uri_to_url;
374/// // URIs are preserved
375/// let url = uri_to_url("s3://bucket/path").unwrap();
376/// assert_eq!(url.scheme(), "s3");
377///
378/// // Local paths are converted to file:// URIs
379/// # #[cfg(unix)]
380/// let url = uri_to_url("/tmp/data").unwrap();
381/// # #[cfg(unix)]
382/// assert_eq!(url.scheme(), "file");
383/// ```
384pub fn uri_to_url(uri: &str) -> Result<Url> {
385    match Url::parse(uri) {
386        Ok(url) if url.scheme().len() == 1 && cfg!(windows) => {
387            // On Windows, the drive is parsed as a scheme
388            local_path_to_url(uri)
389        }
390        Ok(url) => Ok(url),
391        Err(_) => local_path_to_url(uri),
392    }
393}
394
395fn expand_path(str_path: impl AsRef<str>) -> Result<std::path::PathBuf> {
396    let str_path = str_path.as_ref();
397    let expanded = expand_tilde_path(str_path).unwrap_or_else(|| str_path.into());
398
399    let mut expanded_path = path_abs::PathAbs::new(expanded)
400        .unwrap()
401        .as_path()
402        .to_path_buf();
403    // path_abs::PathAbs::new(".") returns an empty string.
404    if let Some(s) = expanded_path.as_path().to_str()
405        && s.is_empty()
406    {
407        expanded_path = std::env::current_dir()?;
408    }
409
410    Ok(expanded_path)
411}
412
413fn expand_tilde_path(path: &str) -> Option<std::path::PathBuf> {
414    let home_dir = std::env::home_dir()?;
415    if path == "~" {
416        return Some(home_dir);
417    }
418    if let Some(stripped) = path.strip_prefix("~/") {
419        return Some(home_dir.join(stripped));
420    }
421    #[cfg(windows)]
422    if let Some(stripped) = path.strip_prefix("~\\") {
423        return Some(home_dir.join(stripped));
424    }
425
426    None
427}
428
429fn local_path_to_url(str_path: &str) -> Result<Url> {
430    let expanded_path = expand_path(str_path)?;
431
432    Url::from_directory_path(expanded_path).map_err(|_| {
433        Error::invalid_input_source(format!("Invalid table location: '{}'", str_path).into())
434    })
435}
436
437#[cfg(feature = "huggingface")]
438fn parse_hf_repo_id(url: &Url) -> Result<String> {
439    // Accept forms with repo type prefix (models/datasets/spaces) or legacy without.
440    let mut segments: Vec<String> = Vec::new();
441    if let Some(host) = url.host_str() {
442        segments.push(host.to_string());
443    }
444    segments.extend(
445        url.path()
446            .trim_start_matches('/')
447            .split('/')
448            .map(|s| s.to_string()),
449    );
450
451    if segments.len() < 2 {
452        return Err(Error::invalid_input(
453            "Huggingface URL must contain at least owner and repo",
454        ));
455    }
456
457    let repo_type_candidates = ["models", "datasets", "spaces"];
458    let (owner, repo_with_rev) = if repo_type_candidates.contains(&segments[0].as_str()) {
459        if segments.len() < 3 {
460            return Err(Error::invalid_input(
461                "Huggingface URL missing owner/repo after repo type",
462            ));
463        }
464        (segments[1].as_str(), segments[2].as_str())
465    } else {
466        (segments[0].as_str(), segments[1].as_str())
467    };
468
469    let repo = repo_with_rev
470        .split_once('@')
471        .map(|(r, _)| r)
472        .unwrap_or(repo_with_rev);
473    Ok(format!("{owner}/{repo}"))
474}
475
476impl ObjectStore {
477    /// Parse from a string URI.
478    ///
479    /// Returns the ObjectStore instance and the absolute path to the object.
480    ///
481    /// This uses the default [ObjectStoreRegistry] to find the object store. To
482    /// allow for potential re-use of object store instances, it's recommended to
483    /// create a shared [ObjectStoreRegistry] and pass that to [Self::from_uri_and_params].
484    pub async fn from_uri(uri: &str) -> Result<(Arc<Self>, Path)> {
485        let registry = Arc::new(ObjectStoreRegistry::default());
486
487        Self::from_uri_and_params(registry, uri, &ObjectStoreParams::default()).await
488    }
489
490    /// Parse from a string URI.
491    ///
492    /// Returns the ObjectStore instance and the absolute path to the object.
493    pub async fn from_uri_and_params(
494        registry: Arc<ObjectStoreRegistry>,
495        uri: &str,
496        params: &ObjectStoreParams,
497    ) -> Result<(Arc<Self>, Path)> {
498        #[allow(deprecated)]
499        if let Some((store, path)) = params.object_store.as_ref() {
500            let mut inner = store.clone();
501            let store_prefix =
502                registry.calculate_object_store_prefix(uri, params.storage_options())?;
503            if let Some(wrapper) = params.object_store_wrapper.as_ref() {
504                inner = wrapper.wrap(&store_prefix, inner);
505            }
506
507            // Always wrap with IO tracking
508            let io_tracker = IOTracker::default();
509            let tracked_store = io_tracker.wrap("", inner);
510
511            let store = Self {
512                inner: tracked_store,
513                scheme: path.scheme().to_string(),
514                block_size: params.block_size.unwrap_or(64 * 1024),
515                max_iop_size: *DEFAULT_MAX_IOP_SIZE,
516                use_constant_size_upload_parts: params.use_constant_size_upload_parts,
517                list_is_lexically_ordered: params.list_is_lexically_ordered.unwrap_or_default(),
518                io_parallelism: DEFAULT_CLOUD_IO_PARALLELISM,
519                download_retry_count: DEFAULT_DOWNLOAD_RETRY_COUNT,
520                io_tracker,
521                store_prefix,
522            };
523            let path = Path::parse(path.path())?;
524            return Ok((Arc::new(store), path));
525        }
526        let url = uri_to_url(uri)?;
527
528        let store = registry.get_store(url.clone(), params).await?;
529        // We know the scheme is valid if we got a store back.
530        let provider = registry.get_provider(url.scheme()).expect_ok()?;
531        let path = provider.extract_path(&url)?;
532
533        Ok((store, path))
534    }
535
536    /// Extract the path component from a URI without initializing the object store.
537    ///
538    /// This is a synchronous operation that only parses the URI and extracts the path,
539    /// without creating or initializing any object store instance.
540    ///
541    /// # Arguments
542    ///
543    /// * `registry` - The object store registry to get the provider
544    /// * `uri` - The URI to extract the path from
545    ///
546    /// # Returns
547    ///
548    /// The extracted path component
549    pub fn extract_path_from_uri(registry: Arc<ObjectStoreRegistry>, uri: &str) -> Result<Path> {
550        let url = uri_to_url(uri)?;
551        let provider = registry
552            .get_provider(url.scheme())
553            .ok_or_else(|| Error::invalid_input(format!("Unknown scheme: {}", url.scheme())))?;
554        provider.extract_path(&url)
555    }
556
557    #[deprecated(note = "Use `from_uri` instead")]
558    pub fn from_path(str_path: &str) -> Result<(Arc<Self>, Path)> {
559        Self::from_uri_and_params(
560            Arc::new(ObjectStoreRegistry::default()),
561            str_path,
562            &Default::default(),
563        )
564        .now_or_never()
565        .unwrap()
566    }
567
568    /// Local object store.
569    pub fn local() -> Self {
570        let provider = FileStoreProvider;
571        provider
572            .new_store(Url::parse("file:///").unwrap(), &Default::default())
573            .now_or_never()
574            .unwrap()
575            .unwrap()
576    }
577
578    /// Create a in-memory object store directly for testing.
579    pub fn memory() -> Self {
580        let provider = MemoryStoreProvider;
581        provider
582            .new_store(Url::parse("memory:///").unwrap(), &Default::default())
583            .now_or_never()
584            .unwrap()
585            .unwrap()
586    }
587
588    /// Returns true if the object store pointed to a local file system.
589    pub fn is_local(&self) -> bool {
590        self.scheme == "file" || self.scheme == "file+uring"
591    }
592
593    pub fn is_cloud(&self) -> bool {
594        if self.is_local() || self.scheme == "memory" || self.scheme == "shared-memory" {
595            return false;
596        }
597        true
598    }
599
600    /// Whether this object store prefers the lite scheduler.
601    ///
602    /// The lite scheduler is designed for backends like io_uring where
603    /// tasks should only be polled when the consumer polls them.
604    pub fn prefers_lite_scheduler(&self) -> bool {
605        self.scheme == "file+uring"
606    }
607
608    pub fn scheme(&self) -> &str {
609        &self.scheme
610    }
611
612    pub fn block_size(&self) -> usize {
613        self.block_size
614    }
615
616    pub fn max_iop_size(&self) -> u64 {
617        self.max_iop_size
618    }
619
620    /// The amount of parallelism to use for I/O operations.
621    ///
622    /// Honors the `LANCE_IO_THREADS` override when set, otherwise the store's configured value.
623    /// Always at least 1: callers feed this straight into `buffered` / `buffer_unordered`, and a
624    /// window of 0 makes those streams never poll their input — e.g. a metadata-only `count_rows`
625    /// would hang rather than return.
626    pub fn io_parallelism(&self) -> usize {
627        std::env::var("LANCE_IO_THREADS")
628            .map(|val| val.parse::<usize>().unwrap())
629            .unwrap_or(self.io_parallelism)
630            .max(1)
631    }
632
633    /// Get the IO tracker for this object store
634    ///
635    /// The IO tracker can be used to get statistics about read/write operations
636    /// performed on this object store.
637    pub fn io_tracker(&self) -> &IOTracker {
638        &self.io_tracker
639    }
640
641    /// Get a snapshot of current IO statistics without resetting counters
642    ///
643    /// Returns the current IO statistics without modifying the internal state.
644    /// Use this when you need to check stats without resetting them.
645    pub fn io_stats_snapshot(&self) -> IoStats {
646        self.io_tracker.stats()
647    }
648
649    /// Get incremental IO statistics since the last call to this method
650    ///
651    /// Returns the accumulated statistics since the last call and resets the
652    /// counters to zero. This is useful for tracking IO operations between
653    /// different stages of processing.
654    pub fn io_stats_incremental(&self) -> IoStats {
655        self.io_tracker.incremental_stats()
656    }
657
658    /// Open a file for path.
659    ///
660    /// Parameters
661    /// - ``path``: Absolute path to the file.
662    pub async fn open(&self, path: &Path) -> Result<Box<dyn Reader>> {
663        match self.scheme.as_str() {
664            "file" => {
665                LocalObjectReader::open_with_tracker(
666                    path,
667                    self.block_size,
668                    None,
669                    Arc::new(self.io_tracker.clone()),
670                )
671                .await
672            }
673            #[cfg(target_os = "linux")]
674            "file+uring" => {
675                // Check if current-thread mode enabled
676                let use_current_thread = std::env::var("LANCE_URING_CURRENT_THREAD")
677                    .map(|v| str_is_truthy(&v))
678                    .unwrap_or(false);
679
680                if use_current_thread {
681                    UringCurrentThreadReader::open(
682                        path,
683                        self.block_size,
684                        None,
685                        Arc::new(self.io_tracker.clone()),
686                    )
687                    .await
688                } else {
689                    UringReader::open(
690                        path,
691                        self.block_size,
692                        None,
693                        Arc::new(self.io_tracker.clone()),
694                    )
695                    .await
696                }
697            }
698            _ => Ok(Box::new(CloudObjectReader::new(
699                self.inner.clone(),
700                path.clone(),
701                self.block_size,
702                None,
703                self.download_retry_count,
704            )?)),
705        }
706    }
707
708    /// Open a reader for a file with known size.
709    ///
710    /// This size may either have been retrieved from a list operation or
711    /// cached metadata. By passing in the known size, we can skip a HEAD / metadata
712    /// call.
713    pub async fn open_with_size(&self, path: &Path, known_size: usize) -> Result<Box<dyn Reader>> {
714        // If we know the file is really small, we can read the whole thing
715        // as a single request.
716        if known_size <= self.block_size {
717            return Ok(Box::new(SmallReader::new(
718                self.inner.clone(),
719                path.clone(),
720                self.download_retry_count,
721                known_size,
722            )));
723        }
724
725        match self.scheme.as_str() {
726            "file" => {
727                LocalObjectReader::open_with_tracker(
728                    path,
729                    self.block_size,
730                    Some(known_size),
731                    Arc::new(self.io_tracker.clone()),
732                )
733                .await
734            }
735            #[cfg(target_os = "linux")]
736            "file+uring" => {
737                // Check if current-thread mode enabled
738                let use_current_thread = std::env::var("LANCE_URING_CURRENT_THREAD")
739                    .map(|v| str_is_truthy(&v))
740                    .unwrap_or(false);
741
742                if use_current_thread {
743                    UringCurrentThreadReader::open(
744                        path,
745                        self.block_size,
746                        Some(known_size),
747                        Arc::new(self.io_tracker.clone()),
748                    )
749                    .await
750                } else {
751                    UringReader::open(
752                        path,
753                        self.block_size,
754                        Some(known_size),
755                        Arc::new(self.io_tracker.clone()),
756                    )
757                    .await
758                }
759            }
760            _ => Ok(Box::new(CloudObjectReader::new(
761                self.inner.clone(),
762                path.clone(),
763                self.block_size,
764                Some(known_size),
765                self.download_retry_count,
766            )?)),
767        }
768    }
769
770    /// Create an [ObjectWriter] from local [std::path::Path]
771    pub async fn create_local_writer(path: &std::path::Path) -> Result<ObjectWriter> {
772        let object_store = Self::local();
773        let absolute_path = expand_path(path.to_string_lossy())?;
774        let os_path = Path::from_absolute_path(absolute_path)?;
775        ObjectWriter::new(&object_store, &os_path).await
776    }
777
778    /// Open an [Reader] from local [std::path::Path]
779    pub async fn open_local(path: &std::path::Path) -> Result<Box<dyn Reader>> {
780        let object_store = Self::local();
781        let absolute_path = expand_path(path.to_string_lossy())?;
782        let os_path = Path::from_absolute_path(absolute_path)?;
783        object_store.open(&os_path).await
784    }
785
786    /// Create a new file.
787    pub async fn create(&self, path: &Path) -> Result<Box<dyn Writer>> {
788        match self.scheme.as_str() {
789            "file" => {
790                let local_path = super::local::to_local_path(path);
791                let local_path = std::path::PathBuf::from(&local_path);
792                if let Some(parent) = local_path.parent() {
793                    tokio::fs::create_dir_all(parent).await?;
794                }
795                let parent = local_path
796                    .parent()
797                    .expect("file path must have parent")
798                    .to_owned();
799                let named_temp =
800                    tokio::task::spawn_blocking(move || tempfile::NamedTempFile::new_in(parent))
801                        .await
802                        .map_err(|e| Error::io(format!("spawn_blocking failed: {}", e)))??;
803                let (std_file, temp_path) = named_temp.into_parts();
804                let file = tokio::fs::File::from_std(std_file);
805                Ok(Box::new(LocalWriter::new(
806                    file,
807                    path.clone(),
808                    temp_path,
809                    Arc::new(self.io_tracker.clone()),
810                )))
811            }
812            _ => Ok(Box::new(ObjectWriter::new(self, path).await?)),
813        }
814    }
815
816    /// A helper function to create a file and write content to it.
817    pub async fn put(&self, path: &Path, content: &[u8]) -> Result<WriteResult> {
818        let mut writer = self.create(path).await?;
819        writer.write_all(content).await?;
820        Writer::shutdown(writer.as_mut()).await
821    }
822
823    pub async fn delete(&self, path: &Path) -> Result<()> {
824        self.inner.delete(path).await?;
825        Ok(())
826    }
827
828    /// AWS S3 and GCS reject a single-shot server-side copy whose source is
829    /// larger than this; such sources are streamed through a multipart write.
830    const MAX_SINGLE_COPY_BYTES: u64 = 5 * 1024 * 1024 * 1024; // 5 GiB
831
832    pub async fn copy(&self, from: &Path, to: &Path) -> Result<()> {
833        // S3 and GCS cap single-shot server-side copies at 5 GiB and object_store
834        // does not fall back to a multipart copy for larger sources
835        // (https://github.com/apache/arrow-rs-object-store/issues/563). Azure and
836        // other blob stores don't have this limit, so we only pay for the fallback
837        // (an extra size lookup) on S3 and GCS.
838        let multipart_copy_fallback = matches!(self.scheme.as_str(), "s3" | "s3+ddb" | "gs");
839        self.copy_impl(
840            from,
841            to,
842            multipart_copy_fallback,
843            Self::MAX_SINGLE_COPY_BYTES,
844        )
845        .await
846    }
847
848    /// Copy `from` to `to`. When `multipart_copy_fallback` is set, a source
849    /// larger than `max_single_copy` is streamed through a multipart write
850    /// instead of a single-shot server-side copy. Both are parameters so tests
851    /// can drive the streaming path without a multi-gigabyte fixture or an S3
852    /// endpoint.
853    async fn copy_impl(
854        &self,
855        from: &Path,
856        to: &Path,
857        multipart_copy_fallback: bool,
858        max_single_copy: u64,
859    ) -> Result<()> {
860        if self.is_local() {
861            // Use std::fs::copy for local filesystem to support cross-filesystem copies
862            return super::local::copy_file(from, to);
863        }
864        if multipart_copy_fallback {
865            // Reuse the reader for both the size lookup (a single cached HEAD)
866            // and the streamed copy, avoiding a separate HEAD request.
867            let reader = self.open(from).await?;
868            if reader.size().await? as u64 > max_single_copy {
869                let mut writer = self.create(to).await?;
870                writer.copy_from_reader(reader.as_ref()).await?;
871                Writer::shutdown(writer.as_mut()).await?;
872                return Ok(());
873            }
874        }
875        Ok(self.inner.copy(from, to).await?)
876    }
877
878    /// Read a directory (start from base directory) and returns all sub-paths in the directory.
879    pub async fn read_dir(&self, dir_path: impl Into<Path>) -> Result<Vec<String>> {
880        let path = dir_path.into();
881        let path = Path::parse(&path)?;
882        let output = self.inner.list_with_delimiter(Some(&path)).await?;
883        Ok(output
884            .common_prefixes
885            .iter()
886            .chain(output.objects.iter().map(|o| &o.location))
887            .filter_map(|s| s.filename().map(|f| f.to_string()))
888            .collect())
889    }
890
891    /// Non-recursive, path-segment delimited list of a single directory level.
892    ///
893    /// Unlike [`Self::list`], which recurses into the entire subtree, this returns
894    /// only the immediate children of `prefix`: the child "directories" as
895    /// [`ListResult::common_prefixes`] and the direct child files as
896    /// [`ListResult::objects`].
897    pub async fn list_with_delimiter(&self, prefix: Option<&Path>) -> Result<ListResult> {
898        Ok(self.inner.list_with_delimiter(prefix).await?)
899    }
900
901    pub fn list(
902        &self,
903        path: Option<Path>,
904    ) -> Pin<Box<dyn Stream<Item = Result<ObjectMeta>> + Send>> {
905        Box::pin(ListRetryStream::new(self.inner.clone(), path, 5).map(|m| m.map_err(|e| e.into())))
906    }
907
908    /// Read all files (start from base directory) recursively
909    ///
910    /// unmodified_since can be specified to only return files that have not been modified since the given time.
911    pub fn read_dir_all<'a, 'b>(
912        &'a self,
913        dir_path: impl Into<&'b Path> + Send,
914        unmodified_since: Option<DateTime<Utc>>,
915    ) -> BoxStream<'a, Result<ObjectMeta>> {
916        self.inner.read_dir_all(dir_path, unmodified_since)
917    }
918
919    /// Remove a directory recursively.
920    pub async fn remove_dir_all(&self, dir_path: impl Into<Path>) -> Result<()> {
921        let path = dir_path.into();
922        let path = Path::parse(&path)?;
923
924        if self.is_local() {
925            // The local file system provider needs to delete both files and directories.
926            return super::local::remove_dir_all(&path);
927        }
928        let sub_entries = self
929            .inner
930            .list(Some(&path))
931            .map(|m| m.map(|meta| meta.location))
932            .boxed();
933        self.inner
934            .delete_stream(sub_entries)
935            .try_collect::<Vec<_>>()
936            .await?;
937        if self.scheme == "file-object-store" {
938            // file-object-store tries to do everything as similarly as possible to the remote
939            // object stores. But we still have to delete the directory entries afterwards.
940            return super::local::remove_dir_all(&path);
941        }
942        Ok(())
943    }
944
945    pub fn remove_stream<'a>(
946        &'a self,
947        locations: BoxStream<'a, Result<Path>>,
948    ) -> BoxStream<'a, Result<Path>> {
949        let store = Arc::clone(&self.inner);
950        locations
951            .and_then(move |location| {
952                let store = Arc::clone(&store);
953                async move {
954                    store.delete(&location).await?;
955                    Ok(location)
956                }
957            })
958            .boxed()
959    }
960
961    /// Check a file exists.
962    pub async fn exists(&self, path: &Path) -> Result<bool> {
963        match self.inner.head(path).await {
964            Ok(_) => Ok(true),
965            Err(object_store::Error::NotFound { path: _, source: _ }) => Ok(false),
966            Err(e) => Err(e.into()),
967        }
968    }
969
970    /// Get file size.
971    pub async fn size(&self, path: &Path) -> Result<u64> {
972        Ok(self.inner.head(path).await?.size)
973    }
974
975    /// Convenience function to open a reader and read all the bytes
976    pub async fn read_one_all(&self, path: &Path) -> Result<Bytes> {
977        let reader = self.open(path).await?;
978        Ok(reader.get_all().await?)
979    }
980
981    /// Convenience function open a reader and make a single request
982    ///
983    /// If you will be making multiple requests to the path it is more efficient to call [`Self::open`]
984    /// and then call [`Reader::get_range`] multiple times.
985    pub async fn read_one_range(&self, path: &Path, range: Range<usize>) -> Result<Bytes> {
986        let reader = self.open(path).await?;
987        Ok(reader.get_range(range).await?)
988    }
989}
990
991/// Options that can be set for multiple object stores
992#[derive(PartialEq, Eq, Hash, Clone, Debug, Copy)]
993pub enum LanceConfigKey {
994    /// Number of times to retry a download that fails
995    DownloadRetryCount,
996}
997
998impl FromStr for LanceConfigKey {
999    type Err = Error;
1000
1001    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
1002        match s.to_ascii_lowercase().as_str() {
1003            "download_retry_count" => Ok(Self::DownloadRetryCount),
1004            _ => Err(Error::invalid_input_source(
1005                format!("Invalid LanceConfigKey: {}", s).into(),
1006            )),
1007        }
1008    }
1009}
1010
1011#[derive(Clone, Debug, Default)]
1012pub struct StorageOptions(pub HashMap<String, String>);
1013
1014impl StorageOptions {
1015    /// Create a new instance of [`StorageOptions`]
1016    pub fn new(options: HashMap<String, String>) -> Self {
1017        let mut options = options;
1018        if let Ok(value) = std::env::var("AZURE_STORAGE_ALLOW_HTTP") {
1019            options.insert("allow_http".into(), value);
1020        }
1021        if let Ok(value) = std::env::var("AZURE_STORAGE_USE_HTTP") {
1022            options.insert("allow_http".into(), value);
1023        }
1024        if let Ok(value) = std::env::var("AWS_ALLOW_HTTP") {
1025            options.insert("allow_http".into(), value);
1026        }
1027        if let Ok(value) = std::env::var("OBJECT_STORE_CLIENT_MAX_RETRIES") {
1028            options.insert("client_max_retries".into(), value);
1029        }
1030        if let Ok(value) = std::env::var("OBJECT_STORE_CLIENT_RETRY_TIMEOUT") {
1031            options.insert("client_retry_timeout".into(), value);
1032        }
1033        Self(options)
1034    }
1035
1036    /// Denotes if unsecure connections via http are allowed
1037    pub fn allow_http(&self) -> bool {
1038        self.0.iter().any(|(key, value)| {
1039            key.to_ascii_lowercase().contains("allow_http") & str_is_truthy(value)
1040        })
1041    }
1042
1043    /// Number of times to retry a download that fails
1044    pub fn download_retry_count(&self) -> usize {
1045        self.0
1046            .iter()
1047            .find(|(key, _)| key.eq_ignore_ascii_case("download_retry_count"))
1048            .map(|(_, value)| value.parse::<usize>().unwrap_or(3))
1049            .unwrap_or(3)
1050    }
1051
1052    /// Max retry times to set in RetryConfig for object store client
1053    pub fn client_max_retries(&self) -> usize {
1054        self.0
1055            .iter()
1056            .find(|(key, _)| key.eq_ignore_ascii_case("client_max_retries"))
1057            .and_then(|(_, value)| value.parse::<usize>().ok())
1058            .unwrap_or(3)
1059    }
1060
1061    /// Seconds of timeout to set in RetryConfig for object store client
1062    pub fn client_retry_timeout(&self) -> u64 {
1063        self.0
1064            .iter()
1065            .find(|(key, _)| key.eq_ignore_ascii_case("client_retry_timeout"))
1066            .and_then(|(_, value)| value.parse::<u64>().ok())
1067            .unwrap_or(180)
1068    }
1069
1070    pub fn get(&self, key: &str) -> Option<&String> {
1071        self.0.get(key)
1072    }
1073
1074    /// Build [`ClientOptions`] with default headers extracted from `headers.*` keys.
1075    ///
1076    /// Keys prefixed with `headers.` are parsed into HTTP headers. For example,
1077    /// `headers.x-ms-version = 2023-11-03` results in a default header
1078    /// `x-ms-version: 2023-11-03`.
1079    ///
1080    /// Returns an error if any `headers.*` key has an invalid header name or value.
1081    #[cfg(any(feature = "aws", feature = "azure", feature = "gcp"))]
1082    pub fn client_options(&self) -> Result<ClientOptions> {
1083        let mut headers = HeaderMap::new();
1084        for (key, value) in &self.0 {
1085            if let Some(header_name) = key.strip_prefix("headers.") {
1086                let name = header_name
1087                    .parse::<http::header::HeaderName>()
1088                    .map_err(|e| {
1089                        Error::invalid_input(format!("invalid header name '{header_name}': {e}"))
1090                    })?;
1091                let val = HeaderValue::from_str(value).map_err(|e| {
1092                    Error::invalid_input(format!("invalid header value for '{header_name}': {e}"))
1093                })?;
1094                headers.insert(name, val);
1095            }
1096        }
1097        let mut client_options = ClientOptions::default();
1098        if !headers.is_empty() {
1099            client_options = client_options.with_default_headers(headers);
1100        }
1101        Ok(client_options)
1102    }
1103
1104    /// Get the expiration time in milliseconds since epoch, if present
1105    pub fn expires_at_millis(&self) -> Option<u64> {
1106        self.0
1107            .get(EXPIRES_AT_MILLIS_KEY)
1108            .and_then(|s| s.parse::<u64>().ok())
1109    }
1110}
1111
1112impl From<HashMap<String, String>> for StorageOptions {
1113    fn from(value: HashMap<String, String>) -> Self {
1114        Self::new(value)
1115    }
1116}
1117
1118static DEFAULT_OBJECT_STORE_REGISTRY: std::sync::LazyLock<ObjectStoreRegistry> =
1119    std::sync::LazyLock::new(ObjectStoreRegistry::default);
1120
1121impl ObjectStore {
1122    #[allow(clippy::too_many_arguments)]
1123    pub fn new(
1124        store: Arc<DynObjectStore>,
1125        location: Url,
1126        block_size: Option<usize>,
1127        wrapper: Option<Arc<dyn WrappingObjectStore>>,
1128        use_constant_size_upload_parts: bool,
1129        list_is_lexically_ordered: bool,
1130        io_parallelism: usize,
1131        download_retry_count: usize,
1132        storage_options: Option<&HashMap<String, String>>,
1133    ) -> Self {
1134        let scheme = location.scheme();
1135        let block_size = block_size.unwrap_or_else(|| infer_block_size(scheme));
1136        let store_prefix = match DEFAULT_OBJECT_STORE_REGISTRY.get_provider(scheme) {
1137            Some(provider) => provider
1138                .calculate_object_store_prefix(&location, storage_options)
1139                .unwrap(),
1140            None => {
1141                let store_prefix = format!("{}${}", location.scheme(), location.authority());
1142                log::warn!(
1143                    "Guessing that object store prefix is {}, since object store scheme is not found in registry.",
1144                    store_prefix
1145                );
1146                store_prefix
1147            }
1148        };
1149        let store = match wrapper {
1150            Some(wrapper) => wrapper.wrap(&store_prefix, store),
1151            None => store,
1152        };
1153
1154        // Always wrap with IO tracking
1155        let io_tracker = IOTracker::default();
1156        let tracked_store = io_tracker.wrap("", store);
1157
1158        Self {
1159            inner: tracked_store,
1160            scheme: scheme.into(),
1161            block_size,
1162            max_iop_size: *DEFAULT_MAX_IOP_SIZE,
1163            use_constant_size_upload_parts,
1164            list_is_lexically_ordered,
1165            io_parallelism,
1166            download_retry_count,
1167            io_tracker,
1168            store_prefix,
1169        }
1170    }
1171}
1172
1173fn infer_block_size(scheme: &str) -> usize {
1174    // Block size: On local file systems, we use 4KB block size. On cloud
1175    // object stores, we use 64KB block size. This is generally the largest
1176    // block size where we don't see a latency penalty.
1177    match scheme {
1178        "file" => 4 * 1024,
1179        _ => 64 * 1024,
1180    }
1181}
1182
1183#[cfg(test)]
1184mod tests {
1185    use super::*;
1186    use async_trait::async_trait;
1187    use bytes::Bytes;
1188    use lance_core::utils::tempfile::{TempStdDir, TempStdFile, TempStrDir};
1189    use object_store::memory::InMemory;
1190    use object_store::{
1191        CopyOptions, GetOptions, GetResult, ListResult, MultipartUpload, PutMultipartOptions,
1192        PutOptions, PutPayload, PutResult, Result as OSResult,
1193    };
1194    use rstest::rstest;
1195    use std::env::set_current_dir;
1196    use std::fmt::{Display, Formatter};
1197    use std::fs::{create_dir_all, write};
1198    use std::ops::Range;
1199    use std::path::Path as StdPath;
1200    use std::sync::atomic::{AtomicBool, Ordering};
1201
1202    /// Write test content to file.
1203    fn write_to_file(path_str: &str, contents: &str) -> std::io::Result<()> {
1204        let path = expand_path(path_str).map_err(std::io::Error::other)?;
1205        std::fs::create_dir_all(path.parent().unwrap())?;
1206        write(path, contents)
1207    }
1208
1209    async fn read_from_store(store: &ObjectStore, path: &Path) -> Result<String> {
1210        let test_file_store = store.open(path).await.unwrap();
1211        let size = test_file_store.size().await.unwrap();
1212        let bytes = test_file_store.get_range(0..size).await.unwrap();
1213        let contents = String::from_utf8(bytes.to_vec()).unwrap();
1214        Ok(contents)
1215    }
1216
1217    #[test]
1218    fn test_io_parallelism_clamped_to_nonzero() {
1219        // `io_parallelism()` feeds `buffered`/`buffer_unordered` windows; a value of 0 makes those
1220        // streams never poll, hanging callers (e.g. a metadata-only `count_rows`). It must clamp.
1221        let store = ObjectStore::local();
1222
1223        // SAFETY: process-global env var, set and restored within this test. `io_parallelism()`
1224        // only reads it, and a concurrent reader observes a valid clamped value, never 0.
1225        unsafe { std::env::set_var("LANCE_IO_THREADS", "0") };
1226        assert_eq!(
1227            store.io_parallelism(),
1228            1,
1229            "LANCE_IO_THREADS=0 must clamp to 1"
1230        );
1231
1232        unsafe { std::env::set_var("LANCE_IO_THREADS", "8") };
1233        assert_eq!(
1234            store.io_parallelism(),
1235            8,
1236            "a positive override must pass through unchanged"
1237        );
1238
1239        unsafe { std::env::remove_var("LANCE_IO_THREADS") };
1240        assert!(
1241            store.io_parallelism() >= 1,
1242            "the configured default parallelism must be at least 1"
1243        );
1244    }
1245
1246    #[tokio::test]
1247    async fn test_absolute_paths() {
1248        let tmp_path = TempStrDir::default();
1249        write_to_file(
1250            &format!("{tmp_path}/bar/foo.lance/test_file"),
1251            "TEST_CONTENT",
1252        )
1253        .unwrap();
1254
1255        // test a few variations of the same path
1256        for uri in &[
1257            format!("{tmp_path}/bar/foo.lance"),
1258            format!("{tmp_path}/./bar/foo.lance"),
1259            format!("{tmp_path}/bar/foo.lance/../foo.lance"),
1260        ] {
1261            let (store, path) = ObjectStore::from_uri(uri).await.unwrap();
1262            let contents = read_from_store(store.as_ref(), &path.clone().join("test_file"))
1263                .await
1264                .unwrap();
1265            assert_eq!(contents, "TEST_CONTENT");
1266        }
1267    }
1268
1269    #[tokio::test]
1270    async fn test_cloud_paths() {
1271        let uri = "s3://bucket/foo.lance";
1272        let (store, path) = ObjectStore::from_uri(uri).await.unwrap();
1273        assert_eq!(store.scheme, "s3");
1274        assert_eq!(path.to_string(), "foo.lance");
1275
1276        let (store, path) = ObjectStore::from_uri("s3+ddb://bucket/foo.lance")
1277            .await
1278            .unwrap();
1279        assert_eq!(store.scheme, "s3");
1280        assert_eq!(path.to_string(), "foo.lance");
1281
1282        let (store, path) = ObjectStore::from_uri("gs://bucket/foo.lance")
1283            .await
1284            .unwrap();
1285        assert_eq!(store.scheme, "gs");
1286        assert_eq!(path.to_string(), "foo.lance");
1287
1288        let (store, path) =
1289            ObjectStore::from_uri("abfss://filesystem@account.dfs.core.windows.net/foo.lance")
1290                .await
1291                .unwrap();
1292        assert_eq!(store.scheme, "abfss");
1293        assert_eq!(path.to_string(), "foo.lance");
1294    }
1295
1296    async fn test_block_size_used_test_helper(
1297        uri: &str,
1298        storage_options: Option<HashMap<String, String>>,
1299        default_expected_block_size: usize,
1300    ) {
1301        // Test the default
1302        let registry = Arc::new(ObjectStoreRegistry::default());
1303        let accessor = storage_options
1304            .clone()
1305            .map(|opts| Arc::new(StorageOptionsAccessor::with_static_options(opts)));
1306        let params = ObjectStoreParams {
1307            storage_options_accessor: accessor.clone(),
1308            ..ObjectStoreParams::default()
1309        };
1310        let (store, _) = ObjectStore::from_uri_and_params(registry, uri, &params)
1311            .await
1312            .unwrap();
1313        assert_eq!(store.block_size, default_expected_block_size);
1314
1315        // Ensure param is used
1316        let registry = Arc::new(ObjectStoreRegistry::default());
1317        let params = ObjectStoreParams {
1318            block_size: Some(1024),
1319            storage_options_accessor: accessor,
1320            ..ObjectStoreParams::default()
1321        };
1322        let (store, _) = ObjectStore::from_uri_and_params(registry, uri, &params)
1323            .await
1324            .unwrap();
1325        assert_eq!(store.block_size, 1024);
1326    }
1327
1328    #[rstest]
1329    #[case("s3://bucket/foo.lance", None)]
1330    #[case("gs://bucket/foo.lance", None)]
1331    #[case("az://account/bucket/foo.lance",
1332      Some(HashMap::from([
1333            (String::from("account_name"), String::from("account")),
1334            (String::from("container_name"), String::from("container"))
1335           ])))]
1336    #[case("abfss://filesystem@account.dfs.core.windows.net/foo.lance",
1337      Some(HashMap::from([
1338            (String::from("account_name"), String::from("account")),
1339            (String::from("container_name"), String::from("filesystem"))
1340           ])))]
1341    #[tokio::test]
1342    async fn test_block_size_used_cloud(
1343        #[case] uri: &str,
1344        #[case] storage_options: Option<HashMap<String, String>>,
1345    ) {
1346        test_block_size_used_test_helper(uri, storage_options, 64 * 1024).await;
1347    }
1348
1349    #[rstest]
1350    #[case("file")]
1351    #[case("file-object-store")]
1352    #[case("memory:///bucket/foo.lance")]
1353    #[tokio::test]
1354    async fn test_block_size_used_file(#[case] prefix: &str) {
1355        let tmp_path = TempStrDir::default();
1356        let path = format!("{tmp_path}/bar/foo.lance/test_file");
1357        write_to_file(&path, "URL").unwrap();
1358        let uri = format!("{prefix}:///{path}");
1359        test_block_size_used_test_helper(&uri, None, 4 * 1024).await;
1360    }
1361
1362    #[tokio::test]
1363    async fn test_relative_paths() {
1364        let tmp_path = TempStrDir::default();
1365        write_to_file(
1366            &format!("{tmp_path}/bar/foo.lance/test_file"),
1367            "RELATIVE_URL",
1368        )
1369        .unwrap();
1370
1371        set_current_dir(StdPath::new(tmp_path.as_ref())).expect("Error changing current dir");
1372        let (store, path) = ObjectStore::from_uri("./bar/foo.lance").await.unwrap();
1373
1374        let contents = read_from_store(store.as_ref(), &path.clone().join("test_file"))
1375            .await
1376            .unwrap();
1377        assert_eq!(contents, "RELATIVE_URL");
1378    }
1379
1380    #[tokio::test]
1381    async fn test_tilde_expansion() {
1382        let uri = "~/foo.lance";
1383        write_to_file(&format!("{uri}/test_file"), "TILDE").unwrap();
1384        let (store, path) = ObjectStore::from_uri(uri).await.unwrap();
1385        let contents = read_from_store(store.as_ref(), &path.clone().join("test_file"))
1386            .await
1387            .unwrap();
1388        assert_eq!(contents, "TILDE");
1389    }
1390
1391    #[tokio::test]
1392    async fn test_read_directory() {
1393        let path = TempStdDir::default();
1394        create_dir_all(path.join("foo").join("bar")).unwrap();
1395        create_dir_all(path.join("foo").join("zoo")).unwrap();
1396        create_dir_all(path.join("foo").join("zoo").join("abc")).unwrap();
1397        write_to_file(
1398            path.join("foo").join("test_file").to_str().unwrap(),
1399            "read_dir",
1400        )
1401        .unwrap();
1402        let (store, base) = ObjectStore::from_uri(path.to_str().unwrap()).await.unwrap();
1403
1404        let sub_dirs = store.read_dir(base.clone().join("foo")).await.unwrap();
1405        assert_eq!(sub_dirs, vec!["bar", "zoo", "test_file"]);
1406    }
1407
1408    #[tokio::test]
1409    async fn test_delete_directory_local_store() {
1410        test_delete_directory("").await;
1411    }
1412
1413    #[tokio::test]
1414    async fn test_delete_directory_file_object_store() {
1415        test_delete_directory("file-object-store").await;
1416    }
1417
1418    async fn test_delete_directory(scheme: &str) {
1419        let path = TempStdDir::default();
1420        create_dir_all(path.join("foo").join("bar")).unwrap();
1421        create_dir_all(path.join("foo").join("zoo")).unwrap();
1422        create_dir_all(path.join("foo").join("zoo").join("abc")).unwrap();
1423        write_to_file(
1424            path.join("foo")
1425                .join("bar")
1426                .join("test_file")
1427                .to_str()
1428                .unwrap(),
1429            "delete",
1430        )
1431        .unwrap();
1432        let file_url = Url::from_directory_path(&path).unwrap();
1433        let url = if scheme.is_empty() {
1434            file_url
1435        } else {
1436            let mut url = Url::parse(&format!("{scheme}:///")).unwrap();
1437            // Use the file:// URL's normalized path so this works on Windows too.
1438            url.set_path(file_url.path());
1439            url
1440        };
1441        let (store, base) = ObjectStore::from_uri(url.as_ref()).await.unwrap();
1442        store
1443            .remove_dir_all(base.clone().join("foo"))
1444            .await
1445            .unwrap();
1446
1447        assert!(!path.join("foo").exists());
1448    }
1449
1450    #[derive(Debug)]
1451    struct TestWrapper {
1452        called: AtomicBool,
1453
1454        return_value: Arc<dyn OSObjectStore>,
1455    }
1456
1457    impl WrappingObjectStore for TestWrapper {
1458        fn wrap(
1459            &self,
1460            _store_prefix: &str,
1461            _original: Arc<dyn OSObjectStore>,
1462        ) -> Arc<dyn OSObjectStore> {
1463            self.called.store(true, Ordering::Relaxed);
1464
1465            // return a mocked value so we can check if the final store is the one we expect
1466            self.return_value.clone()
1467        }
1468    }
1469
1470    impl TestWrapper {
1471        fn called(&self) -> bool {
1472            self.called.load(Ordering::Relaxed)
1473        }
1474    }
1475
1476    #[tokio::test]
1477    async fn test_wrapper_identity_is_stable_across_tasks() {
1478        let wrapper = Arc::new(TestWrapper {
1479            called: AtomicBool::new(false),
1480            return_value: Arc::new(InMemory::new()),
1481        });
1482        let initial_params = ObjectStoreParams {
1483            object_store_wrapper: Some(wrapper.clone()),
1484            ..ObjectStoreParams::default()
1485        };
1486        let task_params = tokio::spawn(async move {
1487            ObjectStoreParams {
1488                object_store_wrapper: Some(wrapper),
1489                ..ObjectStoreParams::default()
1490            }
1491        })
1492        .await
1493        .unwrap();
1494
1495        assert_eq!(initial_params, task_params);
1496
1497        let mut initial_hasher = std::hash::DefaultHasher::new();
1498        std::hash::Hash::hash(&initial_params, &mut initial_hasher);
1499        let mut task_hasher = std::hash::DefaultHasher::new();
1500        std::hash::Hash::hash(&task_params, &mut task_hasher);
1501        assert_eq!(
1502            std::hash::Hasher::finish(&initial_hasher),
1503            std::hash::Hasher::finish(&task_hasher)
1504        );
1505    }
1506
1507    #[tokio::test]
1508    async fn test_wrapping_object_store_option_is_used() {
1509        // Make a store for the inner store first
1510        let mock_inner_store: Arc<dyn OSObjectStore> = Arc::new(InMemory::new());
1511        let registry = Arc::new(ObjectStoreRegistry::default());
1512
1513        assert_eq!(Arc::strong_count(&mock_inner_store), 1);
1514
1515        let wrapper = Arc::new(TestWrapper {
1516            called: AtomicBool::new(false),
1517            return_value: mock_inner_store.clone(),
1518        });
1519
1520        let params = ObjectStoreParams {
1521            object_store_wrapper: Some(wrapper.clone()),
1522            ..ObjectStoreParams::default()
1523        };
1524
1525        // not called yet
1526        assert!(!wrapper.called());
1527
1528        let _ = ObjectStore::from_uri_and_params(registry, "memory:///", &params)
1529            .await
1530            .unwrap();
1531
1532        // called after construction
1533        assert!(wrapper.called());
1534
1535        // hard to compare two trait pointers as the point to vtables
1536        // using the ref count as a proxy to make sure that the store is correctly kept
1537        assert_eq!(Arc::strong_count(&mock_inner_store), 2);
1538    }
1539
1540    #[tokio::test]
1541    async fn test_local_paths() {
1542        let file_path = TempStdFile::default();
1543        let mut writer = ObjectStore::create_local_writer(&file_path).await.unwrap();
1544        writer.write_all(b"LOCAL").await.unwrap();
1545        Writer::shutdown(&mut writer).await.unwrap();
1546
1547        let reader = ObjectStore::open_local(&file_path).await.unwrap();
1548        let buf = reader.get_range(0..5).await.unwrap();
1549        assert_eq!(buf.as_ref(), b"LOCAL");
1550    }
1551
1552    #[tokio::test]
1553    async fn test_read_one() {
1554        let file_path = TempStdFile::default();
1555        let mut writer = ObjectStore::create_local_writer(&file_path).await.unwrap();
1556        writer.write_all(b"LOCAL").await.unwrap();
1557        Writer::shutdown(&mut writer).await.unwrap();
1558
1559        let file_path_os = object_store::path::Path::parse(file_path.to_str().unwrap()).unwrap();
1560        let obj_store = ObjectStore::local();
1561        let buf = obj_store.read_one_all(&file_path_os).await.unwrap();
1562        assert_eq!(buf.as_ref(), b"LOCAL");
1563
1564        let buf = obj_store.read_one_range(&file_path_os, 0..5).await.unwrap();
1565        assert_eq!(buf.as_ref(), b"LOCAL");
1566    }
1567
1568    #[tokio::test]
1569    #[cfg(windows)]
1570    async fn test_windows_paths() {
1571        use std::path::Component;
1572        use std::path::Prefix;
1573        use std::path::Prefix::*;
1574
1575        fn get_path_prefix(path: &StdPath) -> Prefix<'_> {
1576            match path.components().next().unwrap() {
1577                Component::Prefix(prefix_component) => prefix_component.kind(),
1578                _ => panic!(),
1579            }
1580        }
1581
1582        fn get_drive_letter(prefix: Prefix) -> String {
1583            match prefix {
1584                Disk(bytes) => String::from_utf8(vec![bytes]).unwrap(),
1585                _ => panic!(),
1586            }
1587        }
1588
1589        let tmp_path = TempStdFile::default();
1590        let prefix = get_path_prefix(&tmp_path);
1591        let drive_letter = get_drive_letter(prefix);
1592
1593        write_to_file(
1594            &(format!("{drive_letter}:/test_folder/test.lance") + "/test_file"),
1595            "WINDOWS",
1596        )
1597        .unwrap();
1598
1599        for uri in &[
1600            format!("{drive_letter}:/test_folder/test.lance"),
1601            format!("{drive_letter}:\\test_folder\\test.lance"),
1602        ] {
1603            let (store, base) = ObjectStore::from_uri(uri).await.unwrap();
1604            let contents = read_from_store(store.as_ref(), &base.clone().join("test_file"))
1605                .await
1606                .unwrap();
1607            assert_eq!(contents, "WINDOWS");
1608        }
1609    }
1610
1611    #[tokio::test]
1612    async fn test_cross_filesystem_copy() {
1613        // Create two temporary directories that simulate different filesystems
1614        let source_dir = TempStdDir::default();
1615        let dest_dir = TempStdDir::default();
1616
1617        // Create a test file in the source directory
1618        let source_file_name = "test_file.txt";
1619        let source_file = source_dir.join(source_file_name);
1620        std::fs::write(&source_file, b"test content").unwrap();
1621
1622        // Create ObjectStore for local filesystem
1623        let (store, base_path) = ObjectStore::from_uri(source_dir.to_str().unwrap())
1624            .await
1625            .unwrap();
1626
1627        // Create paths relative to the ObjectStore base
1628        let from_path = base_path.clone().join(source_file_name);
1629
1630        // Use object_store::Path::parse for the destination
1631        let dest_file = dest_dir.join("copied_file.txt");
1632        let dest_str = dest_file.to_str().unwrap();
1633        let to_path = object_store::path::Path::parse(dest_str).unwrap();
1634
1635        // Perform the copy operation
1636        store.copy(&from_path, &to_path).await.unwrap();
1637
1638        // Verify the file was copied correctly
1639        assert!(dest_file.exists());
1640        let copied_content = std::fs::read(&dest_file).unwrap();
1641        assert_eq!(copied_content, b"test content");
1642    }
1643
1644    #[tokio::test]
1645    async fn test_copy_creates_parent_directories() {
1646        let source_dir = TempStdDir::default();
1647        let dest_dir = TempStdDir::default();
1648
1649        // Create a test file in the source directory
1650        let source_file_name = "test_file.txt";
1651        let source_file = source_dir.join(source_file_name);
1652        std::fs::write(&source_file, b"test content").unwrap();
1653
1654        // Create ObjectStore for local filesystem
1655        let (store, base_path) = ObjectStore::from_uri(source_dir.to_str().unwrap())
1656            .await
1657            .unwrap();
1658
1659        // Create paths
1660        let from_path = base_path.clone().join(source_file_name);
1661
1662        // Create destination with nested directories that don't exist yet
1663        let dest_file = dest_dir.join("nested").join("dirs").join("copied_file.txt");
1664        let dest_str = dest_file.to_str().unwrap();
1665        let to_path = object_store::path::Path::parse(dest_str).unwrap();
1666
1667        // Perform the copy operation - should create parent directories
1668        store.copy(&from_path, &to_path).await.unwrap();
1669
1670        // Verify the file was copied correctly and directories were created
1671        assert!(dest_file.exists());
1672        assert!(dest_file.parent().unwrap().exists());
1673        let copied_content = std::fs::read(&dest_file).unwrap();
1674        assert_eq!(copied_content, b"test content");
1675    }
1676
1677    /// Inner store that forwards everything to `InMemory` except single-shot
1678    /// server-side copy (`copy_opts`), which always fails. This lets a test
1679    /// prove that `ObjectStore::copy` fell back to a streaming multipart copy
1680    /// for an oversized source rather than issuing a single `CopyObject`.
1681    #[derive(Debug)]
1682    struct CopyFailingStore {
1683        inner: InMemory,
1684    }
1685
1686    impl Display for CopyFailingStore {
1687        fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
1688            write!(f, "CopyFailingStore")
1689        }
1690    }
1691
1692    #[async_trait]
1693    impl OSObjectStore for CopyFailingStore {
1694        async fn put_opts(
1695            &self,
1696            location: &Path,
1697            bytes: PutPayload,
1698            opts: PutOptions,
1699        ) -> OSResult<PutResult> {
1700            self.inner.put_opts(location, bytes, opts).await
1701        }
1702        async fn put_multipart_opts(
1703            &self,
1704            location: &Path,
1705            opts: PutMultipartOptions,
1706        ) -> OSResult<Box<dyn MultipartUpload>> {
1707            self.inner.put_multipart_opts(location, opts).await
1708        }
1709        async fn get_opts(&self, location: &Path, options: GetOptions) -> OSResult<GetResult> {
1710            self.inner.get_opts(location, options).await
1711        }
1712        async fn get_ranges(&self, location: &Path, ranges: &[Range<u64>]) -> OSResult<Vec<Bytes>> {
1713            self.inner.get_ranges(location, ranges).await
1714        }
1715        fn delete_stream(
1716            &self,
1717            locations: BoxStream<'static, OSResult<Path>>,
1718        ) -> BoxStream<'static, OSResult<Path>> {
1719            self.inner.delete_stream(locations)
1720        }
1721        fn list(&self, prefix: Option<&Path>) -> BoxStream<'static, OSResult<ObjectMeta>> {
1722            self.inner.list(prefix)
1723        }
1724        fn list_with_offset(
1725            &self,
1726            prefix: Option<&Path>,
1727            offset: &Path,
1728        ) -> BoxStream<'static, OSResult<ObjectMeta>> {
1729            self.inner.list_with_offset(prefix, offset)
1730        }
1731        async fn list_with_delimiter(&self, prefix: Option<&Path>) -> OSResult<ListResult> {
1732            self.inner.list_with_delimiter(prefix).await
1733        }
1734        async fn copy_opts(&self, _from: &Path, _to: &Path, _opts: CopyOptions) -> OSResult<()> {
1735            Err(object_store::Error::Generic {
1736                store: "CopyFailingStore",
1737                source: "single-shot copy disabled in test".into(),
1738            })
1739        }
1740    }
1741
1742    #[tokio::test]
1743    async fn test_copy_streams_objects_larger_than_threshold() {
1744        // memory:// is non-local but isn't an S3/GCS scheme, so copy() wouldn't
1745        // enable the fallback on its own. Drive copy_impl directly with
1746        // multipart_copy_fallback = true to exercise the streaming path. The
1747        // inner store rejects any single-shot copy, so a successful copy can only
1748        // have gone through the streaming branch.
1749        let mut store = ObjectStore::memory();
1750        store.inner = Arc::new(CopyFailingStore {
1751            inner: InMemory::new(),
1752        });
1753
1754        let from = Path::from("source.bin");
1755        let contents = b"streaming multipart copy payload well past the tiny threshold";
1756        store.put(&from, contents).await.unwrap();
1757
1758        // Source size (61 bytes) exceeds the threshold -> must stream via a
1759        // multipart write rather than a single-shot server-side copy.
1760        let streamed = Path::from("streamed.bin");
1761        store.copy_impl(&from, &streamed, true, 8).await.unwrap();
1762        let copied = store.read_one_all(&streamed).await.unwrap();
1763        assert_eq!(copied.as_ref(), contents.as_slice());
1764
1765        // Source size below the threshold -> single-shot copy, which the inner
1766        // store rejects, confirming that the streaming branch (not native copy)
1767        // is what made the first copy succeed.
1768        let native = Path::from("native.bin");
1769        assert!(
1770            store
1771                .copy_impl(&from, &native, true, u64::MAX)
1772                .await
1773                .is_err()
1774        );
1775    }
1776
1777    #[test]
1778    #[cfg(any(feature = "aws", feature = "azure", feature = "gcp"))]
1779    fn test_client_options_extracts_headers() {
1780        let opts = StorageOptions(HashMap::from([
1781            ("headers.x-custom-foo".to_string(), "bar".to_string()),
1782            ("headers.x-ms-version".to_string(), "2023-11-03".to_string()),
1783            ("region".to_string(), "us-west-2".to_string()),
1784        ]));
1785        let client_options = opts.client_options().unwrap();
1786
1787        // Verify non-header keys are not consumed as headers by creating
1788        // another StorageOptions with no headers.* keys.
1789        let opts_no_headers = StorageOptions(HashMap::from([(
1790            "region".to_string(),
1791            "us-west-2".to_string(),
1792        )]));
1793        opts_no_headers.client_options().unwrap();
1794
1795        // Smoke test: the client_options with headers should be usable
1796        // in a builder (we can't inspect the headers directly, but building
1797        // should not fail).
1798        #[cfg(feature = "gcp")]
1799        {
1800            use object_store::gcp::GoogleCloudStorageBuilder;
1801            let _builder = GoogleCloudStorageBuilder::new()
1802                .with_client_options(client_options)
1803                .with_url("gs://test-bucket");
1804        }
1805    }
1806
1807    #[test]
1808    #[cfg(any(feature = "aws", feature = "azure", feature = "gcp"))]
1809    fn test_client_options_rejects_invalid_header_name() {
1810        let opts = StorageOptions(HashMap::from([(
1811            "headers.bad header".to_string(),
1812            "value".to_string(),
1813        )]));
1814        let err = opts.client_options().unwrap_err();
1815        assert!(err.to_string().contains("invalid header name"));
1816    }
1817
1818    #[test]
1819    #[cfg(any(feature = "aws", feature = "azure", feature = "gcp"))]
1820    fn test_client_options_rejects_invalid_header_value() {
1821        let opts = StorageOptions(HashMap::from([(
1822            "headers.x-good-name".to_string(),
1823            "bad\x01value".to_string(),
1824        )]));
1825        let err = opts.client_options().unwrap_err();
1826        assert!(err.to_string().contains("invalid header value"));
1827    }
1828
1829    #[test]
1830    #[cfg(any(feature = "aws", feature = "azure", feature = "gcp"))]
1831    fn test_client_options_empty_when_no_header_keys() {
1832        let opts = StorageOptions(HashMap::from([
1833            ("region".to_string(), "us-east-1".to_string()),
1834            ("access_key_id".to_string(), "AKID".to_string()),
1835        ]));
1836        opts.client_options().unwrap();
1837    }
1838}