This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git
The following commit(s) were added to refs/heads/main by this push:
new d57e85a9 [jindo] Fix unsafe fallback after process fork (#999)
d57e85a9 is described below
commit d57e85a9dff07fa62b0740792b7dc39bc811ab7a
Author: XiaoHongbo <[email protected]>
AuthorDate: Fri Oct 2 21:40:29 2026 +0800
[jindo] Fix unsafe fallback after process fork (#999)
---
Cargo.lock | 1 +
bindings/python/src/error.rs | 115 ++++++++++++++++++++++-
bindings/python/src/lib.rs | 1 +
bindings/python/tests/test_runtime_fork.py | 5 +
crates/paimon/Cargo.toml | 1 +
crates/paimon/src/arrow/format/blob.rs | 39 ++++++++
crates/paimon/src/arrow/format/orc.rs | 122 +++++++++++++++++++++---
crates/paimon/src/arrow/format/parquet.rs | 73 ++++++++++++---
crates/paimon/src/error.rs | 136 ++++++++++++++++++++++++++-
crates/paimon/src/io/file_io.rs | 144 +++++++++++++++++++++--------
crates/paimon/src/io/storage_jindo.rs | 130 +++++++++++++++++++++++++-
docs/src/getting-started.md | 4 +-
12 files changed, 695 insertions(+), 76 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 5a29dcdc..d9d01852 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4677,6 +4677,7 @@ dependencies = [
"http",
"indexmap 2.14.0",
"jiff",
+ "libc",
"libloading 0.9.0",
"log",
"lru 0.18.2",
diff --git a/bindings/python/src/error.rs b/bindings/python/src/error.rs
index 7255bf31..b6ca355a 100644
--- a/bindings/python/src/error.rs
+++ b/bindings/python/src/error.rs
@@ -15,10 +15,24 @@
// specific language governing permissions and limitations
// under the License.
-use pyo3::exceptions::{PyNotImplementedError, PyValueError};
-use pyo3::PyErr;
+use pyo3::exceptions::{PyNotImplementedError, PyRuntimeError, PyValueError};
+use pyo3::prelude::*;
+
+pyo3::create_exception!(
+ pypaimon_rust,
+ ForkSafetyError,
+ PyRuntimeError,
+ "Raised when native state inherited across process fork cannot be used
safely."
+);
+
+pub fn register_module(py: Python<'_>, module: &Bound<'_, PyModule>) ->
PyResult<()> {
+ module.add("ForkSafetyError", py.get_type::<ForkSafetyError>())
+}
pub fn to_py_err(err: paimon::Error) -> PyErr {
+ if err.is_process_fork_unsupported() {
+ return ForkSafetyError::new_err(err.to_string());
+ }
match err {
// Unimplemented scan semantics: distinct from malformed input so upper
// layers can catch NotImplementedError and decide on a fallback.
@@ -28,5 +42,102 @@ pub fn to_py_err(err: paimon::Error) -> PyErr {
}
pub fn df_to_py_err(err: datafusion::error::DataFusionError) -> PyErr {
+ let mut causes: Vec<&(dyn std::error::Error + 'static)> = vec![&err];
+ while let Some(cause) = causes.pop() {
+ if cause
+ .downcast_ref::<paimon::Error>()
+ .is_some_and(paimon::Error::is_process_fork_unsupported)
+ {
+ return ForkSafetyError::new_err(err.to_string());
+ }
+ if let Some(datafusion::error::DataFusionError::Collection(errors)) =
+ cause.downcast_ref::<datafusion::error::DataFusionError>()
+ {
+ causes.extend(
+ errors
+ .iter()
+ .map(|error| error as &(dyn std::error::Error + 'static)),
+ );
+ } else if let Some(source) = cause.source() {
+ causes.push(source);
+ }
+ }
PyValueError::new_err(err.to_string())
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn process_fork_error_has_distinct_python_type() {
+ Python::attach(|py| {
+ let error = to_py_err(paimon::Error::ProcessForkUnsupported {
+ message: "fork is not supported".to_string(),
+ });
+ assert!(error.is_instance_of::<ForkSafetyError>(py));
+ });
+ }
+
+ #[test]
+ fn wrapped_process_fork_error_has_distinct_python_type() {
+ Python::attach(|py| {
+ let error = to_py_err(paimon::Error::UnexpectedError {
+ message: "read failed".to_string(),
+ source: Some(Box::new(paimon::Error::ProcessForkUnsupported {
+ message: "fork is not supported".to_string(),
+ })),
+ });
+ assert!(error.is_instance_of::<ForkSafetyError>(py));
+ });
+ }
+
+ #[test]
+ fn datafusion_fork_error_has_distinct_python_type() {
+ Python::attach(|py| {
+ let error = datafusion::error::DataFusionError::External(Box::new(
+ paimon::Error::ProcessForkUnsupported {
+ message: "fork is not supported".to_string(),
+ },
+ ));
+ assert!(df_to_py_err(error).is_instance_of::<ForkSafetyError>(py));
+ });
+ }
+
+ #[test]
+ fn datafusion_context_preserves_fork_error_type() {
+ Python::attach(|py| {
+ let error = datafusion::error::DataFusionError::External(Box::new(
+ paimon::Error::ProcessForkUnsupported {
+ message: "fork is not supported".to_string(),
+ },
+ ))
+ .context("SQL collection failed");
+ assert!(df_to_py_err(error).is_instance_of::<ForkSafetyError>(py));
+ });
+ }
+
+ #[test]
+ fn datafusion_collection_preserves_fork_error_type() {
+ Python::attach(|py| {
+ let error = datafusion::error::DataFusionError::Collection(vec![
+ datafusion::error::DataFusionError::Plan("invalid
SQL".to_string()),
+ datafusion::error::DataFusionError::External(Box::new(
+ paimon::Error::ProcessForkUnsupported {
+ message: "fork is not supported".to_string(),
+ },
+ )),
+ ])
+ .context("SQL planning failed");
+ assert!(df_to_py_err(error).is_instance_of::<ForkSafetyError>(py));
+ });
+ }
+
+ #[test]
+ fn unrelated_datafusion_error_remains_value_error() {
+ Python::attach(|py| {
+ let error = datafusion::error::DataFusionError::Plan("invalid
SQL".to_string());
+ assert!(df_to_py_err(error).is_instance_of::<PyValueError>(py));
+ });
+ }
+}
diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs
index 310cf78a..6756fc7a 100644
--- a/bindings/python/src/lib.rs
+++ b/bindings/python/src/lib.rs
@@ -35,6 +35,7 @@ mod tag;
#[pymodule]
fn pypaimon_rust(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
+ error::register_module(py, m)?;
context::register_module(py, m)?;
variant::register_module(py, m)?;
Ok(())
diff --git a/bindings/python/tests/test_runtime_fork.py
b/bindings/python/tests/test_runtime_fork.py
index 740e42fe..6b28c79e 100644
--- a/bindings/python/tests/test_runtime_fork.py
+++ b/bindings/python/tests/test_runtime_fork.py
@@ -19,9 +19,14 @@ import multiprocessing
import pytest
+from pypaimon_rust import ForkSafetyError
from pypaimon_rust.datafusion import PaimonCatalog, SQLContext
+def test_fork_safety_error_is_runtime_error():
+ assert issubclass(ForkSafetyError, RuntimeError)
+
+
def _plan_rows(catalog):
assert "fork_db" in catalog.list_databases()
builder = catalog.get_table("fork_db.t").new_read_builder()
diff --git a/crates/paimon/Cargo.toml b/crates/paimon/Cargo.toml
index 865fd0d5..9ca18244 100644
--- a/crates/paimon/Cargo.toml
+++ b/crates/paimon/Cargo.toml
@@ -159,3 +159,4 @@ cc = "1"
[dev-dependencies]
axum = { version = "0.7", features = ["macros", "tokio", "http1", "http2"] }
+libc = "0.2"
diff --git a/crates/paimon/src/arrow/format/blob.rs
b/crates/paimon/src/arrow/format/blob.rs
index 6b992a66..fff61b82 100644
--- a/crates/paimon/src/arrow/format/blob.rs
+++ b/crates/paimon/src/arrow/format/blob.rs
@@ -2348,6 +2348,45 @@ mod tests {
include!(concat!(env!("CARGO_MANIFEST_DIR"), "/blob_test_utils.rs"));
}
+ struct ForkFailingBlobRead {
+ tail: Option<Bytes>,
+ }
+
+ #[async_trait::async_trait]
+ impl FileRead for ForkFailingBlobRead {
+ async fn read(&self, range: Range<u64>) -> crate::Result<Bytes> {
+ if range == (4..4100) {
+ if let Some(tail) = &self.tail {
+ return Ok(tail.clone());
+ }
+ }
+ Err(Error::ProcessForkUnsupported {
+ message: crate::error::JINDO_FORK_ERROR.to_string(),
+ })
+ }
+ }
+
+ #[tokio::test]
+ async fn blob_index_reads_preserve_fork_safety_error() {
+ let error = BlobFileIndex::load(&ForkFailingBlobRead { tail: None },
4100)
+ .await
+ .unwrap_err();
+ assert!(matches!(error, Error::ProcessForkUnsupported { .. }));
+
+ let mut tail = vec![0; 4096];
+ tail[4091..4095].copy_from_slice(&4092_i32.to_le_bytes());
+ tail[4095] = BLOB_FORMAT_VERSION;
+ let error = BlobFileIndex::load(
+ &ForkFailingBlobRead {
+ tail: Some(Bytes::from(tail)),
+ },
+ 4100,
+ )
+ .await
+ .unwrap_err();
+ assert!(matches!(error, Error::ProcessForkUnsupported { .. }));
+ }
+
#[tokio::test]
async fn test_index_tail_prefetch_boundaries() {
for rows in [0, 1, 4090, 4091, 4092, 9000] {
diff --git a/crates/paimon/src/arrow/format/orc.rs
b/crates/paimon/src/arrow/format/orc.rs
index f8f96b2a..85c77780 100644
--- a/crates/paimon/src/arrow/format/orc.rs
+++ b/crates/paimon/src/arrow/format/orc.rs
@@ -30,6 +30,7 @@ use orc_rust::projection::ProjectionMask;
use orc_rust::reader::AsyncChunkReader;
use orc_rust::ArrowReaderBuilder;
use std::collections::HashMap;
+use std::error::Error as StdError;
use std::io::Write;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
@@ -39,6 +40,37 @@ const ORC_IN_PREDICATE_MAX_LITERALS: usize = 20;
pub(crate) struct OrcFormatReader;
+fn orc_error<E>(error: E, context: &str) -> Error
+where
+ E: StdError + Send + Sync + 'static,
+{
+ if let Some(error) = find_fork_error(&error) {
+ return error;
+ }
+ Error::UnexpectedError {
+ message: format!("{context}: {error}"),
+ source: Some(Box::new(error)),
+ }
+}
+
+fn find_fork_error(error: &(dyn StdError + 'static)) -> Option<Error> {
+ if let Some(Error::ProcessForkUnsupported { message }) =
error.downcast_ref::<Error>() {
+ return Some(Error::ProcessForkUnsupported {
+ message: message.clone(),
+ });
+ }
+ // `std::io::Error::source` does not expose the custom error stored by
+ // `Error::other`, so inspect it explicitly before following normal
sources.
+ if let Some(io_error) = error.downcast_ref::<std::io::Error>() {
+ if let Some(source) = io_error.get_ref() {
+ if let Some(error) = find_fork_error(source) {
+ return Some(error);
+ }
+ }
+ }
+ error.source().and_then(find_fork_error)
+}
+
const ORC_STRIPE_SIZE: usize = 64 * 1024 * 1024;
const ORC_WORKER_COUNT: usize = 4;
static NEXT_ORC_WRITER_ID: AtomicU64 = AtomicU64::new(1);
@@ -336,10 +368,7 @@ impl FormatFileReader for OrcFormatReader {
let builder = ArrowReaderBuilder::try_new_async(orc_reader)
.await
- .map_err(|e| Error::UnexpectedError {
- message: format!("Failed to open ORC file: {e}"),
- source: Some(Box::new(e)),
- })?;
+ .map_err(|error| orc_error(error, "Failed to open ORC file"))?;
// Widen the scan to include predicate columns so the residual filter
can
// see every column it references, even when a predicate column is not
part
@@ -394,10 +423,7 @@ impl FormatFileReader for OrcFormatReader {
});
Ok(stream
.map(move |r| {
- let batch = r.map_err(|e| Error::UnexpectedError {
- message: format!("ORC read error: {e}"),
- source: Some(Box::new(e)),
- })?;
+ let batch = r.map_err(|error| orc_error(error, "ORC read
error"))?;
match &residual {
Some((fp, scan_fields)) => {
crate::arrow::residual::filter_record_batch_by_predicates(
@@ -664,10 +690,7 @@ pub(crate) async fn read_row_count(
) -> crate::Result<i64> {
let builder =
ArrowReaderBuilder::try_new_async(OrcFileReader::new(file_size, reader))
.await
- .map_err(|error| Error::UnexpectedError {
- message: format!("Failed to open ORC file: {error}"),
- source: Some(Box::new(error)),
- })?;
+ .map_err(|error| orc_error(error, "Failed to open ORC file"))?;
let rows = builder
.file_metadata()
.stripe_metadatas()
@@ -709,7 +732,7 @@ impl AsyncChunkReader for OrcFileReader {
self.r
.read(offset_from_start..offset_from_start + length)
.await
- .map_err(|e| std::io::Error::other(e.to_string()))
+ .map_err(std::io::Error::other)
})
}
}
@@ -1039,6 +1062,79 @@ mod tests {
buf
}
+ #[derive(Clone)]
+ struct SwitchableForkFailingFileRead {
+ data: Bytes,
+ fail: Arc<std::sync::atomic::AtomicBool>,
+ }
+
+ #[async_trait::async_trait]
+ impl FileRead for SwitchableForkFailingFileRead {
+ async fn read(&self, range: std::ops::Range<u64>) ->
crate::Result<Bytes> {
+ if self.fail.load(std::sync::atomic::Ordering::SeqCst) {
+ return Err(Error::ProcessForkUnsupported {
+ message: crate::error::JINDO_FORK_ERROR.to_string(),
+ });
+ }
+ Ok(self.data.slice(range.start as usize..range.end as usize))
+ }
+ }
+
+ #[tokio::test]
+ async fn orc_read_paths_preserve_fork_safety_error() {
+ let schema = Arc::new(Schema::new(vec![Field::new(
+ "id",
+ ArrowDataType::Int32,
+ true,
+ )]));
+ let batch = RecordBatch::try_new(
+ Arc::clone(&schema),
+ vec![Arc::new(Int32Array::from(vec![1, 2, 3]))],
+ )
+ .unwrap();
+ let data = Bytes::from(write_single_stripe_orc(schema, &batch));
+ let file_size = data.len() as u64;
+ let fail = Arc::new(std::sync::atomic::AtomicBool::new(true));
+ let file_read = SwitchableForkFailingFileRead {
+ data,
+ fail: Arc::clone(&fail),
+ };
+ let fields = vec![field(0, "id", DataType::Int(IntType::new()))];
+
+ let error = match OrcFormatReader
+ .read_batch_stream(
+ Box::new(file_read.clone()),
+ file_size,
+ &fields,
+ None,
+ None,
+ None,
+ )
+ .await
+ {
+ Ok(_) => panic!("ORC metadata read should propagate the storage
error"),
+ Err(error) => error,
+ };
+ assert!(
+ matches!(error, Error::ProcessForkUnsupported { .. }),
+ "{error:?}"
+ );
+
+ let error = super::read_row_count(Box::new(file_read.clone()),
file_size)
+ .await
+ .unwrap_err();
+ assert!(matches!(error, Error::ProcessForkUnsupported { .. }));
+
+ fail.store(false, std::sync::atomic::Ordering::SeqCst);
+ let mut stream = OrcFormatReader
+ .read_batch_stream(Box::new(file_read), file_size, &fields, None,
None, None)
+ .await
+ .unwrap();
+ fail.store(true, std::sync::atomic::Ordering::SeqCst);
+ let error = stream.next().await.unwrap().unwrap_err();
+ assert!(matches!(error, Error::ProcessForkUnsupported { .. }));
+ }
+
#[tokio::test]
async fn orc_writer_flushes_stripes_and_accepts_more_rows() {
use crate::io::FileIOBuilder;
diff --git a/crates/paimon/src/arrow/format/parquet.rs
b/crates/paimon/src/arrow/format/parquet.rs
index 5ce567bf..0214582a 100644
--- a/crates/paimon/src/arrow/format/parquet.rs
+++ b/crates/paimon/src/arrow/format/parquet.rs
@@ -2567,13 +2567,9 @@ pub(crate) async fn read_row_count(
) -> crate::Result<i64> {
let mut reader =
ArrowFileReader::new(file_size,
Arc::from(reader)).with_metadata_cache_enabled(false);
- let metadata = reader
- .get_metadata(None)
- .await
- .map_err(|error| Error::UnexpectedError {
- message: format!("Failed to read the Parquet footer: {error}"),
- source: Some(Box::new(error)),
- })?;
+ let metadata = reader.get_metadata(None).await.map_err(|error| {
+ Error::from_parquet_with_context(error, "Failed to read the Parquet
footer")
+ })?;
Ok(metadata.file_metadata().num_rows())
}
@@ -2592,10 +2588,11 @@ impl ArrowFileReader {
}
fn read_bytes(&mut self, range: Range<u64>) -> BoxFuture<'_,
parquet::errors::Result<Bytes>> {
- Box::pin(self.r.read(range.start..range.end).map_err(|err| {
- let err_msg = format!("{err}");
- parquet::errors::ParquetError::External(err_msg.into())
- }))
+ Box::pin(
+ self.r
+ .read(range.start..range.end)
+ .map_err(|error|
parquet::errors::ParquetError::External(Box::new(error))),
+ )
}
async fn load_metadata(
@@ -2655,15 +2652,15 @@ impl AsyncFileReader for ArrowFileReader {
// All ranges fit within the concurrency limit — fire them all
at once.
futures::future::try_join_all(fetch_ranges.iter().map(|range| {
r.read(range.clone())
- .map_err(|e|
parquet::errors::ParquetError::External(format!("{e}").into()))
+ .map_err(|error|
parquet::errors::ParquetError::External(Box::new(error)))
}))
.await?
} else {
// More ranges than concurrency slots — use buffered stream.
futures::stream::iter(fetch_ranges.iter().cloned())
.map(|range| async move {
- r.read(range).await.map_err(|e| {
-
parquet::errors::ParquetError::External(format!("{e}").into())
+ r.read(range).await.map_err(|error| {
+
parquet::errors::ParquetError::External(Box::new(error))
})
})
.buffered(concurrency)
@@ -3397,6 +3394,54 @@ mod tests {
.unwrap()
}
+ struct ForkFailingFileRead;
+
+ #[async_trait::async_trait]
+ impl crate::io::FileRead for ForkFailingFileRead {
+ async fn read(&self, _range: std::ops::Range<u64>) ->
crate::Result<Bytes> {
+ Err(Error::ProcessForkUnsupported {
+ message: crate::error::JINDO_FORK_ERROR.to_string(),
+ })
+ }
+ }
+
+ #[tokio::test]
+ async fn parquet_read_paths_preserve_fork_safety_error() {
+ let fields = vec![DataField::new(
+ 0,
+ "id".to_string(),
+ DataType::Int(IntType::new()),
+ )];
+ let error = match ParquetFormatReader::default()
+ .read_batch_stream(Box::new(ForkFailingFileRead), 8, &fields,
None, None, None)
+ .await
+ {
+ Ok(_) => panic!("Parquet reader should propagate the storage
error"),
+ Err(error) => error,
+ };
+ assert!(matches!(error, Error::ProcessForkUnsupported { .. }));
+
+ let error = super::read_row_count(Box::new(ForkFailingFileRead), 8)
+ .await
+ .unwrap_err();
+ assert!(matches!(error, Error::ProcessForkUnsupported { .. }));
+
+ for count in [2, super::RANGE_FETCH_CONCURRENCY + 1] {
+ let step = super::RANGE_COALESCE_BYTES + 2;
+ let ranges = (0..count)
+ .map(|index| {
+ let start = index as u64 * step;
+ start..start + 1
+ })
+ .collect::<Vec<_>>();
+ let mut reader =
+ super::ArrowFileReader::new(count as u64 * step + 1,
Arc::new(ForkFailingFileRead));
+ let error = reader.get_byte_ranges(ranges).await.unwrap_err();
+ let error = Error::from(error);
+ assert!(matches!(error, Error::ProcessForkUnsupported { .. }));
+ }
+ }
+
#[derive(Clone)]
struct ConcurrentTrackingFileRead {
data: Bytes,
diff --git a/crates/paimon/src/error.rs b/crates/paimon/src/error.rs
index 44aa4a74..47cd9e6e 100644
--- a/crates/paimon/src/error.rs
+++ b/crates/paimon/src/error.rs
@@ -17,6 +17,9 @@
use snafu::prelude::*;
+pub(crate) const JINDO_FORK_ERROR: &str =
+ "Jindo SDK cannot be reused after process fork; use spawn or avoid
initializing Jindo in the parent process";
+
/// Result type used in paimon.
pub type Result<T, E = Error> = std::result::Result<T, E>;
@@ -66,6 +69,8 @@ pub enum Error {
display("Paimon hitting unsupported io error {}", message)
)]
IoUnsupported { message: String },
+ #[snafu(display("{}", message))]
+ ProcessForkUnsupported { message: String },
#[snafu(
visibility(pub(crate)),
display("Paimon hitting invalid config: {}", message)
@@ -136,12 +141,68 @@ pub enum Error {
impl From<opendal::Error> for Error {
fn from(source: opendal::Error) -> Self {
- // TODO: Simple use IoUnexpected for now
+ Error::from_opendal_with_context(source, "IO operation failed on
underlying storage")
+ }
+}
+
+impl Error {
+ pub(crate) fn from_opendal_with_context(
+ source: opendal::Error,
+ message: impl Into<String>,
+ ) -> Self {
+ if source.kind() == opendal::ErrorKind::Unsupported &&
source.message() == JINDO_FORK_ERROR
+ {
+ return Error::ProcessForkUnsupported {
+ message: source.message().to_string(),
+ };
+ }
Error::IoUnexpected {
- message: "IO operation failed on underlying storage".to_string(),
+ message: message.into(),
source: Box::new(source),
}
}
+
+ /// Whether this error or one of its wrapped causes rejects inherited
native state.
+ #[doc(hidden)]
+ pub fn is_process_fork_unsupported(&self) -> bool {
+ match self {
+ Error::ProcessForkUnsupported { .. } => true,
+ Error::DataInvalid { source, .. } | Error::UnexpectedError {
source, .. } => source
+ .as_deref()
+ .is_some_and(|source|
error_chain_contains_process_fork(source)),
+ Error::IoUnexpected { source, .. } => is_jindo_fork_error(source),
+ Error::DataUnexpected { source, .. } =>
error_chain_contains_process_fork(source),
+ Error::ParquetDataUnexpected { source, .. } => {
+ error_chain_contains_process_fork(source)
+ }
+ Error::RestApi { source } =>
error_chain_contains_process_fork(source),
+ _ => false,
+ }
+ }
+}
+
+fn is_jindo_fork_error(error: &opendal::Error) -> bool {
+ error.kind() == opendal::ErrorKind::Unsupported && error.message() ==
JINDO_FORK_ERROR
+}
+
+fn error_chain_contains_process_fork(error: &(dyn std::error::Error +
'static)) -> bool {
+ if let Some(error) = error.downcast_ref::<Error>() {
+ return error.is_process_fork_unsupported();
+ }
+ if let Some(error) = error.downcast_ref::<opendal::Error>() {
+ return is_jindo_fork_error(error);
+ }
+ if let Some(error) = error.downcast_ref::<std::io::Error>() {
+ if error
+ .get_ref()
+ .is_some_and(|source| error_chain_contains_process_fork(source))
+ {
+ return true;
+ }
+ }
+ error
+ .source()
+ .is_some_and(error_chain_contains_process_fork)
}
impl From<apache_avro::Error> for Error {
@@ -153,17 +214,82 @@ impl From<apache_avro::Error> for Error {
}
}
-impl From<parquet::errors::ParquetError> for Error {
- fn from(source: parquet::errors::ParquetError) -> Self {
+impl Error {
+ pub(crate) fn from_parquet_with_context(
+ source: parquet::errors::ParquetError,
+ context: &str,
+ ) -> Self {
+ let source = match source {
+ parquet::errors::ParquetError::External(source) => match
source.downcast::<Error>() {
+ Ok(source) => return *source,
+ Err(source) => parquet::errors::ParquetError::External(source),
+ },
+ source => source,
+ };
Error::ParquetDataUnexpected {
- message: format!("Failed to read a Parquet file: {source}"),
+ message: format!("{context}: {source}"),
source: Box::new(source),
}
}
}
+impl From<parquet::errors::ParquetError> for Error {
+ fn from(source: parquet::errors::ParquetError) -> Self {
+ Error::from_parquet_with_context(source, "Failed to read a Parquet
file")
+ }
+}
+
impl From<crate::api::rest_error::RestError> for Error {
fn from(source: crate::api::rest_error::RestError) -> Self {
Error::RestApi { source }
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn preserves_jindo_fork_error_kind() {
+ let error: Error =
+ opendal::Error::new(opendal::ErrorKind::Unsupported,
JINDO_FORK_ERROR).into();
+ assert!(matches!(error, Error::ProcessForkUnsupported { .. }));
+ }
+
+ #[test]
+ fn preserves_paimon_error_through_parquet_external_error() {
+ let parquet_error =
+
parquet::errors::ParquetError::External(Box::new(Error::ProcessForkUnsupported {
+ message: JINDO_FORK_ERROR.to_string(),
+ }));
+
+ let error: Error = parquet_error.into();
+ assert!(matches!(error, Error::ProcessForkUnsupported { .. }));
+ }
+
+ #[test]
+ fn keeps_non_paimon_parquet_external_error_wrapped() {
+ let parquet_error = parquet::errors::ParquetError::External(Box::new(
+ std::io::Error::other("external parquet failure"),
+ ));
+
+ let error: Error = parquet_error.into();
+ assert!(matches!(error, Error::ParquetDataUnexpected { .. }));
+ }
+
+ #[test]
+ fn detects_wrapped_process_fork_error() {
+ let error = Error::UnexpectedError {
+ message: "outer context".to_string(),
+ source: Some(Box::new(Error::IoUnexpected {
+ message: "storage context".to_string(),
+ source: Box::new(opendal::Error::new(
+ opendal::ErrorKind::Unsupported,
+ JINDO_FORK_ERROR,
+ )),
+ })),
+ };
+
+ assert!(error.is_process_fork_unsupported());
+ }
+}
diff --git a/crates/paimon/src/io/file_io.rs b/crates/paimon/src/io/file_io.rs
index 7dba9fe8..67038518 100644
--- a/crates/paimon/src/io/file_io.rs
+++ b/crates/paimon/src/io/file_io.rs
@@ -33,7 +33,6 @@ use futures::stream::BoxStream;
use futures::{StreamExt, TryStreamExt};
use opendal::raw::{normalize_path, normalize_root};
use opendal::Operator;
-use snafu::ResultExt;
use tokio_util::compat::FuturesAsyncWriteCompatExt;
use url::Url;
@@ -322,12 +321,12 @@ impl FileIO {
/// Reference:
<https://github.com/apache/paimon/blob/release-0.8.2/paimon-common/src/main/java/org/apache/paimon/fs/FileIO.java#L97>
pub async fn get_status(&self, path: &str) -> Result<FileStatus> {
let (op, relative_path) = self.create(path).await?;
- let meta = op
- .stat(relative_path.as_ref())
- .await
- .context(IoUnexpectedSnafu {
- message: format!("Failed to get file status for '{path}'"),
- })?;
+ let meta = op.stat(relative_path.as_ref()).await.map_err(|error| {
+ Error::from_opendal_with_context(
+ error,
+ format!("Failed to get file status for '{path}'"),
+ )
+ })?;
Ok(FileStatus {
size: meta.content_length(),
@@ -351,8 +350,8 @@ impl FileIO {
// use normalize_root to make sure it end with `/`.
let list_path = normalize_root(relative_path.as_ref());
- let entries = op.list_with(&list_path).await.context(IoUnexpectedSnafu
{
- message: format!("Failed to list files in '{path}'"),
+ let entries = op.list_with(&list_path).await.map_err(|error| {
+ Error::from_opendal_with_context(error, format!("Failed to list
files in '{path}'"))
})?;
let mut statuses = Vec::new();
@@ -409,21 +408,27 @@ impl FileIO {
let has_provider = matches!(self.backend, FileIOBackend::Provider(_));
let list_path = normalize_root(relative_path.as_ref());
- let entries =
- op.lister_with(&list_path)
- .recursive(true)
- .await
- .context(IoUnexpectedSnafu {
- message: format!("Failed to list files recursively in
'{path}'"),
- })?;
+ let entries = op
+ .lister_with(&list_path)
+ .recursive(true)
+ .await
+ .map_err(|error| {
+ Error::from_opendal_with_context(
+ error,
+ format!("Failed to list files recursively in '{path}'"),
+ )
+ })?;
let path = path.to_string();
let list_path_normalized =
list_path.trim_start_matches('/').to_string();
Ok(Box::pin(async_stream::try_stream! {
let mut entries = entries;
let mut emitted = 0usize;
- while let Some(entry) =
entries.try_next().await.context(IoUnexpectedSnafu {
- message: format!("Failed to list files recursively in
'{path}'"),
+ while let Some(entry) = entries.try_next().await.map_err(|error| {
+ Error::from_opendal_with_context(
+ error,
+ format!("Failed to list files recursively in '{path}'"),
+ )
})? {
let entry_path = entry.path();
if has_provider {
@@ -458,11 +463,12 @@ impl FileIO {
pub async fn exists(&self, path: &str) -> Result<bool> {
let (op, relative_path) = self.create(path).await?;
- op.exists(relative_path.as_ref())
- .await
- .context(IoUnexpectedSnafu {
- message: format!("Failed to check existence of '{path}'"),
- })
+ op.exists(relative_path.as_ref()).await.map_err(|error| {
+ Error::from_opendal_with_context(
+ error,
+ format!("Failed to check existence of '{path}'"),
+ )
+ })
}
/// Check if a directory exists.
@@ -470,8 +476,11 @@ impl FileIO {
let (op, relative_path) = self.create(path).await?;
let dir_path = normalize_root(relative_path.as_ref());
- op.exists(&dir_path).await.context(IoUnexpectedSnafu {
- message: format!("Failed to check existence of directory
'{path}'"),
+ op.exists(&dir_path).await.map_err(|error| {
+ Error::from_opendal_with_context(
+ error,
+ format!("Failed to check existence of directory '{path}'"),
+ )
})
}
@@ -482,11 +491,9 @@ impl FileIO {
let (op, relative_path) = self.create(path).await?;
let cache_path = cache_object_path(&op, relative_path.as_ref());
- op.delete(relative_path.as_ref())
- .await
- .context(IoUnexpectedSnafu {
- message: format!("Failed to delete file '{path}'"),
- })?;
+ op.delete(relative_path.as_ref()).await.map_err(|error| {
+ Error::from_opendal_with_context(error, format!("Failed to delete
file '{path}'"))
+ })?;
if let Some(cache) = self.cache.as_ref().filter(|cache|
cache.is_cacheable(path)) {
cache.invalidate_path(&cache_path).await;
}
@@ -504,8 +511,11 @@ impl FileIO {
op.delete_with(relative_path.as_ref())
.recursive(true)
.await
- .context(IoUnexpectedSnafu {
- message: format!("Failed to delete directory '{path}'"),
+ .map_err(|error| {
+ Error::from_opendal_with_context(
+ error,
+ format!("Failed to delete directory '{path}'"),
+ )
})?;
if let Some(cache) = &self.cache {
cache.invalidate_prefix(&cache_path).await;
@@ -523,8 +533,8 @@ impl FileIO {
let (op, relative_path) = self.create(path).await?;
// Opendal create_dir expects the path to end with `/` to indicate a
directory.
let dir_path = normalize_root(relative_path.as_ref());
- op.create_dir(&dir_path).await.context(IoUnexpectedSnafu {
- message: format!("Failed to create directory '{path}'"),
+ op.create_dir(&dir_path).await.map_err(|error| {
+ Error::from_opendal_with_context(error, format!("Failed to create
directory '{path}'"))
})?;
Ok(())
@@ -592,8 +602,11 @@ impl FileIO {
op_src
.rename(relative_path_src.as_ref(), relative_path_dst.as_ref())
.await
- .context(IoUnexpectedSnafu {
- message: format!("Failed to rename '{src}' to '{dst}'"),
+ .map_err(|error| {
+ Error::from_opendal_with_context(
+ error,
+ format!("Failed to rename '{src}' to '{dst}'"),
+ )
})?;
if let Some(cache) = &self.cache {
cache.invalidate_prefix(&cache_path_src).await;
@@ -1188,6 +1201,7 @@ mod file_action_test {
async fn create(&self, _path: &str) -> crate::Result<(Operator,
String)> {
let service: Servicer = Arc::new(CountingListService {
pulls: Arc::clone(&self.pulls),
+ fail_with_fork_error: false,
});
Ok((
Operator::from_parts(OperationContext::default(), service),
@@ -1199,6 +1213,7 @@ mod file_action_test {
#[derive(Debug)]
struct CountingListService {
pulls: Arc<AtomicUsize>,
+ fail_with_fork_error: bool,
}
impl Service for CountingListService {
@@ -1214,6 +1229,7 @@ mod file_action_test {
fn capability(&self) -> Capability {
Capability {
+ stat: true,
list: true,
list_with_recursive: true,
..Default::default()
@@ -1235,6 +1251,9 @@ mod file_action_test {
_path: &str,
_args: OpStat,
) -> opendal::Result<RpStat> {
+ if self.fail_with_fork_error {
+ return Err(fork_test_error());
+ }
Err(unsupported_test_operation())
}
@@ -1269,6 +1288,7 @@ mod file_action_test {
Ok(CountingLister {
pulls: Arc::clone(&self.pulls),
next: 0,
+ fail_with_fork_error: self.fail_with_fork_error,
})
}
@@ -1310,14 +1330,22 @@ mod file_action_test {
)
}
+ fn fork_test_error() -> opendal::Error {
+ opendal::Error::new(opendal::ErrorKind::Unsupported, JINDO_FORK_ERROR)
+ }
+
struct CountingLister {
pulls: Arc<AtomicUsize>,
next: usize,
+ fail_with_fork_error: bool,
}
impl oio::List for CountingLister {
async fn next(&mut self) -> opendal::Result<Option<oio::Entry>> {
self.pulls.fetch_add(1, AtomicOrdering::SeqCst);
+ if self.fail_with_fork_error {
+ return Err(fork_test_error());
+ }
if self.next == 0 {
self.next += 1;
return Ok(Some(oio::Entry::new(
@@ -1460,6 +1488,50 @@ mod file_action_test {
assert_eq!(pulls.load(AtomicOrdering::SeqCst), 1);
}
+ #[tokio::test]
+ async fn file_status_reads_preserve_fork_safety_error() {
+ let pulls = Arc::new(AtomicUsize::new(0));
+ let service: Servicer = Arc::new(CountingListService {
+ pulls,
+ fail_with_fork_error: true,
+ });
+ let operator = Operator::from_parts(OperationContext::default(),
service);
+
+ #[derive(Debug)]
+ struct FixedProvider(Operator);
+
+ #[async_trait::async_trait]
+ impl FileIOProvider for FixedProvider {
+ async fn create(&self, path: &str) -> crate::Result<(Operator,
String)> {
+ Ok((
+ self.0.clone(),
+ path.strip_prefix("counting:/").unwrap().to_string(),
+ ))
+ }
+ }
+
+ let file_io =
setup_memory_file_io().with_provider(Arc::new(FixedProvider(operator)));
+ let error = file_io
+ .get_status("counting:/objects/file")
+ .await
+ .unwrap_err();
+ assert!(
+ matches!(error, Error::ProcessForkUnsupported { .. }),
+ "{error:?}"
+ );
+
+ let error =
file_io.list_status("counting:/objects/").await.unwrap_err();
+ assert!(matches!(error, Error::ProcessForkUnsupported { .. }));
+
+ let snapshots =
+ crate::table::SnapshotManager::new(file_io,
"counting:/objects/table".to_string());
+ let error = snapshots.get_latest_snapshot_id().await.unwrap_err();
+ assert!(
+ matches!(error, Error::ProcessForkUnsupported { .. }),
+ "{error:?}"
+ );
+ }
+
#[tokio::test]
async fn test_recursive_listing_stream_yields_before_polling_next_entry() {
let pulls = Arc::new(AtomicUsize::new(0));
diff --git a/crates/paimon/src/io/storage_jindo.rs
b/crates/paimon/src/io/storage_jindo.rs
index 699b41b7..bfda2a3d 100644
--- a/crates/paimon/src/io/storage_jindo.rs
+++ b/crates/paimon/src/io/storage_jindo.rs
@@ -19,6 +19,7 @@ use std::ffi::{CStr, CString, OsStr};
use std::fmt::{Debug, Formatter};
use std::os::raw::{c_char, c_void};
use std::path::{Path, PathBuf};
+use std::sync::atomic::{AtomicU32, Ordering as AtomicOrdering};
use std::sync::mpsc::{self, Sender};
use std::sync::{Arc, Mutex, OnceLock};
@@ -28,7 +29,7 @@ use opendal::{Buffer, Builder, BytesRange, Capability,
EntryMode, ErrorKind, Met
use opendal::{Error as OpendalError, OperationContext, Operator, Result as
OpendalResult};
use tokio::sync::{OnceCell, Semaphore};
-use crate::error::Error;
+use crate::error::{Error, JINDO_FORK_ERROR};
use crate::Result;
const OSS_IMPL: &str = "fs.oss.impl";
@@ -48,7 +49,6 @@ const JDO_REST_HTTP_403_ERROR: i32 = 6403;
const JDO_REST_HTTP_404_ERROR: i32 = 6404;
const JDO_REST_HTTP_503_ERROR: i32 = 6503;
const JINDO_EXCEPTION_BUFFER_SIZE: usize = 1024;
-
type JdoPtr = *mut c_void;
type JindoTeardown = Box<dyn FnOnce() + Send + 'static>;
@@ -424,6 +424,7 @@ impl LazyJindoClient {
}
async fn get(&self) -> OpendalResult<Arc<JindoClient>> {
+ ensure_jindo_process()?;
let client = self
.client
.get_or_try_init(|| {
@@ -471,8 +472,20 @@ fn unsupported<T>(operation: &str) -> OpendalResult<T> {
))
}
+fn ensure_jindo_process() -> OpendalResult<()> {
+ static SDK_PID: AtomicU32 = AtomicU32::new(0);
+
+ let pid = std::process::id();
+ match SDK_PID.compare_exchange(0, pid, AtomicOrdering::AcqRel,
AtomicOrdering::Acquire) {
+ Ok(_) => Ok(()),
+ Err(owner) if owner == pid => Ok(()),
+ Err(_) => Err(OpendalError::new(ErrorKind::Unsupported,
JINDO_FORK_ERROR)),
+ }
+}
+
fn jindo_teardown_sender() -> OpendalResult<&'static Sender<JindoTeardown>> {
- static SENDER: OnceLock<std::result::Result<Sender<JindoTeardown>,
String>> = OnceLock::new();
+ static SENDER: OnceLock<std::result::Result<(u32, Sender<JindoTeardown>),
String>> =
+ OnceLock::new();
match SENDER.get_or_init(|| {
let (sender, receiver) = mpsc::channel::<JindoTeardown>();
@@ -483,10 +496,14 @@ fn jindo_teardown_sender() -> OpendalResult<&'static
Sender<JindoTeardown>> {
teardown();
}
})
- .map(|_| sender)
+ .map(|_| (std::process::id(), sender))
.map_err(|error| error.to_string())
}) {
- Ok(sender) => Ok(sender),
+ Ok((pid, sender)) if *pid == std::process::id() => Ok(sender),
+ Ok(_) => Err(OpendalError::new(
+ ErrorKind::Unsupported,
+ "Jindo teardown worker is not available after process fork",
+ )),
Err(message) => Err(OpendalError::new(
ErrorKind::Unexpected,
"failed to start Jindo teardown worker",
@@ -665,6 +682,7 @@ struct JindoClient {
options: JdoPtr,
store: JdoPtr,
root: String,
+ pid: u32,
initialized: bool,
}
@@ -682,6 +700,7 @@ impl Debug for JindoClient {
impl JindoClient {
fn new(config: &JindoStorageConfig, bucket: &str) -> OpendalResult<Self> {
+ ensure_jindo_process()?;
jindo_teardown_sender()?;
let api = JindoApi::load(config.library_path.as_deref())?;
let root = format!("oss://{bucket}/");
@@ -722,6 +741,7 @@ impl JindoClient {
options,
store,
root,
+ pid: std::process::id(),
initialized: false,
};
client.with_handle(|handle| {
@@ -1001,6 +1021,9 @@ impl JindoClient {
}
fn with_handle<T>(&self, call: impl FnOnce(JdoPtr) -> OpendalResult<T>) ->
OpendalResult<T> {
+ if self.pid != std::process::id() {
+ return Err(OpendalError::new(ErrorKind::Unsupported,
JINDO_FORK_ERROR));
+ }
let handle = unsafe { (self.api.create_handle)(self.store) };
if handle.is_null() {
return Err(OpendalError::new(
@@ -1051,6 +1074,14 @@ impl JindoClient {
impl Drop for JindoClient {
fn drop(&mut self) {
+ if self.pid != std::process::id() {
+ // These handles belong to the parent process. The child must not
+ // call SDK teardown for them.
+ self.options = std::ptr::null_mut();
+ self.store = std::ptr::null_mut();
+ self.initialized = false;
+ return;
+ }
let api = Arc::clone(&self.api);
let options = std::mem::replace(&mut self.options,
std::ptr::null_mut()) as usize;
let store = std::mem::replace(&mut self.store, std::ptr::null_mut())
as usize;
@@ -1158,6 +1189,17 @@ mod tests {
#[cfg(target_os = "linux")]
use axum::Router;
+ #[cfg(target_os = "linux")]
+ fn wait_for_jindo_teardown() -> bool {
+ let (done_tx, done_rx) = std::sync::mpsc::channel();
+ enqueue_jindo_teardown(Box::new(move || {
+ let _ = done_tx.send(());
+ }));
+ done_rx
+ .recv_timeout(std::time::Duration::from_secs(5))
+ .is_ok()
+ }
+
#[test]
fn test_use_jindo() {
assert!(!use_jindo(&HashMap::new()).unwrap());
@@ -1323,6 +1365,81 @@ mod tests {
assert!(returned_before_timeout.load(Ordering::SeqCst));
}
+ #[cfg(target_os = "linux")]
+ async fn assert_operator_rejects_reuse_after_fork(operator: Operator) {
+ operator.stat("objects/data.bin").await.unwrap();
+
+ let child_pid = unsafe { libc::fork() };
+ assert!(child_pid >= 0, "fork failed");
+ if child_pid == 0 {
+ let success = std::thread::spawn(move || {
+ let runtime = match
tokio::runtime::Builder::new_current_thread()
+ .enable_all()
+ .build()
+ {
+ Ok(runtime) => runtime,
+ Err(_) => return false,
+ };
+ let rejected = runtime.block_on(async {
+ match operator.stat("objects/data.bin").await {
+ Ok(_) => false,
+ Err(error) => {
+ error.kind() == ErrorKind::Unsupported
+ && error.to_string().contains("after process
fork")
+ }
+ }
+ });
+ drop(operator);
+ rejected
+ })
+ .join()
+ .unwrap_or(false);
+ unsafe { libc::_exit(i32::from(!success)) };
+ }
+
+ let (status, timed_out) = tokio::task::spawn_blocking(move || {
+ let deadline = std::time::Instant::now() +
std::time::Duration::from_secs(10);
+ loop {
+ let mut status = 0;
+ let waited = unsafe { libc::waitpid(child_pid, &mut status,
libc::WNOHANG) };
+ if waited == child_pid {
+ return std::io::Result::Ok((status, false));
+ }
+ if waited < 0 {
+ let error = std::io::Error::last_os_error();
+ if error.kind() != std::io::ErrorKind::Interrupted {
+ return Err(error);
+ }
+ }
+ if std::time::Instant::now() >= deadline {
+ unsafe { libc::kill(child_pid, libc::SIGKILL) };
+ loop {
+ let waited = unsafe { libc::waitpid(child_pid, &mut
status, 0) };
+ if waited == child_pid {
+ return Ok((status, true));
+ }
+ let error = std::io::Error::last_os_error();
+ if error.kind() != std::io::ErrorKind::Interrupted {
+ return Err(error);
+ }
+ }
+ }
+ std::thread::sleep(std::time::Duration::from_millis(10));
+ }
+ })
+ .await
+ .unwrap()
+ .unwrap();
+ assert!(!timed_out, "Jindo child process hung after fork");
+ assert!(libc::WIFEXITED(status));
+ assert_eq!(libc::WEXITSTATUS(status), 0);
+
+ // The parent-side client and teardown worker remain usable.
+ assert!(operator.stat("objects/data.bin").await.unwrap().is_file());
+ drop(operator);
+ assert!(wait_for_jindo_teardown());
+ }
+
#[test]
fn test_parse_config_preserves_endpoint_protocol() {
for (endpoint, configured, expected) in [
@@ -1670,6 +1787,9 @@ mod tests {
assert_eq!(state.list_page_requests.load(Ordering::SeqCst), 3);
assert_eq!(state.range_requests.load(Ordering::SeqCst), 1);
assert_eq!(state.unavailable_requests.load(Ordering::SeqCst), 1);
+
+ let fork_operator = jindo_config_build(&config,
"jindo-test-bucket").unwrap();
+ assert_operator_rejects_reuse_after_fork(fork_operator).await;
server.abort();
}
}
diff --git a/docs/src/getting-started.md b/docs/src/getting-started.md
index 5297c27a..fa5f6434 100644
--- a/docs/src/getting-started.md
+++ b/docs/src/getting-started.md
@@ -310,7 +310,9 @@ also discovers it from an installed `pyjindosdk` package.
This initial
integration supports object stat, reads, and listings, but not writes, deletes,
or copies. Jindo reads are limited to 8 concurrent requests per OSS operator by
default. Set `fs.jindo.max.concurrent.reads` to a positive integer to tune this
-limit for the available network and JindoSDK connection capacity.
+limit for the available network and JindoSDK connection capacity. JindoSDK
state
+initialized before `fork` cannot be reused in the child; use `spawn` or
initialize
+Jindo only after worker processes start.
Supported metastore types: