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