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 017f0bb2 feat: upgrade to arrow 59, DataFusion 55 and pyo3 0.29 with
Lance kept on by default (#698)
017f0bb2 is described below
commit 017f0bb278ab882898dd78cd0b25a0a9db1c979e
Author: Xuanyi Li <[email protected]>
AuthorDate: Fri Oct 23 22:55:01 2026 -0700
feat: upgrade to arrow 59, DataFusion 55 and pyo3 0.29 with Lance kept on
by default (#698)
Co-authored-by: Y Ethan Guo <[email protected]>
---
AGENTS.md | 5 +-
Cargo.toml | 36 +-
README.md | 30 +-
benchmark/tpch/Cargo.toml | 12 +-
benchmark/tpch/src/datagen.rs | 12 +-
build-wrapper.sh | 29 +-
crates/core/Cargo.toml | 9 +-
crates/core/src/avro_to_arrow/schema.rs | 2 +-
crates/core/src/config/read.rs | 5 +
crates/core/src/file_group/base_file/lance.rs | 23 +-
.../core/src/file_group/base_file/lance_arrow.rs | 450 +++++++++++++++++++++
crates/core/src/file_group/base_file/mod.rs | 1 +
crates/core/src/file_group/base_file/parquet.rs | 8 +
crates/core/src/file_group/reader_v2/row_serde.rs | 2 +-
crates/datafusion/src/hudi_exec.rs | 9 +
crates/datafusion/src/lib.rs | 165 +++++++-
crates/datafusion/tests/plan_tests.rs | 39 +-
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 | 2 +-
22 files changed, 771 insertions(+), 76 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 9cd1d0a3..5221f4a1 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -25,7 +25,10 @@ benchmark/filegroup/ file-group read benchmark (fg-bench /
fg-gen)
The [`Makefile`](./Makefile) is the canonical command surface — **prefer `make
<target>`** so agents
and humans run what CI runs. Cargo / maturin go through
[`build-wrapper.sh`](./build-wrapper.sh)
-(macOS 26+ SDK env vars).
+(macOS 26+ SDK env vars; on macOS arm64 it also builds dependencies at
opt-level 1, without which
+the `hudi-datafusion` test binaries and the Python extension fail to link, see
+[#844](https://github.com/apache/hudi-rs/issues/844)). On an arm64 Mac, run
`cargo test` for
+`hudi-datafusion` through the wrapper: `./build-wrapper.sh cargo test -p
hudi-datafusion`.
```bash
make setup-venv && source .venv/bin/activate
diff --git a/Cargo.toml b/Cargo.toml
index f6dfe768..0b6bda38 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -38,31 +38,31 @@ repository = "https://github.com/apache/hudi-rs"
[workspace.dependencies]
# arrow
-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" }
+arrow = { version = "59.2.0" }
+arrow-arith = { version = "59.2.0" }
+arrow-array = { version = "59.2.0" }
+arrow-buffer = { version = "59.2.0" }
+arrow-cast = { version = "59.2.0" }
+arrow-ipc = { version = "59.2.0" }
+arrow-json = { version = "59.2.0" }
+arrow-ord = { version = "59.2.0" }
+arrow-row = { version = "59.2.0" }
+arrow-schema = { version = "59.2.0", features = ["serde"] }
+arrow-select = { version = "59.2.0" }
object_store = { version = "0.13.2", features = ["aws", "azure", "gcp"] }
-parquet = { version = "58", features = ["async", "object_store"] }
+parquet = { version = "59.2.0", features = ["async", "object_store"] }
# avro
-arrow-avro = { version = "58" }
+arrow-avro = { version = "59.2.0" }
apache-avro = { version = "0.21", features = ["derive"] }
apache-avro-derive = { version = "0.21" }
# datafusion
-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" }
+datafusion = { version = "55.2" }
+datafusion-expr = { version = "55.2" }
+datafusion-common = { version = "55.2" }
+datafusion-physical-expr = { version = "55.2" }
+datafusion-ffi = { version = "55.2" }
# serde
percent-encoding = { version = "2" }
diff --git a/README.md b/README.md
index 4be8911f..3760aabc 100644
--- a/README.md
+++ b/README.md
@@ -364,7 +364,7 @@ silently redirect every later read.
| `hoodie.read.stream.batch_size` | `1024`
| table or read | Rows per batch for streaming reads.
|
| `hoodie.read.file.slice.read.concurrency` | `4`
| table or read | File slices read concurrently.
|
| `hoodie.read.scan.max.memory.size` | unset
| table or read | Total bytes a whole scan may use for concurrent slice
reads; when set, the concurrency is derived from it. Reaching the limit lowers
throughput, it never fails the read. |
-| `hoodie.read.input.partitions` | `0`
| table or read | How many partitions the DataFusion table provider buckets
the file slices into. `0` defers to DataFusion's `target_partitions`. |
+| `hoodie.read.input.partitions` | `0`
| table or read | How many partitions the DataFusion table provider buckets
the file slices into. `0` defers to DataFusion's `target_partitions`. On the
parquet-source path (copy-on-write, read-optimized merge-on-read) DataFusion
may split a bucket further once it exceeds
`datafusion.optimizer.repartition_file_min_size`, unless
`datafusion.optimizer.repartition_file_scans` is off. |
| `hoodie.merge.use.record.positions` | `false`
| table or read | Match a log record to the base row it updates by position
rather than by record key. Honored by reader version 2 only; a log block
written without positions is merged by key regardless. |
A table whose `hoodie.record.merge.mode` is `CUSTOM` needs a merger for its
payload class. When
@@ -392,12 +392,38 @@ Create a Hudi file group reader instance using its
constructor or the Hudi table
Enabling the `hudi` crate with `datafusion` feature will provide a
[DataFusion](https://datafusion.apache.org/)
extension to query Hudi tables.
+Each hudi-rs release targets one DataFusion major, and with it one arrow
major. The Rust `hudi`
+crate's `TableProvider` and the Python `hudi[datafusion]` extra both have to
match the DataFusion
+version of the application they plug into, so pick the hudi-rs release that
pairs with yours.
+Readers that take Arrow data out through the C data interface (Python
`read()`, the C++ and JVM
+bindings) are not tied to these versions. The `hudi[datafusion]` extra first
shipped in 0.5.
+
+| hudi-rs | DataFusion | arrow | Python `datafusion` package |
+|---------|------------|-------|-----------------------------|
+| 0.6 (unreleased) | 55 | 59 | `>=55.0.0,<56` |
+| 0.5 | 54 | 58 | `>=54.0.0,<55` |
+| 0.4 | 46 | 54 | not available |
+| 0.3 | 43 | 53 | not available |
+
+On macOS arm64, a debug build of an application that links `hudi` with the
`datafusion` feature
+can fail with `ld: B/BL out of range ... (max +/-128MB)`: unoptimized
dependencies push the
+binary's code section past the arm64 branch range. Optimize dependencies in
the dev profile:
+
+```toml
+[profile.dev.package."*"]
+opt-level = 1
+```
+
+This workspace applies the same setting from `build-wrapper.sh` on macOS arm64
only, since it
+slows compilation noticeably. It goes away once a Lance release shares the
workspace's arrow major
+([#844](https://github.com/apache/hudi-rs/issues/844)).
+
<details>
<summary>Add crate hudi with datafusion feature to your application to query a
Hudi table.</summary>
```shell
cargo new my_project --bin && cd my_project
-cargo add tokio@1 datafusion@54
+cargo add tokio@1 datafusion@55
cargo add hudi --features datafusion
```
diff --git a/benchmark/tpch/Cargo.toml b/benchmark/tpch/Cargo.toml
index 09977eed..91efe1d7 100644
--- a/benchmark/tpch/Cargo.toml
+++ b/benchmark/tpch/Cargo.toml
@@ -35,22 +35,12 @@ comfy-table = "8"
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 }
-# 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 198edc7e..fb4f6752 100644
--- a/benchmark/tpch/src/datagen.rs
+++ b/benchmark/tpch/src/datagen.rs
@@ -17,15 +17,15 @@
use std::sync::Arc;
-use arrow_gen::datatypes::SchemaRef;
-use arrow_gen::record_batch::RecordBatch;
+use arrow::datatypes::SchemaRef;
+use arrow::record_batch::RecordBatch;
use object_store::buffered::BufWriter;
use object_store::path::Path as ObjectPath;
use object_store::{ObjectStore, parse_url_opts};
-use parquet_gen::arrow::ArrowWriter;
-use parquet_gen::arrow::async_writer::AsyncArrowWriter;
-use parquet_gen::basic::Compression;
-use parquet_gen::file::properties::WriterProperties;
+use parquet::arrow::ArrowWriter;
+use parquet::arrow::async_writer::AsyncArrowWriter;
+use parquet::basic::Compression;
+use parquet::file::properties::WriterProperties;
use tpchgen::generators::{
CustomerGenerator, LineItemGenerator, NationGenerator, OrderGenerator,
PartGenerator,
PartSuppGenerator, RegionGenerator, SupplierGenerator,
diff --git a/build-wrapper.sh b/build-wrapper.sh
index de58e1e3..8eb5d90e 100755
--- a/build-wrapper.sh
+++ b/build-wrapper.sh
@@ -16,8 +16,8 @@
# specific language governing permissions and limitations
# under the License.
-# Setup script for macOS 26 (Tahoe) build environment
-# This script detects macOS version and sets SDK environment variables if
needed
+# Build environment wrapper for macOS: sets SDK environment variables on
+# macOS 26 (Tahoe) and later, and optimizes dependencies in dev builds on
arm64.
set -e
@@ -41,5 +41,30 @@ if [[ "$OSTYPE" == "darwin"* ]]; then
fi
fi
+# On macOS arm64, dependencies are compiled at opt-level 1 in dev builds.
+# hudi-core links two arrow majors while Lance stays on its own, and at
+# opt-level 0 that puts the code section of the hudi-datafusion test binaries
+# and the Python extension past the arm64 +/-128MB branch range, where the
+# linker cannot reach `ctor`'s startup section (pulled in through
+# lance-io -> opendal). Workspace crates stay at opt-level 0 for debugging.
+# The override is a global cargo flag, so it reaches cargo's built-in
+# subcommands and maturin, but not external ones such as `cargo tarpaulin`.
+# Remove with the Lance arrow bridge:
https://github.com/apache/hudi-rs/issues/844
+if [[ "$OSTYPE" == "darwin"* && "$(uname -m)" == "arm64" ]]; then
+ DEP_OPT_LEVEL='profile.dev.package."*".opt-level=1'
+ case "$1" in
+ cargo)
+ echo "Detected macOS arm64: dependencies build at opt-level 1"
+ set -- "$1" --config "$DEP_OPT_LEVEL" "${@:2}"
+ ;;
+ maturin)
+ if [[ "$2" == "build" || "$2" == "develop" ]]; then
+ echo "Detected macOS arm64: dependencies build at opt-level 1"
+ set -- "$1" "$2" --config "$DEP_OPT_LEVEL" "${@:3}"
+ fi
+ ;;
+ esac
+fi
+
# Run the command passed as arguments
exec "$@"
diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml
index 62b657cf..b1acb775 100644
--- a/crates/core/Cargo.toml
+++ b/crates/core/Cargo.toml
@@ -32,7 +32,7 @@ unexpected_cfgs = { level = "warn", check-cfg =
['cfg(tarpaulin_include)'] }
[dependencies]
# arrow
-arrow = { workspace = true }
+arrow = { workspace = true, features = ["ffi"] }
arrow-arith = { workspace = true }
arrow-array = { workspace = true }
arrow-buffer = { workspace = true }
@@ -96,6 +96,13 @@ 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
+# 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"] }
# datafusion
datafusion = { workspace = true, optional = true }
diff --git a/crates/core/src/avro_to_arrow/schema.rs
b/crates/core/src/avro_to_arrow/schema.rs
index a342224c..3a63a9f9 100644
--- a/crates/core/src/avro_to_arrow/schema.rs
+++ b/crates/core/src/avro_to_arrow/schema.rs
@@ -126,7 +126,7 @@ fn schema_to_field_with_props(
.map(|s| schema_to_field_with_props(s, None, has_nullable,
None))
.collect::<Result<Vec<Field>>>()?;
let type_ids = 0_i8..fields.len() as i8;
- DataType::Union(UnionFields::new(type_ids, fields),
UnionMode::Dense)
+ DataType::Union(UnionFields::try_new(type_ids, fields)?,
UnionMode::Dense)
}
}
AvroSchema::Record(RecordSchema { fields, .. }) => {
diff --git a/crates/core/src/config/read.rs b/crates/core/src/config/read.rs
index 4370fe79..50cf3a41 100644
--- a/crates/core/src/config/read.rs
+++ b/crates/core/src/config/read.rs
@@ -114,6 +114,11 @@ pub enum HudiReadConfig {
/// Number of input partitions to read the data in parallel.
///
/// For processing 100 files, [InputPartitions] being 5 will produce 5
partitions, with each partition having 20 files.
+ ///
+ /// On the parquet-source path (copy-on-write, or read-optimized
merge-on-read)
+ /// DataFusion may split these buckets further once one exceeds
+ /// `datafusion.optimizer.repartition_file_min_size`, unless
+ /// `datafusion.optimizer.repartition_file_scans` is off.
InputPartitions,
/// When set to true, only base files will be read for optimized reads.
diff --git a/crates/core/src/file_group/base_file/lance.rs
b/crates/core/src/file_group/base_file/lance.rs
index b66614dc..9f41a67e 100644
--- a/crates/core/src/file_group/base_file/lance.rs
+++ b/crates/core/src/file_group/base_file/lance.rs
@@ -41,6 +41,7 @@ use object_store::ObjectStoreExt;
use object_store::path::Path as ObjPath;
use tokio::sync::OnceCell;
+use super::lance_arrow;
use super::reader::{BaseFileReadOptions, BaseFileReader, BaseFileStream};
use crate::statistics::{ColumnStatistics, StatisticsContainer,
StatsGranularity};
use crate::storage::Storage;
@@ -232,7 +233,13 @@ impl LanceBaseFileReader {
let (reader, _) = self
.open_file_reader_with_metadata(relative_path, None, None)
.await?;
- Ok(arrow_schema::Schema::from(reader.schema().as_ref()))
+ Self::file_schema(&reader)
+ }
+
+ /// The Lance file's schema in the workspace's arrow.
+ fn file_schema(reader: &FileReader) -> Result<arrow_schema::Schema> {
+ let lance_schema =
arrow_schema_lance::Schema::from(reader.schema().as_ref());
+ lance_arrow::schema_to_host(&lance_schema)
}
/// Build a single zero-column [`RecordBatch`] with the Lance file's
@@ -320,10 +327,12 @@ impl BaseFileReader for LanceBaseFileReader {
))
})?;
- let arrow_schema = lance_stream.schema().clone();
+ let arrow_schema: SchemaRef =
+
Arc::new(lance_arrow::schema_to_host(lance_stream.schema().as_ref())?);
+ let batch_schema = arrow_schema.clone();
let mapped_stream = lance_stream
- .map(|result| result.map_err(StorageError::from))
+ .map(move |result| lance_arrow::batch_to_host(result?,
batch_schema.clone()))
.boxed();
Ok(BaseFileStream::new(arrow_schema, mapped_stream))
@@ -368,12 +377,12 @@ impl BaseFileReader for LanceBaseFileReader {
// pruning falls back to "include".
let mut col_stats =
StatisticsContainer::new(StatsGranularity::File);
col_stats.num_rows = Some(num_rows);
- let file_schema = reader.schema();
- for field in &file_schema.fields {
- let field_name = field.name.clone();
+ let file_schema = Self::file_schema(&reader)?;
+ for field in file_schema.fields() {
+ let field_name = field.name().clone();
col_stats.columns.insert(
field_name.clone(),
- ColumnStatistics::new(field_name, field.data_type()),
+ ColumnStatistics::new(field_name,
field.data_type().clone()),
);
}
diff --git a/crates/core/src/file_group/base_file/lance_arrow.rs
b/crates/core/src/file_group/base_file/lance_arrow.rs
new file mode 100644
index 00000000..03229873
--- /dev/null
+++ b/crates/core/src/file_group/base_file/lance_arrow.rs
@@ -0,0 +1,450 @@
+/*
+ * 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.
+ */
+//! Carries Lance's record batches into the workspace's arrow through the
+//! Arrow C data interface.
+//!
+//! Lance is compiled against a different arrow major than the rest of
+//! hudi-core, and to rustc the two crates' `RecordBatch` types are unrelated,
+//! so a batch cannot be handed across directly. The C data interface structs
+//! (`ArrowArray`, `ArrowSchema`) are `#[repr(C)]` with a layout the Arrow
+//! specification fixes for every implementation, so a struct one arrow fills
+//! can be read by the other. The imported array wraps the exporter's buffers
+//! (`from_ffi` copies only a buffer that misses arrow's alignment, which
+//! Lance's decoders do not produce) and frees them through the exporter's
+//! `release` callback when the host batch drops.
+
+use std::mem::transmute;
+
+use arrow::array::{RecordBatch, RecordBatchOptions, make_array};
+use arrow::datatypes::{Schema, SchemaRef};
+use arrow::error::ArrowError;
+use arrow::ffi::{FFI_ArrowArray, FFI_ArrowSchema, from_ffi};
+use arrow_array_lance::RecordBatch as LanceRecordBatch;
+use arrow_array_lance::ffi::{FFI_ArrowArray as LanceFfiArray, to_ffi};
+use arrow_schema_lance::Schema as LanceSchema;
+use arrow_schema_lance::ffi::FFI_ArrowSchema as LanceFfiSchema;
+
+use crate::storage::error::Result;
+
+/// Converts a schema from Lance's arrow into the workspace's arrow.
+///
+/// Field names, nullability, nested types and metadata (including extension
+/// type names) all travel in the C `ArrowSchema` struct.
+pub(super) fn schema_to_host(schema: &LanceSchema) -> Result<Schema> {
+ let exported = LanceFfiSchema::try_from(schema).map_err(export_error)?;
+ let imported = cross_schema(exported);
+ Ok(Schema::try_from(&imported)?)
+}
+
+/// Converts a batch from Lance's arrow into the workspace's arrow, sharing
+/// the column buffers.
+///
+/// `schema` is the stream's schema, already crossed by [`schema_to_host`].
+pub(super) fn batch_to_host(batch: LanceRecordBatch, schema: SchemaRef) ->
Result<RecordBatch> {
+ let mut columns = Vec::with_capacity(batch.num_columns());
+ for column in batch.columns() {
+ let (array, array_schema) =
to_ffi(&column.to_data()).map_err(export_error)?;
+ let array_schema = cross_schema(array_schema);
+ let array = cross_array(array);
+ // SAFETY: `array` and `array_schema` were produced together by
`to_ffi`
+ // from one valid array, and `cross_*` only relabeled their types.
+ let data = unsafe { from_ffi(array, &array_schema)? };
+ columns.push(make_array(data));
+ }
+ let options =
RecordBatchOptions::new().with_row_count(Some(batch.num_rows()));
+ Ok(RecordBatch::try_new_with_options(
+ schema, columns, &options,
+ )?)
+}
+
+/// Reads a C `ArrowSchema` struct filled by Lance's arrow as the workspace
+/// arrow's struct of the same name.
+fn cross_schema(schema: LanceFfiSchema) -> FFI_ArrowSchema {
+ // SAFETY: both types are `#[repr(C)]` definitions of the `ArrowSchema`
+ // struct from the Arrow C data interface specification, whose layout is
+ // fixed, so the bytes of one are a valid value of the other. `transmute`
+ // moves `schema` without running its `Drop`, so the `release` callback is
+ // owned, and later invoked exactly once, by the returned struct.
+ unsafe { transmute::<LanceFfiSchema, FFI_ArrowSchema>(schema) }
+}
+
+/// Reads a C `ArrowArray` struct filled by Lance's arrow as the workspace
+/// arrow's struct of the same name.
+fn cross_array(array: LanceFfiArray) -> FFI_ArrowArray {
+ // SAFETY: as in `cross_schema`, for the `ArrowArray` struct.
+ unsafe { transmute::<LanceFfiArray, FFI_ArrowArray>(array) }
+}
+
+/// Lance's arrow has its own `ArrowError` type; carry its message into ours.
+fn export_error(e: arrow_schema_lance::ArrowError) -> ArrowError {
+ ArrowError::CDataInterface(format!("Failed to export a Lance array: {e}"))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ use std::collections::HashMap;
+ use std::sync::Arc;
+
+ use arrow::array::{Array, AsArray};
+ use arrow::datatypes::{DataType, Field, Int32Type, TimeUnit};
+ use arrow_array_lance::builder::{ListBuilder, StringBuilder,
StructBuilder};
+ use arrow_array_lance::types::Int32Type as LanceInt32Type;
+ use arrow_array_lance::{
+ ArrayRef as LanceArrayRef, DictionaryArray as LanceDictionaryArray,
+ Int32Array as LanceInt32Array, StringArray as LanceStringArray,
+ TimestampMicrosecondArray as LanceTimestampMicrosecondArray,
+ };
+ use arrow_schema_lance::{
+ DataType as LanceDataType, Field as LanceField, Fields as LanceFields,
+ TimeUnit as LanceTimeUnit,
+ };
+
+ fn to_host(batch: LanceRecordBatch) -> Result<RecordBatch> {
+ let schema = Arc::new(schema_to_host(batch.schema().as_ref())?);
+ batch_to_host(batch, schema)
+ }
+
+ /// A batch with the type shapes that take different paths through the C
+ /// data interface: a primitive with nulls, a variable-width string, a
+ /// timestamp with timezone, a dictionary, a list of structs, plus field
+ /// and schema metadata.
+ fn lance_batch() -> LanceRecordBatch {
+ let id = LanceInt32Array::from(vec![Some(1), None, Some(3)]);
+ let name = LanceStringArray::from(vec![Some("a"), Some("bb"), None]);
+ let ts = LanceTimestampMicrosecondArray::from(vec![Some(1_000_000),
Some(2_000_000), None])
+ .with_timezone("UTC");
+ let keys = LanceInt32Array::from(vec![Some(0), Some(1), Some(0)]);
+ let values = LanceStringArray::from(vec!["x", "y"]);
+ let dict = LanceDictionaryArray::<LanceInt32Type>::try_new(keys,
Arc::new(values)).unwrap();
+
+ let item_fields = LanceFields::from(vec![
+ LanceField::new("k", LanceDataType::Utf8, true),
+ LanceField::new("v", LanceDataType::Int32, true),
+ ]);
+ let mut items =
ListBuilder::new(StructBuilder::from_fields(item_fields.clone(), 4));
+ for row in 0..3 {
+ for i in 0..row {
+ let s = items.values();
+ s.field_builder::<StringBuilder>(0)
+ .unwrap()
+ .append_value(format!("k{i}"));
+ s.field_builder::<arrow_array_lance::builder::Int32Builder>(1)
+ .unwrap()
+ .append_value(i);
+ s.append(true);
+ }
+ items.append(row != 1);
+ }
+ let items = items.finish();
+
+ let mut field_meta = HashMap::new();
+ field_meta.insert("ARROW:extension:name".to_string(),
"hudi.test".to_string());
+ let schema = Arc::new(LanceSchema::new_with_metadata(
+ vec![
+ LanceField::new("id", LanceDataType::Int32,
true).with_metadata(field_meta),
+ LanceField::new("name", LanceDataType::Utf8, true),
+ LanceField::new(
+ "ts",
+ LanceDataType::Timestamp(LanceTimeUnit::Microsecond,
Some("UTC".into())),
+ true,
+ ),
+ LanceField::new(
+ "dict",
+ LanceDataType::Dictionary(
+ Box::new(LanceDataType::Int32),
+ Box::new(LanceDataType::Utf8),
+ ),
+ true,
+ ),
+ LanceField::new(
+ "items",
+ LanceDataType::List(Arc::new(LanceField::new(
+ "item",
+ LanceDataType::Struct(item_fields),
+ true,
+ ))),
+ true,
+ ),
+ ],
+ HashMap::from([("table".to_string(), "t1".to_string())]),
+ ));
+ let columns: Vec<LanceArrayRef> = vec![
+ Arc::new(id),
+ Arc::new(name),
+ Arc::new(ts),
+ Arc::new(dict),
+ Arc::new(items),
+ ];
+ LanceRecordBatch::try_new(schema, columns).unwrap()
+ }
+
+ #[test]
+ fn test_schema_to_host_preserves_fields_types_and_metadata() {
+ let batch = lance_batch();
+ let schema = schema_to_host(batch.schema().as_ref()).unwrap();
+
+ assert_eq!(
+ schema.metadata().get("table").map(String::as_str),
+ Some("t1")
+ );
+ let names: Vec<&str> = schema.fields().iter().map(|f|
f.name().as_str()).collect();
+ assert_eq!(names, ["id", "name", "ts", "dict", "items"]);
+
+ let id = schema.field(0);
+ assert_eq!(id.data_type(), &DataType::Int32);
+ assert!(id.is_nullable());
+ assert_eq!(
+ id.metadata()
+ .get("ARROW:extension:name")
+ .map(String::as_str),
+ Some("hudi.test")
+ );
+ assert_eq!(
+ schema.field(2).data_type(),
+ &DataType::Timestamp(TimeUnit::Microsecond, Some("UTC".into()))
+ );
+ assert_eq!(
+ schema.field(3).data_type(),
+ &DataType::Dictionary(Box::new(DataType::Int32),
Box::new(DataType::Utf8))
+ );
+ let DataType::List(item) = schema.field(4).data_type() else {
+ panic!("items should be a list");
+ };
+ let DataType::Struct(item_fields) = item.data_type() else {
+ panic!("list item should be a struct");
+ };
+ assert_eq!(item_fields.len(), 2);
+ assert_eq!(item_fields[0].name(), "k");
+ assert_eq!(item_fields[1].data_type(), &DataType::Int32);
+ }
+
+ #[test]
+ fn test_batch_to_host_preserves_values_and_nulls() {
+ let host = to_host(lance_batch()).unwrap();
+ assert_eq!(host.num_rows(), 3);
+ assert_eq!(host.num_columns(), 5);
+ assert_eq!(host.schema().field(0).name(), "id");
+
+ let id = host.column(0).as_primitive::<Int32Type>();
+ assert_eq!(id.values(), &[1, 0, 3][..]);
+ assert!(id.is_null(1));
+
+ let name = host.column(1).as_string::<i32>();
+ assert_eq!(name.value(0), "a");
+ assert_eq!(name.value(1), "bb");
+ assert!(name.is_null(2));
+
+ let ts = host
+ .column(2)
+ .as_primitive::<arrow::datatypes::TimestampMicrosecondType>();
+ assert_eq!(ts.value(1), 2_000_000);
+ assert!(ts.is_null(2));
+
+ let dict = host.column(3).as_dictionary::<Int32Type>();
+ let dict_values = dict.values().as_string::<i32>();
+ assert_eq!(dict.keys().values(), &[0, 1, 0][..]);
+ assert_eq!(dict_values.value(1), "y");
+
+ let items = host.column(4).as_list::<i32>();
+ assert_eq!(items.value_length(0), 0);
+ assert!(items.is_null(1));
+ assert_eq!(items.value_length(2), 2);
+ // Row 2 holds the items (k0, 0) and (k1, 1).
+ let last = items.value(2);
+ let structs = last.as_struct();
+ assert_eq!(structs.column(0).as_string::<i32>().value(1), "k1");
+ assert_eq!(structs.column(1).as_primitive::<Int32Type>().value(1), 1);
+ }
+
+ #[test]
+ fn test_batch_to_host_carries_the_given_schema() {
+ let batch = lance_batch();
+ let schema =
Arc::new(schema_to_host(batch.schema().as_ref()).unwrap());
+ let host = batch_to_host(batch, schema.clone()).unwrap();
+ assert!(
+ Arc::ptr_eq(&host.schema(), &schema),
+ "the batch must share the stream's schema rather than cross its
own"
+ );
+ }
+
+ #[test]
+ fn test_batch_to_host_shares_column_buffers() {
+ let batch = lance_batch();
+ let source_ptr = batch.column(0).to_data().buffers()[0].as_ptr();
+ let host = to_host(batch).unwrap();
+ let host_ptr = host.column(0).to_data().buffers()[0].as_ptr();
+ assert_eq!(
+ source_ptr, host_ptr,
+ "the crossing must not copy column data"
+ );
+ }
+
+ #[test]
+ fn test_batch_to_host_keeps_buffers_alive_after_source_drops() {
+ let host = to_host(lance_batch()).unwrap();
+ // The source batch is gone; the imported arrays hold its buffers
+ // through the exporter's `release` callback.
+ let name = host.column(1).as_string::<i32>();
+ assert_eq!(name.value(1), "bb");
+ drop(host);
+ }
+
+ #[test]
+ fn test_batch_to_host_zero_rows_and_zero_columns() {
+ let one_field = Arc::new(LanceSchema::new(vec![LanceField::new(
+ "id",
+ LanceDataType::Int32,
+ true,
+ )]));
+ let no_rows = LanceRecordBatch::try_new(
+ one_field,
+ vec![Arc::new(LanceInt32Array::from(Vec::<i32>::new()))],
+ )
+ .unwrap();
+ let host = to_host(no_rows).unwrap();
+ assert_eq!(host.num_rows(), 0);
+ assert_eq!(host.num_columns(), 1);
+
+ let no_columns = LanceRecordBatch::try_new_with_options(
+ Arc::new(LanceSchema::empty()),
+ vec![],
+
&arrow_array_lance::RecordBatchOptions::new().with_row_count(Some(7)),
+ )
+ .unwrap();
+ let host = to_host(no_columns).unwrap();
+ assert_eq!(host.num_rows(), 7);
+ assert_eq!(host.num_columns(), 0);
+ }
+
+ #[test]
+ fn test_batch_to_host_handles_sliced_wide_and_fixed_width_types() {
+ use arrow_array_lance::{
+ Decimal128Array as LanceDecimal128Array, FixedSizeListArray as
LanceFixedSizeListArray,
+ LargeStringArray as LanceLargeStringArray, StructArray as
LanceStructArray,
+ };
+
+ // A slice carries a non-zero offset through the C interface.
+ let sliced = LanceInt32Array::from(vec![Some(1), None, Some(3),
Some(4)]).slice(1, 3);
+ let large = LanceLargeStringArray::from(vec![Some("x"), None,
Some("zz")]);
+ let decimal = LanceDecimal128Array::from(vec![Some(12345_i128), None,
Some(-1)])
+ .with_precision_and_scale(10, 2)
+ .unwrap();
+ let fixed = LanceFixedSizeListArray::try_new(
+ Arc::new(LanceField::new("item", LanceDataType::Int32, true)),
+ 2,
+ Arc::new(LanceInt32Array::from(vec![1, 2, 3, 4, 5, 6])),
+ None,
+ )
+ .unwrap();
+ let child_fields =
+ LanceFields::from(vec![LanceField::new("n", LanceDataType::Int32,
false)]);
+ let strukt = LanceStructArray::try_new(
+ child_fields.clone(),
+ vec![Arc::new(LanceInt32Array::from(vec![7, 8, 9])) as
LanceArrayRef],
+ None,
+ )
+ .unwrap();
+ let schema = Arc::new(LanceSchema::new(vec![
+ LanceField::new("sliced", LanceDataType::Int32, true),
+ LanceField::new("large", LanceDataType::LargeUtf8, true),
+ LanceField::new("decimal", LanceDataType::Decimal128(10, 2), true),
+ LanceField::new(
+ "fixed",
+ LanceDataType::FixedSizeList(
+ Arc::new(LanceField::new("item", LanceDataType::Int32,
true)),
+ 2,
+ ),
+ true,
+ ),
+ LanceField::new("strukt", LanceDataType::Struct(child_fields),
true),
+ ]));
+ let batch = LanceRecordBatch::try_new(
+ schema,
+ vec![
+ Arc::new(sliced),
+ Arc::new(large),
+ Arc::new(decimal),
+ Arc::new(fixed),
+ Arc::new(strukt),
+ ],
+ )
+ .unwrap();
+
+ let host = to_host(batch).unwrap();
+ assert_eq!(host.num_rows(), 3);
+
+ let sliced = host.column(0).as_primitive::<Int32Type>();
+ assert!(sliced.is_null(0));
+ assert_eq!(sliced.value(1), 3);
+ assert_eq!(sliced.value(2), 4);
+
+ let large = host.column(1).as_string::<i64>();
+ assert_eq!(large.value(2), "zz");
+ assert!(large.is_null(1));
+
+ let decimal = host
+ .column(2)
+ .as_primitive::<arrow::datatypes::Decimal128Type>();
+ assert_eq!(decimal.value(0), 12345);
+ assert_eq!(
+ host.schema().field(2).data_type(),
+ &DataType::Decimal128(10, 2)
+ );
+
+ let fixed = host.column(3).as_fixed_size_list();
+ assert_eq!(fixed.value_length(), 2);
+ assert_eq!(
+ fixed.value(2).as_primitive::<Int32Type>().values(),
+ &[5, 6][..]
+ );
+
+ let strukt = host.column(4).as_struct();
+ assert_eq!(strukt.column(0).as_primitive::<Int32Type>().value(2), 9);
+ let host_schema = host.schema();
+ let DataType::Struct(fields) = host_schema.field(4).data_type() else {
+ panic!("strukt should be a struct");
+ };
+ assert!(!fields[0].is_nullable(), "child nullability must survive");
+ }
+
+ #[test]
+ fn test_export_error_names_the_lance_side() {
+ let err = export_error(arrow_schema_lance::ArrowError::CDataInterface(
+ "bad".to_string(),
+ ));
+ let msg = err.to_string();
+ assert!(msg.contains("Failed to export a Lance array"), "{msg}");
+ assert!(msg.contains("bad"), "{msg}");
+ }
+
+ #[test]
+ fn test_host_field_type_round_trips_through_the_workspace_arrow() {
+ // A field built in the workspace arrow equals the crossed field, so
the
+ // two arrows agree on the type encoding the C interface carries.
+ let host = to_host(lance_batch()).unwrap();
+ let expected = Field::new(
+ "ts",
+ DataType::Timestamp(TimeUnit::Microsecond, Some("UTC".into())),
+ true,
+ );
+ assert_eq!(host.schema().field(2), &expected);
+ }
+}
diff --git a/crates/core/src/file_group/base_file/mod.rs
b/crates/core/src/file_group/base_file/mod.rs
index 569d167b..32a8e3fd 100644
--- a/crates/core/src/file_group/base_file/mod.rs
+++ b/crates/core/src/file_group/base_file/mod.rs
@@ -18,6 +18,7 @@
*/
pub mod hfile;
pub mod lance;
+mod lance_arrow;
pub mod parquet;
pub mod reader;
diff --git a/crates/core/src/file_group/base_file/parquet.rs
b/crates/core/src/file_group/base_file/parquet.rs
index d6fe3ca4..e47ae066 100644
--- a/crates/core/src/file_group/base_file/parquet.rs
+++ b/crates/core/src/file_group/base_file/parquet.rs
@@ -25,6 +25,12 @@ use futures::future::BoxFuture;
use object_store::ObjectStoreExt;
use object_store::path::Path as ObjPath;
use parquet::arrow::arrow_reader::{ArrowReaderMetadata, ArrowReaderOptions};
+// `ParquetObjectReader` is deprecated as of parquet 59 in favour of
hand-rolling
+// an `AsyncFileReader` over `object_store`. Replacing it is its own change
+// (apache/hudi-rs#843): it has to carry the counting wrapper and the metadata
+// normalization below, so the deprecated reader is kept, allowed at each use
+// site rather than for the whole file.
+#[allow(deprecated)]
use parquet::arrow::async_reader::{AsyncFileReader, ParquetObjectReader};
use parquet::arrow::{ParquetRecordBatchStreamBuilder, RowNumber,
parquet_to_arrow_schema};
use parquet::file::metadata::ParquetMetaData;
@@ -89,6 +95,7 @@ impl<R: AsyncFileReader> AsyncFileReader for
CountingReader<R> {
/// The builder every read here is driven from: a parquet object reader with
the
/// read-volume counters wrapped around it.
+#[allow(deprecated)]
type CountedBuilder =
ParquetRecordBatchStreamBuilder<CountingReader<ParquetObjectReader>>;
/// Parquet implementation of [`BaseFileReader`].
@@ -134,6 +141,7 @@ impl ParquetBaseFileReader {
file_size: u64,
row_index_column: Option<&str>,
) -> Result<CountedBuilder> {
+ #[allow(deprecated)]
let mut reader =
ParquetObjectReader::new(self.storage.object_store.clone(), obj_path)
.with_file_size(file_size);
diff --git a/crates/core/src/file_group/reader_v2/row_serde.rs
b/crates/core/src/file_group/reader_v2/row_serde.rs
index ba9c314a..3b016790 100644
--- a/crates/core/src/file_group/reader_v2/row_serde.rs
+++ b/crates/core/src/file_group/reader_v2/row_serde.rs
@@ -80,7 +80,7 @@ pub fn to_binary_row_body(record: &RecordBatch) ->
Option<Vec<u8>> {
let generator = arrow_ipc::writer::IpcDataGenerator {};
let mut dict_tracker = arrow_ipc::writer::DictionaryTracker::new(false);
let opts = arrow_ipc::writer::IpcWriteOptions::default();
- let mut compression_context =
arrow_ipc::writer::CompressionContext::default();
+ let mut compression_context =
arrow_ipc::writer::IpcWriteContext::default();
let (dictionaries, encoded) = generator
.encode(record, &mut dict_tracker, &opts, &mut compression_context)
.ok()?;
diff --git a/crates/datafusion/src/hudi_exec.rs
b/crates/datafusion/src/hudi_exec.rs
index 73606154..d3a4ea48 100644
--- a/crates/datafusion/src/hudi_exec.rs
+++ b/crates/datafusion/src/hudi_exec.rs
@@ -36,7 +36,9 @@ use datafusion::physical_plan::{
};
use datafusion_common::DataFusionError::Execution;
use datafusion_common::stats::Precision;
+use datafusion_common::tree_node::TreeNodeRecursion;
use datafusion_common::{ColumnStatistics, DataFusionError, Result, Statistics};
+use datafusion_physical_expr::PhysicalExpr;
use futures::stream::{self, BoxStream, TryStreamExt};
use futures::{Stream, StreamExt};
@@ -269,6 +271,13 @@ impl ExecutionPlan for HudiScanExec {
vec![]
}
+ fn apply_expressions(
+ &self,
+ _f: &mut dyn FnMut(&Arc<dyn PhysicalExpr>) ->
Result<TreeNodeRecursion>,
+ ) -> Result<TreeNodeRecursion> {
+ Ok(TreeNodeRecursion::Continue)
+ }
+
fn with_new_children(
self: Arc<Self>,
children: Vec<Arc<dyn ExecutionPlan>>,
diff --git a/crates/datafusion/src/lib.rs b/crates/datafusion/src/lib.rs
index 2e70738c..2900fbd9 100644
--- a/crates/datafusion/src/lib.rs
+++ b/crates/datafusion/src/lib.rs
@@ -45,7 +45,6 @@ use datafusion_common::stats::Precision;
use datafusion_common::{DataFusionError, Statistics};
use datafusion_expr::utils::split_conjunction;
use datafusion_expr::{CreateExternalTable, Expr, TableProviderFilterPushDown,
TableType};
-use datafusion_physical_expr::create_physical_expr;
use log::warn;
use crate::hudi_exec::HudiScanExec;
@@ -121,6 +120,7 @@ fn filter_field_matches_partition_column(filter_field:
&str, partition_column: &
#[derive(Clone)]
pub struct HudiDataSource {
table: Arc<HudiTable>,
+ options: HashMap<String, String>,
/// Cached table schema (with meta fields) for synchronous access in
`TableProvider::schema()`.
/// This provider is a construction-time metadata snapshot; create a new
/// provider to observe schema/stat changes from later commits.
@@ -231,6 +231,7 @@ impl HudiDataSource {
}
None => default_file_slice_read_concurrency(),
};
+ let options = all_options.iter().cloned().collect();
let table = HudiTable::new_with_options(base_uri, all_options)
.await
.map_err(|e| external_error("Failed to create Hudi table", e))?;
@@ -292,6 +293,7 @@ impl HudiDataSource {
Ok(Self {
table: Arc::new(table),
+ options,
schema,
partition_schema,
cached_stats,
@@ -302,6 +304,16 @@ impl HudiDataSource {
})
}
+ /// Returns the table location needed to reconstruct this provider.
+ pub fn base_uri(&self) -> String {
+ self.table.base_url().to_string()
+ }
+
+ /// Returns the caller-supplied options needed to reconstruct this
provider.
+ pub fn options(&self) -> &HashMap<String, String> {
+ &self.options
+ }
+
fn get_input_partitions(&self) -> usize {
self.input_partitions
}
@@ -408,7 +420,7 @@ impl HudiDataSource {
Some(format) => matches!(format, BaseFileFormatValue::Parquet),
None => Self::file_slices_are_parquet(file_slices)?,
};
- if !parquet_base_files {
+ if !parquet_base_files || !Self::file_slices_have_sizes(file_slices) {
return Ok(false);
}
if !self.table.is_mor() {
@@ -419,6 +431,35 @@ impl HudiDataSource {
.map_err(|e| external_error("Invalid read-optimized option", e))
}
+ /// The scan's routing once the file slices are known: a table whose
+ /// persisted format already settled on parquet still needs every base file
+ /// sized, and a table without a persisted format decides from the slices.
+ fn scan_uses_parquet_source(
+ &self,
+ format_decision: Option<bool>,
+ read_options: &ReadOptions,
+ file_slices: &[FileSlice],
+ ) -> Result<bool> {
+ match format_decision {
+ Some(false) => Ok(false),
+ Some(true) => Ok(Self::file_slices_have_sizes(file_slices)),
+ None => self.use_parquet_source(read_options, file_slices),
+ }
+ }
+
+ /// Whether every base file carries its size. The parquet source needs it:
+ /// DataFusion reads the footer at the size it is given, and its file-group
+ /// repartitioner assigns a zero-length file to no partition at all, so a
+ /// slice without one goes through `HudiScanExec`, which looks the size up.
+ fn file_slices_have_sizes(file_slices: &[FileSlice]) -> bool {
+ file_slices.iter().all(|slice| {
+ slice
+ .base_file
+ .as_ref()
+ .is_none_or(|base_file| base_file.file_metadata.is_some())
+ })
+ }
+
/// Check if the given expression can be pushed down to the Hudi table.
///
/// The expression can be pushed down if it is:
@@ -659,7 +700,12 @@ impl HudiDataSource {
.base_file
.as_ref()
.and_then(|b| b.file_metadata.as_ref())
- .map_or(0, |m| m.size);
+ .map(|m| m.size)
+ .ok_or_else(|| {
+ DataFusionError::Internal(format!(
+ "Base file {relative_path} has no known size"
+ ))
+ })?;
let partitioned_file = PartitionedFile::new(url.path(), size);
parquet_file_group_vec.push(partitioned_file);
}
@@ -682,7 +728,7 @@ impl HudiDataSource {
let filter = filters.iter().cloned().reduce(|acc, new| acc.and(new));
if let Some(expr) = filter {
let df_schema = DFSchema::try_from(table_schema.clone())?;
- let predicate = create_physical_expr(&expr, &df_schema,
state.execution_props())?;
+ let predicate = state.create_physical_expr(expr, &df_schema)?;
parquet_source = parquet_source.with_predicate(predicate)
}
@@ -854,16 +900,9 @@ impl TableProvider for HudiDataSource {
self.scan_read_options(all_pushdown_filters, read_optimized)?
};
- match
self.use_parquet_source_without_file_slices(&partition_read_options)? {
- Some(true) => {
- let flat_slices = self
- .table
- .get_file_slices(&partition_read_options)
- .await
- .map_err(|e| external_error("Failed to get file slices
from Hudi table", e))?;
- self.scan_parquet(state, projection, filters, limit,
flat_slices)
- .await
- }
+ let format_decision =
+
self.use_parquet_source_without_file_slices(&partition_read_options)?;
+ match format_decision {
Some(false) => {
let flat_slices = self
.table
@@ -879,14 +918,18 @@ impl TableProvider for HudiDataSource {
)
.await
}
- None => {
+ _ => {
let partition_flat_slices = self
.table
.get_file_slices(&partition_read_options)
.await
.map_err(|e| external_error("Failed to get file slices
from Hudi table", e))?;
- if self.use_parquet_source(&partition_read_options,
&partition_flat_slices)? {
+ if self.scan_uses_parquet_source(
+ format_decision,
+ &partition_read_options,
+ &partition_flat_slices,
+ )? {
self.scan_parquet(state, projection, filters, limit,
partition_flat_slices)
.await
} else {
@@ -1010,7 +1053,15 @@ impl TableProviderFactory for HudiTableFactory {
cmd: &CreateExternalTable,
) -> Result<Arc<dyn TableProvider>> {
let options = HudiTableFactory::resolve_options(state, cmd)?;
- let base_uri = cmd.location.as_str();
+ let base_uri = match cmd.locations.as_slice() {
+ [location] => location.as_str(),
+ locations => {
+ return Err(DataFusionError::Plan(format!(
+ "A Hudi table has exactly one location, got {}",
+ locations.len()
+ )));
+ }
+ };
let table_provider = HudiDataSource::new_with_options(base_uri,
options).await?;
Ok(Arc::new(table_provider))
}
@@ -1029,6 +1080,7 @@ mod tests {
use datafusion::logical_expr::BinaryExpr;
use datafusion::prelude::SessionContext;
+ use hudi_test::QuickstartTripsTable;
use hudi_test::SampleTable::{V6Nonpartitioned, V6SimplekeygenNonhivestyle,
V9LanceTxnsSimple};
use crate::HudiDataSource;
@@ -1081,6 +1133,85 @@ mod tests {
assert!(!HudiDataSource::file_slices_are_parquet(&[]).unwrap());
}
+ #[test]
+ fn test_file_slices_have_sizes_requires_metadata_on_every_base_file() {
+ use hudi_core::file_group::base_file::BaseFile;
+ use hudi_core::storage::file_metadata::FileMetadata;
+ use std::str::FromStr;
+
+ let name =
"0832997e-30d8-4a42-a52d-e2c540816593-0_0-43-78_20261016221427272.parquet";
+ let sized = {
+ let mut base_file = BaseFile::from_str(name).unwrap();
+ base_file.file_metadata = Some(FileMetadata::new(name.to_string(),
4096));
+ FileSlice::new(base_file, "city=sf".to_string())
+ };
+ let without_size = FileSlice::new(BaseFile::from_str(name).unwrap(),
"city=sf".to_string());
+
+ assert!(HudiDataSource::file_slices_have_sizes(&[]));
+ assert!(HudiDataSource::file_slices_have_sizes(
+ std::slice::from_ref(&sized)
+ ));
+ assert!(!HudiDataSource::file_slices_have_sizes(&[
+ sized,
+ without_size
+ ]));
+ }
+
+ #[tokio::test]
+ async fn
test_scan_routing_requires_sizes_even_when_the_table_persists_parquet() {
+ // This fixture persists `hoodie.table.base.file.format=PARQUET`, so a
+ // read-optimized read settles on the parquet source before any slice
is
+ // read.
+ let base_uri =
QuickstartTripsTable::MorDeleteOrdInt.path_to_mor_avro();
+ let hudi = HudiDataSource::new_with_options(base_uri.as_str(),
empty_options())
+ .await
+ .unwrap();
+ let read_options = hudi.scan_read_options(Vec::new(), true).unwrap();
+ let decision = hudi
+ .use_parquet_source_without_file_slices(&read_options)
+ .unwrap();
+ assert_eq!(
+ decision,
+ Some(true),
+ "the persisted format settles on parquet"
+ );
+
+ let mut slices =
hudi.table.get_file_slices(&read_options).await.unwrap();
+ assert!(!slices.is_empty());
+ assert!(
+ hudi.scan_uses_parquet_source(decision, &read_options, &slices)
+ .unwrap()
+ );
+
+ slices[0].base_file.as_mut().unwrap().file_metadata = None;
+ assert!(
+ !hudi
+ .scan_uses_parquet_source(decision, &read_options, &slices)
+ .unwrap(),
+ "a base file without a size must leave the parquet source"
+ );
+ }
+
+ #[tokio::test]
+ async fn test_scan_parquet_rejects_a_base_file_without_size() {
+ let base_uri = V6Nonpartitioned.path_to_cow();
+ let hudi = HudiDataSource::new_with_options(base_uri.as_str(),
empty_options())
+ .await
+ .unwrap();
+ let read_options = hudi.scan_read_options(Vec::new(), false).unwrap();
+ let mut slices =
hudi.table.get_file_slices(&read_options).await.unwrap();
+ assert!(!slices.is_empty());
+ slices[0].base_file.as_mut().unwrap().file_metadata = None;
+
+ let ctx = SessionContext::new();
+ let state = ctx.state();
+ let err = hudi
+ .scan_parquet(&state, None, &[], None, slices)
+ .await
+ .expect_err("a sizeless base file must not reach DataFusion as
zero bytes");
+ assert!(err.to_string().contains("has no known size"), "{err}");
+ }
+
#[tokio::test]
async fn test_new_with_options_rejects_hfile_format_for_regular_scan() {
let result = HudiDataSource::new_with_options(
diff --git a/crates/datafusion/tests/plan_tests.rs
b/crates/datafusion/tests/plan_tests.rs
index 24b0c1af..e9fd0934 100644
--- a/crates/datafusion/tests/plan_tests.rs
+++ b/crates/datafusion/tests/plan_tests.rs
@@ -42,10 +42,20 @@ use hudi_test::{SampleTable, assert_arrow_field_names_eq};
// ============================================================================
async fn create_test_session() -> SessionContext {
- let config = SessionConfig::new().set(
- "datafusion.sql_parser.enable_ident_normalization",
- &ScalarValue::from(false),
- );
+ let config = SessionConfig::new()
+ .set(
+ "datafusion.sql_parser.enable_ident_normalization",
+ &ScalarValue::from(false),
+ )
+ // The plan assertions below check how the provider buckets file slices
+ // under `hoodie.read.input.partitions`. DataFusion may then split
those
+ // groups further for parallelism once they pass
+ // `repartition_file_min_size` (1 MiB since DataFusion 55), which the
+ // fixtures do, so that pass is turned off to keep the groups visible.
+ .set(
+ "datafusion.optimizer.repartition_file_scans",
+ &ScalarValue::from(false),
+ );
let table_factory: Arc<dyn TableProviderFactory> =
Arc::new(HudiTableFactory::default());
let session_state = SessionStateBuilder::new()
@@ -828,3 +838,24 @@ mod lance_tests {
assert_eq!(txn_types, ["reversal", "transfer", "debit", "debit"]);
}
}
+
+mod factory_tests {
+ use super::*;
+
+ #[tokio::test]
+ async fn test_create_external_table_rejects_multiple_locations() {
+ let ctx = create_test_session().await;
+ let path = SampleTable::V6Nonpartitioned.path_to_cow();
+ let sql = format!(
+ "CREATE EXTERNAL TABLE two_locations STORED AS HUDI LOCATION
('{path}', '{path}')"
+ );
+ let err = ctx
+ .sql(&sql)
+ .await
+ .expect_err("two locations must be rejected");
+ assert!(
+ err.to_string().contains("exactly one location, got 2"),
+ "unexpected error: {err}"
+ );
+ }
+}
diff --git a/demo/apps/datafusion/Cargo.toml b/demo/apps/datafusion/Cargo.toml
index bb9ea81b..aaaec3dd 100644
--- a/demo/apps/datafusion/Cargo.toml
+++ b/demo/apps/datafusion/Cargo.toml
@@ -25,5 +25,5 @@ edition = "2021"
[dependencies]
tokio = "1"
-datafusion = "54"
+datafusion = "55"
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 33df90fa..7c5b60c1 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 = "58" }
+arrow = { version = "59" }
hudi = { path = "../../../../crates/hudi" }
diff --git a/python/Cargo.toml b/python/Cargo.toml
index 09c6c0b9..8fbdb3e9 100644
--- a/python/Cargo.toml
+++ b/python/Cargo.toml
@@ -50,7 +50,7 @@ futures = { workspace = true }
tokio = { workspace = true }
[dependencies.pyo3]
-version = "0.28"
+version = "0.29"
features = ["extension-module", "abi3", "abi3-py310"]
[features]
diff --git a/python/pyproject.toml b/python/pyproject.toml
index 787530c3..77dbd399 100644
--- a/python/pyproject.toml
+++ b/python/pyproject.toml
@@ -56,7 +56,7 @@ lint = [
datafusion = [
# The FFI contract is per DataFusion major: keep the Python package on the
# same major as the Rust workspace's datafusion dependency.
- "datafusion>=54.0.0,<55",
+ "datafusion>=55.0.0,<56",
]
[tool.maturin]
diff --git a/python/src/datafusion_internal.rs
b/python/src/datafusion_internal.rs
index 96f46cdd..6c1d7006 100644
--- a/python/src/datafusion_internal.rs
+++ b/python/src/datafusion_internal.rs
@@ -91,6 +91,6 @@ impl HudiDataFusionDataSource {
let provider = Arc::new(self.table.clone());
let codec = extract_codec(session)?;
let ffi_provider = FFI_TableProvider::new_with_ffi_codec(provider,
false, None, codec);
- PyCapsule::new(py, ffi_provider,
Some(cr"datafusion_table_provider".into()))
+ PyCapsule::new_with_value(py, ffi_provider,
c"datafusion_table_provider")
}
}