Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions rust/lance-io/src/object_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,17 @@ pub(crate) mod dynamic_opendal;
mod list_retry;
#[cfg(feature = "metrics")]
pub mod metrics;
#[cfg(any(
feature = "aws",
feature = "gcp",
feature = "azure",
feature = "oss",
feature = "tencent",
feature = "huggingface",
feature = "tos",
feature = "goosefs",
))]
pub(crate) mod opendal_store;
pub mod providers;
pub mod storage_options;
#[cfg(test)]
Expand Down
2 changes: 1 addition & 1 deletion rust/lance-io/src/object_store/dynamic_opendal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@ use object_store::{
ObjectStore as OSObjectStore, PutMultipartOptions, PutOptions, PutPayload, PutResult,
RenameOptions,
};
use object_store_opendal::OpendalStore;
use tokio::sync::RwLock;

use crate::object_store::StorageOptionsAccessor;
use crate::object_store::opendal_store::OpendalStore;
use lance_core::Result;

type NormalizeConfigFn = fn(&HashMap<String, String>) -> Result<HashMap<String, String>>;
Expand Down
226 changes: 226 additions & 0 deletions rust/lance-io/src/object_store/opendal_store.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright The Lance Authors

use std::fmt;
use std::ops::Range;

use async_trait::async_trait;
use bytes::Bytes;
use futures::{StreamExt, TryStreamExt, future, stream::BoxStream};
use object_store::path::Path;
use object_store::{
CopyOptions, GetOptions, GetResult, ListResult, MultipartUpload, ObjectMeta,
ObjectStore as OSObjectStore, PutMultipartOptions, PutOptions, PutPayload, PutResult,
RenameOptions,
};
use object_store_opendal::OpendalStore as InnerOpendalStore;
use opendal::Operator;

/// Adapts OpenDAL listing paths to the spelling used by the request.
///
/// The upstream bridge builds listed locations with [`Path::from`], which
/// percent-encodes reserved characters. Lance builds dataset base paths with
/// [`Path::from_url_path`], so mismatched listed locations must be decoded.
/// Locations that already match the requested prefix retain their spelling to
/// preserve paths containing literal percent escapes.
#[derive(Debug, Clone)]
pub(super) struct OpendalStore {
inner: InnerOpendalStore,
}

impl OpendalStore {
pub(super) fn new(operator: Operator) -> Self {
Self {
inner: InnerOpendalStore::new(operator),
}
}
}

impl fmt::Display for OpendalStore {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
self.inner.fmt(formatter)
}
}

fn normalize_location(location: &Path, prefix: Option<&Path>) -> object_store::Result<Path> {
if prefix.is_none_or(|prefix| location.prefix_matches(prefix)) {
return Ok(location.clone());
}

Path::from_url_path(location.as_ref()).map_err(|source| object_store::Error::Generic {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unconditionally decoding the returned location breaks a path spelling that works before this change. A URI segment run%25231 produces the logical Path run%231; InnerOpendalStore decodes requests to physical run#1 and, on the base revision, re-encodes that listing as run%231, so it still matches the logical base. This adapter decodes it again to run#1, and prefix_matches(run%231) now fails. Relative-listing consumers then receive the absolute location, and cleanup can misclassify a referenced file when that location begins with a managed subtree such as data.

The mapping must preserve existing double-encoded URI paths while normalizing the reserved-character mismatch. One bounded option is to retain the upstream spelling when it already matches the requested prefix and decode only a mismatch; a full bidirectional translation needs a compatibility path for keys written through the current bridge. Please cover a literal percent-escape case.

Reproducer

I inserted this temporary test in the new adapter test module:

let operator = Operator::new(Memory::default()).unwrap();
let base_store = InnerOpendalStore::new(operator.clone());
let head_store = OpendalStore::new(operator);
let base = Path::from_url_path("tables/run%25231/t.lance").unwrap();
let location = base.clone().join("manifest.lance");
head_store
    .put(&location, Bytes::from_static(b"data").into())
    .await
    .unwrap();

let base_listed = base_store
    .list(Some(&base))
    .try_collect::<Vec<_>>()
    .await
    .unwrap();
let head_listed = head_store
    .list(Some(&base))
    .try_collect::<Vec<_>>()
    .await
    .unwrap();

assert_eq!(base_listed[0].location, location);
assert!(base_listed[0].location.prefix_matches(&base));
assert_eq!(
    head_listed[0].location.as_ref(),
    "tables/run#1/t.lance/manifest.lance"
);
assert!(!head_listed[0].location.prefix_matches(&base));

Run with:

cargo test -p lance-io --no-default-features --features oss object_store::opendal_store::tests::control_literal_percent_escape_is_not_raw_end_to_end -- --exact

Observed: 1 passed; 0 failed. The base listing matched run%231; the adapter listing became run#1 and did not match.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 3247cf1: the adapter now preserves upstream locations that already match the requested prefix and decodes only mismatches. Parameterized regressions cover raw reserved characters and literal percent escapes across recursive, offset, and delimiter listings.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3247cf181: prefix-aware normalization now retains upstream locations that already match the request, so raw reserved characters and literal percent escapes both preserve the requested spelling across recursive, offset, and delimiter listings. Both parameterized regressions pass on the current head.

store: "OpendalStore",
source: Box::new(source),
})
}

