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 dd2f0688 fix(cpp): catch panics at the FFI boundary so they surface as
errors (#766)
dd2f0688 is described below
commit dd2f0688e7a9b1e7a43ebb1fdff7d8df7e39b335
Author: Sagar Sumit <[email protected]>
AuthorDate: Tue Oct 27 23:06:38 2026 +0530
fix(cpp): catch panics at the FFI boundary so they surface as errors (#766)
cxx's shims are extern "C", so a panic reachable from a read (an
unsupported Avro type, an internal invariant) unwinds out of the shim
and aborts the host process. For an engine that embeds the library in
a long-lived multi-tenant worker that takes every co-running query
down with it.
Wrap each extern "Rust" entry point in catch_unwind and report the
panic through the Result the bridge already throws as rust::Error, so
the one call fails and the reader stays usable. The default panic hook
still prints the message to stderr; embedders that find that noisy can
install their own.
hudi-test moves to dev-dependencies: it is only used by the new tests
and consumers should not pull the fixture crate.
Closes #636
Signed-off-by: codope <[email protected]>
---
cpp/Cargo.toml | 4 +-
cpp/src/lib.rs | 381 +++++++++++++++++++++++++++++++++++++++++++--------------
2 files changed, 291 insertions(+), 94 deletions(-)
diff --git a/cpp/Cargo.toml b/cpp/Cargo.toml
index 3323d41b..721a9feb 100644
--- a/cpp/Cargo.toml
+++ b/cpp/Cargo.toml
@@ -33,11 +33,13 @@ crate-type = ["cdylib", "rlib"]
[dependencies]
hudi = { path = "../crates/hudi", version = "0.6.0-dev" }
-hudi-test = { path = "../crates/test", version = "0.6.0-dev" }
cxx = { version = "1.0" }
arrow = { workspace = true , features = ["ffi"]}
arrow-array = { workspace = true , features = ["ffi"]}
tokio = { workspace = true }
+[dev-dependencies]
+hudi-test = { path = "../crates/test", version = "0.6.0-dev" }
+
[build-dependencies]
cxx-build = { version = "1.0" }
diff --git a/cpp/src/lib.rs b/cpp/src/lib.rs
index 08c14658..fd8d54e4 100644
--- a/cpp/src/lib.rs
+++ b/cpp/src/lib.rs
@@ -24,6 +24,8 @@ use hudi::file_group::FileGroup;
use hudi::file_group::file_slice::FileSlice;
use hudi::file_group::reader::FileGroupReader;
use hudi::table::ReadOptions;
+use std::any::Any;
+use std::panic::{AssertUnwindSafe, catch_unwind};
#[cxx::bridge]
mod ffi {
@@ -35,24 +37,36 @@ mod ffi {
extern "Rust" {
type HudiFileGroupReader;
+ /// Opens a reader for the table at `base_uri` with `key=value`
options.
+ ///
+ /// Throws `rust::Error` on failure, including a panic inside the
library.
fn new_file_group_reader_with_options(
base_uri: &CxxString,
options: &CxxVector<CxxString>,
) -> Result<Box<HudiFileGroupReader>>;
type HudiFileSlice;
+ /// Builds a file slice from file names within `partition_path`.
+ ///
+ /// Throws `rust::Error` on failure, including a panic inside the
library.
fn new_file_slice_from_file_names(
partition_path: &CxxString,
base_file_name: &CxxString,
log_file_names: &CxxVector<CxxString>,
) -> Result<Box<HudiFileSlice>>;
+ /// Reads and merges a file slice given paths relative to the table
base.
+ ///
+ /// Throws `rust::Error` on failure, including a panic inside the
library.
fn read_file_slice_from_paths(
self: &HudiFileGroupReader,
base_file_path: &CxxString,
log_file_paths: &CxxVector<CxxString>,
) -> Result<*mut ArrowArrayStream>;
+ /// Reads and merges a file slice.
+ ///
+ /// Throws `rust::Error` on failure, including a panic inside the
library.
fn read_file_slice(
self: &HudiFileGroupReader,
file_slice: &HudiFileSlice,
@@ -60,6 +74,44 @@ mod ffi {
}
}
+/// Converts a panic in `f` into the `Err` the bridge already reports.
+///
+/// cxx's shims are `extern "C"`, so a panic unwinding out of one aborts the
+/// process, and with it every query in a host that embeds this library.
+/// `AssertUnwindSafe` holds because the reader is read-only across calls;
+/// the only state a panic can leave mid-update is storage read counters,
+/// which nothing depends on for correctness.
+fn guard<T>(entry_point: &str, f: impl FnOnce() -> Result<T, String>) ->
Result<T, String> {
+ catch_unwind(AssertUnwindSafe(f)).unwrap_or_else(|payload| {
+ Err(format!(
+ "{entry_point} panicked: {}",
+ panic_message(payload.as_ref())
+ ))
+ })
+}
+
+/// The message a panic carried, for the two payload types `panic!` produces.
+fn panic_message(payload: &(dyn Any + Send)) -> String {
+ if let Some(message) = payload.downcast_ref::<&str>() {
+ (*message).to_string()
+ } else if let Some(message) = payload.downcast_ref::<String>() {
+ message.clone()
+ } else {
+ "non-string panic payload".to_string()
+ }
+}
+
+fn cxx_str(value: &CxxString) -> Result<&str, String> {
+ value
+ .to_str()
+ .map_err(|e| format!("Failed to convert CxxString to str: {e}"))
+}
+
+fn cxx_strs(values: &CxxVector<CxxString>) -> Result<Vec<&str>, String> {
+ values.iter().map(cxx_str).collect()
+}
+
+#[derive(Debug)]
pub struct HudiFileGroupReader {
inner: FileGroupReader,
rt: tokio::runtime::Runtime,
@@ -68,29 +120,24 @@ pub struct HudiFileGroupReader {
pub fn new_file_group_reader_with_options(
base_uri: &CxxString,
options: &CxxVector<CxxString>,
-) -> std::result::Result<Box<HudiFileGroupReader>, String> {
- let base_uri = base_uri
- .to_str()
- .map_err(|e| format!("Failed to convert CxxString to str: {e}"))?;
-
- let mut opt_vec = Vec::new();
- for opt in options.iter() {
- let opt_str = opt
- .to_str()
- .map_err(|e| format!("Failed to convert CxxString to str: {e}"))?;
- if let Some((key, value)) = opt_str.split_once('=') {
- opt_vec.push((key, value))
- }
- }
-
- let rt = tokio::runtime::Builder::new_current_thread()
- .enable_all()
- .build()
- .map_err(|e| format!("Failed to create tokio runtime: {e}"))?;
- let reader = rt
- .block_on(FileGroupReader::new_with_options(base_uri, opt_vec))
- .map_err(|e| format!("Failed to create FileGroupReader: {e}"))?;
- Ok(Box::new(HudiFileGroupReader { inner: reader, rt }))
+) -> Result<Box<HudiFileGroupReader>, String> {
+ guard("new_file_group_reader_with_options", || {
+ let base_uri = cxx_str(base_uri)?;
+
+ let opt_vec: Vec<(&str, &str)> = cxx_strs(options)?
+ .into_iter()
+ .filter_map(|opt| opt.split_once('='))
+ .collect();
+
+ let rt = tokio::runtime::Builder::new_current_thread()
+ .enable_all()
+ .build()
+ .map_err(|e| format!("Failed to create tokio runtime: {e}"))?;
+ let reader = rt
+ .block_on(FileGroupReader::new_with_options(base_uri, opt_vec))
+ .map_err(|e| format!("Failed to create FileGroupReader: {e}"))?;
+ Ok(Box::new(HudiFileGroupReader { inner: reader, rt }))
+ })
}
impl HudiFileGroupReader {
@@ -98,56 +145,51 @@ impl HudiFileGroupReader {
&self,
base_file_path: &CxxString,
log_file_paths: &CxxVector<CxxString>,
- ) -> std::result::Result<*mut ffi::ArrowArrayStream, String> {
- let base_file_path = base_file_path
- .to_str()
- .map_err(|e| format!("Failed to convert CxxString to str: {e}"))?;
-
- let log_file_paths = log_file_paths
- .iter()
- .map(|p| {
- p.to_str()
- .map(String::from)
- .map_err(|e| format!("Failed to convert CxxString to str:
{e}"))
- })
- .collect::<std::result::Result<Vec<_>, _>>()?;
-
- let record_batch = self
- .rt
- .block_on(self.inner.read_file_slice_from_paths(
- base_file_path,
- log_file_paths,
- &ReadOptions::new(),
- ))
- .map_err(|e| format!("Failed to read file batch: {e}"))?;
- let schema = record_batch.schema();
+ ) -> Result<*mut ffi::ArrowArrayStream, String> {
+ guard("read_file_slice_from_paths", || {
+ let base_file_path = cxx_str(base_file_path)?;
+ let log_file_paths = cxx_strs(log_file_paths)?;
- Ok(create_raw_pointer_for_record_batches(
- vec![record_batch],
- schema,
- ))
+ let record_batch = self
+ .rt
+ .block_on(self.inner.read_file_slice_from_paths(
+ base_file_path,
+ log_file_paths,
+ &ReadOptions::new(),
+ ))
+ .map_err(|e| format!("Failed to read file batch: {e}"))?;
+ let schema = record_batch.schema();
+
+ Ok(create_raw_pointer_for_record_batches(
+ vec![record_batch],
+ schema,
+ ))
+ })
}
pub fn read_file_slice(
&self,
file_slice: &HudiFileSlice,
- ) -> std::result::Result<*mut ffi::ArrowArrayStream, String> {
- let record_batch = self
- .rt
- .block_on(
- self.inner
- .read_file_slice(&file_slice.inner, &ReadOptions::new()),
- )
- .map_err(|e| format!("Failed to read file slice: {e}"))?;
- let schema = record_batch.schema();
-
- Ok(create_raw_pointer_for_record_batches(
- vec![record_batch],
- schema,
- ))
+ ) -> Result<*mut ffi::ArrowArrayStream, String> {
+ guard("read_file_slice", || {
+ let record_batch = self
+ .rt
+ .block_on(
+ self.inner
+ .read_file_slice(&file_slice.inner,
&ReadOptions::new()),
+ )
+ .map_err(|e| format!("Failed to read file slice: {e}"))?;
+ let schema = record_batch.schema();
+
+ Ok(create_raw_pointer_for_record_batches(
+ vec![record_batch],
+ schema,
+ ))
+ })
}
}
+#[derive(Debug)]
pub struct HudiFileSlice {
inner: FileSlice,
}
@@ -156,35 +198,188 @@ pub fn new_file_slice_from_file_names(
partition_path: &CxxString,
base_file_name: &CxxString,
log_file_names: &CxxVector<CxxString>,
-) -> std::result::Result<Box<HudiFileSlice>, String> {
- let partition_path = partition_path
- .to_str()
- .map_err(|e| format!("Failed to convert CxxString to str: {e}"))?;
- let base_file_name = base_file_name
- .to_str()
- .map_err(|e| format!("Failed to convert CxxString to str: {e}"))?;
+) -> Result<Box<HudiFileSlice>, String> {
+ guard("new_file_slice_from_file_names", || {
+ let partition_path = cxx_str(partition_path)?;
+ let base_file_name = cxx_str(base_file_name)?;
+ let log_file_names = cxx_strs(log_file_names)?;
+
+ let mut file_group =
FileGroup::new_with_base_file_name(base_file_name, partition_path)
+ .map_err(|e| format!("Failed to create FileGroup: {e}"))?;
+ file_group
+ .add_log_files_from_names(&log_file_names)
+ .map_err(|e| format!("Failed to add files to FileGroup: {e}"))?;
+
+ let (_, file_slice) =
+ file_group.file_slices.iter().next().ok_or_else(|| {
+ format!("Failed to get file slice from FileGroup:
{file_group:?}")
+ })?;
+
+ Ok(Box::new(HudiFileSlice {
+ inner: file_slice.clone(),
+ }))
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use arrow_array::ffi_stream::{ArrowArrayStreamReader,
FFI_ArrowArrayStream};
+ use cxx::let_cxx_string;
+ use hudi_test::SampleTable;
+
+ /// Takes back the stream a bridge function leaked for C++ and reads it.
+ fn rows_in(raw: *mut ffi::ArrowArrayStream) -> usize {
+ // SAFETY: `raw` is the one box the bridge leaks for the C++ caller,
+ // reclaimed here instead.
+ let stream = unsafe { Box::from_raw(raw as *mut FFI_ArrowArrayStream)
};
+ ArrowArrayStreamReader::try_new(*stream)
+ .unwrap()
+ .map(|batch| batch.unwrap().num_rows())
+ .sum()
+ }
+
+ #[test]
+ fn test_guard_ok_returns_value() {
+ assert_eq!(guard("op", || Ok(7)), Ok(7));
+ }
+
+ #[test]
+ fn test_guard_err_passes_through_unchanged() {
+ let err = guard("op", || Err::<(), _>("bad
input".to_string())).unwrap_err();
+ assert_eq!(err, "bad input");
+ }
+
+ #[test]
+ fn test_guard_str_panic_becomes_error_naming_entry_point() {
+ let err = guard("read_file_slice", || -> Result<(), String> {
+ panic!("unsupported avro type")
+ })
+ .unwrap_err();
+ assert_eq!(err, "read_file_slice panicked: unsupported avro type");
+ }
- let log_file_names = log_file_names
- .iter()
- .map(|name| {
- name.to_str()
- .map_err(|e| format!("Failed to convert CxxString to str:
{e}"))
+ #[test]
+ fn test_guard_string_payload_keeps_message() {
+ let column = "map_col";
+ let err = guard("op", || -> Result<(), String> {
+ panic!("no support for {column}")
})
- .collect::<std::result::Result<Vec<_>, _>>()?;
-
- let mut file_group = FileGroup::new_with_base_file_name(base_file_name,
partition_path)
- .map_err(|e| format!("Failed to create FileGroup: {e}"))?;
- file_group
- .add_log_files_from_names(&log_file_names)
- .map_err(|e| format!("Failed to add files to FileGroup: {e}"))?;
-
- let (_, file_slice) = file_group
- .file_slices
- .iter()
- .next()
- .ok_or_else(|| format!("Failed to get file slice from FileGroup:
{file_group:?}"))?;
-
- Ok(Box::new(HudiFileSlice {
- inner: file_slice.clone(),
- }))
+ .unwrap_err();
+ assert_eq!(err, "op panicked: no support for map_col");
+ }
+
+ #[test]
+ fn test_guard_non_string_payload_is_still_an_error() {
+ let err = guard("op", || -> Result<(), String> {
+ std::panic::panic_any(42u8)
+ })
+ .unwrap_err();
+ assert_eq!(err, "op panicked: non-string panic payload");
+ }
+
+ #[test]
+ fn test_new_file_group_reader_with_options_unreadable_table_is_error() {
+ let_cxx_string!(base_uri = "/definitely/not/a/hudi/table");
+ let options = CxxVector::<CxxString>::new();
+ let err = new_file_group_reader_with_options(&base_uri,
&options).unwrap_err();
+ assert!(
+ err.starts_with("Failed to create FileGroupReader"),
+ "unexpected error: {err}"
+ );
+ }
+
+ #[test]
+ fn test_new_file_slice_from_file_names_bad_base_file_name_is_error() {
+ let_cxx_string!(partition_path = "");
+ let_cxx_string!(base_file_name = "not-a-hudi-base-file");
+ let log_file_names = CxxVector::<CxxString>::new();
+ let err = new_file_slice_from_file_names(&partition_path,
&base_file_name, &log_file_names)
+ .unwrap_err();
+ assert!(
+ err.starts_with("Failed to create FileGroup"),
+ "unexpected error: {err}"
+ );
+ }
+
+ fn first_base_file(table_path: &str) -> String {
+ let mut base_files: Vec<String> = std::fs::read_dir(table_path)
+ .unwrap()
+ .filter_map(|e| e.ok())
+ .map(|e| e.file_name().to_string_lossy().into_owned())
+ .filter(|name| name.ends_with(".parquet") &&
!name.starts_with('.'))
+ .collect();
+ base_files.sort();
+ base_files
+ .into_iter()
+ .next()
+ .expect("fixture has a base file")
+ }
+
+ /// The happy path through the same entry points the C++ caller uses, so
+ /// wrapping them in the guard is shown not to change a successful read.
+ #[test]
+ fn test_entry_points_through_bridge_read_base_file() {
+ let table_path = SampleTable::V8Nonpartitioned.path_to_cow();
+ let base_file_name = first_base_file(&table_path);
+
+ let_cxx_string!(base_uri = table_path.as_str());
+ let options = CxxVector::<CxxString>::new();
+ let reader = new_file_group_reader_with_options(&base_uri,
&options).unwrap();
+
+ let_cxx_string!(base_file_path = base_file_name.as_str());
+ let log_file_paths = CxxVector::<CxxString>::new();
+ let raw = reader
+ .read_file_slice_from_paths(&base_file_path, &log_file_paths)
+ .unwrap();
+ assert!(rows_in(raw) > 0);
+
+ let_cxx_string!(partition_path = "");
+ let file_slice =
+ new_file_slice_from_file_names(&partition_path, &base_file_path,
&log_file_paths)
+ .unwrap();
+ let raw = reader.read_file_slice(&file_slice).unwrap();
+ assert!(rows_in(raw) > 0);
+ }
+
+ /// A host keeps one reader across many queries, so a panic in one read
+ /// must leave the runtime and reader usable for the next.
+ #[test]
+ fn test_reader_usable_after_panic_inside_block_on() {
+ let table_path = SampleTable::V8Nonpartitioned.path_to_cow();
+ let_cxx_string!(base_uri = table_path.as_str());
+ let options = CxxVector::<CxxString>::new();
+ let reader = new_file_group_reader_with_options(&base_uri,
&options).unwrap();
+
+ let err = guard("read", || -> Result<(), String> {
+ reader.rt.block_on(async { panic!("mid-read") })
+ })
+ .unwrap_err();
+ assert_eq!(err, "read panicked: mid-read");
+
+ let_cxx_string!(base_file_path = first_base_file(&table_path));
+ let log_file_paths = CxxVector::<CxxString>::new();
+ let raw = reader
+ .read_file_slice_from_paths(&base_file_path, &log_file_paths)
+ .unwrap();
+ assert!(rows_in(raw) > 0);
+ }
+
+ #[test]
+ fn test_read_file_slice_from_paths_missing_base_file_is_error() {
+ let table_path = SampleTable::V8Nonpartitioned.path_to_cow();
+ let_cxx_string!(base_uri = table_path.as_str());
+ let options = CxxVector::<CxxString>::new();
+ let reader = new_file_group_reader_with_options(&base_uri,
&options).unwrap();
+
+ let_cxx_string!(base_file_path = "missing-file.parquet");
+ let log_file_paths = CxxVector::<CxxString>::new();
+ let err = reader
+ .read_file_slice_from_paths(&base_file_path, &log_file_paths)
+ .unwrap_err();
+ assert!(
+ err.starts_with("Failed to read file batch"),
+ "unexpected error: {err}"
+ );
+ }
}