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 71538f24 feat(core): read the metadata table as Arrow, and expose 
hudi-rs over a C ABI for JVM callers (#700)
71538f24 is described below

commit 71538f24b39bdd2a5cadd87c4b5f2527d9971e18
Author: Lin Liu <[email protected]>
AuthorDate: Wed Sep 2 15:21:44 2026 -0700

    feat(core): read the metadata table as Arrow, and expose hudi-rs over a C 
ABI for JVM callers (#700)
    
    Co-authored-by: Y Ethan Guo <[email protected]>
---
 crates/core/src/metadata/table/mod.rs       |  72 ++++-
 crates/core/src/metadata/table/v2_reader.rs |  23 +-
 crates/jvm-ffi/Cargo.toml                   |  41 +++
 crates/jvm-ffi/src/lib.rs                   | 471 ++++++++++++++++++++++++++++
 4 files changed, 601 insertions(+), 6 deletions(-)

diff --git a/crates/core/src/metadata/table/mod.rs 
b/crates/core/src/metadata/table/mod.rs
index c0f7aa75..d19ab6d8 100644
--- a/crates/core/src/metadata/table/mod.rs
+++ b/crates/core/src/metadata/table/mod.rs
@@ -176,6 +176,29 @@ impl Table {
             .await
     }
 
+    /// The `files` partition as an Arrow batch, undecoded.
+    ///
+    /// Same read as [`Self::read_metadata_table_files_partition`], stopping 
one
+    /// step earlier. A consumer that wants Arrow — a JVM caller importing 
through
+    /// the C Data Interface, or an engine that merges this with other batches 
—
+    /// would otherwise take decoded structs and re-encode them, paying for a
+    /// decode it did not want and an encode that loses nothing but time.
+    ///
+    /// `keys` empty reads every record; otherwise only those keys. Unlike the
+    /// decoded form, keys are matched **as stored**, so a non-partitioned 
table's
+    /// record is asked for as `"."` and arrives with that key rather than 
`""`:
+    /// normalisation happens during decode, which this skips.
+    ///
+    /// # Note
+    /// Must be called on a DATA table, not a METADATA table.
+    pub async fn read_metadata_table_files_partition_arrow(
+        &self,
+        keys: &[&str],
+    ) -> Result<arrow_array::RecordBatch> {
+        let metadata_table = self.get_or_init_metadata_table().await?;
+        metadata_table.read_files_partition_batch(keys).await
+    }
+
     /// Fetch records from the `files` partition with optional partition 
pruning.
     ///
     /// For non-partitioned tables, directly fetches the "." record.
@@ -234,9 +257,49 @@ impl Table {
         &self,
         keys: &[&str],
     ) -> Result<HashMap<String, FilesPartitionRecord>> {
-        let Some(timestamp) = 
self.timeline.get_latest_commit_timestamp_as_option() else {
+        let Some((reader, file_slice)) = self.files_partition_reader().await? 
else {
             return Ok(HashMap::new());
         };
+        reader.read_files_partition(&file_slice, keys).await
+    }
+
+    /// The `files` partition's merged batch, for a caller that wants Arrow.
+    ///
+    /// An empty batch when the metadata table has no commits, matching the 
empty
+    /// map the decoded form returns in that case.
+    ///
+    /// # Note
+    /// Must be called on a METADATA table instance.
+    pub(crate) async fn read_files_partition_batch(
+        &self,
+        keys: &[&str],
+    ) -> Result<arrow_array::RecordBatch> {
+        let Some((reader, file_slice)) = self.files_partition_reader().await? 
else {
+            return Ok(arrow_array::RecordBatch::new_empty(std::sync::Arc::new(
+                Schema::empty(),
+            )));
+        };
+        reader.read_files_partition_batch(&file_slice, keys).await
+    }
+
+    /// Resolve the `files` partition's single file slice and build a reader 
for it.
+    ///
+    /// `None` when the metadata table has no commits, which both callers 
treat as
+    /// an empty result rather than an error.
+    ///
+    /// # Note
+    /// Must be called on a METADATA table instance.
+    async fn files_partition_reader(
+        &self,
+    ) -> Result<
+        Option<(
+            v2_reader::MetadataTableV2Reader,
+            crate::file_group::file_slice::FileSlice,
+        )>,
+    > {
+        let Some(timestamp) = 
self.timeline.get_latest_commit_timestamp_as_option() else {
+            return Ok(None);
+        };
 
         let timeline_view = self.timeline.create_view_as_of(timestamp).await?;
 
@@ -294,9 +357,10 @@ impl Table {
         // where per-block fixed cost cannot amortise; on larger blocks it 
overtakes
         // the reader it replaces. So the ratio above is not the production 
ratio, and
         // nothing measured here establishes what that is.
-        v2_reader::MetadataTableV2Reader::new(configs, storage)
-            .read_files_partition(&file_slice, keys)
-            .await
+        Ok(Some((
+            v2_reader::MetadataTableV2Reader::new(configs, storage),
+            file_slice,
+        )))
     }
 }
 
diff --git a/crates/core/src/metadata/table/v2_reader.rs 
b/crates/core/src/metadata/table/v2_reader.rs
index d1d625b1..dff1c7ca 100644
--- a/crates/core/src/metadata/table/v2_reader.rs
+++ b/crates/core/src/metadata/table/v2_reader.rs
@@ -75,11 +75,31 @@ impl MetadataTableV2Reader {
     /// `keys` empty reads every record; otherwise only those keys, pushed 
into the
     /// base file reader so the read seeks rather than scans — which is what 
the
     /// reader this replaces does through `HFileReader::lookup_records`.
+    /// Read the `files` partition's records from one file slice.
+    ///
+    /// Decodes the merged batch into the records the caller expects. Callers 
that
+    /// want the batch itself, rather than decoded structs, use
+    /// [`Self::read_files_partition_batch`]; this is that plus the decode.
     pub(crate) async fn read_files_partition(
         &self,
         file_slice: &FileSlice,
         keys: &[&str],
     ) -> Result<HashMap<String, FilesPartitionRecord>> {
+        let batch = self.read_files_partition_batch(file_slice, keys).await?;
+        Self::records_from_batch(&batch, keys)
+    }
+
+    /// The merged `files` partition batch, before it is decoded into records.
+    ///
+    /// Separate because the batch is what an Arrow consumer wants and 
decoding it
+    /// only to re-encode would be work in both directions. Nothing about the 
read
+    /// differs between the two; this is the read, and the other is this plus a
+    /// decode.
+    pub(crate) async fn read_files_partition_batch(
+        &self,
+        file_slice: &FileSlice,
+        keys: &[&str],
+    ) -> Result<RecordBatch> {
         let base_file_path = file_slice.base_file_relative_path()?;
         let log_file_paths = if file_slice.has_log_file() {
             file_slice
@@ -129,14 +149,13 @@ impl MetadataTableV2Reader {
         )?;
 
         let batch = reader.read().await?;
-        let records = Self::records_from_batch(&batch, keys);
         log::debug!(
             "metadata read of '{}' with {} named key(s): merge map peaked at 
{} entries",
             FilesPartitionRecord::PARTITION_NAME,
             keys.len(),
             reader.read_stats().merge_map_peak_entries
         );
-        records
+        Ok(batch)
     }
 
     /// Convert the merged Arrow batch into the decoded records the caller 
expects.
diff --git a/crates/jvm-ffi/Cargo.toml b/crates/jvm-ffi/Cargo.toml
new file mode 100644
index 00000000..bc557728
--- /dev/null
+++ b/crates/jvm-ffi/Cargo.toml
@@ -0,0 +1,41 @@
+# 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.
+
+
+[package]
+name = "hudi-jvm-ffi"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+rust-version.workspace = true
+description = "Plain C ABI over hudi-rs, so a JVM can call it through the 
Arrow C Data Interface."
+homepage.workspace = true
+repository.workspace = true
+
+[lib]
+name = "hudi_jvm_ffi"
+crate-type = ["cdylib", "rlib"]
+
+[features]
+# Exports a deliberately panicking function, so a caller can prove a panic does
+# not cross the C ABI. Off by default: it must not exist in a shipped library.
+ffi-test-hooks = []
+
+[dependencies]
+hudi = { path = "../hudi", version = "0.5.0-dev" }
+arrow = { workspace = true, features = ["ffi"] }
+tokio = { workspace = true }
diff --git a/crates/jvm-ffi/src/lib.rs b/crates/jvm-ffi/src/lib.rs
new file mode 100644
index 00000000..cadeaf61
--- /dev/null
+++ b/crates/jvm-ffi/src/lib.rs
@@ -0,0 +1,471 @@
+/*
+ * 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.
+ */
+//! Crate `hudi-core`.
+//!
+//! # The [config] module is responsible for managing configurations.
+//!
+
+//! A plain C ABI over hudi-rs, so a JVM can read a file slice.
+//!
+//! The `cpp` crate already exposes the same reads, but `cxx` generates
+//! C++-mangled symbols and `CxxString`/`CxxVector` arguments, neither of 
which a
+//! JVM can bind to. This crate restates those calls in C: null-terminated
+//! strings in, opaque handles and an `ArrowArrayStream` out. It holds no 
reading
+//! logic of its own.
+//!
+//! # Ownership
+//!
+//! Every non-null pointer returned here is owned by the caller and must be
+//! released exactly once, with the matching `hudi_ffi_free_*`. Releasing twice
+//! is a double free; not releasing leaks the reader or the Arrow buffers 
behind
+//! the stream.
+//!
+//! # Errors and panics
+//!
+//! A failing call returns null and leaves a message retrievable with
+//! [`hudi_ffi_last_error`] until the next call on the same thread. Panics are
+//! caught at every boundary and turned into that same null-plus-message, 
because
+//! a panic unwinding into the JVM aborts the process.
+
+use std::cell::RefCell;
+use std::ffi::{CStr, CString, c_char};
+use std::panic::{AssertUnwindSafe, catch_unwind};
+
+use arrow::array::RecordBatchIterator;
+use arrow::ffi_stream::FFI_ArrowArrayStream;
+use hudi::file_group::reader::FileGroupReader;
+use hudi::table::{ReadOptions, Table};
+
+thread_local! {
+    static LAST_ERROR: RefCell<Option<CString>> = const { RefCell::new(None) };
+}
+
+fn set_error(message: impl Into<String>) {
+    let text = message.into();
+    let encoded = CString::new(text).unwrap_or_else(|_| {
+        CString::new("hudi-rs error message contained an interior nul byte")
+            .expect("this literal has no nul")
+    });
+    LAST_ERROR.with(|slot| *slot.borrow_mut() = Some(encoded));
+}
+
+/// Run `body`, turning any error or panic into a null return plus a message.
+///
+/// Every exported function goes through this. A panic that unwinds across the 
C
+/// ABI aborts the process, so catching it here is what keeps a Rust bug from
+/// killing the JVM that called us.
+fn guard<T>(what: &str, body: impl FnOnce() -> Result<*mut T, String>) -> *mut 
T {
+    // The previous call's message dies here, so `hudi_ffi_last_error` means
+    // "the last call failed", not "some call once failed" — a binding that
+    // checks the message rather than the return value must not see a stale 
one.
+    LAST_ERROR.with(|slot| *slot.borrow_mut() = None);
+    match catch_unwind(AssertUnwindSafe(body)) {
+        Ok(Ok(ptr)) => ptr,
+        Ok(Err(message)) => {
+            set_error(format!("{what}: {message}"));
+            std::ptr::null_mut()
+        }
+        Err(payload) => {
+            let detail = payload
+                .downcast_ref::<&str>()
+                .map(|s| (*s).to_string())
+                .or_else(|| payload.downcast_ref::<String>().cloned())
+                .unwrap_or_else(|| "non-string panic payload".to_string());
+            set_error(format!("{what} panicked: {detail}"));
+            std::ptr::null_mut()
+        }
+    }
+}
+
+/// Borrow a C string, naming the argument in any error.
+unsafe fn as_str<'a>(ptr: *const c_char, name: &str) -> Result<&'a str, 
String> {
+    if ptr.is_null() {
+        return Err(format!("{name} is null"));
+    }
+    unsafe { CStr::from_ptr(ptr) }
+        .to_str()
+        .map_err(|e| format!("{name} is not valid UTF-8: {e}"))
+}
+
+/// Borrow `len` C strings from an array.
+unsafe fn as_strs<'a>(
+    ptr: *const *const c_char,
+    len: usize,
+    name: &str,
+) -> Result<Vec<&'a str>, String> {
+    if len == 0 {
+        return Ok(Vec::new());
+    }
+    if ptr.is_null() {
+        return Err(format!("{name} is null but len is {len}"));
+    }
+    let slice = unsafe { std::slice::from_raw_parts(ptr, len) };
+    slice
+        .iter()
+        .enumerate()
+        .map(|(i, p)| unsafe { as_str(*p, &format!("{name}[{i}]")) })
+        .collect()
+}
+
+/// A reader plus the runtime its async calls are driven on.
+///
+/// The runtime is owned here rather than created per read: a current-thread
+/// runtime is cheap to build but the reader holds object-store clients whose
+/// connection pools should outlive one call.
+pub struct HudiFfiReader {
+    inner: FileGroupReader,
+    runtime: tokio::runtime::Runtime,
+}
+
+/// A table handle, for the metadata read.
+///
+/// Separate from [`HudiFfiReader`] because the two wrap different things: a 
file
+/// group reader is handed a slice, while a metadata read resolves its own.
+pub struct HudiFfiTable {
+    inner: Table,
+    runtime: tokio::runtime::Runtime,
+}
+
+/// Open the table at `base_uri` for a metadata read.
+///
+/// # Safety
+/// Pointers must be null-terminated C strings valid for the call, and the two
+/// option arrays must each hold `option_count` entries.
+#[unsafe(no_mangle)]
+pub unsafe extern "C" fn hudi_ffi_table_open(
+    base_uri: *const c_char,
+    option_keys: *const *const c_char,
+    option_values: *const *const c_char,
+    option_count: usize,
+) -> *mut HudiFfiTable {
+    guard("hudi_ffi_table_open", || {
+        let base_uri = unsafe { as_str(base_uri, "base_uri") }?;
+        let keys = unsafe { as_strs(option_keys, option_count, "option_keys") 
}?;
+        let values = unsafe { as_strs(option_values, option_count, 
"option_values") }?;
+        let options: Vec<(&str, &str)> = 
keys.into_iter().zip(values).collect();
+
+        let runtime = tokio::runtime::Builder::new_current_thread()
+            .enable_all()
+            .build()
+            .map_err(|e| format!("failed to build a tokio runtime: {e}"))?;
+        let inner = runtime
+            .block_on(Table::new_with_options(base_uri, options))
+            .map_err(|e| format!("failed to open {base_uri}: {e}"))?;
+        Ok(Box::into_raw(Box::new(HudiFfiTable { inner, runtime })))
+    })
+}
+
+/// Read the metadata table's `files` partition as an Arrow stream.
+///
+/// `keys` may be null when `key_count` is zero, which reads every record.
+/// Keys are matched as stored, so a non-partitioned table's record is asked 
for
+/// as `"."`.
+///
+/// # Safety
+/// `table` must come from [`hudi_ffi_table_open`] and not yet be freed. The
+/// returned stream must be released with [`hudi_ffi_free_stream`] exactly 
once,
+/// including on paths where the caller stops reading partway.
+#[unsafe(no_mangle)]
+pub unsafe extern "C" fn hudi_ffi_read_metadata_files_partition(
+    table: *mut HudiFfiTable,
+    keys: *const *const c_char,
+    key_count: usize,
+) -> *mut FFI_ArrowArrayStream {
+    guard("hudi_ffi_read_metadata_files_partition", || {
+        if table.is_null() {
+            return Err("table is null".to_string());
+        }
+        let table = unsafe { &*table };
+        let keys = unsafe { as_strs(keys, key_count, "keys") }?;
+
+        let batch = table
+            .runtime
+            
.block_on(table.inner.read_metadata_table_files_partition_arrow(&keys))
+            .map_err(|e| format!("failed to read the metadata table: {e}"))?;
+
+        let schema = batch.schema();
+        let iterator = RecordBatchIterator::new(vec![Ok(batch)].into_iter(), 
schema);
+        Ok(Box::into_raw(Box::new(FFI_ArrowArrayStream::new(
+            Box::new(iterator),
+        ))))
+    })
+}
+
+/// Release a table handle. Null is accepted and ignored.
+///
+/// # Safety
+/// `table` must come from [`hudi_ffi_table_open`] and be released once only.
+#[unsafe(no_mangle)]
+pub unsafe extern "C" fn hudi_ffi_free_table(table: *mut HudiFfiTable) {
+    if !table.is_null() {
+        drop(unsafe { Box::from_raw(table) });
+    }
+}
+
+/// Open a reader for the table at `base_uri`.
+///
+/// `option_keys` and `option_values` are parallel arrays of `option_count`
+/// entries. Returns null on failure; see [`hudi_ffi_last_error`].
+///
+/// # Safety
+/// All pointers must be null-terminated C strings valid for the call, and the
+/// two option arrays must each hold `option_count` entries.
+#[unsafe(no_mangle)]
+pub unsafe extern "C" fn hudi_ffi_reader_open(
+    base_uri: *const c_char,
+    option_keys: *const *const c_char,
+    option_values: *const *const c_char,
+    option_count: usize,
+) -> *mut HudiFfiReader {
+    guard("hudi_ffi_reader_open", || {
+        let base_uri = unsafe { as_str(base_uri, "base_uri") }?;
+        let keys = unsafe { as_strs(option_keys, option_count, "option_keys") 
}?;
+        let values = unsafe { as_strs(option_values, option_count, 
"option_values") }?;
+        let options: Vec<(&str, &str)> = 
keys.into_iter().zip(values).collect();
+
+        let runtime = tokio::runtime::Builder::new_current_thread()
+            .enable_all()
+            .build()
+            .map_err(|e| format!("failed to build a tokio runtime: {e}"))?;
+        let inner = runtime
+            .block_on(FileGroupReader::new_with_options(base_uri, options))
+            .map_err(|e| format!("failed to open {base_uri}: {e}"))?;
+        Ok(Box::into_raw(Box::new(HudiFfiReader { inner, runtime })))
+    })
+}
+
+/// Read one file slice, named by its base file and log files, into a stream.
+///
+/// `log_file_paths` may be null when `log_file_count` is zero, which reads a
+/// base file on its own. Returns null on failure.
+///
+/// # Safety
+/// `reader` must come from [`hudi_ffi_reader_open`] and not yet be freed. The
+/// returned stream must be released with [`hudi_ffi_free_stream`] exactly 
once,
+/// including on paths where the caller aborts partway through reading it.
+#[unsafe(no_mangle)]
+pub unsafe extern "C" fn hudi_ffi_read_slice(
+    reader: *mut HudiFfiReader,
+    base_file_path: *const c_char,
+    log_file_paths: *const *const c_char,
+    log_file_count: usize,
+) -> *mut FFI_ArrowArrayStream {
+    guard("hudi_ffi_read_slice", || {
+        if reader.is_null() {
+            return Err("reader is null".to_string());
+        }
+        let reader = unsafe { &*reader };
+        let base_file_path = unsafe { as_str(base_file_path, "base_file_path") 
}?;
+        let logs = unsafe { as_strs(log_file_paths, log_file_count, 
"log_file_paths") }?;
+
+        let batch = reader
+            .runtime
+            .block_on(reader.inner.read_file_slice_from_paths(
+                base_file_path,
+                logs,
+                &ReadOptions::new(),
+            ))
+            .map_err(|e| format!("failed to read {base_file_path}: {e}"))?;
+
+        // Eager, matching what the cxx bridge does today: one batch wrapped 
as a
+        // stream. `read_file_slice_from_paths_stream` exists and would avoid
+        // materialising the slice, but driving an async stream from inside the
+        // stream's synchronous `get_next` callback needs care that belongs in 
its
+        // own change.
+        let schema = batch.schema();
+        let iterator = RecordBatchIterator::new(vec![Ok(batch)].into_iter(), 
schema);
+        Ok(Box::into_raw(Box::new(FFI_ArrowArrayStream::new(
+            Box::new(iterator),
+        ))))
+    })
+}
+
+/// Release a reader. Null is accepted and ignored.
+///
+/// # Safety
+/// `reader` must come from [`hudi_ffi_reader_open`] and be released once only.
+#[unsafe(no_mangle)]
+pub unsafe extern "C" fn hudi_ffi_free_reader(reader: *mut HudiFfiReader) {
+    if !reader.is_null() {
+        drop(unsafe { Box::from_raw(reader) });
+    }
+}
+
+/// Release a stream. Null is accepted and ignored.
+///
+/// # Safety
+/// `stream` must come from [`hudi_ffi_read_slice`] and be released once only.
+#[unsafe(no_mangle)]
+pub unsafe extern "C" fn hudi_ffi_free_stream(stream: *mut 
FFI_ArrowArrayStream) {
+    if !stream.is_null() {
+        drop(unsafe { Box::from_raw(stream) });
+    }
+}
+
+/// The last error on this thread, or null if the last call succeeded.
+///
+/// The returned string is owned by this library and stays valid until the next
+/// call on the same thread.
+#[unsafe(no_mangle)]
+pub extern "C" fn hudi_ffi_last_error() -> *const c_char {
+    LAST_ERROR.with(|slot| match slot.borrow().as_ref() {
+        Some(message) => message.as_ptr(),
+        None => std::ptr::null(),
+    })
+}
+
+/// Panic on purpose, so a caller can prove a panic does not cross the 
boundary.
+///
+/// Behind a feature that is off by default, so it is absent from a shipped
+/// library. A test that needs it builds with `--features ffi-test-hooks`; the
+/// point is to exercise [`guard`]'s `catch_unwind` from the far side of the C
+/// ABI, which a Rust unit test cannot do because it never crosses it.
+#[cfg(feature = "ffi-test-hooks")]
+#[unsafe(no_mangle)]
+pub extern "C" fn hudi_ffi_panic_for_test() -> *mut FFI_ArrowArrayStream {
+    guard("hudi_ffi_panic_for_test", || {
+        panic!("deliberate panic, to prove it is caught at the boundary")
+    })
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    /// A panic inside a guarded body becomes null plus a message, not an 
abort.
+    ///
+    /// This is the whole of the panic-safety contract on the Rust side: a 
panic
+    /// unwinding across `extern "C"` aborts the process, so every export 
routes
+    /// through `guard`. Removing the `catch_unwind` fails this test by 
aborting
+    /// the test binary, which is the loudest possible failure.
+    #[test]
+    fn a_panic_becomes_a_null_and_a_message() {
+        let ptr = guard::<u8>("boom", || panic!("deliberate"));
+        assert!(ptr.is_null(), "a panicking body must return null");
+        let message = unsafe { CStr::from_ptr(hudi_ffi_last_error()) }
+            .to_str()
+            .unwrap();
+        assert!(
+            message.contains("boom panicked") && 
message.contains("deliberate"),
+            "the message must name the call and the payload, got {message:?}"
+        );
+    }
+
+    /// An error becomes null plus a message that names the call.
+    #[test]
+    fn an_error_becomes_a_null_and_a_message() {
+        let ptr = guard::<u8>("open", || Err("no such table".to_string()));
+        assert!(ptr.is_null());
+        let message = unsafe { CStr::from_ptr(hudi_ffi_last_error()) }
+            .to_str()
+            .unwrap();
+        assert_eq!(message, "open: no such table");
+    }
+
+    /// A successful call clears the previous failure's message.
+    ///
+    /// `hudi_ffi_last_error` documents "null if the last call succeeded", and 
a
+    /// JVM binding that checks the message rather than the return value relies
+    /// on it: without the clear, one failure makes every later success look
+    /// like that same failure.
+    #[test]
+    fn a_success_clears_the_previous_failure() {
+        let _ = guard::<u8>("first", || Err("first failed".to_string()));
+        assert!(
+            !hudi_ffi_last_error().is_null(),
+            "the failure must set a message, or the clearing below is unproven"
+        );
+
+        let ptr = guard::<u8>("second", || Ok(Box::into_raw(Box::new(7u8))));
+        assert!(!ptr.is_null());
+        drop(unsafe { Box::from_raw(ptr) });
+        assert!(
+            hudi_ffi_last_error().is_null(),
+            "a successful call must leave no error to read"
+        );
+    }
+
+    /// Freeing null is a no-op, on every free.
+    ///
+    /// A JVM caller's `finally` runs whether or not the call succeeded, so it
+    /// will free a null handle on the failure path. That has to be safe or the
+    /// error path becomes a crash.
+    #[test]
+    fn freeing_null_is_a_no_op() {
+        unsafe {
+            hudi_ffi_free_reader(std::ptr::null_mut());
+            hudi_ffi_free_stream(std::ptr::null_mut());
+            hudi_ffi_free_table(std::ptr::null_mut());
+        }
+    }
+
+    /// A null argument is refused rather than dereferenced.
+    #[test]
+    fn null_arguments_are_refused() {
+        let reader = unsafe {
+            hudi_ffi_reader_open(std::ptr::null(), std::ptr::null(), 
std::ptr::null(), 0)
+        };
+        assert!(reader.is_null(), "a null base_uri must not open a reader");
+
+        let stream = unsafe {
+            hudi_ffi_read_metadata_files_partition(std::ptr::null_mut(), 
std::ptr::null(), 0)
+        };
+        assert!(stream.is_null(), "a null table must not produce a stream");
+    }
+
+    /// A non-UTF-8 argument is refused with a message rather than panicking.
+    #[test]
+    fn invalid_utf8_is_refused() {
+        let bad = [0xffu8, 0xfe, 0x00];
+        let ptr = unsafe {
+            hudi_ffi_reader_open(
+                bad.as_ptr() as *const c_char,
+                std::ptr::null(),
+                std::ptr::null(),
+                0,
+            )
+        };
+        assert!(ptr.is_null());
+        let message = unsafe { CStr::from_ptr(hudi_ffi_last_error()) }
+            .to_str()
+            .unwrap();
+        assert!(message.contains("not valid UTF-8"), "got {message:?}");
+    }
+
+    /// The error slot is per thread, so one thread's failure is not another's.
+    #[test]
+    fn the_error_slot_is_per_thread() {
+        let _ = guard::<u8>("outer", || Err("outer failed".to_string()));
+        std::thread::spawn(|| {
+            assert!(
+                hudi_ffi_last_error().is_null(),
+                "a fresh thread must start with no error"
+            );
+        })
+        .join()
+        .unwrap();
+        let message = unsafe { CStr::from_ptr(hudi_ffi_last_error()) }
+            .to_str()
+            .unwrap();
+        assert_eq!(
+            message, "outer: outer failed",
+            "the outer thread keeps its own"
+        );
+    }
+}

Reply via email to