fn normalize_object_meta(
mut meta: ObjectMeta,
prefix: Option<&Path>,
) -> object_store::Result<ObjectMeta> {
meta.location = normalize_location(&meta.location, prefix)?;
Ok(meta)
}

#[async_trait]
impl OSObjectStore for OpendalStore {
async fn put_opts(
&self,
location: &Path,
payload: PutPayload,
options: PutOptions,
) -> object_store::Result<PutResult> {
self.inner.put_opts(location, payload, options).await
}

async fn put_multipart_opts(
&self,
location: &Path,
options: PutMultipartOptions,
) -> object_store::Result<Box<dyn MultipartUpload>> {
self.inner.put_multipart_opts(location, options).await
}

async fn get_opts(
&self,
location: &Path,
options: GetOptions,
) -> object_store::Result<GetResult> {
self.inner.get_opts(location, options).await
}

async fn get_ranges(
&self,
location: &Path,
ranges: &[Range<u64>],
) -> object_store::Result<Vec<Bytes>> {
self.inner.get_ranges(location, ranges).await
}

fn delete_stream(
&self,
locations: BoxStream<'static, object_store::Result<Path>>,
) -> BoxStream<'static, object_store::Result<Path>> {
self.inner.delete_stream(locations)
}

fn list(&self, prefix: Option<&Path>) -> BoxStream<'static, object_store::Result<ObjectMeta>> {
let listed = self.inner.list(prefix);
let prefix = prefix.cloned();
listed
.map(move |result| result.and_then(|meta| normalize_object_meta(meta, prefix.as_ref())))
.boxed()
}

fn list_with_offset(
&self,
prefix: Option<&Path>,
offset: &Path,
) -> BoxStream<'static, object_store::Result<ObjectMeta>> {
if self.inner.info().capability().list_with_start_after {
let listed = self.inner.list_with_offset(prefix, offset);
let prefix = prefix.cloned();
listed
.map(move |result| {
result.and_then(|meta| normalize_object_meta(meta, prefix.as_ref()))
})
.boxed()
} else {
// The bridge's fallback compares its encoded output with the raw
// offset. Filter normalized locations so both sides use one form.
let offset = offset.clone();
self.list(prefix)
.try_filter(move |meta| future::ready(meta.location > offset))
.boxed()
}
}

async fn list_with_delimiter(&self, prefix: Option<&Path>) -> object_store::Result<ListResult> {
let mut result = self.inner.list_with_delimiter(prefix).await?;
for object in &mut result.objects {
object.location = normalize_location(&object.location, prefix)?;
}
for common_prefix in &mut result.common_prefixes {
*common_prefix = normalize_location(common_prefix, prefix)?;
}
Ok(result)
}

async fn copy_opts(
&self,
from: &Path,
to: &Path,
options: CopyOptions,
) -> object_store::Result<()> {
self.inner.copy_opts(from, to, options).await
}

