This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new e088b934b5 [python] Propagate native fork-safety errors (#10328)
e088b934b5 is described below
commit e088b934b5831f95da5bbb35dea19c57ab0dc611
Author: XiaoHongbo <[email protected]>
AuthorDate: Tue Oct 6 23:09:58 2026 +0800
[python] Propagate native fork-safety errors (#10328)
---
docs/docs/pypaimon/pyjindosdk-support.md | 3 +
paimon-python/pypaimon/read/native_plan.py | 16 +++++
.../pypaimon/read/streaming_table_scan.py | 12 ++--
paimon-python/pypaimon/read/table_read.py | 7 +-
paimon-python/pypaimon/read/table_scan.py | 4 +-
paimon-python/pypaimon/tests/native_plan_test.py | 15 ++++
paimon-python/pypaimon/tests/native_read_test.py | 18 +++++
.../pypaimon/tests/streaming_table_scan_test.py | 79 ++++++++++++++++++++++
8 files changed, 148 insertions(+), 6 deletions(-)
diff --git a/docs/docs/pypaimon/pyjindosdk-support.md
b/docs/docs/pypaimon/pyjindosdk-support.md
index 32ca06816b..9537622c28 100644
--- a/docs/docs/pypaimon/pyjindosdk-support.md
+++ b/docs/docs/pypaimon/pyjindosdk-support.md
@@ -24,6 +24,9 @@ pip install pyjindosdk
Once installed, PyPaimon will automatically use PyJindoSDK as the default file
I/O implementation for accessing OSS. No additional configuration is required.
+With multiprocessing, use `spawn` or initialize PyPaimon inside each worker;
+JindoSDK state created before `fork` cannot be reused.
+
### Fallback to Legacy Implementation
Since JindoSDK is a native implementation, pre-built Python packages may not
be available for all OS or platform versions. If you need to fall back to the
legacy PyArrow-based implementation for any reason, there are two ways to do so:
diff --git a/paimon-python/pypaimon/read/native_plan.py
b/paimon-python/pypaimon/read/native_plan.py
index e4eb38cec8..fbad1d8c37 100644
--- a/paimon-python/pypaimon/read/native_plan.py
+++ b/paimon-python/pypaimon/read/native_plan.py
@@ -40,6 +40,22 @@ from pypaimon.read.split_serializer import (
from pypaimon.schema.data_types import DataField, RowType
+def _raise_if_native_fork_safety_error(error):
+ """Propagate native fork errors instead of using inherited SDK state."""
+ try:
+ from pypaimon_rust import ForkSafetyError
+ except ImportError:
+ return
+
+ current = error
+ seen = set()
+ while current is not None and id(current) not in seen:
+ seen.add(id(current))
+ if isinstance(current, ForkSafetyError):
+ raise current
+ current = current.__cause__ or current.__context__
+
+
def native_runtime_available() -> bool:
"""Whether an installed pypaimon-rust exposes the full split-planning API.
diff --git a/paimon-python/pypaimon/read/streaming_table_scan.py
b/paimon-python/pypaimon/read/streaming_table_scan.py
index 2481293a8c..cc904e3961 100644
--- a/paimon-python/pypaimon/read/streaming_table_scan.py
+++ b/paimon-python/pypaimon/read/streaming_table_scan.py
@@ -35,6 +35,7 @@ from pypaimon.consumer.consumer import Consumer
from pypaimon.consumer.consumer_manager import ConsumerManager
from pypaimon.manifest.manifest_file_manager import ManifestFileManager
from pypaimon.manifest.manifest_list_manager import ManifestListManager
+from pypaimon.read.native_plan import _raise_if_native_fork_safety_error
from pypaimon.read.plan import Plan
from pypaimon.read.query_auth_split import wrap_plan_with_auth
from pypaimon.read.scanner.append_table_split_generator import \
@@ -194,7 +195,8 @@ class AsyncStreamingTableScan:
if prefetch_plan is not None:
plan = prefetch_plan
- except Exception:
+ except Exception as error:
+ _raise_if_native_fork_safety_error(error)
# Prefetch failed, fall back to synchronous
prefetch_used = False
finally:
@@ -304,7 +306,8 @@ class AsyncStreamingTableScan:
plan = self._create_follow_up_plan(snapshot)
return (plan, next_id, skipped_count)
- except Exception:
+ except Exception as error:
+ _raise_if_native_fork_safety_error(error)
logging.exception("Prefetch failed for snapshot_id=%d; falling
back to synchronous", start_id)
return None
@@ -372,8 +375,8 @@ class AsyncStreamingTableScan:
An arbitrary Python bucket predicate cannot be represented by the Rust
planner, so sharded stream consumers retain the Python plan. Any native
- setup or planning error is an optimization miss and falls back before
- a plan is returned.
+ setup or planning error other than a fork-safety failure falls back
+ before a plan is returned.
"""
if (not self.table.options.native_plan_enabled()
or self._bucket_filter is not None):
@@ -405,6 +408,7 @@ class AsyncStreamingTableScan:
return None
return plan
except Exception as error:
+ _raise_if_native_fork_safety_error(error)
logging.warning(
"Native streaming plan failed, falling back to Python
planning: %s",
error)
diff --git a/paimon-python/pypaimon/read/table_read.py
b/paimon-python/pypaimon/read/table_read.py
index 2f52c1fb9c..0c572ef921 100644
--- a/paimon-python/pypaimon/read/table_read.py
+++ b/paimon-python/pypaimon/read/table_read.py
@@ -525,7 +525,8 @@ class TableRead:
"requires the Python reader")
try:
from pypaimon.read.native_plan import (
- _prepare_native_read, native_read, native_split_from_python)
+ _prepare_native_read, _raise_if_native_fork_safety_error,
+ native_read, native_split_from_python)
except Exception as e:
return self._native_fallback(str(e), e, log=True)
rust_splits = []
@@ -551,6 +552,7 @@ class TableRead:
try:
read_splits = _prepare_native_read(self.table, **read_kwargs)
except Exception as e:
+ _raise_if_native_fork_safety_error(e)
return self._native_fallback(str(e), e, log=True)
if streaming:
groups = self._native_split_groups(
@@ -571,6 +573,7 @@ class TableRead:
"another reader failed to start",
exc_info=True,
)
+ _raise_if_native_fork_safety_error(e)
return self._native_fallback(str(e), e, log=True)
batches = self._native_batches_parallel_streaming(readers)
return self._convert_native_batches(batches, schema)
@@ -579,11 +582,13 @@ class TableRead:
read_splits, rust_splits, schema, parallelism,
split_weights)
except _NativeReadSetupError as e:
+ _raise_if_native_fork_safety_error(e)
return self._native_fallback(str(e), e, log=True)
try:
read_kwargs = self._native_read_kwargs(blob_parallelism)
batches = native_read(self.table, rust_splits, **read_kwargs)
except Exception as e:
+ _raise_if_native_fork_safety_error(e)
return self._native_fallback(str(e), e, log=True)
return self._convert_native_batches(batches, schema)
diff --git a/paimon-python/pypaimon/read/table_scan.py
b/paimon-python/pypaimon/read/table_scan.py
index 344e1a1703..a2755cf6fb 100755
--- a/paimon-python/pypaimon/read/table_scan.py
+++ b/paimon-python/pypaimon/read/table_scan.py
@@ -171,7 +171,8 @@ class TableScan:
by the reader. Snapshot metadata is preserved even when pruning removes
every split.
"""
- from pypaimon.read.native_plan import native_plan
+ from pypaimon.read.native_plan import (
+ _raise_if_native_fork_safety_error, native_plan)
try:
fs = self.file_scanner
@@ -256,6 +257,7 @@ class TableScan:
) for split in splits]
return Plan(splits, snapshot_id=plan.snapshot_id)
except Exception as e:
+ _raise_if_native_fork_safety_error(e)
# Any native construction/planning/pruning failure -> fall back.
logger.warning(
"Native plan failed, falling back to the Python scanner: %s",
e)
diff --git a/paimon-python/pypaimon/tests/native_plan_test.py
b/paimon-python/pypaimon/tests/native_plan_test.py
index e2dad2f191..7f5bd3386f 100644
--- a/paimon-python/pypaimon/tests/native_plan_test.py
+++ b/paimon-python/pypaimon/tests/native_plan_test.py
@@ -588,6 +588,21 @@ class NativePlanTest(unittest.TestCase):
self.assertIs(scan.plan(), sentinel)
fs.scan.assert_called_once_with()
+ def test_plan_propagates_native_fork_safety_error(self):
+ class ForkSafetyError(RuntimeError):
+ pass
+
+ module = ModuleType('pypaimon_rust')
+ module.ForkSafetyError = ForkSafetyError
+ fs = Mock(partition_key_predicate=None)
+ scan = _scan(native_enabled=True, file_scanner=fs)
+ with patch.dict(sys.modules, {'pypaimon_rust': module}), patch(
+ 'pypaimon.read.native_plan.native_plan',
+ side_effect=ForkSafetyError('cannot reuse Jindo after fork')):
+ with self.assertRaises(ForkSafetyError):
+ scan.plan()
+ fs.scan.assert_not_called()
+
def test_plan_uses_resolved_schema_for_jdbc_catalog_loader(self):
fs = Mock(partition_key_predicate=None)
scan = _scan(native_enabled=True, file_scanner=fs)
diff --git a/paimon-python/pypaimon/tests/native_read_test.py
b/paimon-python/pypaimon/tests/native_read_test.py
index c076fa2666..e01e95ede3 100644
--- a/paimon-python/pypaimon/tests/native_read_test.py
+++ b/paimon-python/pypaimon/tests/native_read_test.py
@@ -15,7 +15,9 @@
# specific language governing permissions and limitations
# under the License.
+import sys
import threading
+from types import ModuleType
from unittest.mock import Mock, patch
import pyarrow as pa
@@ -214,6 +216,22 @@ def
test_native_read_consumes_retained_rust_splits_and_enforces_limit():
)
+def test_native_read_propagates_native_fork_safety_error():
+ class ForkSafetyError(RuntimeError):
+ pass
+
+ module = ModuleType('pypaimon_rust')
+ module.ForkSafetyError = ForkSafetyError
+ read = _table_read()
+ split = _Split()
+ split._native_split = object()
+ with patch.dict(sys.modules, {'pypaimon_rust': module}), patch(
+ 'pypaimon.read.native_plan.native_read',
+ side_effect=ForkSafetyError('cannot reuse Jindo after fork')):
+ with pytest.raises(ForkSafetyError):
+ read._try_native_batches([split], pa.schema([('id', pa.int32())]))
+
+
def test_native_read_flattens_nested_rows_and_map_keys_with_parent_nulls():
read = _table_read()
read.table.fields = [
diff --git a/paimon-python/pypaimon/tests/streaming_table_scan_test.py
b/paimon-python/pypaimon/tests/streaming_table_scan_test.py
index 04ecb48ad3..41c1416a1c 100644
--- a/paimon-python/pypaimon/tests/streaming_table_scan_test.py
+++ b/paimon-python/pypaimon/tests/streaming_table_scan_test.py
@@ -18,7 +18,10 @@
"""Tests for AsyncStreamingTableScan."""
import asyncio
+import sys
import unittest
+from concurrent.futures import Future
+from types import ModuleType
from unittest.mock import Mock, patch
from pypaimon.common.options.core_options import ChangelogProducer
@@ -137,6 +140,82 @@ class AsyncStreamingTableScanTest(unittest.TestCase):
self.assertEqual(
native_plan.call_args.kwargs['projection'], ['payload'])
+ @patch('pypaimon.read.streaming_table_scan.ManifestListManager')
+ @patch('pypaimon.read.streaming_table_scan.ManifestFileManager')
+ @patch('pypaimon.read.native_plan.native_plan')
+ def test_streaming_plans_propagate_native_fork_safety_error(
+ self, native_plan, _manifest_files, manifest_lists):
+ class ForkSafetyError(RuntimeError):
+ pass
+
+ module = ModuleType('pypaimon_rust')
+ module.ForkSafetyError = ForkSafetyError
+ native_plan.side_effect = ForkSafetyError('cannot reuse Jindo after
fork')
+
+ for mode in ('initial', 'delta', 'changelog'):
+ with self.subTest(mode=mode):
+ table, _ = _create_mock_table()
+ table.options.native_plan_enabled.return_value = True
+ table.options.batch_scan_skip_level0.return_value = False
+ scan = AsyncStreamingTableScan(table, prefetch_enabled=False)
+ snapshot = _create_mock_snapshot(5)
+ with patch.dict(sys.modules, {'pypaimon_rust': module}),
patch.object(
+ scan, '_AsyncStreamingTableScan__auth_query',
return_value=None), patch.object(
+ scan,
'_AsyncStreamingTableScan__create_initial_plan_raw') as initial, patch.object(
+ scan, '_create_plan_from_manifests') as from_manifests:
+ with self.assertRaises(ForkSafetyError):
+ getattr(scan, '_create_{}_plan'.format(mode))(snapshot)
+ initial.assert_not_called()
+ from_manifests.assert_not_called()
+ manifest_lists.return_value.read_all.assert_not_called()
+ manifest_lists.return_value.read_delta.assert_not_called()
+ manifest_lists.return_value.read_changelog.assert_not_called()
+
+ @patch('pypaimon.read.streaming_table_scan.ManifestListManager')
+ @patch('pypaimon.read.streaming_table_scan.ManifestFileManager')
+ @patch('pypaimon.read.native_plan.native_plan',
side_effect=RuntimeError('unsupported'))
+ def test_delta_scan_falls_back_after_other_native_error(
+ self, _native_plan, _manifest_files, manifest_lists):
+ table, _ = _create_mock_table()
+ table.options.native_plan_enabled.return_value = True
+ manifest_lists.return_value.read_delta.return_value = []
+ scan = AsyncStreamingTableScan(table, prefetch_enabled=False)
+ snapshot = _create_mock_snapshot(5)
+
+ self.assertEqual(scan._create_delta_plan(snapshot).snapshot_id, 5)
+
manifest_lists.return_value.read_delta.assert_called_once_with(snapshot)
+
+ @patch('pypaimon.read.streaming_table_scan.ManifestListManager')
+ @patch('pypaimon.read.streaming_table_scan.ManifestFileManager')
+ @patch('pypaimon.read.native_plan.native_plan')
+ def test_prefetch_propagates_native_fork_safety_error(
+ self, native_plan, _manifest_files, _manifest_lists):
+ class ForkSafetyError(RuntimeError):
+ pass
+
+ module = ModuleType('pypaimon_rust')
+ module.ForkSafetyError = ForkSafetyError
+ error = ForkSafetyError('cannot reuse Jindo after fork')
+ native_plan.side_effect = error
+ table, _ = _create_mock_table()
+ table.options.native_plan_enabled.return_value = True
+ scan = AsyncStreamingTableScan(table, prefetch_enabled=False)
+ scan._snapshot_manager.find_next_scannable.return_value = (
+ _create_mock_snapshot(5), 6, 0)
+
+ with patch.dict(sys.modules, {'pypaimon_rust': module}):
+ with self.assertRaises(ForkSafetyError):
+ scan._fetch_plan_with_lookahead(5)
+
+ future = Future()
+ future.set_exception(error)
+ scan._prefetch_future = future
+ scan.next_snapshot_id = 5
+ with patch.object(scan, '_should_use_diff_catch_up',
return_value=False):
+ with self.assertRaises(ForkSafetyError):
+ asyncio.run(scan.stream().__anext__())
+ scan._snapshot_manager.find_next_scannable.assert_called_once()
+
@patch('pypaimon.read.streaming_table_scan.ManifestListManager')
@patch('pypaimon.read.streaming_table_scan.ManifestFileManager')
@patch('pypaimon.read.native_plan.native_plan')