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 eb95599c feat(deps): upgrade to arrow 58, DataFusion 54, object_store 
0.13, and Lance 11 (#711)
eb95599c is described below

commit eb95599c29dc9859c62294335e51d302a59d10e7
Author: Y Ethan Guo <[email protected]>
AuthorDate: Thu Sep 3 14:35:17 2026 -0700

    feat(deps): upgrade to arrow 58, DataFusion 54, object_store 0.13, and 
Lance 11 (#711)
---
 Cargo.toml                                      | 38 ++++++------
 README.md                                       |  2 +-
 benchmark/filegroup/src/gen.rs                  |  2 +-
 benchmark/tpch/Cargo.toml                       | 17 +++++-
 benchmark/tpch/src/datagen.rs                   | 12 ++--
 cpp/src/util.rs                                 | 39 +++++++++++++
 crates/core/Cargo.toml                          |  8 +--
 crates/core/src/file_group/base_file/lance.rs   | 11 ++--
 crates/core/src/file_group/base_file/parquet.rs |  1 +
 crates/core/src/file_group/reader_v2/engine.rs  |  2 +-
 crates/core/src/storage/counting.rs             | 31 +++++-----
 crates/core/src/storage/mod.rs                  |  2 +-
 crates/core/src/storage/reader.rs               |  2 +-
 crates/core/tests/statistics_tests.rs           |  2 +-
 crates/datafusion/src/hudi_exec.rs              | 35 +++--------
 crates/datafusion/src/lib.rs                    |  7 ---
 crates/datafusion/tests/plan_tests.rs           | 60 ++++++++++++-------
 demo/apps/datafusion/Cargo.toml                 |  2 +-
 demo/apps/hudi-table-api/rust/Cargo.toml        |  2 +-
 python/Cargo.toml                               |  2 +-
 python/pyproject.toml                           |  2 +-
 python/src/datafusion_internal.rs               | 32 +++++-----
 python/src/internal.rs                          | 10 ++--
 python/tests/test_datafusion_ffi.py             | 77 +++++++++++++++++++++++++
 24 files changed, 266 insertions(+), 132 deletions(-)

diff --git a/Cargo.toml b/Cargo.toml
index d469c460..4a380c5d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -38,31 +38,31 @@ repository = "https://github.com/apache/hudi-rs";
 
 [workspace.dependencies]
 # arrow
-arrow = { version = "57" }
-arrow-arith = { version = "57" }
-arrow-array = { version = "57" }
-arrow-buffer = { version = "57" }
-arrow-cast = { version = "57" }
-arrow-ipc = { version = "57" }
-arrow-json = { version = "57" }
-arrow-ord = { version = "57" }
-arrow-row = { version = "57" }
-arrow-schema = { version = "57", features = ["serde"] }
-arrow-select = { version = "57" }
-object_store = { version = "0.12", features = ["aws", "azure", "gcp"] }
-parquet = { version = "57", features = ["async", "object_store"] }
+arrow = { version = "58" }
+arrow-arith = { version = "58" }
+arrow-array = { version = "58" }
+arrow-buffer = { version = "58" }
+arrow-cast = { version = "58" }
+arrow-ipc = { version = "58" }
+arrow-json = { version = "58" }
+arrow-ord = { version = "58" }
+arrow-row = { version = "58" }
+arrow-schema = { version = "58", features = ["serde"] }
+arrow-select = { version = "58" }
+object_store = { version = "0.13.2", features = ["aws", "azure", "gcp"] }
+parquet = { version = "58", features = ["async", "object_store"] }
 
 # avro
-arrow-avro = { version = "57" }
+arrow-avro = { version = "58" }
 apache-avro = { version = "0.21", features = ["derive"] }
 apache-avro-derive = { version = "0.21" }
 
 # datafusion
-datafusion = { version = "52" }
-datafusion-expr = { version = "52" }
-datafusion-common = { version = "52" }
-datafusion-physical-expr = { version = "52" }
-datafusion-ffi = { version = "52" }
+datafusion = { version = "54.1" }
+datafusion-expr = { version = "54.1" }
+datafusion-common = { version = "54.1" }
+datafusion-physical-expr = { version = "54.1" }
+datafusion-ffi = { version = "54.1" }
 
 # serde
 percent-encoding = { version = "2" }
diff --git a/README.md b/README.md
index 2541e7b8..aab198cd 100644
--- a/README.md
+++ b/README.md
@@ -362,7 +362,7 @@ extension to query Hudi tables.
 
 ```shell
 cargo new my_project --bin && cd my_project
-cargo add tokio@1 datafusion@52
+cargo add tokio@1 datafusion@54
 cargo add hudi --features datafusion
 ```
 
diff --git a/benchmark/filegroup/src/gen.rs b/benchmark/filegroup/src/gen.rs
index ceba15d1..f2a5f8fd 100644
--- a/benchmark/filegroup/src/gen.rs
+++ b/benchmark/filegroup/src/gen.rs
@@ -252,7 +252,7 @@ fn batch(from: u64, rows: usize) -> RecordBatch {
 fn write_one(path: &Path, target_bytes: u64, row_group_rows: usize, start_key: 
u64) -> u64 {
     let props = WriterProperties::builder()
         .set_compression(Compression::UNCOMPRESSED)
-        .set_max_row_group_size(row_group_rows)
+        .set_max_row_group_row_count(Some(row_group_rows))
         .build();
     let file = fs::File::create(path).expect("create parquet");
     let mut writer = ArrowWriter::try_new(file, schema(), 
Some(props)).expect("writer");
diff --git a/benchmark/tpch/Cargo.toml b/benchmark/tpch/Cargo.toml
index 218b899e..42cc918b 100644
--- a/benchmark/tpch/Cargo.toml
+++ b/benchmark/tpch/Cargo.toml
@@ -34,11 +34,22 @@ comfy-table = "7"
 datafusion = { workspace = true }
 hudi = { path = "../../crates/hudi", features = ["datafusion"] }
 object_store = { workspace = true }
-parquet = { workspace = true }
+
 serde = { workspace = true }
 serde_json = { workspace = true }
 tokio = { workspace = true }
-tpchgen = "2"
-tpchgen-arrow = "2"
+# The TPC-H generator has no arrow-58 line: tpchgen-arrow ships 2.x on arrow 57
+# and 3.x on arrow 59, and the workspace sits on 58. Generation is a
+# self-contained step — `datagen::run_generate` takes a scale factor and an
+# output path and hands nothing arrow-shaped back — so it builds against its 
own
+# arrow and parquet rather than holding the workspace back. `object_store` is
+# not duplicated: parquet 59 wants the same 0.13 the workspace does.
+tpchgen = "3"
+tpchgen-arrow = "3"
+arrow-gen = { package = "arrow", version = "59" }
+parquet-gen = { package = "parquet", version = "59", features = [
+    "async",
+    "object_store",
+] }
 serde_yaml = "0.9"
 url = { workspace = true }
diff --git a/benchmark/tpch/src/datagen.rs b/benchmark/tpch/src/datagen.rs
index fb4f6752..198edc7e 100644
--- a/benchmark/tpch/src/datagen.rs
+++ b/benchmark/tpch/src/datagen.rs
@@ -17,15 +17,15 @@
 
 use std::sync::Arc;
 
-use arrow::datatypes::SchemaRef;
-use arrow::record_batch::RecordBatch;
+use arrow_gen::datatypes::SchemaRef;
+use arrow_gen::record_batch::RecordBatch;
 use object_store::buffered::BufWriter;
 use object_store::path::Path as ObjectPath;
 use object_store::{ObjectStore, parse_url_opts};
-use parquet::arrow::ArrowWriter;
-use parquet::arrow::async_writer::AsyncArrowWriter;
-use parquet::basic::Compression;
-use parquet::file::properties::WriterProperties;
+use parquet_gen::arrow::ArrowWriter;
+use parquet_gen::arrow::async_writer::AsyncArrowWriter;
+use parquet_gen::basic::Compression;
+use parquet_gen::file::properties::WriterProperties;
 use tpchgen::generators::{
     CustomerGenerator, LineItemGenerator, NationGenerator, OrderGenerator, 
PartGenerator,
     PartSuppGenerator, RegionGenerator, SupplierGenerator,
diff --git a/cpp/src/util.rs b/cpp/src/util.rs
index 5a30cf3d..2bbf59bc 100644
--- a/cpp/src/util.rs
+++ b/cpp/src/util.rs
@@ -31,3 +31,42 @@ pub fn create_raw_pointer_for_record_batches(
     let raw_ptr = Box::into_raw(Box::new(ffi_array_stream));
     raw_ptr as *mut ffi::ArrowArrayStream
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use arrow::datatypes::{DataType, Field, Schema};
+    use arrow_array::ffi_stream::ArrowArrayStreamReader;
+    use arrow_array::{Int32Array, StringArray};
+    use std::sync::Arc;
+
+    /// The C++ caller only ever sees batches as a C Data Interface stream, so 
an
+    /// arrow upgrade that changed how that stream is exported would break the
+    /// binding without failing anything else: running the C++ side needs Arrow
+    /// C++, which the Rust test suite does not have. Importing the exported
+    /// pointer back keeps the surface covered here instead.
+    #[test]
+    fn exported_stream_round_trips_through_the_c_data_interface() {
+        let schema = Arc::new(Schema::new(vec![
+            Field::new("id", DataType::Int32, false),
+            Field::new("name", DataType::Utf8, false),
+        ]));
+        let batch = RecordBatch::try_new(
+            schema.clone(),
+            vec![
+                Arc::new(Int32Array::from(vec![1, 2, 3])),
+                Arc::new(StringArray::from(vec!["a", "b", "c"])),
+            ],
+        )
+        .unwrap();
+
+        let raw = create_raw_pointer_for_record_batches(vec![batch.clone()], 
schema);
+        // SAFETY: the pointer is the one box 
`create_raw_pointer_for_record_batches`
+        // leaks, taken back here rather than by the C++ caller that normally 
frees it.
+        let stream = unsafe { Box::from_raw(raw as *mut FFI_ArrowArrayStream) 
};
+
+        let mut reader = ArrowArrayStreamReader::try_new(*stream).unwrap();
+        assert_eq!(reader.next().unwrap().unwrap(), batch);
+        assert!(reader.next().is_none());
+    }
+}
diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml
index b3f733b7..62b657cf 100644
--- a/crates/core/Cargo.toml
+++ b/crates/core/Cargo.toml
@@ -92,10 +92,10 @@ 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 = "4.0.1" }
-lance-encoding = { version = "4.0.1" }
-lance-file = { version = "4.0.1" }
-lance-io = { version = "4.0.1" }
+lance-core = { version = "11.0.0" }
+lance-encoding = { version = "11.0.0" }
+lance-file = { version = "11.0.0" }
+lance-io = { version = "11.0.0" }
 
 # datafusion
 datafusion = { workspace = true, optional = true }
diff --git a/crates/core/src/file_group/base_file/lance.rs 
b/crates/core/src/file_group/base_file/lance.rs
index 5e3d396d..b66614dc 100644
--- a/crates/core/src/file_group/base_file/lance.rs
+++ b/crates/core/src/file_group/base_file/lance.rs
@@ -32,10 +32,12 @@ use 
lance_core::utils::tokio::get_num_compute_intensive_cpus;
 use lance_encoding::decoder::{DecoderPlugins, FilterExpression};
 use lance_file::LanceEncodingsIo;
 use lance_file::reader::{CachedFileMetadata, FileReader, FileReaderOptions, 
ReaderProjection};
+use lance_file::versions::reader_projection_from_column_names;
 use lance_io::ReadBatchParams;
 use lance_io::object_store::{ObjectStore as LanceObjectStore, 
ObjectStoreRegistry};
 use lance_io::scheduler::{FileScheduler, ScanScheduler, SchedulerConfig};
 use lance_io::utils::CachedFileSize;
+use object_store::ObjectStoreExt;
 use object_store::path::Path as ObjPath;
 use tokio::sync::OnceCell;
 
@@ -81,7 +83,7 @@ impl LanceBaseFileReader {
                     ),
                 );
                 // `ObjectStoreParams::object_store` is marked deprecated in 
lance-io
-                // 4.0.x in favor of implementing `ObjectStoreProvider`. We 
still set
+                // 11.x 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.
@@ -154,7 +156,7 @@ impl LanceBaseFileReader {
             return Ok(None);
         }
         let col_refs: Vec<&str> = col_names.iter().map(|s| 
s.as_str()).collect();
-        ReaderProjection::from_column_names(
+        reader_projection_from_column_names(
             metadata.version(),
             metadata.file_schema.as_ref(),
             &col_refs,
@@ -304,13 +306,14 @@ impl BaseFileReader for LanceBaseFileReader {
                     ReadBatchParams::RangeFull,
                     batch_size,
                     batch_readahead,
-                    // lance-file 4.0.x decoders do not act on 
FilterExpression:
+                    // lance-file 11.x decoders do not act on FilterExpression:
                     // per lance-encoding, "the core decoders do not currently
                     // take advantage of filtering in any way." Callers needing
                     // row-level predicates must apply them on the returned
                     // record batches.
                     FilterExpression::no_filter(),
                 )
+                .await
                 .map_err(|e| {
                     StorageError::Creation(format!(
                         "Failed to create Lance read stream for 
{relative_path}: {e}"
@@ -359,7 +362,7 @@ impl BaseFileReader for LanceBaseFileReader {
                 num_records: num_rows,
             };
 
-            // lance-file 4.0.x v2 format does not expose per-column min/max 
via
+            // lance-file 11.x v2 format does not expose per-column min/max via
             // `FileReader` (only num_pages and size_bytes via 
`FileStatistics`).
             // Populate an entry per file column with empty bounds so 
column-level
             // pruning falls back to "include".
diff --git a/crates/core/src/file_group/base_file/parquet.rs 
b/crates/core/src/file_group/base_file/parquet.rs
index c2b5e494..d6fe3ca4 100644
--- a/crates/core/src/file_group/base_file/parquet.rs
+++ b/crates/core/src/file_group/base_file/parquet.rs
@@ -22,6 +22,7 @@ use std::sync::Arc;
 
 use futures::StreamExt;
 use futures::future::BoxFuture;
+use object_store::ObjectStoreExt;
 use object_store::path::Path as ObjPath;
 use parquet::arrow::arrow_reader::{ArrowReaderMetadata, ArrowReaderOptions};
 use parquet::arrow::async_reader::{AsyncFileReader, ParquetObjectReader};
diff --git a/crates/core/src/file_group/reader_v2/engine.rs 
b/crates/core/src/file_group/reader_v2/engine.rs
index f5f089a3..4483abe2 100644
--- a/crates/core/src/file_group/reader_v2/engine.rs
+++ b/crates/core/src/file_group/reader_v2/engine.rs
@@ -1428,7 +1428,7 @@ mod tests {
         use parquet::arrow::ArrowWriter;
         use parquet::file::properties::WriterProperties;
         let props = WriterProperties::builder()
-            .set_max_row_group_size(rows_per_group)
+            .set_max_row_group_row_count(Some(rows_per_group))
             .build();
         let file = std::fs::File::create(dir.join(name)).unwrap();
         let mut writer = ArrowWriter::try_new(file, batch.schema(), 
Some(props)).unwrap();
diff --git a/crates/core/src/storage/counting.rs 
b/crates/core/src/storage/counting.rs
index fb72eaa6..5ae6c153 100644
--- a/crates/core/src/storage/counting.rs
+++ b/crates/core/src/storage/counting.rs
@@ -32,7 +32,7 @@ use async_trait::async_trait;
 use futures::stream::BoxStream;
 use object_store::path::Path;
 use object_store::{
-    GetOptions, GetResult, ListResult, MultipartUpload, ObjectMeta, 
ObjectStore,
+    CopyOptions, GetOptions, GetResult, ListResult, MultipartUpload, 
ObjectMeta, ObjectStore,
     PutMultipartOptions, PutOptions, PutPayload, PutResult, Result,
 };
 
@@ -101,17 +101,22 @@ impl ObjectStore for CountingObjectStore {
     }
 
     async fn get_opts(&self, location: &Path, options: GetOptions) -> 
Result<GetResult> {
-        self.counts.gets.fetch_add(1, Ordering::Relaxed);
+        // A metadata-only lookup reaches the store as a `get_opts` carrying
+        // `head`, not as its own trait method, so the two request kinds are
+        // told apart here.
+        if options.head {
+            self.counts.heads.fetch_add(1, Ordering::Relaxed);
+        } else {
+            self.counts.gets.fetch_add(1, Ordering::Relaxed);
+        }
         self.inner.get_opts(location, options).await
     }
 
-    async fn head(&self, location: &Path) -> Result<ObjectMeta> {
-        self.counts.heads.fetch_add(1, Ordering::Relaxed);
-        self.inner.head(location).await
-    }
-
-    async fn delete(&self, location: &Path) -> Result<()> {
-        self.inner.delete(location).await
+    fn delete_stream(
+        &self,
+        locations: BoxStream<'static, Result<Path>>,
+    ) -> BoxStream<'static, Result<Path>> {
+        self.inner.delete_stream(locations)
     }
 
     fn list(&self, prefix: Option<&Path>) -> BoxStream<'static, 
Result<ObjectMeta>> {
@@ -122,11 +127,7 @@ impl ObjectStore for CountingObjectStore {
         self.inner.list_with_delimiter(prefix).await
     }
 
-    async fn copy(&self, from: &Path, to: &Path) -> Result<()> {
-        self.inner.copy(from, to).await
-    }
-
-    async fn copy_if_not_exists(&self, from: &Path, to: &Path) -> Result<()> {
-        self.inner.copy_if_not_exists(from, to).await
+    async fn copy_opts(&self, from: &Path, to: &Path, options: CopyOptions) -> 
Result<()> {
+        self.inner.copy_opts(from, to, options).await
     }
 }
diff --git a/crates/core/src/storage/mod.rs b/crates/core/src/storage/mod.rs
index 69e7474a..a025878f 100644
--- a/crates/core/src/storage/mod.rs
+++ b/crates/core/src/storage/mod.rs
@@ -26,7 +26,7 @@ use std::sync::atomic::{AtomicU64, Ordering};
 use async_recursion::async_recursion;
 use bytes::Bytes;
 use object_store::path::Path as ObjPath;
-use object_store::{ObjectStore, parse_url_opts};
+use object_store::{ObjectStore, ObjectStoreExt, parse_url_opts};
 use url::Url;
 
 use crate::config::HudiConfigs;
diff --git a/crates/core/src/storage/reader.rs 
b/crates/core/src/storage/reader.rs
index 7527a372..c0687a50 100644
--- a/crates/core/src/storage/reader.rs
+++ b/crates/core/src/storage/reader.rs
@@ -19,7 +19,7 @@
 use crate::config::HudiConfigs;
 use bytes::Bytes;
 use object_store::path::Path as ObjPath;
-use object_store::{ObjectMeta, ObjectStore};
+use object_store::{ObjectMeta, ObjectStore, ObjectStoreExt};
 use std::io::{Error, ErrorKind, Result};
 use std::sync::Arc;
 use std::sync::atomic::{AtomicU64, Ordering};
diff --git a/crates/core/tests/statistics_tests.rs 
b/crates/core/tests/statistics_tests.rs
index 0186b2a0..c694f0fb 100644
--- a/crates/core/tests/statistics_tests.rs
+++ b/crates/core/tests/statistics_tests.rs
@@ -69,7 +69,7 @@ fn write_parquet_file_multiple_row_groups(batches: 
&[RecordBatch], path: &std::p
     let props = WriterProperties::builder()
         .set_compression(Compression::SNAPPY)
         
.set_statistics_enabled(parquet::file::properties::EnabledStatistics::Page)
-        .set_max_row_group_size(3) // Force smaller row groups
+        .set_max_row_group_row_count(Some(3)) // Force smaller row groups
         .build();
     let mut writer = ArrowWriter::try_new(file, batches[0].schema(), 
Some(props)).unwrap();
     for batch in batches {
diff --git a/crates/datafusion/src/hudi_exec.rs 
b/crates/datafusion/src/hudi_exec.rs
index 9c23af1f..73606154 100644
--- a/crates/datafusion/src/hudi_exec.rs
+++ b/crates/datafusion/src/hudi_exec.rs
@@ -19,7 +19,6 @@
 //! Custom DataFusion execution plan for reading Hudi tables through
 //! [`FileGroupReader`], supporting all base file formats and MOR log merging.
 
-use std::any::Any;
 use std::fmt;
 use std::pin::Pin;
 use std::sync::Arc;
@@ -63,7 +62,7 @@ pub struct HudiScanExec {
     projected_schema: SchemaRef,
     projection: Option<Vec<usize>>,
     limit: Option<usize>,
-    properties: PlanProperties,
+    properties: Arc<PlanProperties>,
     metrics: ExecutionPlanMetricsSet,
 }
 
@@ -152,7 +151,7 @@ impl HudiScanExec {
             projected_schema,
             projection,
             limit,
-            properties,
+            properties: Arc::new(properties),
             metrics: ExecutionPlanMetricsSet::new(),
         }
     }
@@ -262,11 +261,7 @@ impl ExecutionPlan for HudiScanExec {
         "HudiScanExec"
     }
 
-    fn as_any(&self) -> &dyn Any {
-        self
-    }
-
-    fn properties(&self) -> &PlanProperties {
+    fn properties(&self) -> &Arc<PlanProperties> {
         &self.properties
     }
 
@@ -394,11 +389,7 @@ impl ExecutionPlan for HudiScanExec {
         )))
     }
 
-    fn statistics(&self) -> Result<Statistics> {
-        Ok(self.aggregate_file_slice_statistics())
-    }
-
-    fn partition_statistics(&self, partition: Option<usize>) -> 
Result<Statistics> {
+    fn partition_statistics(&self, partition: Option<usize>) -> 
Result<Arc<Statistics>> {
         let column_statistics =
             vec![ColumnStatistics::new_unknown(); 
self.projected_schema.fields().len()];
 
@@ -410,11 +401,14 @@ impl ExecutionPlan for HudiScanExec {
             ),
             Some(idx) => match self.file_slice_partitions.get(idx) {
                 Some(slices) => Box::new(std::iter::once(slices.as_slice())),
-                None => return 
Ok(Statistics::new_unknown(&self.projected_schema)),
+                None => return 
Ok(Arc::new(Statistics::new_unknown(&self.projected_schema))),
             },
         };
 
-        Ok(Self::aggregate_partitions(partitions, column_statistics))
+        Ok(Arc::new(Self::aggregate_partitions(
+            partitions,
+            column_statistics,
+        )))
     }
 
     fn with_fetch(&self, limit: Option<usize>) -> Option<Arc<dyn 
ExecutionPlan>> {
@@ -440,17 +434,6 @@ impl ExecutionPlan for HudiScanExec {
 }
 
 impl HudiScanExec {
-    fn aggregate_file_slice_statistics(&self) -> Statistics {
-        let column_statistics =
-            vec![ColumnStatistics::new_unknown(); 
self.projected_schema.fields().len()];
-        Self::aggregate_partitions(
-            self.file_slice_partitions
-                .iter()
-                .map(|slices| slices.as_slice()),
-            column_statistics,
-        )
-    }
-
     fn aggregate_partitions<'a, I>(
         partitions: I,
         column_statistics: Vec<ColumnStatistics>,
diff --git a/crates/datafusion/src/lib.rs b/crates/datafusion/src/lib.rs
index 9d8ebc0b..2e70738c 100644
--- a/crates/datafusion/src/lib.rs
+++ b/crates/datafusion/src/lib.rs
@@ -20,7 +20,6 @@
 pub(crate) mod hudi_exec;
 pub(crate) mod util;
 
-use std::any::Any;
 use std::collections::HashMap;
 use std::error::Error;
 use std::fmt::Debug;
@@ -787,10 +786,6 @@ impl HudiDataSource {
 
 #[async_trait]
 impl TableProvider for HudiDataSource {
-    fn as_any(&self) -> &dyn Any {
-        self
-    }
-
     fn schema(&self) -> SchemaRef {
         self.schema.clone()
     }
@@ -1158,7 +1153,6 @@ mod tests {
         let state = ctx.state();
         let plan = hudi.scan(&state, None, filters, None).await.unwrap();
         let exec = plan
-            .as_any()
             .downcast_ref::<HudiScanExec>()
             .expect("scan should route to HudiScanExec");
 
@@ -1431,7 +1425,6 @@ mod tests {
             .await
             .unwrap();
         let exec = plan
-            .as_any()
             .downcast_ref::<HudiScanExec>()
             .expect("MOR snapshot scan should use HudiScanExec");
 
diff --git a/crates/datafusion/tests/plan_tests.rs 
b/crates/datafusion/tests/plan_tests.rs
index 419d2ced..24b0c1af 100644
--- a/crates/datafusion/tests/plan_tests.rs
+++ b/crates/datafusion/tests/plan_tests.rs
@@ -148,26 +148,46 @@ async fn verify_plan(
         plan.contains("SortExec: TopK(fetch=10)"),
         "Plan should contain TopK sort"
     );
+    // The projection, struct field access included, is pushed into the Parquet
+    // source rather than planned as its own `ProjectionExec`. Keeping the 
whole
+    // column list in the anchor is what makes this catch a scan that stopped
+    // projecting; only the `structField@N` index varies per table, so the 
alias
+    // is matched separately.
     assert!(
-        plan.contains(&format!(
-            "ProjectionExec: expr=[id@0 as id, name@1 as name, isActive@2 as 
isActive, \
-            get_field(structField@3, field2) as 
{table_name}.structField[field2]]"
-        )),
-        "Plan should contain expected projection"
+        plan.contains("projection=[id, name, isActive, get_field(structField@")
+            && plan.contains(&format!(", field2) as 
{table_name}.structField[field2]")),
+        "Plan should project the struct field"
     );
-    // With pushdown_filters enabled, simple predicates (id % 2 = 0, name != 
Alice)
-    // are pushed into the Parquet source. Only non-pushable predicates like
-    // struct field access remain in FilterExec.
+    // Simple predicates (id % 2 = 0, name != Alice) and the struct field 
access
+    // alike are pushed into the Parquet source.
     assert!(
-        plan.contains("get_field(structField@3, field2) > 30"),
-        "Plan should contain struct field filter (either in FilterExec or 
DataSourceExec)"
+        scan_predicate(&plan).contains(", field2) > 30"),
+        "Scan predicate should contain the struct field filter"
     );
+    // `hoodie.read.input.partitions` decides how many groups the scan is split
+    // into. One group renders in the singular, which this prefix covers too.
     assert!(
-        
plan.contains(&format!("input_partitions={planned_input_partitioned}")),
-        "Plan should contain expected 
input_partitions={planned_input_partitioned}"
+        plan.contains(&format!("file_groups={{{planned_input_partitioned} 
group")),
+        "Plan should scan {planned_input_partitioned} file group(s)"
     );
 }
 
+/// The predicate the scan evaluates per row, which it renders as `, 
predicate=`.
+///
+/// The scan also renders a `, pruning_predicate=` derived from that one, and a
+/// bare substring search cannot tell the two apart. Only the former says what
+/// actually reached the source.
+fn scan_predicate(plan: &str) -> &str {
+    let predicate = plan
+        .split(", predicate=")
+        .nth(1)
+        .expect("plan should carry a scan predicate");
+    predicate
+        .split(", pruning_predicate=")
+        .next()
+        .unwrap_or(predicate)
+}
+
 async fn verify_data(ctx: &SessionContext, sql: &str, table_name: &str) {
     let df = ctx.sql(sql).await.unwrap();
     let rb = df.collect().await.unwrap();
@@ -385,16 +405,16 @@ mod v8_tests {
             "Should have TopK sort"
         );
         assert!(
-            plan_lines[2].contains("ProjectionExec"),
-            "Should have ProjectionExec"
+            plan_lines[2].starts_with("DataSourceExec"),
+            "Should scan through DataSourceExec"
         );
         assert!(
-            plan.contains("FilterExec"),
-            "Should have FilterExec for non-partition filters"
+            scan_predicate(&plan).contains(", field2) > 30"),
+            "Non-partition filters should reach the source"
         );
         assert!(
-            plan.contains("input_partitions=2"),
-            "Should have input_partitions=2"
+            plan.contains("file_groups={2 groups:"),
+            "Should scan 2 file groups"
         );
 
         // Verify data
@@ -430,8 +450,8 @@ mod v8_tests {
         let plan = get_str_column(explaining_rb, "plan").join("");
 
         assert!(
-            plan.contains("input_partitions=2"),
-            "Complex keygen table should have input_partitions=2"
+            plan.contains("file_groups={2 groups:"),
+            "Complex keygen table should scan 2 file groups"
         );
         assert!(
             plan.contains("DataSourceExec"),
diff --git a/demo/apps/datafusion/Cargo.toml b/demo/apps/datafusion/Cargo.toml
index cabebd1c..bb9ea81b 100644
--- a/demo/apps/datafusion/Cargo.toml
+++ b/demo/apps/datafusion/Cargo.toml
@@ -25,5 +25,5 @@ edition = "2021"
 
 [dependencies]
 tokio = "1"
-datafusion = "52"
+datafusion = "54"
 hudi = { path = "../../../crates/hudi", features = ["datafusion"] }
diff --git a/demo/apps/hudi-table-api/rust/Cargo.toml 
b/demo/apps/hudi-table-api/rust/Cargo.toml
index cca2e3fe..33df90fa 100644
--- a/demo/apps/hudi-table-api/rust/Cargo.toml
+++ b/demo/apps/hudi-table-api/rust/Cargo.toml
@@ -25,6 +25,6 @@ edition = "2021"
 
 [dependencies]
 tokio = "1"
-arrow = { version = "57" }
+arrow = { version = "58" }
 
 hudi = { path = "../../../../crates/hudi" }
diff --git a/python/Cargo.toml b/python/Cargo.toml
index df673504..09c6c0b9 100644
--- a/python/Cargo.toml
+++ b/python/Cargo.toml
@@ -50,7 +50,7 @@ futures = { workspace = true }
 tokio = { workspace = true }
 
 [dependencies.pyo3]
-version = "0.26"
+version = "0.28"
 features = ["extension-module", "abi3", "abi3-py310"]
 
 [features]
diff --git a/python/pyproject.toml b/python/pyproject.toml
index 35a5d47a..a6b345e7 100644
--- a/python/pyproject.toml
+++ b/python/pyproject.toml
@@ -53,7 +53,7 @@ lint = [
     "mypy==2.3.1",
 ]
 datafusion = [
-    "datafusion==52.3.0",
+    "datafusion==54.0.0",
 ]
 
 [tool.maturin]
diff --git a/python/src/datafusion_internal.rs 
b/python/src/datafusion_internal.rs
index 242bd372..f25167d0 100644
--- a/python/src/datafusion_internal.rs
+++ b/python/src/datafusion_internal.rs
@@ -40,23 +40,29 @@ fn extract_codec(session: Bound<PyAny>) -> 
PyResult<FFI_LogicalExtensionCodec> {
     } else {
         session
     };
-    let capsule = capsule_obj.downcast::<PyCapsule>()?;
-    if let Some(name) = capsule.name()? {
-        let name = name
-            .to_str()
-            .map_err(|e| PyValueError::new_err(format!("{e}")))?;
-        if name != "datafusion_logical_extension_codec" {
-            return Err(PyValueError::new_err(format!(
-                "Expected PyCapsule name 'datafusion_logical_extension_codec', 
got '{name}'"
-            )));
-        }
-    }
-    let codec = unsafe { capsule.reference::<FFI_LogicalExtensionCodec>() };
+    let capsule = capsule_obj.cast::<PyCapsule>()?;
+    // `pointer_checked` is the name check and the pointer read in one step: it
+    // refuses any capsule not carrying exactly this name. An unnamed capsule 
no
+    // longer passes, where the previous name-then-read pair let one through.
+    let codec = capsule
+        .pointer_checked(Some(c"datafusion_logical_extension_codec"))
+        .map_err(|e| {
+            PyValueError::new_err(format!(
+                "Expected a PyCapsule named 
'datafusion_logical_extension_codec': {e}"
+            ))
+        })?;
+    // SAFETY: DataFusion puts that name only on a capsule holding an
+    // `FFI_LogicalExtensionCodec`, so the check above settles what the capsule
+    // holds. It does not settle which `datafusion-ffi` built it, because the
+    // name carries no version: a `datafusion` wheel built against another 
major
+    // would present the same name over a different layout. Holding the two in
+    // step is what the pinned `datafusion` extra in pyproject.toml is for.
+    let codec = unsafe { codec.cast::<FFI_LogicalExtensionCodec>().as_ref() };
     Ok(codec.clone())
 }
 
 #[cfg(not(tarpaulin_include))]
-#[pyclass(name = "HudiDataFusionDataSource")]
+#[pyclass(name = "HudiDataFusionDataSource", from_py_object)]
 #[derive(Clone)]
 pub struct HudiDataFusionDataSource {
     table: InternalDataFusionHudiDataSource,
diff --git a/python/src/internal.rs b/python/src/internal.rs
index 0d2be9bd..98639e12 100644
--- a/python/src/internal.rs
+++ b/python/src/internal.rs
@@ -84,7 +84,7 @@ impl From<PythonError> for PyErr {
 /// Python wrapper around [`hudi::table::QueryType`].
 #[cfg(not(tarpaulin_include))]
 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
-#[pyclass(eq)]
+#[pyclass(eq, from_py_object)]
 pub struct HudiQueryType {
     inner: QueryType,
 }
@@ -121,7 +121,7 @@ impl HudiQueryType {
 
 #[cfg(not(tarpaulin_include))]
 #[derive(Clone, Debug, Default)]
-#[pyclass]
+#[pyclass(from_py_object)]
 pub struct HudiReadOptions {
     inner: ReadOptions,
 }
@@ -347,7 +347,7 @@ impl HudiRecordBatchStream {
 
 #[cfg(not(tarpaulin_include))]
 #[derive(Clone, Debug)]
-#[pyclass]
+#[pyclass(from_py_object)]
 pub struct HudiFileGroupReader {
     inner: FileGroupReader,
 }
@@ -488,7 +488,7 @@ impl HudiFileGroupReader {
 
 #[cfg(not(tarpaulin_include))]
 #[derive(Clone, Debug)]
-#[pyclass]
+#[pyclass(from_py_object)]
 pub struct HudiFileSlice {
     #[pyo3(get)]
     file_id: String,
@@ -632,7 +632,7 @@ impl From<&FileSlice> for HudiFileSlice {
 
 #[cfg(not(tarpaulin_include))]
 #[derive(Clone, Debug)]
-#[pyclass]
+#[pyclass(from_py_object)]
 pub struct HudiInstant {
     inner: Instant,
 }
diff --git a/python/tests/test_datafusion_ffi.py 
b/python/tests/test_datafusion_ffi.py
new file mode 100644
index 00000000..51628fba
--- /dev/null
+++ b/python/tests/test_datafusion_ffi.py
@@ -0,0 +1,77 @@
+#  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.
+
+"""Reads through the FFI table provider, against the real DataFusion Python 
package.
+
+This is the one path where a version skew between the `datafusion-ffi` this
+crate is built against and the `datafusion` wheel installed alongside it shows
+up, and it shows up as an ABI mismatch rather than a compile error, so nothing
+else in the suite would catch it.
+"""
+
+import pytest
+
+datafusion = pytest.importorskip("datafusion")
+
+from hudi._internal import HudiDataFusionDataSource, get_test_table_path  # 
noqa: E402
+
+
[email protected]
+def v6_table() -> str:
+    return get_test_table_path("v6_simplekeygen_nonhivestyle", "cow")
+
+
+def test_datafusion_ffi_reads_through_the_table_provider(v6_table):
+    ctx = datafusion.SessionContext()
+    ctx.register_table("hudi_t", HudiDataFusionDataSource(v6_table))
+
+    rows = []
+    for batch in ctx.sql(
+        'SELECT id, name, "isActive" FROM hudi_t ORDER BY id'
+    ).collect():
+        columns = batch.to_pydict()
+        for i in range(batch.num_rows):
+            rows.append((columns["id"][i], columns["name"][i], 
columns["isActive"][i]))
+
+    assert rows == [
+        (1, "Alice", False),
+        (2, "Bob", False),
+        (3, "Carol", True),
+        (4, "Diana", True),
+    ]
+
+
+def test_datafusion_ffi_pushes_the_filter_into_the_provider(v6_table):
+    ctx = datafusion.SessionContext()
+    ctx.register_table("hudi_t", HudiDataFusionDataSource(v6_table))
+
+    batch = ctx.sql("SELECT count(*) AS n FROM hudi_t WHERE id % 2 = 
0").collect()[0]
+    assert batch.to_pydict()["n"] == [2]
+
+
+def test_datafusion_ffi_plan_goes_through_the_ffi_boundary(v6_table):
+    ctx = datafusion.SessionContext()
+    ctx.register_table("hudi_t", HudiDataFusionDataSource(v6_table))
+
+    plans = []
+    for batch in ctx.sql("EXPLAIN SELECT id FROM hudi_t").collect():
+        columns = batch.to_pydict()
+        for i in range(batch.num_rows):
+            if columns["plan_type"][i] == "physical_plan":
+                plans.append(columns["plan"][i])
+
+    assert any("FFI_ExecutionPlan" in plan for plan in plans), plans

Reply via email to