async fn rename_opts(
&self,
from: &Path,
to: &Path,
options: RenameOptions,
) -> object_store::Result<()> {
self.inner.rename_opts(from, to, options).await
}
}

#[cfg(test)]
mod tests {
use bytes::Bytes;
use futures::TryStreamExt;
use object_store::ObjectStoreExt;
use opendal::services::Memory;
use rstest::rstest;

use super::*;

#[rstest]
#[case::raw_reserved_character("tables/run~1/t.lance")]
#[case::literal_percent_escape("tables/run%25231/t.lance")]
#[tokio::test]
async fn test_list_preserves_request_path_spelling(#[case] base_url: &str) {
let operator = Operator::new(Memory::default()).unwrap();
let store = OpendalStore::new(operator);
let base = Path::from_url_path(base_url).unwrap();
let direct_location = base.clone().join("manifest.lance");
let nested_location = Path::from_url_path(format!("{base_url}/data/part.lance")).unwrap();
for location in [&direct_location, &nested_location] {
store
.put(location, Bytes::from_static(b"data").into())
.await
.unwrap();
}

let listed = store
.list(Some(&base))
.try_collect::<Vec<_>>()
.await
.unwrap();
let mut listed_locations = listed
.into_iter()
.map(|meta| meta.location)
.collect::<Vec<_>>();
listed_locations.sort();
let mut expected_locations = vec![direct_location.clone(), nested_location.clone()];
expected_locations.sort();
assert_eq!(listed_locations, expected_locations);
assert!(
listed_locations
.iter()
.all(|location| location.prefix_matches(&base))
);

let listed_after_nested = store
.list_with_offset(Some(&base), &nested_location)
.try_collect::<Vec<_>>()
.await
.unwrap();
assert_eq!(listed_after_nested.len(), 1);
assert_eq!(listed_after_nested[0].location, direct_location);

let delimited = store.list_with_delimiter(Some(&base)).await.unwrap();
assert_eq!(delimited.objects.len(), 1);
assert_eq!(delimited.objects[0].location, direct_location);
assert_eq!(delimited.common_prefixes, vec![base.clone().join("data")]);
}
}
2 changes: 1 addition & 1 deletion rust/lance-io/src/object_store/providers/aws.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ use mock_instant::thread_local::{SystemTime, UNIX_EPOCH};
use std::time::{SystemTime, UNIX_EPOCH};

use object_store::ObjectStore as OSObjectStore;
use object_store_opendal::OpendalStore;
use opendal::{Operator, services::S3};

