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, HashSet};
8use std::ops::Range;
9#[cfg(unix)]
10use std::os::unix::fs::PermissionsExt;
11use std::pin::Pin;
12use std::str::FromStr;
13use std::sync::Arc;
14use std::time::{Duration, Instant};
15
16use ::tracing::{Span, field::Empty, instrument};
17use async_trait::async_trait;
18use bytes::Bytes;
19use chrono::{DateTime, Utc};
20use futures::{FutureExt, Stream};
21use futures::{StreamExt, TryStreamExt, future, stream::BoxStream};
22use lance_core::deepsize::DeepSizeOf;
23use lance_core::error::LanceOptionExt;
24use lance_core::utils::parse::{parse_env_as_bool, str_is_truthy};
25use list_retry::ListRetryStream;
26use object_store::DynObjectStore;
27use object_store::ObjectStoreExt as OSObjectStoreExt;
28#[cfg(feature = "aws")]
29use object_store::aws::AwsCredentialProvider;
30use object_store::list::PaginatedListStore;
31#[cfg(any(feature = "aws", feature = "azure", feature = "gcp"))]
32use object_store::{ClientOptions, HeaderMap, HeaderValue};
33use object_store::{
34    ListResult, ObjectMeta, ObjectStore as OSObjectStore, PutMode, PutOptions, PutPayload,
35    path::Path,
36};
37use providers::local::FileStoreProvider;
38use providers::memory::MemoryStoreProvider;
39use tokio::io::AsyncWriteExt;
40use url::Url;
41
42use super::local::LocalObjectReader;
43#[cfg(target_os = "linux")]
44use crate::uring::{UringCurrentThreadReader, UringReader};
45#[cfg(any(feature = "aws", feature = "azure", feature = "gcp"))]
46pub(crate) mod dynamic_credentials;
47#[cfg(any(feature = "oss", feature = "huggingface", feature = "tos"))]
48pub(crate) mod dynamic_opendal;
49mod list_retry;
50#[cfg(feature = "metrics")]
51pub mod metrics;
52#[cfg(any(
53    feature = "aws",
54    feature = "gcp",
55    feature = "azure",
56    feature = "oss",
57    feature = "tencent",
58    feature = "huggingface",
59    feature = "tos",
60    feature = "goosefs",
61))]
62pub(crate) mod opendal_store;
63pub mod providers;
64pub(crate) mod read_dir;
65pub mod storage_options;
66#[cfg(test)]
67pub(crate) mod test_utils;
68pub mod throttle;
69mod tracing;
70use crate::object_reader::SmallReader;
71use crate::object_writer::{LocalWriter, WriteResult};
72use crate::traits::{WriteExt, Writer};
73use crate::utils::tracking_store::{IOTracker, IoStats};
74use crate::{object_reader::CloudObjectReader, object_writer::ObjectWriter, traits::Reader};
75use lance_core::{Error, Result};
76
77// Local disks tend to do fine with a few threads
78// Note: the number of threads here also impacts the number of files
79// we need to read in some situations.  So keeping this at 8 keeps the
80// RAM on our scanner down.
81pub const DEFAULT_LOCAL_IO_PARALLELISM: usize = 8;
82// Cloud disks often need many many threads to saturate the network
83pub const DEFAULT_CLOUD_IO_PARALLELISM: usize = 64;
84
85const SERVER_SIDE_COPY_ENABLED_ENV: &str = "LANCE_IO_SERVER_SIDE_COPY_ENABLED";
86
87const DEFAULT_LOCAL_BLOCK_SIZE: usize = 4 * 1024; // 4KB block size
88#[cfg(any(
89    feature = "aws",
90    feature = "gcp",
91    feature = "azure",
92    feature = "oss",
93    feature = "tencent",
94    feature = "huggingface",
95    feature = "tos",
96    feature = "goosefs",
97))]
98const DEFAULT_CLOUD_BLOCK_SIZE: usize = 64 * 1024; // 64KB block size
99
100pub static DEFAULT_MAX_IOP_SIZE: std::sync::LazyLock<u64> = std::sync::LazyLock::new(|| {
101    std::env::var("LANCE_MAX_IOP_SIZE")
102        .map(|val| val.parse().unwrap())
103        .unwrap_or(16 * 1024 * 1024)
104});
105
106pub const DEFAULT_DOWNLOAD_RETRY_COUNT: usize = 3;
107
108#[derive(Debug)]
109struct StreamCopyError {
110    stage: &'static str,
111    source_path: String,
112    destination_path: String,
113    source: Box<dyn std::error::Error + Send + Sync>,
114}
115
116impl std::fmt::Display for StreamCopyError {
117    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118        write!(
119            f,
120            "multipart_stream_copy failed during {} from {} to {}: {}",
121            self.stage, self.source_path, self.destination_path, self.source
122        )
123    }
124}
125
126impl std::error::Error for StreamCopyError {
127    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
128        Some(self.source.as_ref())
129    }
130}
131
132fn stream_copy_error(
133    stage: &'static str,
134    source_path: &Path,
135    destination_path: &Path,
136    source: impl std::error::Error + Send + Sync + 'static,
137) -> Error {
138    Error::io_source(Box::new(StreamCopyError {
139        stage,
140        source_path: source_path.to_string(),
141        destination_path: destination_path.to_string(),
142        source: Box::new(source),
143    }))
144}
145
146pub use providers::{ObjectStoreProvider, ObjectStoreRegistry};
147pub use read_dir::ReadDirOptions;
148pub use storage_options::{
149    BASE_SCOPED_OPTION_PREFIX, BaseScopedStorageOptionsProvider, EXPIRES_AT_MILLIS_KEY,
150    LanceNamespaceStorageOptionsProvider, REFRESH_OFFSET_MILLIS_KEY, StorageOptionsAccessor,
151    StorageOptionsProvider, has_base_scoped_options, parse_base_scoped_key,
152    resolve_base_scoped_options,
153};
154
155#[async_trait]
156pub trait ObjectStoreExt {
157    /// Returns true if the file exists.
158    async fn exists(&self, path: &Path) -> Result<bool>;
159
160    /// Read all files (start from base directory) recursively
161    ///
162    /// unmodified_since can be specified to only return files that have not been modified since the given time.
163    fn read_dir_all<'a, 'b>(
164        &'a self,
165        dir_path: impl Into<&'b Path> + Send,
166        unmodified_since: Option<DateTime<Utc>>,
167    ) -> BoxStream<'a, Result<ObjectMeta>>;
168}
169
170#[async_trait]
171pub(super) trait LocalDirOperations: std::fmt::Debug + Send + Sync {
172    async fn remove_dir_all(&self, path: &Path) -> Result<()>;
173}
174
175#[async_trait]
176impl<O: OSObjectStore + ?Sized> ObjectStoreExt for O {
177    fn read_dir_all<'a, 'b>(
178        &'a self,
179        dir_path: impl Into<&'b Path> + Send,
180        unmodified_since: Option<DateTime<Utc>>,
181    ) -> BoxStream<'a, Result<ObjectMeta>> {
182        let output = self.list(Some(dir_path.into())).map_err(|e| e.into());
183        if let Some(unmodified_since_val) = unmodified_since {
184            output
185                .try_filter(move |file| future::ready(file.last_modified <= unmodified_since_val))
186                .boxed()
187        } else {
188            output.boxed()
189        }
190    }
191
192    async fn exists(&self, path: &Path) -> Result<bool> {
193        match self.head(path).await {
194            Ok(_) => Ok(true),
195            Err(object_store::Error::NotFound { path: _, source: _ }) => Ok(false),
196            Err(e) => Err(e.into()),
197        }
198    }
199}
200
201/// Wraps [ObjectStore](object_store::ObjectStore)
202#[derive(Clone)]
203pub struct ObjectStore {
204    // Inner object store
205    pub inner: Arc<dyn OSObjectStore>,
206    // Provider-owned native directory operations for rooted local stores.
207    local_dir_operations: Option<Arc<dyn LocalDirOperations>>,
208    scheme: String,
209    block_size: usize,
210    max_iop_size: u64,
211    /// Whether to use constant size upload parts for multipart uploads. This
212    /// is only necessary for Cloudflare R2.
213    pub use_constant_size_upload_parts: bool,
214    /// Whether we can assume that the list of files is lexically ordered. This
215    /// is true for object stores, but not for local filesystems.
216    pub list_is_lexically_ordered: bool,
217    io_parallelism: usize,
218    /// Number of times to retry a failed download
219    download_retry_count: usize,
220    /// IO tracker for monitoring read/write operations
221    io_tracker: IOTracker,
222    /// The datastore prefix that uniquely identifies this object store. It encodes information
223    /// which usually cannot be found in the URL such as Azure account name. The prefix plus the
224    /// path uniquely identifies any object inside the store.
225    pub store_prefix: String,
226    /// The backend's paginated listing API, when it has one. `None` means
227    /// [`Self::read_dir_page`] has to list a directory in full to page through it.
228    pub(crate) paginated_lister: Option<Arc<dyn PaginatedListStore>>,
229}
230
231// Hand-written because `PaginatedListStore` is not `Debug`.
232impl std::fmt::Debug for ObjectStore {
233    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
234        f.debug_struct("ObjectStore")
235            .field("inner", &self.inner)
236            .field("scheme", &self.scheme)
237            .field("block_size", &self.block_size)
238            .field("max_iop_size", &self.max_iop_size)
239            .field(
240                "use_constant_size_upload_parts",
241                &self.use_constant_size_upload_parts,
242            )
243            .field("list_is_lexically_ordered", &self.list_is_lexically_ordered)
244            .field("io_parallelism", &self.io_parallelism)
245            .field("download_retry_count", &self.download_retry_count)
246            .field("io_tracker", &self.io_tracker)
247            .field("store_prefix", &self.store_prefix)
248            .field("paginated_lister", &self.paginated_lister.is_some())
249            .finish()
250    }
251}
252
253impl DeepSizeOf for ObjectStore {
254    fn deep_size_of_children(&self, context: &mut lance_core::deepsize::Context) -> usize {
255        // We aren't counting `inner` here which is problematic but an ObjectStore
256        // shouldn't be too big.  The only exception might be the write cache but, if
257        // the writer cache has data, it means we're using it somewhere else that isn't
258        // a cache and so that doesn't really count.
259        self.scheme.deep_size_of_children(context) + self.block_size.deep_size_of_children(context)
260    }
261}
262
263impl std::fmt::Display for ObjectStore {
264    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
265        write!(f, "ObjectStore({})", self.scheme)
266    }
267}
268
269pub trait WrappingObjectStore: std::fmt::Debug + Send + Sync {
270    /// Wrap an object store with additional functionality
271    ///
272    /// The store_prefix is a string which uniquely identifies the object
273    /// store being wrapped.
274    fn wrap(&self, store_prefix: &str, original: Arc<dyn OSObjectStore>) -> Arc<dyn OSObjectStore>;
275
276    /// Wrap the paginated listing API that goes with the store, if it has one.
277    ///
278    /// [`ObjectStore::read_dir_page`] pushes the page size and the resume position into
279    /// [`PaginatedListStore`], which is a separate trait from [`OSObjectStore`] and so cannot
280    /// be reached through the store [`Self::wrap`] returns. A listing that is pushed down
281    /// therefore does not pass through [`Self::wrap`], and this is where a wrapper says what
282    /// should happen instead:
283    ///
284    /// - `Some(lister)` keeps the pushdown, wrapping the lister or handing back the one
285    ///   given. Right for a wrapper that observes rather than intercepts — metering, caching,
286    ///   mirroring writes.
287    /// - `None` gives up the pushdown, so listings go through [`Self::wrap`] as a full
288    ///   directory read. Right for a wrapper that hides, rewrites or fails paths, which a
289    ///   pushed-down listing would otherwise walk straight past.
290    ///
291    /// A wrapper that keeps the pushdown must leave the listing itself alone: setting
292    /// [`offset`](object_store::list::PaginatedListOptions::offset) or changing the delimiter
293    /// breaks paging, since `read_dir_page` reads one directory level and resumes by the token
294    /// it got back.
295    ///
296    /// There is deliberately no default: getting this wrong is either a silent loss of speed
297    /// or a silent loss of the wrapper, and neither announces itself.
298    fn wrap_paginated(
299        &self,
300        store_prefix: &str,
301        original: Arc<dyn PaginatedListStore>,
302    ) -> Option<Arc<dyn PaginatedListStore>>;
303}
304
305#[derive(Debug, Clone)]
306pub struct ChainedWrappingObjectStore {
307    wrappers: Vec<Arc<dyn WrappingObjectStore>>,
308}
309
310impl ChainedWrappingObjectStore {
311    pub fn new(wrappers: Vec<Arc<dyn WrappingObjectStore>>) -> Self {
312        Self { wrappers }
313    }
314
315    pub fn add_wrapper(&mut self, wrapper: Arc<dyn WrappingObjectStore>) {
316        self.wrappers.push(wrapper);
317    }
318}
319
320impl WrappingObjectStore for ChainedWrappingObjectStore {
321    fn wrap(&self, store_prefix: &str, original: Arc<dyn OSObjectStore>) -> Arc<dyn OSObjectStore> {
322        self.wrappers
323            .iter()
324            .fold(original, |acc, wrapper| wrapper.wrap(store_prefix, acc))
325    }
326
327    // One wrapper giving up the pushdown gives it up for the chain: the listing has to go
328    // through `wrap`, which is every wrapper in the chain at once.
329    fn wrap_paginated(
330        &self,
331        store_prefix: &str,
332        original: Arc<dyn PaginatedListStore>,
333    ) -> Option<Arc<dyn PaginatedListStore>> {
334        self.wrappers.iter().try_fold(original, |acc, wrapper| {
335            wrapper.wrap_paginated(store_prefix, acc)
336        })
337    }
338}
339
340/// Parameters to create an [ObjectStore]
341///
342#[derive(Debug, Clone)]
343pub struct ObjectStoreParams {
344    pub block_size: Option<usize>,
345    #[deprecated(note = "Implement an ObjectStoreProvider instead")]
346    pub object_store: Option<(Arc<DynObjectStore>, Url)>,
347    /// Refresh offset for AWS credentials when using the legacy AWS credentials path.
348    /// For StorageOptionsAccessor, use `refresh_offset_millis` storage option instead.
349    pub s3_credentials_refresh_offset: Duration,
350    #[cfg(feature = "aws")]
351    pub aws_credentials: Option<AwsCredentialProvider>,
352    pub object_store_wrapper: Option<Arc<dyn WrappingObjectStore>>,
353    /// Unified storage options accessor with caching and automatic refresh
354    ///
355    /// Provides storage options and optionally a dynamic provider for automatic
356    /// credential refresh. Use `StorageOptionsAccessor::with_static_options()` for static
357    /// options or `StorageOptionsAccessor::with_initial_and_provider()` for dynamic refresh.
358    pub storage_options_accessor: Option<Arc<StorageOptionsAccessor>>,
359    /// Use constant size upload parts for multipart uploads. Only necessary
360    /// for Cloudflare R2, which doesn't support variable size parts. When this
361    /// is false, max upload size is 2.5TB. When this is true, the max size is
362    /// 50GB.
363    pub use_constant_size_upload_parts: bool,
364    pub list_is_lexically_ordered: Option<bool>,
365}
366
367impl Default for ObjectStoreParams {
368    fn default() -> Self {
369        #[allow(deprecated)]
370        Self {
371            object_store: None,
372            block_size: None,
373            s3_credentials_refresh_offset: Duration::from_secs(60),
374            #[cfg(feature = "aws")]
375            aws_credentials: None,
376            object_store_wrapper: None,
377            storage_options_accessor: None,
378            use_constant_size_upload_parts: false,
379            list_is_lexically_ordered: None,
380        }
381    }
382}
383
384impl ObjectStoreParams {
385    /// Get the StorageOptionsAccessor from the params
386    pub fn get_accessor(&self) -> Option<Arc<StorageOptionsAccessor>> {
387        self.storage_options_accessor.clone()
388    }
389
390    /// Get storage options from the accessor, if any
391    ///
392    /// Returns the initial storage options from the accessor without triggering refresh.
393    pub fn storage_options(&self) -> Option<&HashMap<String, String>> {
394        self.storage_options_accessor
395            .as_ref()
396            .and_then(|a| a.initial_storage_options())
397    }
398
399    /// The block size to use: the explicit `block_size` parameter, else the
400    /// `block_size` storage option, else `None` for the store's default.
401    pub fn resolved_block_size(&self) -> Result<Option<usize>> {
402        if self.block_size.is_some() {
403            return Ok(self.block_size);
404        }
405        match self.storage_options() {
406            Some(options) => StorageOptions(options.clone()).block_size(),
407            None => Ok(None),
408        }
409    }
410
411    /// Resolve these params for a single base path scope.
412    ///
413    /// Storage options may carry base-scoped entries (`base_<id>.<key>`) that
414    /// apply only to one registered base path; see
415    /// [`StorageOptionsAccessor::scoped_to_base`]. Returns the params unchanged
416    /// when the storage options contain no base-scoped entries.
417    pub fn scoped_to_base(&self, base_id: Option<u32>) -> Cow<'_, Self> {
418        let Some(accessor) = &self.storage_options_accessor else {
419            return Cow::Borrowed(self);
420        };
421        let scoped = accessor.scoped_to_base(base_id);
422        if Arc::ptr_eq(&scoped, accessor) {
423            Cow::Borrowed(self)
424        } else {
425            Cow::Owned(Self {
426                storage_options_accessor: Some(scoped),
427                ..self.clone()
428            })
429        }
430    }
431}
432
433fn wrapper_allocation_ptr(wrapper: &Arc<dyn WrappingObjectStore>) -> *const () {
434    // Trait object pointers include vtable metadata, which is not stable across codegen units.
435    // Cache identity must follow the Arc allocation instead.
436    Arc::as_ptr(wrapper) as *const ()
437}
438
439// We implement hash for caching
440impl std::hash::Hash for ObjectStoreParams {
441    #[allow(deprecated)]
442    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
443        // For hashing, we use pointer values for ObjectStore, S3 credentials, wrapper
444        self.block_size.hash(state);
445        if let Some((store, url)) = &self.object_store {
446            Arc::as_ptr(store).hash(state);
447            url.hash(state);
448        }
449        self.s3_credentials_refresh_offset.hash(state);
450        #[cfg(feature = "aws")]
451        if let Some(aws_credentials) = &self.aws_credentials {
452            Arc::as_ptr(aws_credentials).hash(state);
453        }
454        if let Some(wrapper) = &self.object_store_wrapper {
455            wrapper_allocation_ptr(wrapper).hash(state);
456        }
457        if let Some(accessor) = &self.storage_options_accessor {
458            accessor.accessor_id().hash(state);
459        }
460        self.use_constant_size_upload_parts.hash(state);
461        self.list_is_lexically_ordered.hash(state);
462    }
463}
464
465// We implement eq for caching
466impl Eq for ObjectStoreParams {}
467impl PartialEq for ObjectStoreParams {
468    #[allow(deprecated)]
469    fn eq(&self, other: &Self) -> bool {
470        #[cfg(feature = "aws")]
471        if self.aws_credentials.is_some() != other.aws_credentials.is_some() {
472            return false;
473        }
474
475        // For equality, we use pointer comparison for ObjectStore, S3 credentials, wrapper
476        // For accessor, we use accessor_id() for semantic equality
477        self.block_size == other.block_size
478            && self
479                .object_store
480                .as_ref()
481                .map(|(store, url)| (Arc::as_ptr(store), url))
482                == other
483                    .object_store
484                    .as_ref()
485                    .map(|(store, url)| (Arc::as_ptr(store), url))
486            && self.s3_credentials_refresh_offset == other.s3_credentials_refresh_offset
487            && self
488                .object_store_wrapper
489                .as_ref()
490                .map(wrapper_allocation_ptr)
491                == other
492                    .object_store_wrapper
493                    .as_ref()
494                    .map(wrapper_allocation_ptr)
495            && self
496                .storage_options_accessor
497                .as_ref()
498                .map(|a| a.accessor_id())
499                == other
500                    .storage_options_accessor
501                    .as_ref()
502                    .map(|a| a.accessor_id())
503            && self.use_constant_size_upload_parts == other.use_constant_size_upload_parts
504            && self.list_is_lexically_ordered == other.list_is_lexically_ordered
505    }
506}
507
508/// Convert a URI string or local path to a URL
509///
510/// This function handles both proper URIs (with schemes like `file://`, `s3://`, etc.)
511/// and plain local filesystem paths. On Windows, it correctly handles drive letters
512/// that might be parsed as URL schemes.
513///
514/// # Examples
515///
516/// ```
517/// # use lance_io::object_store::uri_to_url;
518/// // URIs are preserved
519/// let url = uri_to_url("s3://bucket/path").unwrap();
520/// assert_eq!(url.scheme(), "s3");
521///
522/// // Local paths are converted to file:// URIs
523/// # #[cfg(unix)]
524/// let url = uri_to_url("/tmp/data").unwrap();
525/// # #[cfg(unix)]
526/// assert_eq!(url.scheme(), "file");
527/// ```
528pub fn uri_to_url(uri: &str) -> Result<Url> {
529    match Url::parse(uri) {
530        Ok(url) if url.scheme().len() == 1 && cfg!(windows) => {
531            // On Windows, the drive is parsed as a scheme
532            local_path_to_url(uri)
533        }
534        Ok(url) => Ok(url),
535        Err(_) => local_path_to_url(uri),
536    }
537}
538
539fn expand_path(str_path: impl AsRef<str>) -> Result<std::path::PathBuf> {
540    let str_path = str_path.as_ref();
541    let expanded = expand_tilde_path(str_path).unwrap_or_else(|| str_path.into());
542
543    let mut expanded_path = path_abs::PathAbs::new(expanded)
544        .map_err(|err| {
545            Error::invalid_input(format!(
546                "Invalid table location: '{str_path}' could not be resolved to an absolute path: {err}"
547            ))
548        })?
549        .as_path()
550        .to_path_buf();
551    // path_abs::PathAbs::new(".") returns an empty string.
552    if let Some(s) = expanded_path.as_path().to_str()
553        && s.is_empty()
554    {
555        expanded_path = std::env::current_dir()?;
556    }
557
558    Ok(expanded_path)
559}
560
561fn expand_tilde_path(path: &str) -> Option<std::path::PathBuf> {
562    let home_dir = std::env::home_dir()?;
563    if path == "~" {
564        return Some(home_dir);
565    }
566    if let Some(stripped) = path.strip_prefix("~/") {
567        return Some(home_dir.join(stripped));
568    }
569    #[cfg(windows)]
570    if let Some(stripped) = path.strip_prefix("~\\") {
571        return Some(home_dir.join(stripped));
572    }
573
574    None
575}
576
577fn local_path_to_url(str_path: &str) -> Result<Url> {
578    let expanded_path = expand_path(str_path)?;
579
580    Url::from_directory_path(expanded_path).map_err(|_| {
581        Error::invalid_input_source(format!("Invalid table location: '{}'", str_path).into())
582    })
583}
584
585#[cfg(feature = "huggingface")]
586fn parse_hf_repo_id(url: &Url) -> Result<String> {
587    // Accept forms with repo type prefix (models/datasets/spaces) or legacy without.
588    let mut segments: Vec<String> = Vec::new();
589    if let Some(host) = url.host_str() {
590        segments.push(host.to_string());
591    }
592    segments.extend(
593        url.path()
594            .trim_start_matches('/')
595            .split('/')
596            .map(|s| s.to_string()),
597    );
598
599    if segments.len() < 2 {
600        return Err(Error::invalid_input(
601            "Huggingface URL must contain at least owner and repo",
602        ));
603    }
604
605    let repo_type_candidates = ["models", "datasets", "spaces"];
606    let (owner, repo_with_rev) = if repo_type_candidates.contains(&segments[0].as_str()) {
607        if segments.len() < 3 {
608            return Err(Error::invalid_input(
609                "Huggingface URL missing owner/repo after repo type",
610            ));
611        }
612        (segments[1].as_str(), segments[2].as_str())
613    } else {
614        (segments[0].as_str(), segments[1].as_str())
615    };
616
617    let repo = repo_with_rev
618        .split_once('@')
619        .map(|(r, _)| r)
620        .unwrap_or(repo_with_rev);
621    Ok(format!("{owner}/{repo}"))
622}
623
624impl ObjectStore {
625    /// Parse from a string URI.
626    ///
627    /// Returns the ObjectStore instance and the absolute path to the object.
628    ///
629    /// This uses the default [ObjectStoreRegistry] to find the object store. To
630    /// allow for potential re-use of object store instances, it's recommended to
631    /// create a shared [ObjectStoreRegistry] and pass that to [Self::from_uri_and_params].
632    pub async fn from_uri(uri: &str) -> Result<(Arc<Self>, Path)> {
633        let registry = Arc::new(ObjectStoreRegistry::default());
634
635        Self::from_uri_and_params(registry, uri, &ObjectStoreParams::default()).await
636    }
637
638    /// Parse from a string URI.
639    ///
640    /// Returns the ObjectStore instance and the absolute path to the object.
641    pub async fn from_uri_and_params(
642        registry: Arc<ObjectStoreRegistry>,
643        uri: &str,
644        params: &ObjectStoreParams,
645    ) -> Result<(Arc<Self>, Path)> {
646        Self::from_uri_and_params_impl(registry, uri, params, true).await
647    }
648
649    /// Parse a URI and build a fresh object store outside the registry cache.
650    ///
651    /// The caller must retain the returned store for as long as its
652    /// provider-local state should be reused.
653    #[doc(hidden)]
654    pub async fn from_uri_and_params_uncached(
655        registry: Arc<ObjectStoreRegistry>,
656        uri: &str,
657        params: &ObjectStoreParams,
658    ) -> Result<(Arc<Self>, Path)> {
659        Self::from_uri_and_params_impl(registry, uri, params, false).await
660    }
661
662    async fn from_uri_and_params_impl(
663        registry: Arc<ObjectStoreRegistry>,
664        uri: &str,
665        params: &ObjectStoreParams,
666        use_registry_cache: bool,
667    ) -> Result<(Arc<Self>, Path)> {
668        #[allow(deprecated)]
669        if let Some((store, path)) = params.object_store.as_ref() {
670            let mut inner = store.clone();
671            let store_prefix =
672                registry.calculate_object_store_prefix(uri, params.storage_options())?;
673
674            let mut io_tracker = IOTracker::default();
675            meter_store(
676                &mut inner,
677                &mut io_tracker,
678                &metrics_base(&store_prefix, path),
679            );
680
681            if let Some(wrapper) = params.object_store_wrapper.as_ref() {
682                inner = wrapper.wrap(&store_prefix, inner);
683            }
684
685            // Always wrap with IO tracking
686            let tracked_store = io_tracker.wrap("", inner);
687
688            let store = Self {
689                inner: tracked_store,
690                local_dir_operations: None,
691                scheme: path.scheme().to_string(),
692                block_size: params.resolved_block_size()?.unwrap_or(64 * 1024),
693                max_iop_size: *DEFAULT_MAX_IOP_SIZE,
694                use_constant_size_upload_parts: params.use_constant_size_upload_parts,
695                list_is_lexically_ordered: params.list_is_lexically_ordered.unwrap_or_default(),
696                io_parallelism: DEFAULT_CLOUD_IO_PARALLELISM,
697                download_retry_count: DEFAULT_DOWNLOAD_RETRY_COUNT,
698                io_tracker,
699                store_prefix,
700                // Type-erased on the way in, so there is no telling if it can paginate.
701                paginated_lister: None,
702            };
703            let path = Path::parse(path.path())?;
704            return Ok((Arc::new(store), path));
705        }
706        let url = uri_to_url(uri)?;
707
708        let store = if use_registry_cache {
709            registry.get_store(url.clone(), params).await?
710        } else {
711            registry.new_store(url.clone(), params).await?
712        };
713        // We know the scheme is valid if we got a store back.
714        let provider = registry.get_provider(url.scheme()).expect_ok()?;
715        let path = provider.extract_path(&url)?;
716
717        Ok((store, path))
718    }
719
720    /// Extract the path component from a URI without initializing the object store.
721    ///
722    /// This is a synchronous operation that only parses the URI and extracts the path,
723    /// without creating or initializing any object store instance.
724    ///
725    /// # Arguments
726    ///
727    /// * `registry` - The object store registry to get the provider
728    /// * `uri` - The URI to extract the path from
729    ///
730    /// # Returns
731    ///
732    /// The extracted path component
733    pub fn extract_path_from_uri(registry: Arc<ObjectStoreRegistry>, uri: &str) -> Result<Path> {
734        let url = uri_to_url(uri)?;
735        let provider = registry
736            .get_provider(url.scheme())
737            .ok_or_else(|| Error::invalid_input(format!("Unknown scheme: {}", url.scheme())))?;
738        provider.extract_path(&url)
739    }
740
741    #[deprecated(note = "Use `from_uri` instead")]
742    pub fn from_path(str_path: &str) -> Result<(Arc<Self>, Path)> {
743        Self::from_uri_and_params(
744            Arc::new(ObjectStoreRegistry::default()),
745            str_path,
746            &Default::default(),
747        )
748        .now_or_never()
749        .unwrap()
750    }
751
752    /// Local object store.
753    pub fn local() -> Self {
754        let provider = FileStoreProvider;
755        provider
756            .new_store(Url::parse("file:///").unwrap(), &Default::default())
757            .now_or_never()
758            .unwrap()
759            .unwrap()
760    }
761
762    /// Create a in-memory object store directly for testing.
763    pub fn memory() -> Self {
764        let provider = MemoryStoreProvider;
765        provider
766            .new_store(Url::parse("memory:///").unwrap(), &Default::default())
767            .now_or_never()
768            .unwrap()
769            .unwrap()
770    }
771
772    /// Returns true if the object store pointed to a local file system.
773    pub fn is_local(&self) -> bool {
774        self.scheme == "file" || self.scheme == "file+uring"
775    }
776
777    /// Returns true when object paths directly encode absolute local filesystem paths.
778    ///
779    /// Local stores rooted below the filesystem root, such as UNC-backed stores, use
780    /// their inner object-store implementation instead of direct filesystem access.
781    pub fn has_direct_local_paths(&self) -> bool {
782        self.is_local() && self.store_prefix == self.scheme
783    }
784
785    pub fn is_cloud(&self) -> bool {
786        if self.is_local() || self.scheme == "memory" || self.scheme == "shared-memory" {
787            return false;
788        }
789        true
790    }
791
792    /// Whether this object store prefers the lite scheduler.
793    ///
794    /// The lite scheduler is designed for backends like io_uring where
795    /// tasks should only be polled when the consumer polls them.
796    pub fn prefers_lite_scheduler(&self) -> bool {
797        self.scheme == "file+uring"
798    }
799
800    pub fn scheme(&self) -> &str {
801        &self.scheme
802    }
803
804    pub fn block_size(&self) -> usize {
805        self.block_size
806    }
807
808    /// Bytes one multipart upload of `total_bytes` to this store can hold in
809    /// memory at once. See [`crate::object_writer::upload_memory_bound_bytes`].
810    pub fn upload_memory_bound_bytes(&self, total_bytes: u64) -> u64 {
811        crate::object_writer::upload_memory_bound_bytes(
812            total_bytes,
813            self.use_constant_size_upload_parts,
814        )
815    }
816
817    pub fn max_iop_size(&self) -> u64 {
818        self.max_iop_size
819    }
820
821    /// The amount of parallelism to use for I/O operations.
822    ///
823    /// Honors the `LANCE_IO_THREADS` override when set, otherwise the store's configured value.
824    /// Always at least 1: callers feed this straight into `buffered` / `buffer_unordered`, and a
825    /// window of 0 makes those streams never poll their input — e.g. a metadata-only `count_rows`
826    /// would hang rather than return.
827    pub fn io_parallelism(&self) -> usize {
828        std::env::var("LANCE_IO_THREADS")
829            .map(|val| val.parse::<usize>().unwrap())
830            .unwrap_or(self.io_parallelism)
831            .max(1)
832    }
833
834    /// Get the IO tracker for this object store
835    ///
836    /// The IO tracker can be used to get statistics about read/write operations
837    /// performed on this object store.
838    pub fn io_tracker(&self) -> &IOTracker {
839        &self.io_tracker
840    }
841
842    /// Get a snapshot of current IO statistics without resetting counters
843    ///
844    /// Returns the current IO statistics without modifying the internal state.
845    /// Use this when you need to check stats without resetting them.
846    pub fn io_stats_snapshot(&self) -> IoStats {
847        self.io_tracker.stats()
848    }
849
850    /// Get incremental IO statistics since the last call to this method
851    ///
852    /// Returns the accumulated statistics since the last call and resets the
853    /// counters to zero. This is useful for tracking IO operations between
854    /// different stages of processing.
855    pub fn io_stats_incremental(&self) -> IoStats {
856        self.io_tracker.incremental_stats()
857    }
858
859    /// Apply a [`WrappingObjectStore`] to both `inner` and `paginated_lister` together.
860    ///
861    /// Keeps both halves in sync: a wrapper returning `None` from
862    /// [`WrappingObjectStore::wrap_paginated`] clears the lister so that
863    /// [`Self::read_dir_page`] falls back through the (already-wrapped) `inner`.
864    pub fn apply_wrapper(&mut self, wrapper: &dyn WrappingObjectStore) {
865        self.inner = wrapper.wrap(&self.store_prefix, self.inner.clone());
866        self.paginated_lister = self
867            .paginated_lister
868            .take()
869            .and_then(|lister| wrapper.wrap_paginated(&self.store_prefix, lister));
870    }
871
872    /// Open a file for path.
873    ///
874    /// Parameters
875    /// - ``path``: Absolute path to the file.
876    pub async fn open(&self, path: &Path) -> Result<Box<dyn Reader>> {
877        match self.scheme.as_str() {
878            "file" if self.has_direct_local_paths() => {
879                LocalObjectReader::open_with_tracker(
880                    path,
881                    self.block_size,
882                    None,
883                    Arc::new(self.io_tracker.clone()),
884                )
885                .await
886            }
887            #[cfg(target_os = "linux")]
888            "file+uring" => {
889                // Check if current-thread mode enabled
890                let use_current_thread = std::env::var("LANCE_URING_CURRENT_THREAD")
891                    .map(|v| str_is_truthy(&v))
892                    .unwrap_or(false);
893
894                if use_current_thread {
895                    UringCurrentThreadReader::open(
896                        path,
897                        self.block_size,
898                        None,
899                        Arc::new(self.io_tracker.clone()),
900                    )
901                    .await
902                } else {
903                    UringReader::open(
904                        path,
905                        self.block_size,
906                        None,
907                        Arc::new(self.io_tracker.clone()),
908                    )
909                    .await
910                }
911            }
912            _ => Ok(Box::new(
913                CloudObjectReader::new(
914                    self.inner.clone(),
915                    path.clone(),
916                    self.block_size,
917                    None,
918                    self.download_retry_count,
919                )?
920                .with_io_parallelism(self.io_parallelism()),
921            )),
922        }
923    }
924
925    /// Open a reader for a file with known size.
926    ///
927    /// This size may either have been retrieved from a list operation or
928    /// cached metadata. By passing in the known size, we can skip a HEAD / metadata
929    /// call.
930    pub async fn open_with_size(&self, path: &Path, known_size: usize) -> Result<Box<dyn Reader>> {
931        // If we know the file is really small, we can read the whole thing
932        // as a single request.
933        if known_size <= self.block_size {
934            return Ok(Box::new(SmallReader::new(
935                self.inner.clone(),
936                path.clone(),
937                self.download_retry_count,
938                known_size,
939            )));
940        }
941
942        match self.scheme.as_str() {
943            "file" if self.has_direct_local_paths() => {
944                LocalObjectReader::open_with_tracker(
945                    path,
946                    self.block_size,
947                    Some(known_size),
948                    Arc::new(self.io_tracker.clone()),
949                )
950                .await
951            }
952            #[cfg(target_os = "linux")]
953            "file+uring" => {
954                // Check if current-thread mode enabled
955                let use_current_thread = std::env::var("LANCE_URING_CURRENT_THREAD")
956                    .map(|v| str_is_truthy(&v))
957                    .unwrap_or(false);
958
959                if use_current_thread {
960                    UringCurrentThreadReader::open(
961                        path,
962                        self.block_size,
963                        Some(known_size),
964                        Arc::new(self.io_tracker.clone()),
965                    )
966                    .await
967                } else {
968                    UringReader::open(
969                        path,
970                        self.block_size,
971                        Some(known_size),
972                        Arc::new(self.io_tracker.clone()),
973                    )
974                    .await
975                }
976            }
977            _ => Ok(Box::new(
978                CloudObjectReader::new(
979                    self.inner.clone(),
980                    path.clone(),
981                    self.block_size,
982                    Some(known_size),
983                    self.download_retry_count,
984                )?
985                .with_io_parallelism(self.io_parallelism()),
986            )),
987        }
988    }
989
990    /// Create an [ObjectWriter] from local [std::path::Path]
991    pub async fn create_local_writer(path: &std::path::Path) -> Result<ObjectWriter> {
992        let object_store = Self::local();
993        let absolute_path = expand_path(path.to_string_lossy())?;
994        let os_path = Path::from_absolute_path(absolute_path)?;
995        ObjectWriter::new(&object_store, &os_path).await
996    }
997
998    /// Open an [Reader] from local [std::path::Path]
999    pub async fn open_local(path: &std::path::Path) -> Result<Box<dyn Reader>> {
1000        let object_store = Self::local();
1001        let absolute_path = expand_path(path.to_string_lossy())?;
1002        let os_path = Path::from_absolute_path(absolute_path)?;
1003        object_store.open(&os_path).await
1004    }
1005
1006    /// Create a new file.
1007    pub async fn create(&self, path: &Path) -> Result<Box<dyn Writer>> {
1008        match self.scheme.as_str() {
1009            "file" if self.has_direct_local_paths() => {
1010                let local_path = super::local::to_local_path(path);
1011                let local_path = std::path::PathBuf::from(&local_path);
1012                if let Some(parent) = local_path.parent() {
1013                    tokio::fs::create_dir_all(parent).await?;
1014                }
1015                let parent = local_path
1016                    .parent()
1017                    .expect("file path must have parent")
1018                    .to_owned();
1019                let named_temp = tokio::task::spawn_blocking(move || {
1020                    #[cfg(unix)]
1021                    {
1022                        // NamedTempFile defaults to 0o600. Use ordinary file creation permissions so the published file honors the caller's umask.
1023                        tempfile::Builder::new()
1024                            .permissions(std::fs::Permissions::from_mode(0o666))
1025                            .tempfile_in(parent)
1026                    }
1027                    #[cfg(not(unix))]
1028                    tempfile::NamedTempFile::new_in(parent)
1029                })
1030                .await
1031                .map_err(|e| Error::io(format!("spawn_blocking failed: {}", e)))??;
1032                let (std_file, temp_path) = named_temp.into_parts();
1033                let file = tokio::fs::File::from_std(std_file);
1034                Ok(Box::new(LocalWriter::new(
1035                    file,
1036                    path.clone(),
1037                    temp_path,
1038                    Arc::new(self.io_tracker.clone()),
1039                )))
1040            }
1041            _ => Ok(Box::new(ObjectWriter::new(self, path).await?)),
1042        }
1043    }
1044
1045    /// A helper function to create a file and write content to it.
1046    pub async fn put(&self, path: &Path, content: &[u8]) -> Result<WriteResult> {
1047        let mut writer = self.create(path).await?;
1048        writer.write_all(content).await?;
1049        Writer::shutdown(writer.as_mut()).await
1050    }
1051
1052    /// Atomically creates an object without replacing an existing object.
1053    ///
1054    /// Local stores publish a uniquely named staging object with a conditional
1055    /// rename. Other stores use their conditional create operation. Tencent COS
1056    /// is rejected because it can silently ignore conditional create requests.
1057    ///
1058    /// Returns [`object_store::Error::NotSupported`] without writing when the
1059    /// backend cannot reliably provide put-if-absent semantics.
1060    pub async fn put_if_absent(
1061        &self,
1062        path: &Path,
1063        content: PutPayload,
1064    ) -> object_store::Result<()> {
1065        if self.scheme == "cos" {
1066            return Err(object_store::Error::NotSupported {
1067                source: "Tencent COS does not reliably enforce put-if-absent after bucket \
1068                         versioning has ever been enabled"
1069                    .into(),
1070            });
1071        }
1072
1073        if self.is_local() {
1074            let staging_path =
1075                Path::from(format!("{}.tmp.{}", path, uuid::Uuid::new_v4().simple()));
1076            self.inner.put(&staging_path, content).await?;
1077            let result = self.inner.rename_if_not_exists(&staging_path, path).await;
1078            if result.is_err()
1079                && let Err(error) = self.inner.delete(&staging_path).await
1080            {
1081                log::warn!(
1082                    "Failed to remove staging object {} after atomic create failed: {}",
1083                    staging_path,
1084                    error
1085                );
1086            }
1087            result
1088        } else {
1089            self.inner
1090                .put_opts(
1091                    path,
1092                    content,
1093                    PutOptions {
1094                        mode: PutMode::Create,
1095                        ..Default::default()
1096                    },
1097                )
1098                .await
1099                .map(|_| ())
1100        }
1101    }
1102
1103    pub async fn delete(&self, path: &Path) -> Result<()> {
1104        self.inner.delete(path).await?;
1105        Ok(())
1106    }
1107
1108    /// AWS S3 and GCS reject a single-shot server-side copy whose source is
1109    /// larger than this; such sources are streamed through a multipart write.
1110    const MAX_SINGLE_COPY_BYTES: u64 = 5 * 1024 * 1024 * 1024; // 5 GiB
1111
1112    pub async fn copy(&self, from: &Path, to: &Path) -> Result<()> {
1113        // S3 and GCS cap single-shot server-side copies at 5 GiB and object_store
1114        // does not fall back to a multipart copy for larger sources
1115        // (https://github.com/apache/arrow-rs-object-store/issues/563). Azure and
1116        // other blob stores don't have this limit, so we only pay for the fallback
1117        // (an extra size lookup) on S3 and GCS.
1118        let multipart_copy_fallback = matches!(self.scheme.as_str(), "s3" | "s3+ddb" | "gs");
1119        self.copy_impl(
1120            from,
1121            to,
1122            multipart_copy_fallback,
1123            Self::MAX_SINGLE_COPY_BYTES,
1124        )
1125        .await
1126    }
1127
1128    /// Copy an object using the policy for bulk file movement.
1129    ///
1130    /// Streaming is the default because it works across object stores and does
1131    /// not require provider-native copy support. Setting
1132    /// `LANCE_IO_SERVER_SIDE_COPY_ENABLED` to a truthy value opts same-store
1133    /// copies into [`Self::copy`]. Cross-store and local copies continue to use
1134    /// [`Self::copy_via_stream`].
1135    ///
1136    /// ```no_run
1137    /// # use lance_core::Result;
1138    /// # use lance_io::object_store::ObjectStore;
1139    /// # use object_store::path::Path;
1140    /// # async fn copy(source: &ObjectStore, destination: &ObjectStore) -> Result<()> {
1141    /// source
1142    ///     .copy_bulk(
1143    ///         &Path::from("staging/index.lance"),
1144    ///         destination,
1145    ///         &Path::from("index.lance"),
1146    ///     )
1147    ///     .await?;
1148    /// # Ok(())
1149    /// # }
1150    /// ```
1151    pub async fn copy_bulk(
1152        &self,
1153        source_path: &Path,
1154        destination_store: &Self,
1155        destination_path: &Path,
1156    ) -> Result<WriteResult> {
1157        self.copy_bulk_with_server_side_copy(
1158            source_path,
1159            destination_store,
1160            destination_path,
1161            self.uses_server_side_copy(destination_store),
1162        )
1163        .await
1164    }
1165
1166    fn uses_server_side_copy(&self, destination_store: &Self) -> bool {
1167        parse_env_as_bool(SERVER_SIDE_COPY_ENABLED_ENV, false)
1168            && self.can_server_side_copy_to(destination_store)
1169    }
1170
1171    async fn copy_bulk_with_server_side_copy(
1172        &self,
1173        source_path: &Path,
1174        destination_store: &Self,
1175        destination_path: &Path,
1176        server_side_copy_enabled: bool,
1177    ) -> Result<WriteResult> {
1178        if !server_side_copy_enabled || !self.can_server_side_copy_to(destination_store) {
1179            return self
1180                .copy_via_stream(source_path, destination_store, destination_path)
1181                .await;
1182        }
1183
1184        let source_size = self.size(source_path).await?;
1185        let result_size = usize::try_from(source_size).map_err(|source| {
1186            Error::io(format!(
1187                "server-side copy source size conversion failed from {source_path} to \
1188                 {destination_path}: source_size={source_size}, error={source}"
1189            ))
1190        })?;
1191        destination_store
1192            .copy(source_path, destination_path)
1193            .await?;
1194        let destination_size = destination_store.size(destination_path).await?;
1195        if destination_size != source_size {
1196            return Err(Error::io(format!(
1197                "server-side copy destination size mismatch from {source_path} to \
1198                 {destination_path}: source_size={source_size}, \
1199                 destination_size={destination_size}"
1200            )));
1201        }
1202
1203        Ok(WriteResult {
1204            size: result_size,
1205            e_tag: None,
1206        })
1207    }
1208
1209    fn can_server_side_copy_to(&self, destination_store: &Self) -> bool {
1210        // Prefixes can collide across endpoints or wrappers, where native copy could
1211        // read or write the wrong backend. Exact client identity is required.
1212        self.is_cloud()
1213            && destination_store.is_cloud()
1214            && Arc::ptr_eq(&self.inner, &destination_store.inner)
1215    }
1216
1217    /// Copy an object by streaming its bytes through Lance's multipart-aware writer.
1218    ///
1219    /// Unlike [`Self::copy`], this never delegates to a provider-native server-side
1220    /// copy. The source and destination may use different object stores. The copy
1221    /// succeeds only after the byte count reported by the writer and a destination
1222    /// metadata lookup both match the source size.
1223    ///
1224    /// ```no_run
1225    /// # use lance_core::Result;
1226    /// # use lance_io::object_store::ObjectStore;
1227    /// # use object_store::path::Path;
1228    /// # async fn copy(source: &ObjectStore, destination: &ObjectStore) -> Result<()> {
1229    /// source
1230    ///     .copy_via_stream(
1231    ///         &Path::from("staging/index.lance"),
1232    ///         destination,
1233    ///         &Path::from("index.lance"),
1234    ///     )
1235    ///     .await?;
1236    /// # Ok(())
1237    /// # }
1238    /// ```
1239    #[instrument(
1240        name = "multipart_stream_copy",
1241        level = "info",
1242        skip(self, source_path, destination_store, destination_path),
1243        fields(
1244            source = %source_path,
1245            destination = %destination_path,
1246            source_size = Empty,
1247            read_chunk_size = Empty,
1248            multipart_part_size = crate::object_writer::initial_upload_size(),
1249            multipart_concurrency = crate::object_writer::max_upload_parallelism(),
1250            part_count = Empty,
1251            bytes_transferred = Empty,
1252            destination_size = Empty,
1253            validation = Empty,
1254            elapsed_ms = Empty,
1255        ),
1256        err
1257    )]
1258    pub async fn copy_via_stream(
1259        &self,
1260        source_path: &Path,
1261        destination_store: &Self,
1262        destination_path: &Path,
1263    ) -> Result<WriteResult> {
1264        self.copy_via_stream_inner(source_path, destination_store, destination_path)
1265            .await
1266    }
1267
1268    /// Copy within this store without replacing an existing destination.
1269    ///
1270    /// This uses the provider's native conditional copy. An existing destination
1271    /// with the same size is treated as a successful retry.
1272    pub async fn copy_if_not_exists(
1273        &self,
1274        source_path: &Path,
1275        destination_path: &Path,
1276    ) -> Result<WriteResult> {
1277        let source_size = self.size(source_path).await?;
1278        match self
1279            .inner
1280            .copy_if_not_exists(source_path, destination_path)
1281            .await
1282        {
1283            Ok(()) => {}
1284            Err(
1285                object_store::Error::AlreadyExists { .. }
1286                | object_store::Error::Precondition { .. },
1287            ) => {
1288                let destination_size = self.size(destination_path).await?;
1289                if source_size != destination_size {
1290                    return Err(Error::io(format!(
1291                        "destination {destination_path} already exists with size \
1292                         {destination_size}, but source {source_path} has size {source_size}"
1293                    )));
1294                }
1295            }
1296            Err(error) => return Err(Error::io(error.to_string())),
1297        }
1298
1299        Ok(WriteResult {
1300            size: usize::try_from(source_size).map_err(|_| {
1301                Error::io(format!(
1302                    "source {source_path} size {source_size} does not fit in usize"
1303                ))
1304            })?,
1305            e_tag: None,
1306        })
1307    }
1308
1309    async fn copy_via_stream_inner(
1310        &self,
1311        source_path: &Path,
1312        destination_store: &Self,
1313        destination_path: &Path,
1314    ) -> Result<WriteResult> {
1315        let started_at = Instant::now();
1316        if self.has_direct_local_paths() && destination_store.has_direct_local_paths() {
1317            let source_size = std::fs::metadata(super::local::to_local_path(source_path))
1318                .map_err(|source| {
1319                    let source = if source.kind() == std::io::ErrorKind::NotFound {
1320                        Error::not_found(source_path.to_string())
1321                    } else {
1322                        Error::from(source)
1323                    };
1324                    stream_copy_error("source metadata", source_path, destination_path, source)
1325                })?
1326                .len();
1327            let source_size = usize::try_from(source_size).map_err(|source| {
1328                stream_copy_error(
1329                    "source size conversion",
1330                    source_path,
1331                    destination_path,
1332                    source,
1333                )
1334            })?;
1335            Span::current().record("source_size", source_size as u64);
1336
1337            let metrics = destination_store.io_tracker.begin_io("copy");
1338            let result = super::local::copy_file(source_path, destination_path);
1339            metrics.record(&result, source_size as u64);
1340            result.map_err(|source| {
1341                stream_copy_error(
1342                    "local filesystem copy",
1343                    source_path,
1344                    destination_path,
1345                    source,
1346                )
1347            })?;
1348
1349            let destination_size =
1350                destination_store
1351                    .size(destination_path)
1352                    .await
1353                    .map_err(|source| {
1354                        stream_copy_error(
1355                            "destination validation",
1356                            source_path,
1357                            destination_path,
1358                            source,
1359                        )
1360                    })?;
1361            Span::current().record("bytes_transferred", source_size as u64);
1362            Span::current().record("destination_size", destination_size);
1363            if destination_size != source_size as u64 {
1364                Span::current().record("validation", "failed");
1365                return Err(Error::io(format!(
1366                    "multipart_stream_copy destination size mismatch from {source_path} to \
1367                     {destination_path}: source_size={source_size}, \
1368                     destination_size={destination_size}"
1369                )));
1370            }
1371
1372            Span::current().record("validation", "passed");
1373            Span::current().record("elapsed_ms", started_at.elapsed().as_millis() as u64);
1374            return Ok(WriteResult {
1375                size: source_size,
1376                e_tag: None,
1377            });
1378        }
1379
1380        let reader = self.open(source_path).await.map_err(|source| {
1381            stream_copy_error("source open", source_path, destination_path, source)
1382        })?;
1383        let source_size = reader.size().await.map_err(|source| {
1384            stream_copy_error("source metadata", source_path, destination_path, source)
1385        })?;
1386        Span::current().record("source_size", source_size as u64);
1387
1388        let mut writer = destination_store
1389            .create(destination_path)
1390            .await
1391            .map_err(|source| {
1392                stream_copy_error(
1393                    "destination writer creation",
1394                    source_path,
1395                    destination_path,
1396                    source,
1397                )
1398            })?;
1399        let read_chunk_size = usize::try_from(self.max_iop_size())
1400            .unwrap_or(usize::MAX)
1401            .max(1);
1402        Span::current().record("read_chunk_size", read_chunk_size as u64);
1403        let mut bytes_transferred = 0usize;
1404        if source_size > 0 {
1405            let first_range = 0..read_chunk_size.min(source_size);
1406            let mut current_range = first_range.clone();
1407            let mut current_bytes = reader.get_range(first_range).await.map_err(|source| {
1408                stream_copy_error("source read", source_path, destination_path, source)
1409            })?;
1410
1411            loop {
1412                let expected_bytes = current_range.len();
1413                if current_bytes.len() != expected_bytes {
1414                    Span::current().record("validation", "failed");
1415                    return Err(Error::io(format!(
1416                        "multipart_stream_copy source range size mismatch from {source_path} to \
1417                         {destination_path}: range={current_range:?}, \
1418                         expected_bytes={expected_bytes}, actual_bytes={}",
1419                        current_bytes.len()
1420                    )));
1421                }
1422                bytes_transferred = bytes_transferred
1423                    .checked_add(current_bytes.len())
1424                    .ok_or_else(|| {
1425                        Error::io(format!(
1426                            "multipart_stream_copy byte count overflow from {source_path} to \
1427                             {destination_path}"
1428                        ))
1429                    })?;
1430
1431                if bytes_transferred == source_size {
1432                    writer.write_all(&current_bytes).await.map_err(|source| {
1433                        stream_copy_error(
1434                            "destination write",
1435                            source_path,
1436                            destination_path,
1437                            source,
1438                        )
1439                    })?;
1440                    break;
1441                }
1442
1443                let range_end = bytes_transferred
1444                    .checked_add(read_chunk_size)
1445                    .unwrap_or(source_size)
1446                    .min(source_size);
1447                let next_range = bytes_transferred..range_end;
1448                let next_read = reader.get_range(next_range.clone());
1449                let (write_result, next_bytes) =
1450                    tokio::join!(writer.write_all(&current_bytes), next_read);
1451                write_result.map_err(|source| {
1452                    stream_copy_error("destination write", source_path, destination_path, source)
1453                })?;
1454                current_bytes = next_bytes.map_err(|source| {
1455                    stream_copy_error("source read", source_path, destination_path, source)
1456                })?;
1457                current_range = next_range;
1458            }
1459        }
1460        Span::current().record("bytes_transferred", bytes_transferred as u64);
1461
1462        let write_result = Writer::shutdown(writer.as_mut()).await.map_err(|source| {
1463            stream_copy_error(
1464                "destination completion",
1465                source_path,
1466                destination_path,
1467                source,
1468            )
1469        })?;
1470        if write_result.size != source_size {
1471            Span::current().record("validation", "failed");
1472            return Err(Error::io(format!(
1473                "multipart_stream_copy writer size mismatch from {source_path} to \
1474                 {destination_path}: source_size={source_size}, \
1475                 writer_size={}",
1476                write_result.size
1477            )));
1478        }
1479
1480        let destination_size =
1481            destination_store
1482                .size(destination_path)
1483                .await
1484                .map_err(|source| {
1485                    stream_copy_error(
1486                        "destination validation",
1487                        source_path,
1488                        destination_path,
1489                        source,
1490                    )
1491                })?;
1492        Span::current().record("destination_size", destination_size);
1493        if destination_size != source_size as u64 {
1494            Span::current().record("validation", "failed");
1495            return Err(Error::io(format!(
1496                "multipart_stream_copy destination size mismatch from {source_path} to \
1497                 {destination_path}: source_size={source_size}, \
1498                 destination_size={destination_size}"
1499            )));
1500        }
1501
1502        Span::current().record("validation", "passed");
1503        Span::current().record("elapsed_ms", started_at.elapsed().as_millis() as u64);
1504        Ok(write_result)
1505    }
1506
1507    /// Copy `from` to `to`. When `multipart_copy_fallback` is set, a source
1508    /// larger than `max_single_copy` is streamed through a multipart write
1509    /// instead of a single-shot server-side copy. Both are parameters so tests
1510    /// can drive the streaming path without a multi-gigabyte fixture or an S3
1511    /// endpoint.
1512    async fn copy_impl(
1513        &self,
1514        from: &Path,
1515        to: &Path,
1516        multipart_copy_fallback: bool,
1517        max_single_copy: u64,
1518    ) -> Result<()> {
1519        if self.has_direct_local_paths() {
1520            // Use std::fs::copy for local filesystem to support cross-filesystem copies
1521            let metrics = self.io_tracker.begin_io("copy");
1522            let result = super::local::copy_file(from, to);
1523            metrics.record(&result, 0);
1524            return result;
1525        }
1526        if multipart_copy_fallback {
1527            // Reuse the reader for both the size lookup (a single cached HEAD)
1528            // and the streamed copy, avoiding a separate HEAD request.
1529            let reader = self.open(from).await?;
1530            if reader.size().await? as u64 > max_single_copy {
1531                let mut writer = self.create(to).await?;
1532                writer.copy_from_reader(reader.as_ref()).await?;
1533                Writer::shutdown(writer.as_mut()).await?;
1534                return Ok(());
1535            }
1536        }
1537        Ok(self.inner.copy(from, to).await?)
1538    }
1539
1540    /// Read a directory (start from base directory) and returns all sub-paths in the directory.
1541    ///
1542    /// This enumerates the whole prefix before it returns, however many children it holds.
1543    /// Use [`Self::read_dir_page`] to page through a directory instead.
1544    pub async fn read_dir(&self, dir_path: impl Into<Path>) -> Result<Vec<String>> {
1545        let path = dir_path.into();
1546        let path = Path::parse(&path)?;
1547        let output = self.inner.list_with_delimiter(Some(&path)).await?;
1548        Ok(output
1549            .common_prefixes
1550            .iter()
1551            .chain(output.objects.iter().map(|o| &o.location))
1552            .filter_map(|s| s.filename().map(|f| f.to_string()))
1553            .collect())
1554    }
1555
1556    /// Non-recursive, path-segment delimited list of a single directory level.
1557    ///
1558    /// Unlike [`Self::list`], which recurses into the entire subtree, this returns
1559    /// only the immediate children of `prefix`: the child "directories" as
1560    /// [`ListResult::common_prefixes`] and the direct child files as
1561    /// [`ListResult::objects`].
1562    pub async fn list_with_delimiter(&self, prefix: Option<&Path>) -> Result<ListResult> {
1563        Ok(self.inner.list_with_delimiter(prefix).await?)
1564    }
1565
1566    /// List objects recursively. On unordered stores, a retryable failure after
1567    /// the first entry is returned to the caller because resuming by lexical
1568    /// offset could silently omit entries.
1569    pub fn list(
1570        &self,
1571        path: Option<Path>,
1572    ) -> Pin<Box<dyn Stream<Item = Result<ObjectMeta>> + Send>> {
1573        Box::pin(
1574            ListRetryStream::new(self.inner.clone(), path, 5)
1575                .with_lexical_ordering(self.list_is_lexically_ordered)
1576                .map(|m| m.map_err(|e| e.into())),
1577        )
1578    }
1579
1580    /// Read all files (start from base directory) recursively
1581    ///
1582    /// unmodified_since can be specified to only return files that have not been modified since the given time.
1583    pub fn read_dir_all<'a, 'b>(
1584        &'a self,
1585        dir_path: impl Into<&'b Path> + Send,
1586        unmodified_since: Option<DateTime<Utc>>,
1587    ) -> BoxStream<'a, Result<ObjectMeta>> {
1588        self.inner.read_dir_all(dir_path, unmodified_since)
1589    }
1590
1591    /// Remove a directory recursively.
1592    pub async fn remove_dir_all(&self, dir_path: impl Into<Path>) -> Result<()> {
1593        let path = dir_path.into();
1594        let path = Path::parse(&path)?;
1595
1596        if let Some(local_dir_operations) = &self.local_dir_operations {
1597            let metrics = self.io_tracker.begin_io("delete");
1598            let result = local_dir_operations.remove_dir_all(&path).await;
1599            metrics.record(&result, 0);
1600            return result;
1601        }
1602        if self.has_direct_local_paths() {
1603            // The local file system provider needs to delete both files and directories.
1604            // Counted as a single delete request, matching how `delete_stream`
1605            // counts one batched request regardless of how many paths it removes.
1606            let metrics = self.io_tracker.begin_io("delete");
1607            let result = super::local::remove_dir_all(&path);
1608            metrics.record(&result, 0);
1609            return result;
1610        }
1611        let sub_entries = self
1612            .inner
1613            .list(Some(&path))
1614            .map(|m| m.map(|meta| meta.location))
1615            .boxed();
1616        self.inner
1617            .delete_stream(sub_entries)
1618            .try_collect::<Vec<_>>()
1619            .await?;
1620        if self.scheme == "file-object-store" {
1621            // file-object-store tries to do everything as similarly as possible to the remote
1622            // object stores. But we still have to delete the directory entries afterwards.
1623            return super::local::remove_dir_all(&path);
1624        }
1625        Ok(())
1626    }
1627
1628    /// Remove eligible materialized empty directories below a local root.
1629    ///
1630    /// This is a no-op for object stores, which do not materialize directories.
1631    /// Traversal does not follow symbolic links. Directories in `retained_dirs` and their
1632    /// descendants are preserved. Other directories are removed only if they are empty and
1633    /// either appear in `verified_dirs` or predate `unmodified_since`. Passing `None` for
1634    /// `unmodified_since` disables the age check.
1635    ///
1636    /// ```
1637    /// # use std::collections::HashSet;
1638    /// # use chrono::Utc;
1639    /// # use lance_core::Result;
1640    /// # use lance_io::object_store::ObjectStore;
1641    /// # async fn remove_stale_index_dirs(store: &ObjectStore) -> Result<()> {
1642    /// store
1643    ///     .remove_empty_dirs(
1644    ///         "dataset/_indices",
1645    ///         HashSet::new(),
1646    ///         HashSet::new(),
1647    ///         Some(Utc::now()),
1648    ///     )
1649    ///     .await?;
1650    /// # Ok(())
1651    /// # }
1652    /// ```
1653    pub async fn remove_empty_dirs(
1654        &self,
1655        root_path: impl Into<Path>,
1656        retained_dirs: HashSet<Path>,
1657        verified_dirs: HashSet<Path>,
1658        unmodified_since: Option<DateTime<Utc>>,
1659    ) -> Result<()> {
1660        if !self.has_direct_local_paths() && self.scheme != "file-object-store" {
1661            return Ok(());
1662        }
1663
1664        let path = Path::parse(root_path.into())?;
1665        let metrics = self.io_tracker.begin_io("delete");
1666        let result = tokio::task::spawn_blocking(move || {
1667            super::local::remove_empty_dirs(&path, &retained_dirs, &verified_dirs, unmodified_since)
1668        })
1669        .await
1670        .map_err(|error| Error::io(format!("empty-directory cleanup task failed: {error}")))?;
1671        metrics.record(&result, 0);
1672        result
1673    }
1674
1675    /// Delete every location in the stream, up to `io_parallelism` at a time.
1676    ///
1677    /// Deleting serially costs a round trip per object — ~30/s against a cloud store,
1678    /// which is days for a table with millions of unreferenced files.
1679    ///
1680    /// `ObjectStore::delete_stream` would also batch (S3: 1000 keys per request) but
1681    /// needs a `'static` stream of `object_store::Result`; callers pass a borrowed
1682    /// stream of `lance_core::Result`. Using it means changing this signature.
1683    ///
1684    /// Order is not preserved; no caller may depend on it.
1685    ///
1686    /// A location that is already gone counts as removed. Callers list first and
1687    /// delete after, so a concurrent writer or a second cleanup can remove a path in
1688    /// between; failing there would abandon an entire sweep over one absent object.
1689    pub fn remove_stream<'a>(
1690        &'a self,
1691        locations: BoxStream<'a, Result<Path>>,
1692    ) -> BoxStream<'a, Result<Path>> {
1693        let store = Arc::clone(&self.inner);
1694        locations
1695            .map(move |location| {
1696                let store = Arc::clone(&store);
1697                async move {
1698                    let location = location?;
1699                    match store.delete(&location).await {
1700                        Ok(()) => Ok(location),
1701                        Err(object_store::Error::NotFound { .. }) => Ok(location),
1702                        Err(error) => Err(error.into()),
1703                    }
1704                }
1705            })
1706            .buffer_unordered(self.io_parallelism())
1707            .boxed()
1708    }
1709
1710    /// Check a file exists.
1711    pub async fn exists(&self, path: &Path) -> Result<bool> {
1712        match self.inner.head(path).await {
1713            Ok(_) => Ok(true),
1714            Err(object_store::Error::NotFound { path: _, source: _ }) => Ok(false),
1715            Err(e) => Err(e.into()),
1716        }
1717    }
1718
1719    /// Get file size.
1720    pub async fn size(&self, path: &Path) -> Result<u64> {
1721        Ok(self.inner.head(path).await?.size)
1722    }
1723
1724    /// Convenience function to open a reader and read all the bytes
1725    pub async fn read_one_all(&self, path: &Path) -> Result<Bytes> {
1726        let reader = self.open(path).await?;
1727        Ok(reader.get_all().await?)
1728    }
1729
1730    /// Convenience function open a reader and make a single request
1731    ///
1732    /// If you will be making multiple requests to the path it is more efficient to call [`Self::open`]
1733    /// and then call [`Reader::get_range`] multiple times.
1734    pub async fn read_one_range(&self, path: &Path, range: Range<usize>) -> Result<Bytes> {
1735        let reader = self.open(path).await?;
1736        Ok(reader.get_range(range).await?)
1737    }
1738}
1739
1740/// Options that can be set for multiple object stores
1741#[derive(PartialEq, Eq, Hash, Clone, Debug, Copy)]
1742pub enum LanceConfigKey {
1743    /// Number of times to retry a download that fails
1744    DownloadRetryCount,
1745}
1746
1747impl FromStr for LanceConfigKey {
1748    type Err = Error;
1749
1750    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
1751        match s.to_ascii_lowercase().as_str() {
1752            "download_retry_count" => Ok(Self::DownloadRetryCount),
1753            _ => Err(Error::invalid_input_source(
1754                format!("Invalid LanceConfigKey: {}", s).into(),
1755            )),
1756        }
1757    }
1758}
1759
1760#[derive(Clone, Debug, Default)]
1761pub struct StorageOptions(pub HashMap<String, String>);
1762
1763impl StorageOptions {
1764    #[cfg(any(feature = "aws", feature = "azure", feature = "gcp"))]
1765    fn as_cloud_options<K>(&self) -> HashMap<K, String>
1766    where
1767        K: FromStr + AsRef<str> + Eq + std::hash::Hash,
1768    {
1769        let mut options: HashMap<K, (&str, &str)> = HashMap::new();
1770        for (source_key, value) in &self.0 {
1771            let Ok(config_key) = K::from_str(&source_key.to_ascii_lowercase()) else {
1772                continue;
1773            };
1774            let source_key = source_key.as_str();
1775            let canonical_key = config_key.as_ref();
1776            // Rank spellings so conflicting aliases cannot depend on HashMap iteration order.
1777            if let Some((selected_key, _)) = options.get(&config_key)
1778                && (
1779                    !source_key.eq_ignore_ascii_case(canonical_key),
1780                    source_key != canonical_key,
1781                    source_key,
1782                ) >= (
1783                    !selected_key.eq_ignore_ascii_case(canonical_key),
1784                    *selected_key != canonical_key,
1785                    *selected_key,
1786                )
1787            {
1788                continue;
1789            }
1790            options.insert(config_key, (source_key, value.as_str()));
1791        }
1792        options
1793            .into_iter()
1794            .map(|(key, (_, value))| (key, value.to_string()))
1795            .collect()
1796    }
1797
1798    #[cfg(any(feature = "aws", feature = "azure", feature = "gcp"))]
1799    fn merge_env_options(
1800        &mut self,
1801        env_options: impl IntoIterator<
1802            Item = (impl AsRef<std::ffi::OsStr>, impl AsRef<std::ffi::OsStr>),
1803        >,
1804        canonical_key: impl Fn(&str) -> Option<String>,
1805    ) {
1806        // Aliases must block environment defaults without rewriting the caller's keys.
1807        let mut configured_keys: HashSet<String> = self
1808            .0
1809            .keys()
1810            .filter_map(|key| canonical_key(&key.to_ascii_lowercase()))
1811            .collect();
1812        for (key, value) in env_options {
1813            if let (Some(key), Some(value)) = (key.as_ref().to_str(), value.as_ref().to_str())
1814                && let Some(config_key) = canonical_key(&key.to_ascii_lowercase())
1815                && configured_keys.insert(config_key.clone())
1816            {
1817                self.0.insert(config_key, value.to_string());
1818            }
1819        }
1820    }
1821
1822    /// Create a new instance of [`StorageOptions`]
1823    pub fn new(options: HashMap<String, String>) -> Self {
1824        let mut options = options;
1825        if let Ok(value) = std::env::var("AZURE_STORAGE_ALLOW_HTTP") {
1826            options.insert("allow_http".into(), value);
1827        }
1828        if let Ok(value) = std::env::var("AZURE_STORAGE_USE_HTTP") {
1829            options.insert("allow_http".into(), value);
1830        }
1831        if let Ok(value) = std::env::var("AWS_ALLOW_HTTP") {
1832            options.insert("allow_http".into(), value);
1833        }
1834        if let Ok(value) = std::env::var("OBJECT_STORE_CLIENT_MAX_RETRIES") {
1835            options.insert("client_max_retries".into(), value);
1836        }
1837        if let Ok(value) = std::env::var("OBJECT_STORE_CLIENT_RETRY_TIMEOUT") {
1838            options.insert("client_retry_timeout".into(), value);
1839        }
1840        Self(options)
1841    }
1842
1843    /// Denotes if unsecure connections via http are allowed
1844    pub fn allow_http(&self) -> bool {
1845        self.0.iter().any(|(key, value)| {
1846            key.to_ascii_lowercase().contains("allow_http") & str_is_truthy(value)
1847        })
1848    }
1849
1850    /// Byte gap below which the I/O scheduler merges two reads of one file
1851    /// into a single request, overriding the store's default.
1852    pub fn block_size(&self) -> Result<Option<usize>> {
1853        let Some((_, value)) = self
1854            .0
1855            .iter()
1856            .find(|(key, _)| key.eq_ignore_ascii_case("block_size"))
1857        else {
1858            return Ok(None);
1859        };
1860        let block_size = value.trim().parse::<usize>().map_err(|err| {
1861            Error::invalid_input(format!(
1862                "storage option block_size must be a number of bytes, got `{value}`: {err}"
1863            ))
1864        })?;
1865        Ok(Some(block_size))
1866    }
1867
1868    /// Number of times to retry a download that fails
1869    pub fn download_retry_count(&self) -> usize {
1870        self.0
1871            .iter()
1872            .find(|(key, _)| key.eq_ignore_ascii_case("download_retry_count"))
1873            .map(|(_, value)| value.parse::<usize>().unwrap_or(3))
1874            .unwrap_or(3)
1875    }
1876
1877    /// Max retry times to set in RetryConfig for object store client
1878    pub fn client_max_retries(&self) -> usize {
1879        self.0
1880            .iter()
1881            .find(|(key, _)| key.eq_ignore_ascii_case("client_max_retries"))
1882            .and_then(|(_, value)| value.parse::<usize>().ok())
1883            .unwrap_or(3)
1884    }
1885
1886    /// Seconds of timeout to set in RetryConfig for object store client
1887    pub fn client_retry_timeout(&self) -> u64 {
1888        self.0
1889            .iter()
1890            .find(|(key, _)| key.eq_ignore_ascii_case("client_retry_timeout"))
1891            .and_then(|(_, value)| value.parse::<u64>().ok())
1892            .unwrap_or(180)
1893    }
1894
1895    pub fn get(&self, key: &str) -> Option<&String> {
1896        self.0.get(key)
1897    }
1898
1899    /// Build [`ClientOptions`] with default headers extracted from `headers.*` keys.
1900    ///
1901    /// Keys prefixed with `headers.` are parsed into HTTP headers. For example,
1902    /// `headers.x-ms-version = 2023-11-03` results in a default header
1903    /// `x-ms-version: 2023-11-03`.
1904    ///
1905    /// Returns an error if any `headers.*` key has an invalid header name or value.
1906    #[cfg(any(feature = "aws", feature = "azure", feature = "gcp"))]
1907    pub fn client_options(&self) -> Result<ClientOptions> {
1908        let mut headers = HeaderMap::new();
1909        for (key, value) in &self.0 {
1910            if let Some(header_name) = key.strip_prefix("headers.") {
1911                let name = header_name
1912                    .parse::<http::header::HeaderName>()
1913                    .map_err(|e| {
1914                        Error::invalid_input(format!("invalid header name '{header_name}': {e}"))
1915                    })?;
1916                let val = HeaderValue::from_str(value).map_err(|e| {
1917                    Error::invalid_input(format!("invalid header value for '{header_name}': {e}"))
1918                })?;
1919                headers.insert(name, val);
1920            }
1921        }
1922        let mut client_options = ClientOptions::default();
1923        if !headers.is_empty() {
1924            client_options = client_options.with_default_headers(headers);
1925        }
1926        Ok(client_options)
1927    }
1928
1929    /// Get the expiration time in milliseconds since epoch, if present
1930    pub fn expires_at_millis(&self) -> Option<u64> {
1931        self.0
1932            .get(EXPIRES_AT_MILLIS_KEY)
1933            .and_then(|s| s.parse::<u64>().ok())
1934    }
1935}
1936
1937impl From<HashMap<String, String>> for StorageOptions {
1938    fn from(value: HashMap<String, String>) -> Self {
1939        Self::new(value)
1940    }
1941}
1942
1943static DEFAULT_OBJECT_STORE_REGISTRY: std::sync::LazyLock<ObjectStoreRegistry> =
1944    std::sync::LazyLock::new(ObjectStoreRegistry::default);
1945
1946impl ObjectStore {
1947    #[allow(clippy::too_many_arguments)]
1948    pub fn new(
1949        mut store: Arc<DynObjectStore>,
1950        location: Url,
1951        block_size: Option<usize>,
1952        wrapper: Option<Arc<dyn WrappingObjectStore>>,
1953        use_constant_size_upload_parts: bool,
1954        list_is_lexically_ordered: bool,
1955        io_parallelism: usize,
1956        download_retry_count: usize,
1957        storage_options: Option<&HashMap<String, String>>,
1958    ) -> Self {
1959        let scheme = location.scheme();
1960        let block_size = block_size.unwrap_or_else(|| infer_block_size(scheme));
1961        let store_prefix = match DEFAULT_OBJECT_STORE_REGISTRY.get_provider(scheme) {
1962            Some(provider) => provider
1963                .calculate_object_store_prefix(&location, storage_options)
1964                .unwrap(),
1965            None => {
1966                let store_prefix = format!("{}${}", location.scheme(), location.authority());
1967                log::warn!(
1968                    "Guessing that object store prefix is {}, since object store scheme is not found in registry.",
1969                    store_prefix
1970                );
1971                store_prefix
1972            }
1973        };
1974        let mut io_tracker = IOTracker::default();
1975        meter_store(
1976            &mut store,
1977            &mut io_tracker,
1978            &metrics_base(&store_prefix, &location),
1979        );
1980
1981        let store = match wrapper {
1982            Some(wrapper) => wrapper.wrap(&store_prefix, store),
1983            None => store,
1984        };
1985
1986        // Always wrap with IO tracking
1987        let tracked_store = io_tracker.wrap("", store);
1988
1989        Self {
1990            inner: tracked_store,
1991            local_dir_operations: None,
1992            scheme: scheme.into(),
1993            block_size,
1994            max_iop_size: *DEFAULT_MAX_IOP_SIZE,
1995            use_constant_size_upload_parts,
1996            list_is_lexically_ordered,
1997            io_parallelism,
1998            download_retry_count,
1999            io_tracker,
2000            store_prefix,
2001            // Type-erased on the way in, so there is no telling if it can paginate.
2002            paginated_lister: None,
2003        }
2004    }
2005}
2006
2007/// Wrap `inner` so its operations publish metrics labelled by `store_prefix`,
2008/// and label `io_tracker` with the same prefix so the local reads and writes
2009/// that bypass `inner` publish under it too.
2010///
2011/// The two go together on purpose: a store metered on one path but not the other
2012/// would report a partial picture that reads like a complete one. Every
2013/// constructor that hands an [`ObjectStore`] to a caller must route its `inner`
2014/// through here, or through nothing at all.
2015#[cfg(feature = "metrics")]
2016fn meter_store(inner: &mut Arc<dyn OSObjectStore>, io_tracker: &mut IOTracker, base: &str) {
2017    use crate::object_store::metrics::ObjectStoreMetricsExt;
2018    io_tracker.set_metrics_base(base);
2019    *inner = inner.clone().metered(base.to_owned());
2020}
2021
2022#[cfg(not(feature = "metrics"))]
2023fn meter_store(_inner: &mut Arc<dyn OSObjectStore>, _io_tracker: &mut IOTracker, _base: &str) {}
2024
2025/// The `base` metrics label for a store opened at `location`; see
2026/// [`metrics::metrics_base`]. Without the `metrics` feature the label is unused
2027/// and the prefix keeps the registry cache keyed per bucket as before.
2028#[cfg(feature = "metrics")]
2029pub(crate) fn metrics_base(store_prefix: &str, location: &Url) -> String {
2030    metrics::metrics_base(metrics::base_label_mode(), store_prefix, location)
2031}
2032
2033#[cfg(not(feature = "metrics"))]
2034pub(crate) fn metrics_base(store_prefix: &str, _location: &Url) -> String {
2035    store_prefix.to_owned()
2036}
2037
2038fn infer_block_size(scheme: &str) -> usize {
2039    // Block size: On local file systems, we use 4KB block size. On cloud
2040    // object stores, we use 64KB block size. This is generally the largest
2041    // block size where we don't see a latency penalty.
2042    match scheme {
2043        "file" => 4 * 1024,
2044        _ => 64 * 1024,
2045    }
2046}
2047
2048#[cfg(test)]
2049mod tests {
2050    use super::*;
2051    use async_trait::async_trait;
2052    use bytes::Bytes;
2053    use lance_core::utils::tempfile::{TempStdDir, TempStdFile, TempStrDir};
2054    use object_store::memory::InMemory;
2055    use object_store::{
2056        CopyOptions, GetOptions, GetResult, ListResult, MultipartUpload, PutMultipartOptions,
2057        PutOptions, PutPayload, PutResult, Result as OSResult, UploadPart,
2058    };
2059    use rstest::rstest;
2060    use serial_test::serial;
2061    use std::env::set_current_dir;
2062    use std::fmt::{Display, Formatter};
2063    use std::fs::{create_dir_all, write};
2064    use std::ops::Range;
2065    use std::path::Path as StdPath;
2066    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
2067
2068    #[cfg(any(feature = "aws", feature = "azure", feature = "gcp"))]
2069    fn canonical_test_config_key<K: FromStr + AsRef<str>>(key: &str) -> Option<String> {
2070        K::from_str(key).ok().map(|key| key.as_ref().to_string())
2071    }
2072
2073    #[cfg(any(feature = "aws", feature = "azure", feature = "gcp"))]
2074    #[rstest]
2075    #[cfg_attr(feature = "aws", case::aws("region", "aws_region", canonical_test_config_key::<object_store::aws::AmazonS3ConfigKey>))]
2076    #[cfg_attr(feature = "azure", case::azure("account_name", "azure_storage_account_name", canonical_test_config_key::<object_store::azure::AzureConfigKey>))]
2077    #[cfg_attr(feature = "gcp", case::gcp("service_account_path", "google_service_account", canonical_test_config_key::<object_store::gcp::GoogleConfigKey>))]
2078    fn test_merge_env_options(
2079        #[case] alias: &str,
2080        #[case] canonical: &str,
2081        #[case] canonical_key: fn(&str) -> Option<String>,
2082        #[values(false, true)] use_alias: bool,
2083        #[values(false, true)] uppercase: bool,
2084        #[values(None, Some(""), Some("explicit"))] explicit_value: Option<&str>,
2085    ) {
2086        let key = if use_alias { alias } else { canonical };
2087        let key = if uppercase {
2088            key.to_ascii_uppercase()
2089        } else {
2090            key.to_string()
2091        };
2092        let mut expected = HashMap::from([("lance_custom_option".into(), "unchanged".into())]);
2093        if let Some(value) = explicit_value {
2094            expected.insert(key, value.to_string());
2095        }
2096        let mut options = StorageOptions(expected.clone());
2097        if explicit_value.is_none() {
2098            expected.insert(canonical.to_string(), "environment".to_string());
2099        }
2100        let env_options = [
2101            (canonical.to_ascii_uppercase(), "environment"),
2102            (alias.to_string(), "second_environment_alias"),
2103            ("lance_custom_option".to_string(), "ignored"),
2104            ("unrecognized_env_option".to_string(), "ignored"),
2105        ];
2106
2107        options.merge_env_options(
2108            env_options.iter().map(|(key, value)| (key, value)),
2109            canonical_key,
2110        );
2111        assert_eq!(options.0, expected);
2112        options.merge_env_options(
2113            env_options.iter().map(|(key, value)| (key, value)),
2114            canonical_key,
2115        );
2116        assert_eq!(options.0, expected);
2117    }
2118
2119    /// Write test content to file.
2120    fn write_to_file(path_str: &str, contents: &str) -> std::io::Result<()> {
2121        let path = expand_path(path_str).map_err(std::io::Error::other)?;
2122        std::fs::create_dir_all(path.parent().unwrap())?;
2123        write(path, contents)
2124    }
2125
2126    async fn read_from_store(store: &ObjectStore, path: &Path) -> Result<String> {
2127        let test_file_store = store.open(path).await.unwrap();
2128        let size = test_file_store.size().await.unwrap();
2129        let bytes = test_file_store.get_range(0..size).await.unwrap();
2130        let contents = String::from_utf8(bytes.to_vec()).unwrap();
2131        Ok(contents)
2132    }
2133
2134    #[tokio::test]
2135    async fn test_put_if_absent() {
2136        let temp_dir = TempStrDir::default();
2137        let path = Path::from(format!("{}/atomic-create", temp_dir.as_str()));
2138        let store = ObjectStore::local();
2139        store
2140            .put_if_absent(&path, Bytes::from_static(b"first").into())
2141            .await
2142            .unwrap();
2143        let error = store
2144            .put_if_absent(&path, Bytes::from_static(b"second").into())
2145            .await
2146            .unwrap_err();
2147        assert!(matches!(
2148            error,
2149            object_store::Error::AlreadyExists { .. } | object_store::Error::Precondition { .. }
2150        ));
2151        assert_eq!(
2152            store.read_one_all(&path).await.unwrap(),
2153            b"first".as_slice()
2154        );
2155    }
2156
2157    #[tokio::test]
2158    async fn test_put_if_absent_rejects_cos() {
2159        let mut store = ObjectStore::memory();
2160        store.scheme = "cos".to_string();
2161        let path = Path::from("atomic-create");
2162
2163        let error = store
2164            .put_if_absent(&path, Bytes::from_static(b"value").into())
2165            .await
2166            .unwrap_err();
2167
2168        assert!(matches!(error, object_store::Error::NotSupported { .. }));
2169        assert!(!store.exists(&path).await.unwrap());
2170    }
2171
2172    #[tokio::test]
2173    async fn test_io_parallelism_clamped_to_nonzero() {
2174        // `io_parallelism()` feeds `buffered`/`buffer_unordered` windows; a value of 0 makes those
2175        // streams never poll, hanging callers (e.g. a metadata-only `count_rows`). It must clamp.
2176        let store = ObjectStore::local();
2177        // Readers opened by the store must advertise the store's normalized
2178        // effective parallelism, not the hardcoded cloud default.
2179        let mem_store = ObjectStore::memory();
2180        let path = Path::from("/io_parallelism_probe");
2181        mem_store.put(&path, b"x").await.unwrap();
2182
2183        // SAFETY: process-global env var, set and restored within this test. `io_parallelism()`
2184        // only reads it, and a concurrent reader observes a valid clamped value, never 0.
2185        unsafe { std::env::set_var("LANCE_IO_THREADS", "0") };
2186        assert_eq!(
2187            store.io_parallelism(),
2188            1,
2189            "LANCE_IO_THREADS=0 must clamp to 1"
2190        );
2191        assert_eq!(
2192            mem_store.open(&path).await.unwrap().io_parallelism(),
2193            1,
2194            "an opened reader must report the store's clamped parallelism"
2195        );
2196
2197        unsafe { std::env::set_var("LANCE_IO_THREADS", "8") };
2198        assert_eq!(
2199            store.io_parallelism(),
2200            8,
2201            "a positive override must pass through unchanged"
2202        );
2203        assert_eq!(
2204            mem_store.open(&path).await.unwrap().io_parallelism(),
2205            8,
2206            "an opened reader must honor the configured request limit"
2207        );
2208        assert_eq!(
2209            mem_store
2210                .open_with_size(&path, 1024 * 1024)
2211                .await
2212                .unwrap()
2213                .io_parallelism(),
2214            8,
2215            "a sized reader must honor the configured request limit"
2216        );
2217
2218        unsafe { std::env::remove_var("LANCE_IO_THREADS") };
2219        assert!(
2220            store.io_parallelism() >= 1,
2221            "the configured default parallelism must be at least 1"
2222        );
2223    }
2224
2225    #[tokio::test]
2226    async fn test_absolute_paths() {
2227        let tmp_path = TempStrDir::default();
2228        write_to_file(
2229            &format!("{tmp_path}/bar/foo.lance/test_file"),
2230            "TEST_CONTENT",
2231        )
2232        .unwrap();
2233
2234        // test a few variations of the same path
2235        for uri in &[
2236            format!("{tmp_path}/bar/foo.lance"),
2237            format!("{tmp_path}/./bar/foo.lance"),
2238            format!("{tmp_path}/bar/foo.lance/../foo.lance"),
2239        ] {
2240            let (store, path) = ObjectStore::from_uri(uri).await.unwrap();
2241            let contents = read_from_store(store.as_ref(), &path.clone().join("test_file"))
2242                .await
2243                .unwrap();
2244            assert_eq!(contents, "TEST_CONTENT");
2245        }
2246    }
2247
2248    #[tokio::test]
2249    async fn test_cloud_paths() {
2250        let uri = "s3://bucket/foo.lance";
2251        let (store, path) = ObjectStore::from_uri(uri).await.unwrap();
2252        assert_eq!(store.scheme, "s3");
2253        assert_eq!(path.to_string(), "foo.lance");
2254
2255        let (store, path) = ObjectStore::from_uri("s3+ddb://bucket/foo.lance")
2256            .await
2257            .unwrap();
2258        assert_eq!(store.scheme, "s3");
2259        assert_eq!(path.to_string(), "foo.lance");
2260
2261        let (store, path) = ObjectStore::from_uri("gs://bucket/foo.lance")
2262            .await
2263            .unwrap();
2264        assert_eq!(store.scheme, "gs");
2265        assert_eq!(path.to_string(), "foo.lance");
2266
2267        let (store, path) =
2268            ObjectStore::from_uri("abfss://filesystem@account.dfs.core.windows.net/foo.lance")
2269                .await
2270                .unwrap();
2271        assert_eq!(store.scheme, "abfss");
2272        assert_eq!(path.to_string(), "foo.lance");
2273    }
2274
2275    async fn test_block_size_used_test_helper(
2276        uri: &str,
2277        storage_options: Option<HashMap<String, String>>,
2278        default_expected_block_size: usize,
2279    ) {
2280        // Test the default
2281        let registry = Arc::new(ObjectStoreRegistry::default());
2282        let accessor = storage_options
2283            .clone()
2284            .map(|opts| Arc::new(StorageOptionsAccessor::with_static_options(opts)));
2285        let params = ObjectStoreParams {
2286            storage_options_accessor: accessor.clone(),
2287            ..ObjectStoreParams::default()
2288        };
2289        let (store, _) = ObjectStore::from_uri_and_params(registry, uri, &params)
2290            .await
2291            .unwrap();
2292        assert_eq!(store.block_size, default_expected_block_size);
2293
2294        // Ensure param is used
2295        let registry = Arc::new(ObjectStoreRegistry::default());
2296        let params = ObjectStoreParams {
2297            block_size: Some(1024),
2298            storage_options_accessor: accessor,
2299            ..ObjectStoreParams::default()
2300        };
2301        let (store, _) = ObjectStore::from_uri_and_params(registry, uri, &params)
2302            .await
2303            .unwrap();
2304        assert_eq!(store.block_size, 1024);
2305
2306        // The storage option applies when no parameter is given...
2307        let mut options_with_block_size = storage_options.unwrap_or_default();
2308        options_with_block_size.insert(String::from("block_size"), String::from("2048"));
2309        let accessor = Arc::new(StorageOptionsAccessor::with_static_options(
2310            options_with_block_size,
2311        ));
2312        let registry = Arc::new(ObjectStoreRegistry::default());
2313        let params = ObjectStoreParams {
2314            storage_options_accessor: Some(accessor.clone()),
2315            ..ObjectStoreParams::default()
2316        };
2317        let (store, _) = ObjectStore::from_uri_and_params(registry, uri, &params)
2318            .await
2319            .unwrap();
2320        assert_eq!(store.block_size, 2048);
2321
2322        // ...and the explicit parameter wins over it.
2323        let registry = Arc::new(ObjectStoreRegistry::default());
2324        let params = ObjectStoreParams {
2325            block_size: Some(1024),
2326            storage_options_accessor: Some(accessor),
2327            ..ObjectStoreParams::default()
2328        };
2329        let (store, _) = ObjectStore::from_uri_and_params(registry, uri, &params)
2330            .await
2331            .unwrap();
2332        assert_eq!(store.block_size, 1024);
2333    }
2334
2335    #[tokio::test]
2336    async fn test_block_size_option_rejects_invalid_values() {
2337        let registry = Arc::new(ObjectStoreRegistry::default());
2338        let accessor = Arc::new(StorageOptionsAccessor::with_static_options(HashMap::from(
2339            [(String::from("block_size"), String::from("64KiB"))],
2340        )));
2341        let params = ObjectStoreParams {
2342            storage_options_accessor: Some(accessor),
2343            ..ObjectStoreParams::default()
2344        };
2345        let error =
2346            ObjectStore::from_uri_and_params(registry, "memory:///bucket/foo.lance", &params)
2347                .await
2348                .unwrap_err();
2349        assert!(
2350            matches!(error, lance_core::Error::InvalidInput { .. }),
2351            "{error:?}"
2352        );
2353        assert!(error.to_string().contains("block_size"), "{error}");
2354    }
2355
2356    #[rstest]
2357    #[case("s3://bucket/foo.lance", None)]
2358    #[case("gs://bucket/foo.lance", None)]
2359    #[case("az://account/bucket/foo.lance",
2360      Some(HashMap::from([
2361            (String::from("account_name"), String::from("account")),
2362            (String::from("container_name"), String::from("container"))
2363           ])))]
2364    #[case("abfss://filesystem@account.dfs.core.windows.net/foo.lance",
2365      Some(HashMap::from([
2366            (String::from("account_name"), String::from("account")),
2367            (String::from("container_name"), String::from("filesystem"))
2368           ])))]
2369    #[tokio::test]
2370    async fn test_block_size_used_cloud(
2371        #[case] uri: &str,
2372        #[case] storage_options: Option<HashMap<String, String>>,
2373    ) {
2374        test_block_size_used_test_helper(uri, storage_options, 64 * 1024).await;
2375    }
2376
2377    #[rstest]
2378    #[case("file")]
2379    #[case("file-object-store")]
2380    #[case("memory:///bucket/foo.lance")]
2381    #[tokio::test]
2382    async fn test_block_size_used_file(#[case] prefix: &str) {
2383        let tmp_path = TempStrDir::default();
2384        let path = format!("{tmp_path}/bar/foo.lance/test_file");
2385        write_to_file(&path, "URL").unwrap();
2386        let uri = format!("{prefix}:///{path}");
2387        test_block_size_used_test_helper(&uri, None, 4 * 1024).await;
2388    }
2389
2390    #[tokio::test]
2391    async fn test_relative_paths() {
2392        let tmp_path = TempStrDir::default();
2393        write_to_file(
2394            &format!("{tmp_path}/bar/foo.lance/test_file"),
2395            "RELATIVE_URL",
2396        )
2397        .unwrap();
2398
2399        // The working directory is process state, and this one is about to be
2400        // removed with the temp dir, so put it back as soon as the call that
2401        // needs it returns.
2402        let original_dir = std::env::current_dir().unwrap();
2403        set_current_dir(StdPath::new(tmp_path.as_ref())).expect("Error changing current dir");
2404        let opened = ObjectStore::from_uri("./bar/foo.lance").await;
2405        set_current_dir(&original_dir).expect("Error restoring current dir");
2406        let (store, path) = opened.unwrap();
2407
2408        let contents = read_from_store(store.as_ref(), &path.clone().join("test_file"))
2409            .await
2410            .unwrap();
2411        assert_eq!(contents, "RELATIVE_URL");
2412    }
2413
2414    #[tokio::test]
2415    async fn test_tilde_expansion() {
2416        let uri = "~/foo.lance";
2417        write_to_file(&format!("{uri}/test_file"), "TILDE").unwrap();
2418        let (store, path) = ObjectStore::from_uri(uri).await.unwrap();
2419        let contents = read_from_store(store.as_ref(), &path.clone().join("test_file"))
2420            .await
2421            .unwrap();
2422        assert_eq!(contents, "TILDE");
2423    }
2424
2425    #[tokio::test]
2426    async fn test_read_directory() {
2427        let path = TempStdDir::default();
2428        create_dir_all(path.join("foo").join("bar")).unwrap();
2429        create_dir_all(path.join("foo").join("zoo")).unwrap();
2430        create_dir_all(path.join("foo").join("zoo").join("abc")).unwrap();
2431        write_to_file(
2432            path.join("foo").join("test_file").to_str().unwrap(),
2433            "read_dir",
2434        )
2435        .unwrap();
2436        let (store, base) = ObjectStore::from_uri(path.to_str().unwrap()).await.unwrap();
2437
2438        let sub_dirs = store.read_dir(base.clone().join("foo")).await.unwrap();
2439        assert_eq!(sub_dirs, vec!["bar", "zoo", "test_file"]);
2440    }
2441
2442    #[tokio::test]
2443    async fn test_delete_directory_local_store() {
2444        test_delete_directory("").await;
2445    }
2446
2447    #[tokio::test]
2448    async fn test_delete_directory_file_object_store() {
2449        test_delete_directory("file-object-store").await;
2450    }
2451
2452    async fn test_delete_directory(scheme: &str) {
2453        let path = TempStdDir::default();
2454        create_dir_all(path.join("foo").join("bar")).unwrap();
2455        create_dir_all(path.join("foo").join("zoo")).unwrap();
2456        create_dir_all(path.join("foo").join("zoo").join("abc")).unwrap();
2457        write_to_file(
2458            path.join("foo")
2459                .join("bar")
2460                .join("test_file")
2461                .to_str()
2462                .unwrap(),
2463            "delete",
2464        )
2465        .unwrap();
2466        let file_url = Url::from_directory_path(&path).unwrap();
2467        let url = if scheme.is_empty() {
2468            file_url
2469        } else {
2470            let mut url = Url::parse(&format!("{scheme}:///")).unwrap();
2471            // Use the file:// URL's normalized path so this works on Windows too.
2472            url.set_path(file_url.path());
2473            url
2474        };
2475        let (store, base) = ObjectStore::from_uri(url.as_ref()).await.unwrap();
2476        store
2477            .remove_dir_all(base.clone().join("foo"))
2478            .await
2479            .unwrap();
2480
2481        assert!(!path.join("foo").exists());
2482    }
2483
2484    #[rstest]
2485    #[case("file")]
2486    #[case("file-object-store")]
2487    #[tokio::test]
2488    async fn test_remove_empty_directories(#[case] scheme: &str) {
2489        let path = TempStdDir::default();
2490        let stale_dir = path.join("stale");
2491        let nested_stale_dir = path.join("nested_stale");
2492        let nested_stale_child = nested_stale_dir.join("child");
2493        create_dir_all(&stale_dir).unwrap();
2494        create_dir_all(&nested_stale_child).unwrap();
2495        create_dir_all(path.join("retained").join("child")).unwrap();
2496        write_to_file(
2497            path.join("file_bearing")
2498                .join("test_file")
2499                .to_str()
2500                .unwrap(),
2501            "keep",
2502        )
2503        .unwrap();
2504        create_dir_all(path.join("file_bearing").join("empty_child")).unwrap();
2505
2506        let file_url = Url::from_directory_path(&path).unwrap();
2507        let mut url = Url::parse(&format!("{scheme}:///")).unwrap();
2508        url.set_path(file_url.path());
2509        let (store, base) = ObjectStore::from_uri(url.as_ref()).await.unwrap();
2510
2511        #[cfg(unix)]
2512        let unmodified_since = {
2513            let old_modified_time =
2514                std::time::SystemTime::now() - std::time::Duration::from_secs(10 * 24 * 60 * 60);
2515            for directory in [&stale_dir, &nested_stale_dir, &nested_stale_child] {
2516                std::fs::File::open(directory)
2517                    .unwrap()
2518                    .set_times(std::fs::FileTimes::new().set_modified(old_modified_time))
2519                    .unwrap();
2520            }
2521            DateTime::<Utc>::from(std::time::SystemTime::now())
2522                - chrono::TimeDelta::try_days(7).unwrap()
2523        };
2524        #[cfg(not(unix))]
2525        let unmodified_since = DateTime::<Utc>::from(std::time::SystemTime::now())
2526            + chrono::TimeDelta::try_days(1).unwrap();
2527
2528        store
2529            .remove_empty_dirs(
2530                base.clone(),
2531                HashSet::from([base.clone().join("retained")]),
2532                HashSet::new(),
2533                Some(unmodified_since),
2534            )
2535            .await
2536            .unwrap();
2537
2538        assert!(!path.join("stale").exists());
2539        assert!(!path.join("nested_stale").exists());
2540        assert!(path.join("retained").join("child").exists());
2541        assert!(path.join("file_bearing").join("empty_child").exists());
2542
2543        create_dir_all(path.join("fresh")).unwrap();
2544        create_dir_all(path.join("verified")).unwrap();
2545        store
2546            .remove_empty_dirs(
2547                base.clone(),
2548                HashSet::from([base.clone().join("retained")]),
2549                HashSet::from([base.clone().join("verified")]),
2550                Some(
2551                    DateTime::<Utc>::from(std::time::SystemTime::now())
2552                        - chrono::TimeDelta::try_days(7).unwrap(),
2553                ),
2554            )
2555            .await
2556            .unwrap();
2557
2558        assert!(path.join("fresh").exists());
2559        assert!(!path.join("verified").exists());
2560    }
2561
2562    #[derive(Debug)]
2563    struct TestWrapper {
2564        called: AtomicBool,
2565
2566        return_value: Arc<dyn OSObjectStore>,
2567    }
2568
2569    impl WrappingObjectStore for TestWrapper {
2570        fn wrap(
2571            &self,
2572            _store_prefix: &str,
2573            _original: Arc<dyn OSObjectStore>,
2574        ) -> Arc<dyn OSObjectStore> {
2575            self.called.store(true, Ordering::Relaxed);
2576
2577            // return a mocked value so we can check if the final store is the one we expect
2578            self.return_value.clone()
2579        }
2580
2581        // This one swaps the store out entirely, so a listing that went around it would be
2582        // listing something else.
2583        fn wrap_paginated(
2584            &self,
2585            _store_prefix: &str,
2586            _original: Arc<dyn PaginatedListStore>,
2587        ) -> Option<Arc<dyn PaginatedListStore>> {
2588            None
2589        }
2590    }
2591
2592    impl TestWrapper {
2593        fn called(&self) -> bool {
2594            self.called.load(Ordering::Relaxed)
2595        }
2596    }
2597
2598    /// A lister that exists only to be wrapped.
2599    #[derive(Debug)]
2600    struct StubLister;
2601
2602    #[async_trait]
2603    impl PaginatedListStore for StubLister {
2604        async fn list_paginated(
2605            &self,
2606            _prefix: Option<&str>,
2607            _opts: object_store::list::PaginatedListOptions,
2608        ) -> object_store::Result<object_store::list::PaginatedListResult> {
2609            unimplemented!("this lister exists to be wrapped, not to list")
2610        }
2611    }
2612
2613    /// Records the listers it was handed, and leaves the store alone.
2614    #[derive(Debug)]
2615    struct PaginatedTestWrapper {
2616        name: &'static str,
2617        log: Arc<std::sync::Mutex<Vec<String>>>,
2618    }
2619
2620    impl WrappingObjectStore for PaginatedTestWrapper {
2621        fn wrap(
2622            &self,
2623            _store_prefix: &str,
2624            original: Arc<dyn OSObjectStore>,
2625        ) -> Arc<dyn OSObjectStore> {
2626            original
2627        }
2628
2629        fn wrap_paginated(
2630            &self,
2631            store_prefix: &str,
2632            original: Arc<dyn PaginatedListStore>,
2633        ) -> Option<Arc<dyn PaginatedListStore>> {
2634            self.log
2635                .lock()
2636                .unwrap()
2637                .push(format!("{}@{store_prefix}", self.name));
2638            Some(original)
2639        }
2640    }
2641
2642    /// A chain hands the lister to each of its wrappers in turn. One wrapper giving up the
2643    /// pushdown gives it up for the chain, and the wrappers after it are never asked: the
2644    /// listing is going through `wrap` either way, which is every wrapper at once.
2645    #[rstest]
2646    #[case::every_wrapper_keeps_it(false, vec!["first@memory", "second@memory"])]
2647    #[case::one_wrapper_gives_it_up(true, vec!["first@memory"])]
2648    fn test_a_chain_wraps_the_lister_until_one_gives_it_up(
2649        #[case] gives_up: bool,
2650        #[case] expected_log: Vec<&str>,
2651    ) {
2652        let log = Arc::new(std::sync::Mutex::new(Vec::new()));
2653        let mut wrappers: Vec<Arc<dyn WrappingObjectStore>> =
2654            vec![Arc::new(PaginatedTestWrapper {
2655                name: "first",
2656                log: log.clone(),
2657            })];
2658        if gives_up {
2659            wrappers.push(Arc::new(TestWrapper {
2660                called: AtomicBool::new(false),
2661                return_value: Arc::new(InMemory::new()),
2662            }));
2663        }
2664        wrappers.push(Arc::new(PaginatedTestWrapper {
2665            name: "second",
2666            log: log.clone(),
2667        }));
2668
2669        let wrapped = ChainedWrappingObjectStore::new(wrappers)
2670            .wrap_paginated("memory", Arc::new(StubLister));
2671
2672        assert_eq!(wrapped.is_none(), gives_up);
2673        assert_eq!(*log.lock().unwrap(), expected_log);
2674    }
2675
2676    /// `apply_wrapper` keeps both halves of the store in sync. A wrapper that gives up the
2677    /// pushdown has to clear the lister too, or `read_dir_page` would keep talking to the
2678    /// backend behind the wrapper's back.
2679    #[rstest]
2680    #[case::gives_up_the_pushdown(true)]
2681    #[case::keeps_the_pushdown(false)]
2682    fn test_apply_wrapper_keeps_inner_and_the_lister_in_sync(#[case] gives_up: bool) {
2683        let replacement = Arc::new(InMemory::new());
2684        let giving_up = TestWrapper {
2685            called: AtomicBool::new(false),
2686            return_value: replacement.clone(),
2687        };
2688        let keeping = PaginatedTestWrapper {
2689            name: "passthrough",
2690            log: Arc::new(std::sync::Mutex::new(Vec::new())),
2691        };
2692        let wrapper: &dyn WrappingObjectStore = match gives_up {
2693            true => &giving_up,
2694            false => &keeping,
2695        };
2696
2697        let mut store = ObjectStore::memory();
2698        store.paginated_lister = Some(Arc::new(StubLister) as Arc<dyn PaginatedListStore>);
2699        store.apply_wrapper(wrapper);
2700
2701        assert_eq!(
2702            store.paginated_lister.is_some(),
2703            !gives_up,
2704            "the lister has to follow what the wrapper said"
2705        );
2706        // The wrapper that gives up the pushdown is also the one that swaps the store out, so
2707        // whether `inner` was replaced says that `wrap` ran on the same wrapper.
2708        assert_eq!(
2709            Arc::ptr_eq(&store.inner, &(replacement as Arc<dyn OSObjectStore>)),
2710            gives_up
2711        );
2712    }
2713
2714    #[tokio::test]
2715    async fn test_wrapper_identity_is_stable_across_tasks() {
2716        let wrapper = Arc::new(TestWrapper {
2717            called: AtomicBool::new(false),
2718            return_value: Arc::new(InMemory::new()),
2719        });
2720        let initial_params = ObjectStoreParams {
2721            object_store_wrapper: Some(wrapper.clone()),
2722            ..ObjectStoreParams::default()
2723        };
2724        let task_params = tokio::spawn(async move {
2725            ObjectStoreParams {
2726                object_store_wrapper: Some(wrapper),
2727                ..ObjectStoreParams::default()
2728            }
2729        })
2730        .await
2731        .unwrap();
2732
2733        assert_eq!(initial_params, task_params);
2734
2735        let mut initial_hasher = std::hash::DefaultHasher::new();
2736        std::hash::Hash::hash(&initial_params, &mut initial_hasher);
2737        let mut task_hasher = std::hash::DefaultHasher::new();
2738        std::hash::Hash::hash(&task_params, &mut task_hasher);
2739        assert_eq!(
2740            std::hash::Hasher::finish(&initial_hasher),
2741            std::hash::Hasher::finish(&task_hasher)
2742        );
2743    }
2744
2745    #[tokio::test]
2746    async fn test_wrapping_object_store_option_is_used() {
2747        // Make a store for the inner store first
2748        let mock_inner_store: Arc<dyn OSObjectStore> = Arc::new(InMemory::new());
2749        let registry = Arc::new(ObjectStoreRegistry::default());
2750
2751        assert_eq!(Arc::strong_count(&mock_inner_store), 1);
2752
2753        let wrapper = Arc::new(TestWrapper {
2754            called: AtomicBool::new(false),
2755            return_value: mock_inner_store.clone(),
2756        });
2757
2758        let params = ObjectStoreParams {
2759            object_store_wrapper: Some(wrapper.clone()),
2760            ..ObjectStoreParams::default()
2761        };
2762
2763        // not called yet
2764        assert!(!wrapper.called());
2765
2766        let _ = ObjectStore::from_uri_and_params(registry, "memory:///", &params)
2767            .await
2768            .unwrap();
2769
2770        // called after construction
2771        assert!(wrapper.called());
2772
2773        // hard to compare two trait pointers as the point to vtables
2774        // using the ref count as a proxy to make sure that the store is correctly kept
2775        assert_eq!(Arc::strong_count(&mock_inner_store), 2);
2776    }
2777
2778    #[tokio::test]
2779    async fn test_local_paths() {
2780        let file_path = TempStdFile::default();
2781        let mut writer = ObjectStore::create_local_writer(&file_path).await.unwrap();
2782        writer.write_all(b"LOCAL").await.unwrap();
2783        Writer::shutdown(&mut writer).await.unwrap();
2784
2785        let reader = ObjectStore::open_local(&file_path).await.unwrap();
2786        let buf = reader.get_range(0..5).await.unwrap();
2787        assert_eq!(buf.as_ref(), b"LOCAL");
2788    }
2789
2790    #[cfg(unix)]
2791    #[tokio::test]
2792    async fn test_direct_local_writer_uses_standard_file_permissions() {
2793        let directory = TempStdDir::default();
2794        let reference_path = directory.join("reference");
2795        std::fs::File::create(&reference_path).unwrap();
2796        let expected_mode = std::fs::metadata(reference_path)
2797            .unwrap()
2798            .permissions()
2799            .mode()
2800            & 0o777;
2801
2802        let output_path = directory.join("output");
2803        let object_path = Path::from_absolute_path(&output_path).unwrap();
2804        let store = ObjectStore::local();
2805        let mut writer = store.create(&object_path).await.unwrap();
2806        writer.write_all(b"LOCAL").await.unwrap();
2807        Writer::shutdown(writer.as_mut()).await.unwrap();
2808
2809        let actual_mode = std::fs::metadata(output_path).unwrap().permissions().mode() & 0o777;
2810        assert_eq!(actual_mode, expected_mode);
2811    }
2812
2813    #[tokio::test]
2814    async fn test_read_one() {
2815        let file_path = TempStdFile::default();
2816        let mut writer = ObjectStore::create_local_writer(&file_path).await.unwrap();
2817        writer.write_all(b"LOCAL").await.unwrap();
2818        Writer::shutdown(&mut writer).await.unwrap();
2819
2820        let file_path_os = object_store::path::Path::parse(file_path.to_str().unwrap()).unwrap();
2821        let obj_store = ObjectStore::local();
2822        let buf = obj_store.read_one_all(&file_path_os).await.unwrap();
2823        assert_eq!(buf.as_ref(), b"LOCAL");
2824
2825        let buf = obj_store.read_one_range(&file_path_os, 0..5).await.unwrap();
2826        assert_eq!(buf.as_ref(), b"LOCAL");
2827    }
2828
2829    #[tokio::test]
2830    #[cfg(windows)]
2831    async fn test_windows_paths() {
2832        use std::path::Component;
2833        use std::path::Prefix;
2834        use std::path::Prefix::*;
2835
2836        fn get_path_prefix(path: &StdPath) -> Prefix<'_> {
2837            match path.components().next().unwrap() {
2838                Component::Prefix(prefix_component) => prefix_component.kind(),
2839                _ => panic!(),
2840            }
2841        }
2842
2843        fn get_drive_letter(prefix: Prefix) -> String {
2844            match prefix {
2845                Disk(bytes) => String::from_utf8(vec![bytes]).unwrap(),
2846                _ => panic!(),
2847            }
2848        }
2849
2850        let tmp_path = TempStdFile::default();
2851        let prefix = get_path_prefix(&tmp_path);
2852        let drive_letter = get_drive_letter(prefix);
2853
2854        write_to_file(
2855            &(format!("{drive_letter}:/test_folder/test.lance") + "/test_file"),
2856            "WINDOWS",
2857        )
2858        .unwrap();
2859
2860        for uri in &[
2861            format!("{drive_letter}:/test_folder/test.lance"),
2862            format!("{drive_letter}:\\test_folder\\test.lance"),
2863        ] {
2864            let (store, base) = ObjectStore::from_uri(uri).await.unwrap();
2865            let contents = read_from_store(store.as_ref(), &base.clone().join("test_file"))
2866                .await
2867                .unwrap();
2868            assert_eq!(contents, "WINDOWS");
2869        }
2870    }
2871
2872    #[tokio::test]
2873    async fn test_cross_filesystem_copy() {
2874        // Create two temporary directories that simulate different filesystems
2875        let source_dir = TempStdDir::default();
2876        let dest_dir = TempStdDir::default();
2877
2878        // Create a test file in the source directory
2879        let source_file_name = "test_file.txt";
2880        let source_file = source_dir.join(source_file_name);
2881        std::fs::write(&source_file, b"test content").unwrap();
2882
2883        // Create ObjectStore for local filesystem
2884        let (store, base_path) = ObjectStore::from_uri(source_dir.to_str().unwrap())
2885            .await
2886            .unwrap();
2887
2888        // Create paths relative to the ObjectStore base
2889        let from_path = base_path.clone().join(source_file_name);
2890
2891        // Use object_store::Path::parse for the destination
2892        let dest_file = dest_dir.join("copied_file.txt");
2893        let dest_str = dest_file.to_str().unwrap();
2894        let to_path = object_store::path::Path::parse(dest_str).unwrap();
2895
2896        // Perform the copy operation
2897        store.copy(&from_path, &to_path).await.unwrap();
2898
2899        // Verify the file was copied correctly
2900        assert!(dest_file.exists());
2901        let copied_content = std::fs::read(&dest_file).unwrap();
2902        assert_eq!(copied_content, b"test content");
2903    }
2904
2905    #[tokio::test]
2906    async fn test_copy_creates_parent_directories() {
2907        let source_dir = TempStdDir::default();
2908        let dest_dir = TempStdDir::default();
2909
2910        // Create a test file in the source directory
2911        let source_file_name = "test_file.txt";
2912        let source_file = source_dir.join(source_file_name);
2913        std::fs::write(&source_file, b"test content").unwrap();
2914
2915        // Create ObjectStore for local filesystem
2916        let (store, base_path) = ObjectStore::from_uri(source_dir.to_str().unwrap())
2917            .await
2918            .unwrap();
2919
2920        // Create paths
2921        let from_path = base_path.clone().join(source_file_name);
2922
2923        // Create destination with nested directories that don't exist yet
2924        let dest_file = dest_dir.join("nested").join("dirs").join("copied_file.txt");
2925        let dest_str = dest_file.to_str().unwrap();
2926        let to_path = object_store::path::Path::parse(dest_str).unwrap();
2927
2928        // Perform the copy operation - should create parent directories
2929        store.copy(&from_path, &to_path).await.unwrap();
2930
2931        // Verify the file was copied correctly and directories were created
2932        assert!(dest_file.exists());
2933        assert!(dest_file.parent().unwrap().exists());
2934        let copied_content = std::fs::read(&dest_file).unwrap();
2935        assert_eq!(copied_content, b"test content");
2936    }
2937
2938    /// Inner store that forwards everything to `InMemory` except single-shot
2939    /// server-side copy (`copy_opts`), which always fails. This lets a test
2940    /// prove that `ObjectStore::copy` fell back to a streaming multipart copy
2941    /// for an oversized source rather than issuing a single `CopyObject`.
2942    #[derive(Debug)]
2943    struct CopyFailingStore {
2944        inner: InMemory,
2945    }
2946
2947    impl Display for CopyFailingStore {
2948        fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
2949            write!(f, "CopyFailingStore")
2950        }
2951    }
2952
2953    #[derive(Debug, Default)]
2954    struct MultipartObservations {
2955        part_count: AtomicUsize,
2956        abort_count: AtomicUsize,
2957        native_copy_count: AtomicUsize,
2958        delete_stream_count: AtomicUsize,
2959        delete_in_flight: AtomicUsize,
2960        /// High-water mark of concurrent deletions; 1 means serial.
2961        delete_max_in_flight: AtomicUsize,
2962    }
2963
2964    /// Decrements the in-flight count when its stream is dropped.
2965    struct InFlightGuard(Arc<MultipartObservations>);
2966
2967    impl Drop for InFlightGuard {
2968        fn drop(&mut self) {
2969            self.0.delete_in_flight.fetch_sub(1, Ordering::SeqCst);
2970        }
2971    }
2972
2973    #[derive(Debug)]
2974    struct ObservedMultipartUpload {
2975        inner: Box<dyn MultipartUpload>,
2976        observations: Arc<MultipartObservations>,
2977        fail_parts: bool,
2978    }
2979
2980    #[async_trait]
2981    impl MultipartUpload for ObservedMultipartUpload {
2982        fn put_part(&mut self, data: PutPayload) -> UploadPart {
2983            self.observations.part_count.fetch_add(1, Ordering::SeqCst);
2984            if self.fail_parts {
2985                return Box::pin(async {
2986                    Err(object_store::Error::Generic {
2987                        store: "ObservedMultipartStore",
2988                        source: "injected multipart part failure".into(),
2989                    })
2990                });
2991            }
2992            self.inner.put_part(data)
2993        }
2994
2995        async fn complete(&mut self) -> OSResult<PutResult> {
2996            self.inner.complete().await
2997        }
2998
2999        async fn abort(&mut self) -> OSResult<()> {
3000            self.observations.abort_count.fetch_add(1, Ordering::SeqCst);
3001            self.inner.abort().await
3002        }
3003    }
3004
3005    #[derive(Debug)]
3006    struct ObservedMultipartStore {
3007        inner: InMemory,
3008        observations: Arc<MultipartObservations>,
3009        fail_parts: bool,
3010        destination_size_adjustment: u64,
3011    }
3012
3013    impl Display for ObservedMultipartStore {
3014        fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
3015            write!(f, "ObservedMultipartStore")
3016        }
3017    }
3018
3019    #[async_trait]
3020    impl OSObjectStore for ObservedMultipartStore {
3021        async fn put_opts(
3022            &self,
3023            location: &Path,
3024            bytes: PutPayload,
3025            opts: PutOptions,
3026        ) -> OSResult<PutResult> {
3027            self.inner.put_opts(location, bytes, opts).await
3028        }
3029
3030        async fn put_multipart_opts(
3031            &self,
3032            location: &Path,
3033            opts: PutMultipartOptions,
3034        ) -> OSResult<Box<dyn MultipartUpload>> {
3035            let inner = self.inner.put_multipart_opts(location, opts).await?;
3036            Ok(Box::new(ObservedMultipartUpload {
3037                inner,
3038                observations: self.observations.clone(),
3039                fail_parts: self.fail_parts,
3040            }))
3041        }
3042
3043        async fn get_opts(&self, location: &Path, options: GetOptions) -> OSResult<GetResult> {
3044            let is_head = options.head;
3045            let mut result = self.inner.get_opts(location, options).await?;
3046            if is_head && location.filename() == Some("destination.bin") {
3047                result.meta.size = result
3048                    .meta
3049                    .size
3050                    .checked_add(self.destination_size_adjustment)
3051                    .expect("test destination size should not overflow");
3052            }
3053            Ok(result)
3054        }
3055
3056        async fn get_ranges(&self, location: &Path, ranges: &[Range<u64>]) -> OSResult<Vec<Bytes>> {
3057            self.inner.get_ranges(location, ranges).await
3058        }
3059
3060        fn delete_stream(
3061            &self,
3062            locations: BoxStream<'static, OSResult<Path>>,
3063        ) -> BoxStream<'static, OSResult<Path>> {
3064            let observations = self.observations.clone();
3065            observations
3066                .delete_stream_count
3067                .fetch_add(1, Ordering::SeqCst);
3068            let in_flight = observations.delete_in_flight.fetch_add(1, Ordering::SeqCst) + 1;
3069            observations
3070                .delete_max_in_flight
3071                .fetch_max(in_flight, Ordering::SeqCst);
3072            let guard = InFlightGuard(observations);
3073            let inner = self.inner.delete_stream(locations);
3074            // An in-memory delete resolves on first poll, so overlap is only
3075            // observable if the deletion yields.
3076            async move {
3077                tokio::task::yield_now().await;
3078                tokio::time::sleep(Duration::from_millis(5)).await;
3079                inner.map(move |result| {
3080                    let _ = &guard;
3081                    result
3082                })
3083            }
3084            .flatten_stream()
3085            .boxed()
3086        }
3087
3088        fn list(&self, prefix: Option<&Path>) -> BoxStream<'static, OSResult<ObjectMeta>> {
3089            self.inner.list(prefix)
3090        }
3091
3092        fn list_with_offset(
3093            &self,
3094            prefix: Option<&Path>,
3095            offset: &Path,
3096        ) -> BoxStream<'static, OSResult<ObjectMeta>> {
3097            self.inner.list_with_offset(prefix, offset)
3098        }
3099
3100        async fn list_with_delimiter(&self, prefix: Option<&Path>) -> OSResult<ListResult> {
3101            self.inner.list_with_delimiter(prefix).await
3102        }
3103
3104        async fn copy_opts(&self, from: &Path, to: &Path, opts: CopyOptions) -> OSResult<()> {
3105            self.observations
3106                .native_copy_count
3107                .fetch_add(1, Ordering::SeqCst);
3108            self.inner.copy_opts(from, to, opts).await
3109        }
3110    }
3111
3112    #[async_trait]
3113    impl OSObjectStore for CopyFailingStore {
3114        async fn put_opts(
3115            &self,
3116            location: &Path,
3117            bytes: PutPayload,
3118            opts: PutOptions,
3119        ) -> OSResult<PutResult> {
3120            self.inner.put_opts(location, bytes, opts).await
3121        }
3122        async fn put_multipart_opts(
3123            &self,
3124            location: &Path,
3125            opts: PutMultipartOptions,
3126        ) -> OSResult<Box<dyn MultipartUpload>> {
3127            self.inner.put_multipart_opts(location, opts).await
3128        }
3129        async fn get_opts(&self, location: &Path, options: GetOptions) -> OSResult<GetResult> {
3130            self.inner.get_opts(location, options).await
3131        }
3132        async fn get_ranges(&self, location: &Path, ranges: &[Range<u64>]) -> OSResult<Vec<Bytes>> {
3133            self.inner.get_ranges(location, ranges).await
3134        }
3135        fn delete_stream(
3136            &self,
3137            locations: BoxStream<'static, OSResult<Path>>,
3138        ) -> BoxStream<'static, OSResult<Path>> {
3139            self.inner.delete_stream(locations)
3140        }
3141        fn list(&self, prefix: Option<&Path>) -> BoxStream<'static, OSResult<ObjectMeta>> {
3142            self.inner.list(prefix)
3143        }
3144        fn list_with_offset(
3145            &self,
3146            prefix: Option<&Path>,
3147            offset: &Path,
3148        ) -> BoxStream<'static, OSResult<ObjectMeta>> {
3149            self.inner.list_with_offset(prefix, offset)
3150        }
3151        async fn list_with_delimiter(&self, prefix: Option<&Path>) -> OSResult<ListResult> {
3152            self.inner.list_with_delimiter(prefix).await
3153        }
3154        async fn copy_opts(&self, _from: &Path, _to: &Path, _opts: CopyOptions) -> OSResult<()> {
3155            Err(object_store::Error::Generic {
3156                store: "CopyFailingStore",
3157                source: "single-shot copy disabled in test".into(),
3158            })
3159        }
3160    }
3161
3162    #[tokio::test]
3163    async fn test_copy_streams_objects_larger_than_threshold() {
3164        // memory:// is non-local but isn't an S3/GCS scheme, so copy() wouldn't
3165        // enable the fallback on its own. Drive copy_impl directly with
3166        // multipart_copy_fallback = true to exercise the streaming path. The
3167        // inner store rejects any single-shot copy, so a successful copy can only
3168        // have gone through the streaming branch.
3169        let mut store = ObjectStore::memory();
3170        store.inner = Arc::new(CopyFailingStore {
3171            inner: InMemory::new(),
3172        });
3173
3174        let from = Path::from("source.bin");
3175        let contents = b"streaming multipart copy payload well past the tiny threshold";
3176        store.put(&from, contents).await.unwrap();
3177
3178        // Source size (61 bytes) exceeds the threshold -> must stream via a
3179        // multipart write rather than a single-shot server-side copy.
3180        let streamed = Path::from("streamed.bin");
3181        store.copy_impl(&from, &streamed, true, 8).await.unwrap();
3182        let copied = store.read_one_all(&streamed).await.unwrap();
3183        assert_eq!(copied.as_ref(), contents.as_slice());
3184
3185        // Source size below the threshold -> single-shot copy, which the inner
3186        // store rejects, confirming that the streaming branch (not native copy)
3187        // is what made the first copy succeed.
3188        let native = Path::from("native.bin");
3189        assert!(
3190            store
3191                .copy_impl(&from, &native, true, u64::MAX)
3192                .await
3193                .is_err()
3194        );
3195    }
3196
3197    #[tokio::test]
3198    async fn test_copy_via_stream_never_uses_native_copy() {
3199        let mut store = ObjectStore::memory();
3200        store.inner = Arc::new(CopyFailingStore {
3201            inner: InMemory::new(),
3202        });
3203
3204        let source = Path::from("source.bin");
3205        let destination = Path::from("destination.bin");
3206        let contents = b"stream raw bytes instead of issuing native copy";
3207        store.put(&source, contents).await.unwrap();
3208
3209        let result = store
3210            .copy_via_stream(&source, &store, &destination)
3211            .await
3212            .unwrap();
3213
3214        assert_eq!(result.size, contents.len());
3215        assert_eq!(
3216            store.read_one_all(&destination).await.unwrap().as_ref(),
3217            contents
3218        );
3219    }
3220
3221    #[tokio::test]
3222    async fn test_copy_if_not_exists_accepts_equal_size_local_retry() {
3223        let directory = TempStdDir::default();
3224        let (store, base_path) = ObjectStore::from_uri(directory.to_str().unwrap())
3225            .await
3226            .unwrap();
3227        let source = base_path.clone().join("source.bin");
3228        let destination = base_path.join("destination.bin");
3229        store.put(&source, b"new").await.unwrap();
3230        store.put(&destination, b"old").await.unwrap();
3231
3232        let result = store
3233            .copy_if_not_exists(&source, &destination)
3234            .await
3235            .unwrap();
3236
3237        assert_eq!(result.size, 3);
3238        assert_eq!(
3239            store.read_one_all(&destination).await.unwrap().as_ref(),
3240            b"old"
3241        );
3242    }
3243
3244    #[tokio::test]
3245    async fn test_copy_if_not_exists_uses_native_copy() {
3246        let observations = Arc::new(MultipartObservations::default());
3247        let mut store = ObjectStore::memory();
3248        store.inner = Arc::new(ObservedMultipartStore {
3249            inner: InMemory::new(),
3250            observations: observations.clone(),
3251            fail_parts: false,
3252            destination_size_adjustment: 0,
3253        });
3254
3255        let source = Path::from("source.bin");
3256        let destination = Path::from("destination.bin");
3257        let contents = vec![42; crate::object_writer::initial_upload_size() + 1];
3258        store.put(&source, &contents).await.unwrap();
3259        observations.part_count.store(0, Ordering::SeqCst);
3260
3261        let result = store
3262            .copy_if_not_exists(&source, &destination)
3263            .await
3264            .unwrap();
3265
3266        assert_eq!(result.size, contents.len());
3267        assert_eq!(observations.native_copy_count.load(Ordering::SeqCst), 1);
3268        assert_eq!(observations.part_count.load(Ordering::SeqCst), 0);
3269        assert_eq!(
3270            store.read_one_all(&destination).await.unwrap().as_ref(),
3271            contents.as_slice()
3272        );
3273    }
3274
3275    #[tokio::test]
3276    async fn test_remove_stream_removes_every_path() {
3277        let store = ObjectStore::memory();
3278        let paths: Vec<Path> = (0..64).map(|i| Path::from(format!("obj-{i:03}"))).collect();
3279        for path in &paths {
3280            store.put(path, b"x").await.unwrap();
3281        }
3282
3283        let to_remove = futures::stream::iter(paths.clone().into_iter().map(Ok)).boxed();
3284        let mut reported = store
3285            .remove_stream(to_remove)
3286            .try_collect::<Vec<_>>()
3287            .await
3288            .unwrap();
3289
3290        // Concurrent deletion does not preserve order.
3291        reported.sort_unstable();
3292        let mut expected = paths.clone();
3293        expected.sort_unstable();
3294        assert_eq!(reported, expected);
3295        for path in &paths {
3296            assert!(!store.exists(path).await.unwrap(), "{path} still present");
3297        }
3298    }
3299
3300    #[tokio::test]
3301    async fn test_remove_stream_tolerates_already_deleted() {
3302        // Callers list first and delete after, so a path can disappear in between.
3303        // Failing there abandons the whole sweep over one absent object.
3304        let store = ObjectStore::memory();
3305        let present = Path::from("present");
3306        let absent = Path::from("never-written");
3307        store.put(&present, b"x").await.unwrap();
3308
3309        let to_remove =
3310            futures::stream::iter(vec![Ok(absent.clone()), Ok(present.clone())]).boxed();
3311        let mut removed = store
3312            .remove_stream(to_remove)
3313            .try_collect::<Vec<_>>()
3314            .await
3315            .expect("an already-absent path must not fail the stream");
3316
3317        removed.sort_unstable();
3318        let mut expected = vec![absent, present.clone()];
3319        expected.sort_unstable();
3320        assert_eq!(removed, expected);
3321        assert!(!store.exists(&present).await.unwrap());
3322    }
3323
3324    #[tokio::test]
3325    async fn test_remove_stream_deletes_concurrently() {
3326        // Serial deletion is a round trip per object; the high-water mark is 1
3327        // exactly when that regression happens.
3328        let observations = Arc::new(MultipartObservations::default());
3329        let mut store = ObjectStore::memory();
3330        store.inner = Arc::new(ObservedMultipartStore {
3331            inner: InMemory::new(),
3332            observations: observations.clone(),
3333            fail_parts: false,
3334            destination_size_adjustment: 0,
3335        });
3336
3337        let paths: Vec<Path> = (0..64).map(|i| Path::from(format!("obj-{i:03}"))).collect();
3338        for path in &paths {
3339            store.put(path, b"x").await.unwrap();
3340        }
3341
3342        let to_remove = futures::stream::iter(paths.clone().into_iter().map(Ok)).boxed();
3343        let removed = store
3344            .remove_stream(to_remove)
3345            .try_collect::<Vec<_>>()
3346            .await
3347            .unwrap();
3348
3349        assert_eq!(removed.len(), paths.len());
3350        assert!(
3351            observations.delete_max_in_flight.load(Ordering::SeqCst) > 1,
3352            "deletions ran one at a time; remove_stream must overlap them"
3353        );
3354    }
3355
3356    #[tokio::test]
3357    async fn test_bulk_copy_streams_when_server_side_copy_is_disabled() {
3358        let observations = Arc::new(MultipartObservations::default());
3359        let mut store = ObjectStore::memory();
3360        store.inner = Arc::new(ObservedMultipartStore {
3361            inner: InMemory::new(),
3362            observations: observations.clone(),
3363            fail_parts: false,
3364            destination_size_adjustment: 0,
3365        });
3366
3367        let source = Path::from("source.bin");
3368        let destination = Path::from("destination.bin");
3369        let contents = b"stream by default";
3370        store.put(&source, contents).await.unwrap();
3371
3372        let result = store
3373            .copy_bulk_with_server_side_copy(&source, &store, &destination, false)
3374            .await
3375            .unwrap();
3376
3377        assert_eq!(result.size, contents.len());
3378        assert_eq!(observations.native_copy_count.load(Ordering::SeqCst), 0);
3379        assert_eq!(
3380            store.read_one_all(&destination).await.unwrap().as_ref(),
3381            contents
3382        );
3383    }
3384
3385    #[test]
3386    #[serial(server_side_copy_env)]
3387    fn test_server_side_copy_environment_policy() {
3388        let previous_value = std::env::var_os(SERVER_SIDE_COPY_ENABLED_ENV);
3389        let mut store = ObjectStore::memory();
3390        store.scheme = "test-cloud".to_string();
3391        let destination_store = store.clone();
3392
3393        // SAFETY: this serialized test is the only test that mutates this task-specific
3394        // environment variable, and it restores the original value before returning.
3395        unsafe { std::env::remove_var(SERVER_SIDE_COPY_ENABLED_ENV) };
3396        assert!(!store.uses_server_side_copy(&destination_store));
3397
3398        // SAFETY: see the serialized-test guarantee above.
3399        unsafe { std::env::set_var(SERVER_SIDE_COPY_ENABLED_ENV, "true") };
3400        assert!(store.uses_server_side_copy(&destination_store));
3401
3402        // SAFETY: restore the process environment before the test returns.
3403        unsafe {
3404            match previous_value {
3405                Some(value) => std::env::set_var(SERVER_SIDE_COPY_ENABLED_ENV, value),
3406                None => std::env::remove_var(SERVER_SIDE_COPY_ENABLED_ENV),
3407            }
3408        }
3409    }
3410
3411    #[tokio::test]
3412    async fn test_bulk_copy_uses_server_side_copy_when_enabled_for_same_store() {
3413        let observations = Arc::new(MultipartObservations::default());
3414        let mut source_store = ObjectStore::memory();
3415        source_store.scheme = "test-cloud".to_string();
3416        source_store.inner = Arc::new(ObservedMultipartStore {
3417            inner: InMemory::new(),
3418            observations: observations.clone(),
3419            fail_parts: false,
3420            destination_size_adjustment: 0,
3421        });
3422        let destination_store = source_store.clone();
3423
3424        let source = Path::from("source.bin");
3425        let destination = Path::from("destination.bin");
3426        let contents = b"use native copy when explicitly enabled";
3427        source_store.put(&source, contents).await.unwrap();
3428
3429        let result = source_store
3430            .copy_bulk_with_server_side_copy(&source, &destination_store, &destination, true)
3431            .await
3432            .unwrap();
3433
3434        assert_eq!(result.size, contents.len());
3435        assert_eq!(observations.native_copy_count.load(Ordering::SeqCst), 1);
3436        assert_eq!(
3437            destination_store
3438                .read_one_all(&destination)
3439                .await
3440                .unwrap()
3441                .as_ref(),
3442            contents
3443        );
3444    }
3445
3446    #[tokio::test]
3447    async fn test_bulk_copy_streams_for_distinct_clients_with_same_prefix() {
3448        let shared_inner = InMemory::new();
3449        let source_observations = Arc::new(MultipartObservations::default());
3450        let mut source_store = ObjectStore::memory();
3451        source_store.scheme = "test-cloud".to_string();
3452        source_store.store_prefix = "test-cloud$bucket".to_string();
3453        source_store.inner = Arc::new(ObservedMultipartStore {
3454            inner: shared_inner.clone(),
3455            observations: source_observations.clone(),
3456            fail_parts: false,
3457            destination_size_adjustment: 0,
3458        });
3459        let destination_observations = Arc::new(MultipartObservations::default());
3460        let mut destination_store = ObjectStore::memory();
3461        destination_store.scheme = "test-cloud".to_string();
3462        destination_store.store_prefix = "test-cloud$bucket".to_string();
3463        destination_store.inner = Arc::new(ObservedMultipartStore {
3464            inner: shared_inner,
3465            observations: destination_observations.clone(),
3466            fail_parts: false,
3467            destination_size_adjustment: 0,
3468        });
3469
3470        let source = Path::from("source.bin");
3471        let destination = Path::from("destination.bin");
3472        let contents = b"use native copy when explicitly enabled";
3473        source_store.put(&source, contents).await.unwrap();
3474
3475        let result = source_store
3476            .copy_bulk_with_server_side_copy(&source, &destination_store, &destination, true)
3477            .await
3478            .unwrap();
3479
3480        assert_eq!(result.size, contents.len());
3481        assert_eq!(
3482            source_observations.native_copy_count.load(Ordering::SeqCst),
3483            0
3484        );
3485        assert_eq!(
3486            destination_observations
3487                .native_copy_count
3488                .load(Ordering::SeqCst),
3489            0
3490        );
3491        assert_eq!(
3492            destination_store
3493                .read_one_all(&destination)
3494                .await
3495                .unwrap()
3496                .as_ref(),
3497            contents
3498        );
3499    }
3500
3501    #[tokio::test]
3502    async fn test_bulk_copy_rejects_server_side_destination_size_mismatch() {
3503        let observations = Arc::new(MultipartObservations::default());
3504        let mut source_store = ObjectStore::memory();
3505        source_store.scheme = "test-cloud".to_string();
3506        source_store.inner = Arc::new(ObservedMultipartStore {
3507            inner: InMemory::new(),
3508            observations: observations.clone(),
3509            fail_parts: false,
3510            destination_size_adjustment: 1,
3511        });
3512        let destination_store = source_store.clone();
3513
3514        let source = Path::from("source.bin");
3515        let destination = Path::from("destination.bin");
3516        source_store
3517            .put(&source, b"validate native copy")
3518            .await
3519            .unwrap();
3520
3521        let error = source_store
3522            .copy_bulk_with_server_side_copy(&source, &destination_store, &destination, true)
3523            .await
3524            .unwrap_err();
3525
3526        assert!(
3527            error.to_string().contains("destination size mismatch"),
3528            "expected validation failure, got: {error}"
3529        );
3530        assert_eq!(observations.native_copy_count.load(Ordering::SeqCst), 1);
3531    }
3532
3533    #[tokio::test]
3534    async fn test_bulk_copy_streams_across_stores_when_server_side_copy_is_enabled() {
3535        let source_store = ObjectStore::memory();
3536        let observations = Arc::new(MultipartObservations::default());
3537        let mut destination_store = ObjectStore::memory();
3538        destination_store.inner = Arc::new(ObservedMultipartStore {
3539            inner: InMemory::new(),
3540            observations: observations.clone(),
3541            fail_parts: false,
3542            destination_size_adjustment: 0,
3543        });
3544
3545        let source = Path::from("source.bin");
3546        let destination = Path::from("destination.bin");
3547        let contents = b"cross-store copies must stream";
3548        source_store.put(&source, contents).await.unwrap();
3549
3550        let result = source_store
3551            .copy_bulk_with_server_side_copy(&source, &destination_store, &destination, true)
3552            .await
3553            .unwrap();
3554
3555        assert_eq!(result.size, contents.len());
3556        assert_eq!(observations.native_copy_count.load(Ordering::SeqCst), 0);
3557        assert_eq!(
3558            destination_store
3559                .read_one_all(&destination)
3560                .await
3561                .unwrap()
3562                .as_ref(),
3563            contents
3564        );
3565    }
3566
3567    #[tokio::test]
3568    async fn test_copy_via_stream_preserves_local_not_found() {
3569        let directory = TempStdDir::default();
3570        let (store, base_path) = ObjectStore::from_uri(directory.to_str().unwrap())
3571            .await
3572            .unwrap();
3573        let source = base_path.clone().join("missing.bin");
3574        let destination = base_path.join("destination.bin");
3575
3576        let error = store
3577            .copy_via_stream(&source, &store, &destination)
3578            .await
3579            .unwrap_err();
3580
3581        assert!(
3582            error.is_not_found(),
3583            "expected not-found error, got: {error}"
3584        );
3585    }
3586
3587    #[tokio::test]
3588    async fn test_copy_via_stream_uses_multiple_parts() {
3589        let mut source_store = ObjectStore::memory();
3590        source_store.max_iop_size = 1024 * 1024;
3591        let observations = Arc::new(MultipartObservations::default());
3592        let mut destination_store = ObjectStore::memory();
3593        destination_store.inner = Arc::new(ObservedMultipartStore {
3594            inner: InMemory::new(),
3595            observations: observations.clone(),
3596            fail_parts: false,
3597            destination_size_adjustment: 0,
3598        });
3599
3600        let source = Path::from("source.bin");
3601        let destination = Path::from("destination.bin");
3602        let contents = vec![42; crate::object_writer::initial_upload_size() * 2 + 1];
3603        source_store.put(&source, &contents).await.unwrap();
3604
3605        let result = source_store
3606            .copy_via_stream(&source, &destination_store, &destination)
3607            .await
3608            .unwrap();
3609
3610        assert_eq!(result.size, contents.len());
3611        assert!(
3612            observations.part_count.load(Ordering::SeqCst) >= 2,
3613            "stream copy should split a large destination into multiple upload parts"
3614        );
3615        assert_eq!(
3616            destination_store
3617                .read_one_all(&destination)
3618                .await
3619                .unwrap()
3620                .as_ref(),
3621            contents.as_slice()
3622        );
3623    }
3624
3625    #[tokio::test]
3626    async fn test_copy_via_stream_aborts_failed_upload_and_retains_source() {
3627        let source_store = ObjectStore::memory();
3628        let observations = Arc::new(MultipartObservations::default());
3629        let mut destination_store = ObjectStore::memory();
3630        destination_store.inner = Arc::new(ObservedMultipartStore {
3631            inner: InMemory::new(),
3632            observations: observations.clone(),
3633            fail_parts: true,
3634            destination_size_adjustment: 0,
3635        });
3636
3637        let source = Path::from("source.bin");
3638        let destination = Path::from("destination.bin");
3639        let contents = vec![7; crate::object_writer::initial_upload_size() * 2];
3640        source_store.put(&source, &contents).await.unwrap();
3641
3642        let error = source_store
3643            .copy_via_stream(&source, &destination_store, &destination)
3644            .await
3645            .unwrap_err();
3646        let error_message = error.to_string();
3647        assert!(
3648            (error_message.contains("destination write")
3649                || error_message.contains("destination completion"))
3650                && error_message.contains("injected multipart part failure"),
3651            "expected upload-stage context and the underlying error, got: {error}"
3652        );
3653
3654        tokio::time::timeout(Duration::from_secs(1), async {
3655            loop {
3656                if observations.abort_count.load(Ordering::SeqCst) > 0 {
3657                    break;
3658                }
3659                tokio::task::yield_now().await;
3660            }
3661        })
3662        .await
3663        .expect("multipart abort should complete");
3664        assert_eq!(observations.abort_count.load(Ordering::SeqCst), 1);
3665        assert_eq!(
3666            source_store.read_one_all(&source).await.unwrap().as_ref(),
3667            contents.as_slice()
3668        );
3669        assert!(!destination_store.exists(&destination).await.unwrap());
3670    }
3671
3672    #[tokio::test]
3673    async fn test_copy_via_stream_rejects_destination_size_mismatch() {
3674        let source_store = ObjectStore::memory();
3675        let mut destination_store = ObjectStore::memory();
3676        destination_store.inner = Arc::new(ObservedMultipartStore {
3677            inner: InMemory::new(),
3678            observations: Arc::new(MultipartObservations::default()),
3679            fail_parts: false,
3680            destination_size_adjustment: 1,
3681        });
3682
3683        let source = Path::from("source.bin");
3684        let destination = Path::from("destination.bin");
3685        let contents = b"validate the destination after completion";
3686        source_store.put(&source, contents).await.unwrap();
3687
3688        let error = source_store
3689            .copy_via_stream(&source, &destination_store, &destination)
3690            .await
3691            .unwrap_err();
3692
3693        assert!(
3694            error.to_string().contains("destination size mismatch"),
3695            "expected validation failure, got: {error}"
3696        );
3697    }
3698
3699    #[test]
3700    #[cfg(any(feature = "aws", feature = "azure", feature = "gcp"))]
3701    fn test_client_options_extracts_headers() {
3702        let opts = StorageOptions(HashMap::from([
3703            ("headers.x-custom-foo".to_string(), "bar".to_string()),
3704            ("headers.x-ms-version".to_string(), "2023-11-03".to_string()),
3705            ("region".to_string(), "us-west-2".to_string()),
3706        ]));
3707        let client_options = opts.client_options().unwrap();
3708
3709        // Verify non-header keys are not consumed as headers by creating
3710        // another StorageOptions with no headers.* keys.
3711        let opts_no_headers = StorageOptions(HashMap::from([(
3712            "region".to_string(),
3713            "us-west-2".to_string(),
3714        )]));
3715        opts_no_headers.client_options().unwrap();
3716
3717        // Smoke test: the client_options with headers should be usable
3718        // in a builder (we can't inspect the headers directly, but building
3719        // should not fail).
3720        #[cfg(feature = "gcp")]
3721        {
3722            use object_store::gcp::GoogleCloudStorageBuilder;
3723            let _builder = GoogleCloudStorageBuilder::new()
3724                .with_client_options(client_options)
3725                .with_url("gs://test-bucket");
3726        }
3727    }
3728
3729    #[test]
3730    #[cfg(any(feature = "aws", feature = "azure", feature = "gcp"))]
3731    fn test_client_options_rejects_invalid_header_name() {
3732        let opts = StorageOptions(HashMap::from([(
3733            "headers.bad header".to_string(),
3734            "value".to_string(),
3735        )]));
3736        let err = opts.client_options().unwrap_err();
3737        assert!(err.to_string().contains("invalid header name"));
3738    }
3739
3740    #[test]
3741    #[cfg(any(feature = "aws", feature = "azure", feature = "gcp"))]
3742    fn test_client_options_rejects_invalid_header_value() {
3743        let opts = StorageOptions(HashMap::from([(
3744            "headers.x-good-name".to_string(),
3745            "bad\x01value".to_string(),
3746        )]));
3747        let err = opts.client_options().unwrap_err();
3748        assert!(err.to_string().contains("invalid header value"));
3749    }
3750
3751    #[test]
3752    #[cfg(any(feature = "aws", feature = "azure", feature = "gcp"))]
3753    fn test_client_options_empty_when_no_header_keys() {
3754        let opts = StorageOptions(HashMap::from([
3755            ("region".to_string(), "us-east-1".to_string()),
3756            ("access_key_id".to_string(), "AKID".to_string()),
3757        ]));
3758        opts.client_options().unwrap();
3759    }
3760}