This is an automated email from the ASF dual-hosted git repository.

yihua pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hudi-rs.git


The following commit(s) were added to refs/heads/main by this push:
     new 380062ec build(deps): upgrade lance to 13 (#845)
380062ec is described below

commit 380062ecfb86abfa76d61570c31b61e70898ca9e
Author: Y Ethan Guo <[email protected]>
AuthorDate: Tue Oct 27 16:30:19 2026 -0700

    build(deps): upgrade lance to 13 (#845)
    
    Lance 13 is built on object_store 0.14 while the workspace is on 0.13, so 
the Lance reader now hands Lance its store through an adapter implementing the 
0.14 ObjectStore trait over the workspace's store.
---
 crates/core/Cargo.toml                             |  19 +-
 crates/core/src/file_group/base_file/lance.rs      |  46 ++-
 .../src/file_group/base_file/lance_object_store.rs | 435 +++++++++++++++++++++
 crates/core/src/file_group/base_file/mod.rs        |   1 +
 4 files changed, 490 insertions(+), 11 deletions(-)

diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml
index b1acb775..57efb12c 100644
--- a/crates/core/Cargo.toml
+++ b/crates/core/Cargo.toml
@@ -77,6 +77,7 @@ url = { workspace = true }
 
 # runtime / async
 async-recursion = { workspace = true }
+async-trait = { workspace = true }
 dashmap = { workspace = true }
 futures = { workspace = true }
 tokio = { workspace = true }
@@ -92,17 +93,22 @@ flate2 = { workspace = true }
 # prost-build at compile time, which shells out to `protoc`. Any build of
 # hudi-core (CI, Docker, local dev, downstream consumers) must therefore
 # have `protoc` available on PATH — there is no opt-out.
-lance-core = { version = "11.0.0" }
-lance-encoding = { version = "11.0.0" }
-lance-file = { version = "11.0.0" }
-lance-io = { version = "11.0.0" }
-# Lance 11 is built on arrow 58 while the workspace is on 59. These are the
+lance-core = { version = "13.0.0" }
+lance-encoding = { version = "13.0.0" }
+lance-file = { version = "13.0.0" }
+lance-io = { version = "13.0.0" }
+# Lance 13 is built on arrow 58 while the workspace is on 59. These are the
 # arrow crates Lance's API speaks, renamed so the Lance reader can export its
 # batches through the Arrow C data interface and import them as the
 # workspace's arrow; see `file_group::base_file::lance_arrow`. Drop them once a
 # Lance release shares the workspace's arrow major.
 arrow-array-lance = { package = "arrow-array", version = "58", features = 
["ffi"] }
 arrow-schema-lance = { package = "arrow-schema", version = "58", features = 
["ffi"] }
+# Lance 13 is built on object_store 0.14 while the workspace is on 0.13; the
+# Lance reader wraps the workspace's store in Lance's `ObjectStore` trait; see
+# `file_group::base_file::lance_object_store`. Drop it with the arrow crates
+# above once the majors line up.
+object_store-lance = { package = "object_store", version = "0.14.1", 
default-features = false }
 
 # datafusion
 datafusion = { workspace = true, optional = true }
@@ -112,9 +118,6 @@ datafusion-physical-expr = { workspace = true, optional = 
true }
 percent-encoding = { workspace = true }
 
 [dev-dependencies]
-# Needed only to implement `object_store::ObjectStore` for the test-only 
counting
-# wrapper: that trait is declared with `#[async_trait]`, so an impl must be 
too.
-async-trait = { workspace = true }
 hudi-test = { path = "../test" }
 serial_test = { workspace = true }
 # Also a regular dependency, but only an optional one behind `spill-rocksdb`.
diff --git a/crates/core/src/file_group/base_file/lance.rs 
b/crates/core/src/file_group/base_file/lance.rs
index 9f41a67e..787608a5 100644
--- a/crates/core/src/file_group/base_file/lance.rs
+++ b/crates/core/src/file_group/base_file/lance.rs
@@ -39,9 +39,11 @@ use lance_io::scheduler::{FileScheduler, ScanScheduler, 
SchedulerConfig};
 use lance_io::utils::CachedFileSize;
 use object_store::ObjectStoreExt;
 use object_store::path::Path as ObjPath;
+use object_store_lance::path::Path as LancePath;
 use tokio::sync::OnceCell;
 
 use super::lance_arrow;
+use super::lance_object_store::LanceObjectStoreAdapter;
 use super::reader::{BaseFileReadOptions, BaseFileReader, BaseFileStream};
 use crate::statistics::{ColumnStatistics, StatisticsContainer, 
StatsGranularity};
 use crate::storage::Storage;
@@ -84,14 +86,16 @@ impl LanceBaseFileReader {
                     ),
                 );
                 // `ObjectStoreParams::object_store` is marked deprecated in 
lance-io
-                // 11.x in favor of implementing `ObjectStoreProvider`. We 
still set
+                // in favor of implementing `ObjectStoreProvider`. We still set
                 // it here so Lance reuses the ObjectStore we already built 
(with
                 // hudi-rs's storage options applied) and skips re-resolving
                 // credentials. `storage_options_accessor` itself is current 
API.
                 #[allow(deprecated)]
                 let params = lance_io::object_store::ObjectStoreParams {
                     object_store: Some((
-                        self.storage.object_store.clone(),
+                        Arc::new(LanceObjectStoreAdapter::new(
+                            self.storage.object_store.clone(),
+                        )),
                         (*self.storage.base_url).clone(),
                     )),
                     storage_options_accessor: Some(storage_accessor),
@@ -128,7 +132,8 @@ impl LanceBaseFileReader {
     ) -> Result<FileScheduler> {
         let scheduler = self.scheduler().await?;
         let obj_url = join_url_segments(&self.storage.base_url, 
&[relative_path])?;
-        let obj_path = ObjPath::from_url_path(obj_url.path())?;
+        let obj_path = LancePath::from_url_path(obj_url.path())
+            .map_err(|e| StorageError::InvalidPath(e.to_string()))?;
         let cached_size = match known_size {
             Some(size) => CachedFileSize::new(size),
             None => CachedFileSize::unknown(),
@@ -394,6 +399,8 @@ impl BaseFileReader for LanceBaseFileReader {
 #[cfg(test)]
 mod tests {
     use super::*;
+    use crate::config::HudiConfigs;
+    use crate::storage::counting::CountingObjectStore;
     use hudi_test::SampleTable;
     use std::io::Write;
     use tempfile::TempDir;
@@ -433,6 +440,39 @@ mod tests {
             .to_string()
     }
 
+    #[tokio::test]
+    async fn test_lance_reader_reads_data_through_a_non_local_store() {
+        // Lance reads a file:// URL straight from disk and uses the given 
store
+        // only for size lookups, so only a non-local store carries the data 
reads.
+        let file_name = lance_base_file_name();
+        let table_path = SampleTable::V9LanceNonpartitioned.path_to_cow();
+        let bytes = 
std::fs::read(std::path::Path::new(&table_path).join(&file_name)).unwrap();
+        let memory = object_store::memory::InMemory::new();
+        memory
+            .put(&ObjPath::from(format!("table/{file_name}")), bytes.into())
+            .await
+            .unwrap();
+        let (store, counts) = CountingObjectStore::new(Arc::new(memory));
+        let storage = Storage::new_with_object_store(
+            Url::parse("memory:///table/").unwrap(),
+            store,
+            Arc::new(HudiConfigs::empty()),
+        );
+
+        let from_memory = LanceBaseFileReader::new(storage)
+            .read_data(&file_name, BaseFileReadOptions::new())
+            .await
+            .unwrap();
+        let from_disk = LanceBaseFileReader::new(lance_test_storage())
+            .read_data(&file_name, BaseFileReadOptions::new())
+            .await
+            .unwrap();
+
+        assert!(from_memory.num_rows() > 0);
+        assert_eq!(from_memory, from_disk);
+        assert!(counts.gets() > 0);
+    }
+
     #[tokio::test]
     async fn test_lance_reader_read_data_with_projection() {
         let storage = lance_test_storage();
diff --git a/crates/core/src/file_group/base_file/lance_object_store.rs 
b/crates/core/src/file_group/base_file/lance_object_store.rs
new file mode 100644
index 00000000..1016a0f6
--- /dev/null
+++ b/crates/core/src/file_group/base_file/lance_object_store.rs
@@ -0,0 +1,435 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+//! Hands the workspace's object store to Lance, which is compiled against a
+//! different `object_store` major.
+//!
+//! Lance takes the store as a trait object of its own `object_store`, and to
+//! rustc the two crates' `ObjectStore` traits are unrelated, so the store is
+//! wrapped in an adapter that implements Lance's trait by converting each
+//! request and response. Both majors carry the same read types field for
+//! field. hudi-core only reads through Lance, so the write operations report
+//! `NotImplemented`.
+
+use std::fmt::{Display, Formatter};
+use std::ops::Range;
+use std::sync::Arc;
+
+use async_trait::async_trait;
+use bytes::Bytes;
+use futures::stream::{self, BoxStream, StreamExt};
+use object_store::path::Path as HostPath;
+use object_store::{
+    Attribute as HostAttribute, Attributes as HostAttributes, Error as 
HostError,
+    GetOptions as HostGetOptions, GetRange as HostGetRange, GetResult as 
HostGetResult,
+    GetResultPayload as HostGetResultPayload, ObjectMeta as HostObjectMeta, 
ObjectStore,
+};
+use object_store_lance::path::Path;
+use object_store_lance::{
+    Attribute, AttributeValue, Attributes, CopyOptions, Error, GetOptions, 
GetRange, GetResult,
+    GetResultPayload, ListResult, MultipartUpload, ObjectMeta, 
PutMultipartOptions, PutOptions,
+    PutPayload, PutResult, Result,
+};
+
+const STORE: &str = "LanceObjectStoreAdapter";
+
+/// Implements Lance's `ObjectStore` over the workspace's store.
+#[derive(Debug)]
+pub(super) struct LanceObjectStoreAdapter {
+    inner: Arc<dyn ObjectStore>,
+}
+
+impl LanceObjectStoreAdapter {
+    pub(super) fn new(inner: Arc<dyn ObjectStore>) -> Self {
+        Self { inner }
+    }
+}
+
+impl Display for LanceObjectStoreAdapter {
+    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+        write!(f, "{}", self.inner)
+    }
+}
+
+#[async_trait]
+impl object_store_lance::ObjectStore for LanceObjectStoreAdapter {
+    async fn put_opts(
+        &self,
+        _location: &Path,
+        _payload: PutPayload,
+        _opts: PutOptions,
+    ) -> Result<PutResult> {
+        Err(not_implemented("put_opts"))
+    }
+
+    async fn put_multipart_opts(
+        &self,
+        _location: &Path,
+        _opts: PutMultipartOptions,
+    ) -> Result<Box<dyn MultipartUpload>> {
+        Err(not_implemented("put_multipart_opts"))
+    }
+
+    async fn get_opts(&self, location: &Path, options: GetOptions) -> 
Result<GetResult> {
+        let result = self
+            .inner
+            .get_opts(&to_host_path(location)?, to_host_get_options(options))
+            .await
+            .map_err(to_lance_error)?;
+        to_lance_get_result(result)
+    }
+
+    async fn get_ranges(&self, location: &Path, ranges: &[Range<u64>]) -> 
Result<Vec<Bytes>> {
+        self.inner
+            .get_ranges(&to_host_path(location)?, ranges)
+            .await
+            .map_err(to_lance_error)
+    }
+
+    fn delete_stream(
+        &self,
+        locations: BoxStream<'static, Result<Path>>,
+    ) -> BoxStream<'static, Result<Path>> {
+        locations
+            .map(|_| Err(not_implemented("delete_stream")))
+            .boxed()
+    }
+
+    fn list(&self, prefix: Option<&Path>) -> BoxStream<'static, 
Result<ObjectMeta>> {
+        match prefix.map(to_host_path).transpose() {
+            Ok(prefix) => 
to_lance_meta_stream(self.inner.list(prefix.as_ref())),
+            Err(e) => stream::once(async { Err(e) }).boxed(),
+        }
+    }
+
+    fn list_with_offset(
+        &self,
+        prefix: Option<&Path>,
+        offset: &Path,
+    ) -> BoxStream<'static, Result<ObjectMeta>> {
+        let paths = prefix
+            .map(to_host_path)
+            .transpose()
+            .and_then(|prefix| Ok((prefix, to_host_path(offset)?)));
+        match paths {
+            Ok((prefix, offset)) => {
+                
to_lance_meta_stream(self.inner.list_with_offset(prefix.as_ref(), &offset))
+            }
+            Err(e) => stream::once(async { Err(e) }).boxed(),
+        }
+    }
+
+    async fn list_with_delimiter(&self, prefix: Option<&Path>) -> 
Result<ListResult> {
+        let prefix = prefix.map(to_host_path).transpose()?;
+        let result = self
+            .inner
+            .list_with_delimiter(prefix.as_ref())
+            .await
+            .map_err(to_lance_error)?;
+        Ok(ListResult {
+            common_prefixes: result
+                .common_prefixes
+                .iter()
+                .map(to_lance_path)
+                .collect::<Result<_>>()?,
+            objects: result
+                .objects
+                .into_iter()
+                .map(to_lance_meta)
+                .collect::<Result<_>>()?,
+            extensions: Default::default(),
+        })
+    }
+
+    async fn copy_opts(&self, _from: &Path, _to: &Path, _options: CopyOptions) 
-> Result<()> {
+        Err(not_implemented("copy_opts"))
+    }
+}
+
+fn not_implemented(operation: &str) -> Error {
+    Error::NotImplemented {
+        operation: operation.to_string(),
+        implementer: STORE.to_string(),
+    }
+}
+
+fn to_host_path(path: &Path) -> Result<HostPath> {
+    HostPath::parse(path.as_ref()).map_err(|e| Error::Generic {
+        store: STORE,
+        source: Box::new(e),
+    })
+}
+
+fn to_lance_path(path: &HostPath) -> Result<Path> {
+    Ok(Path::parse(path.as_ref())?)
+}
+
+fn to_host_get_options(options: GetOptions) -> HostGetOptions {
+    HostGetOptions {
+        if_match: options.if_match,
+        if_none_match: options.if_none_match,
+        if_modified_since: options.if_modified_since,
+        if_unmodified_since: options.if_unmodified_since,
+        range: options.range.map(|range| match range {
+            GetRange::Bounded(r) => HostGetRange::Bounded(r),
+            GetRange::Offset(o) => HostGetRange::Offset(o),
+            GetRange::Suffix(s) => HostGetRange::Suffix(s),
+        }),
+        version: options.version,
+        head: options.head,
+        extensions: options.extensions,
+    }
+}
+
+fn to_lance_get_result(result: HostGetResult) -> Result<GetResult> {
+    let payload = match result.payload {
+        HostGetResultPayload::File(file, path) => GetResultPayload::File(file, 
path),
+        HostGetResultPayload::Stream(s) => {
+            GetResultPayload::Stream(s.map(|r| 
r.map_err(to_lance_error)).boxed())
+        }
+    };
+    Ok(GetResult {
+        payload,
+        meta: to_lance_meta(result.meta)?,
+        range: result.range,
+        attributes: to_lance_attributes(&result.attributes),
+        extensions: Default::default(),
+    })
+}
+
+fn to_lance_meta(meta: HostObjectMeta) -> Result<ObjectMeta> {
+    Ok(ObjectMeta {
+        location: to_lance_path(&meta.location)?,
+        last_modified: meta.last_modified,
+        size: meta.size,
+        e_tag: meta.e_tag,
+        version: meta.version,
+    })
+}
+
+fn to_lance_meta_stream(
+    metas: BoxStream<'static, object_store::Result<HostObjectMeta>>,
+) -> BoxStream<'static, Result<ObjectMeta>> {
+    metas
+        .map(|r| r.map_err(to_lance_error).and_then(to_lance_meta))
+        .boxed()
+}
+
+fn to_lance_attributes(attributes: &HostAttributes) -> Attributes {
+    attributes
+        .iter()
+        .filter_map(|(key, value)| {
+            let key = match key {
+                HostAttribute::ContentDisposition => 
Attribute::ContentDisposition,
+                HostAttribute::ContentEncoding => Attribute::ContentEncoding,
+                HostAttribute::ContentLanguage => Attribute::ContentLanguage,
+                HostAttribute::ContentType => Attribute::ContentType,
+                HostAttribute::CacheControl => Attribute::CacheControl,
+                HostAttribute::StorageClass => Attribute::StorageClass,
+                HostAttribute::Metadata(name) => 
Attribute::Metadata(name.clone()),
+                _ => return None,
+            };
+            Some((key, AttributeValue::from(value.to_string())))
+        })
+        .collect()
+}
+
+/// Keeps the variants Lance tells apart (a missing object above all) and
+/// carries every other error as `Generic` with the original as its source.
+fn to_lance_error(error: HostError) -> Error {
+    match error {
+        HostError::NotFound { path, source } => Error::NotFound { path, source 
},
+        HostError::AlreadyExists { path, source } => Error::AlreadyExists { 
path, source },
+        HostError::Precondition { path, source } => Error::Precondition { 
path, source },
+        HostError::NotModified { path, source } => Error::NotModified { path, 
source },
+        HostError::NotSupported { source } => Error::NotSupported { source },
+        HostError::PermissionDenied { path, source } => 
Error::PermissionDenied { path, source },
+        HostError::Unauthenticated { path, source } => Error::Unauthenticated 
{ path, source },
+        HostError::NotImplemented {
+            operation,
+            implementer,
+        } => Error::NotImplemented {
+            operation,
+            implementer,
+        },
+        HostError::Generic { store, source } => Error::Generic { store, source 
},
+        other => Error::Generic {
+            store: STORE,
+            source: Box::new(other),
+        },
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use object_store::memory::InMemory;
+    use object_store::{ObjectStoreExt as HostObjectStoreExt, PutPayload as 
HostPutPayload};
+    use object_store_lance::{ObjectStore as LanceObjectStore, ObjectStoreExt};
+
+    async fn adapter_over(files: &[(&str, &'static [u8])]) -> 
LanceObjectStoreAdapter {
+        let inner = InMemory::new();
+        for (path, data) in files {
+            inner
+                .put(&HostPath::from(*path), HostPutPayload::from_static(data))
+                .await
+                .unwrap();
+        }
+        LanceObjectStoreAdapter::new(Arc::new(inner))
+    }
+
+    #[tokio::test]
+    async fn reads_whole_objects_ranges_and_metadata() {
+        let store = adapter_over(&[("dir/a.lance", b"0123456789")]).await;
+        let path = Path::from("dir/a.lance");
+
+        let whole = store.get(&path).await.unwrap();
+        assert_eq!(whole.meta.size, 10);
+        assert_eq!(whole.range, 0..10);
+        assert_eq!(
+            whole.bytes().await.unwrap(),
+            Bytes::from_static(b"0123456789")
+        );
+
+        assert_eq!(
+            store.get_range(&path, 2..5).await.unwrap(),
+            Bytes::from_static(b"234")
+        );
+        assert_eq!(
+            store.get_ranges(&path, &[0..1, 8..10]).await.unwrap(),
+            vec![Bytes::from_static(b"0"), Bytes::from_static(b"89")]
+        );
+
+        let suffix = store
+            .get_opts(
+                &path,
+                GetOptions {
+                    range: Some(GetRange::Suffix(3)),
+                    ..Default::default()
+                },
+            )
+            .await
+            .unwrap();
+        assert_eq!(suffix.range, 7..10);
+
+        let offset = store
+            .get_opts(
+                &path,
+                GetOptions {
+                    range: Some(GetRange::Offset(6)),
+                    ..Default::default()
+                },
+            )
+            .await
+            .unwrap();
+        assert_eq!(offset.range, 6..10);
+        assert_eq!(offset.bytes().await.unwrap(), Bytes::from_static(b"6789"));
+
+        let head = store.head(&path).await.unwrap();
+        assert_eq!(head.location, path);
+        assert_eq!(head.size, 10);
+    }
+
+    #[tokio::test]
+    async fn reads_local_files_through_the_file_payload() {
+        let dir = tempfile::tempdir().unwrap();
+        std::fs::write(dir.path().join("a.lance"), b"0123456789").unwrap();
+        let inner = 
object_store::local::LocalFileSystem::new_with_prefix(dir.path()).unwrap();
+        let store = LanceObjectStoreAdapter::new(Arc::new(inner));
+        let path = Path::from("a.lance");
+
+        let result = store.get(&path).await.unwrap();
+        assert!(matches!(result.payload, GetResultPayload::File(..)));
+        assert_eq!(
+            result.bytes().await.unwrap(),
+            Bytes::from_static(b"0123456789")
+        );
+        assert_eq!(
+            store.get_range(&path, 4..6).await.unwrap(),
+            Bytes::from_static(b"45")
+        );
+    }
+
+    #[tokio::test]
+    async fn lists_with_and_without_delimiter() {
+        let store = adapter_over(&[
+            ("dir/a.lance", b"a"),
+            ("dir/b.lance", b"b"),
+            ("dir/sub/c.lance", b"c"),
+        ])
+        .await;
+        let prefix = Path::from("dir");
+
+        let mut all: Vec<_> = store
+            .list(Some(&prefix))
+            .map(|r| r.unwrap().location.to_string())
+            .collect()
+            .await;
+        all.sort();
+        assert_eq!(all, ["dir/a.lance", "dir/b.lance", "dir/sub/c.lance"]);
+
+        let after: Vec<_> = store
+            .list_with_offset(Some(&prefix), &Path::from("dir/a.lance"))
+            .map(|r| r.unwrap().location.to_string())
+            .collect()
+            .await;
+        assert!(!after.contains(&"dir/a.lance".to_string()));
+        assert_eq!(after.len(), 2);
+
+        let listed = store.list_with_delimiter(Some(&prefix)).await.unwrap();
+        assert_eq!(listed.common_prefixes, vec![Path::from("dir/sub")]);
+        let mut objects: Vec<_> = listed
+            .objects
+            .iter()
+            .map(|m| m.location.to_string())
+            .collect();
+        objects.sort();
+        assert_eq!(objects, ["dir/a.lance", "dir/b.lance"]);
+    }
+
+    #[tokio::test]
+    async fn keeps_not_found_distinct() {
+        let store = adapter_over(&[]).await;
+        let err = store.head(&Path::from("missing.lance")).await.unwrap_err();
+        assert!(matches!(err, Error::NotFound { .. }), "{err:?}");
+    }
+
+    #[tokio::test]
+    async fn rejects_writes() {
+        let store = adapter_over(&[]).await;
+        let path = Path::from("a.lance");
+
+        let errors = [
+            store
+                .put(&path, PutPayload::from_static(b"x"))
+                .await
+                .err()
+                .unwrap(),
+            store.put_multipart(&path).await.err().unwrap(),
+            store
+                .copy(&path, &Path::from("b.lance"))
+                .await
+                .err()
+                .unwrap(),
+            store.delete(&path).await.err().unwrap(),
+        ];
+        for err in errors {
+            assert!(matches!(err, Error::NotImplemented { .. }), "{err:?}");
+        }
+    }
+}
diff --git a/crates/core/src/file_group/base_file/mod.rs 
b/crates/core/src/file_group/base_file/mod.rs
index 32a8e3fd..e72619d3 100644
--- a/crates/core/src/file_group/base_file/mod.rs
+++ b/crates/core/src/file_group/base_file/mod.rs
@@ -19,6 +19,7 @@
 pub mod hfile;
 pub mod lance;
 mod lance_arrow;
+mod lance_object_store;
 pub mod parquet;
 pub mod reader;
 

Reply via email to