use aws_config::default_provider::credentials::DefaultCredentialsChain;
Expand All @@ -30,6 +29,7 @@ use object_store::{
use tokio::sync::RwLock;
use url::Url;

use crate::object_store::opendal_store::OpendalStore;
use crate::object_store::{
DEFAULT_CLOUD_BLOCK_SIZE, DEFAULT_CLOUD_IO_PARALLELISM, DEFAULT_MAX_IOP_SIZE, ObjectStore,
ObjectStoreParams, ObjectStoreProvider, StorageOptions, StorageOptionsAccessor,
Expand Down
2 changes: 1 addition & 1 deletion rust/lance-io/src/object_store/providers/azure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ use std::{
};

use object_store::ObjectStore as OSObjectStore;
use object_store_opendal::OpendalStore;
use opendal::{Operator, services::Azblob, services::Azdls};

use object_store::{
Expand All @@ -18,6 +17,7 @@ use object_store::{
};
use url::Url;

use crate::object_store::opendal_store::OpendalStore;
use crate::object_store::{
DEFAULT_CLOUD_BLOCK_SIZE, DEFAULT_CLOUD_IO_PARALLELISM, DEFAULT_MAX_IOP_SIZE, ObjectStore,
ObjectStoreParams, ObjectStoreProvider, StorageOptions, StorageOptionsAccessor,
Expand Down
2 changes: 1 addition & 1 deletion rust/lance-io/src/object_store/providers/gcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ use object_store::{
ClientOptions, CredentialProvider, ObjectStore as OSObjectStore, Result as ObjectStoreResult,
client::{HttpClient, HttpConnector, HttpRequestBody, ReqwestConnector},
};
use object_store_opendal::OpendalStore;
use opendal::{Operator, services::Gcs};
use reqsign_core::{Context as ReqsignContext, HttpSend, OsEnv, ProvideCredential};
use reqsign_file_read_tokio::TokioFileRead;
Expand All @@ -20,6 +19,7 @@ use object_store::{
};
use url::Url;

use crate::object_store::opendal_store::OpendalStore;
use crate::object_store::{
DEFAULT_CLOUD_BLOCK_SIZE, DEFAULT_CLOUD_IO_PARALLELISM, DEFAULT_MAX_IOP_SIZE, ObjectStore,
ObjectStoreParams, ObjectStoreProvider, StorageOptions, StorageOptionsAccessor,
Expand Down
2 changes: 1 addition & 1 deletion rust/lance-io/src/object_store/providers/goosefs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@
use std::collections::HashMap;
use std::sync::Arc;

use object_store_opendal::OpendalStore;
use opendal::{Operator, services::GooseFs};
use url::Url;

use crate::object_store::opendal_store::OpendalStore;
use crate::object_store::{
DEFAULT_CLOUD_BLOCK_SIZE, DEFAULT_CLOUD_IO_PARALLELISM, DEFAULT_MAX_IOP_SIZE, ObjectStore,
ObjectStoreParams, ObjectStoreProvider, StorageOptions,
Expand Down
2 changes: 1 addition & 1 deletion rust/lance-io/src/object_store/providers/huggingface.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ use std::sync::Arc;

use object_store::ObjectStore as OSObjectStore;
use object_store::path::Path;
use object_store_opendal::OpendalStore;
use opendal::{Operator, services::Huggingface};
use url::Url;

use crate::object_store::dynamic_opendal::DynamicOpenDalStore;
use crate::object_store::opendal_store::OpendalStore;
use crate::object_store::parse_hf_repo_id;
use crate::object_store::{
DEFAULT_CLOUD_BLOCK_SIZE, DEFAULT_CLOUD_IO_PARALLELISM, DEFAULT_MAX_IOP_SIZE, ObjectStore,
Expand Down
2 changes: 1 addition & 1 deletion rust/lance-io/src/object_store/providers/oss.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@ use std::collections::HashMap;
use std::sync::Arc;

use object_store::ObjectStore as OSObjectStore;
use object_store_opendal::OpendalStore;
use opendal::{Operator, services::Oss};
use url::Url;

use crate::object_store::dynamic_opendal::DynamicOpenDalStore;
use crate::object_store::opendal_store::OpendalStore;
use crate::object_store::{
DEFAULT_CLOUD_BLOCK_SIZE, DEFAULT_CLOUD_IO_PARALLELISM, DEFAULT_MAX_IOP_SIZE, ObjectStore,
ObjectStoreParams, ObjectStoreProvider, StorageOptions,
Expand Down
2 changes: 1 addition & 1 deletion rust/lance-io/src/object_store/providers/tencent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@
use std::collections::HashMap;
use std::sync::Arc;

use object_store_opendal::OpendalStore;
use opendal::{Operator, services::Cos};
use url::Url;

use crate::object_store::opendal_store::OpendalStore;
use crate::object_store::{
DEFAULT_CLOUD_BLOCK_SIZE, DEFAULT_CLOUD_IO_PARALLELISM, DEFAULT_MAX_IOP_SIZE, ObjectStore,
ObjectStoreParams, ObjectStoreProvider, StorageOptions,
Expand Down
2 changes: 1 addition & 1 deletion rust/lance-io/src/object_store/providers/tos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@ use std::collections::HashMap;
use std::sync::Arc;

use object_store::ObjectStore as OSObjectStore;
use object_store_opendal::OpendalStore;
use opendal::{Operator, services::Tos};
use url::Url;

use crate::object_store::dynamic_opendal::DynamicOpenDalStore;
use crate::object_store::opendal_store::OpendalStore;
use crate::object_store::{
DEFAULT_CLOUD_BLOCK_SIZE, DEFAULT_CLOUD_IO_PARALLELISM, DEFAULT_MAX_IOP_SIZE, ObjectStore,
ObjectStoreParams, ObjectStoreProvider, StorageOptions,
Expand Down
Loading