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 c67e38ba9b [python] Forward scan options to native planner (#9124)
c67e38ba9b is described below
commit c67e38ba9b671f88783d15706547ad83665b84a3
Author: XiaoHongbo <[email protected]>
AuthorDate: Sun Aug 9 13:33:26 2026 +0800
[python] Forward scan options to native planner (#9124)
---
.github/workflows/paimon-python-checks.yml | 2 +-
paimon-python/pypaimon/read/native_plan.py | 33 ++++++-
paimon-python/pypaimon/read/table_scan.py | 18 +++-
.../pypaimon/tests/native_plan_integration_test.py | 22 +++++
paimon-python/pypaimon/tests/native_plan_test.py | 105 +++++++++++++++++++++
5 files changed, 175 insertions(+), 5 deletions(-)
diff --git a/.github/workflows/paimon-python-checks.yml
b/.github/workflows/paimon-python-checks.yml
index 1621d9e124..f28e61fe71 100755
--- a/.github/workflows/paimon-python-checks.yml
+++ b/.github/workflows/paimon-python-checks.yml
@@ -34,7 +34,7 @@ env:
JDK_VERSION: 8
MAVEN_OPTS: -Dmaven.wagon.httpconnectionManager.ttlSeconds=30
-Dmaven.wagon.http.retryHandler.requestSentEnabled=true
LUMINA_DATA_VERSION: 0.1.0
- PYPAIMON_RUST_REV: b27c30054e17ee11f7400bf07a8fd41cf264f08b
+ PYPAIMON_RUST_REV: 7a8512f18f47a0634ee02ae8a09f2fff76c12d37
concurrency:
diff --git a/paimon-python/pypaimon/read/native_plan.py
b/paimon-python/pypaimon/read/native_plan.py
index 55b91d93b8..3d3732a4f4 100644
--- a/paimon-python/pypaimon/read/native_plan.py
+++ b/paimon-python/pypaimon/read/native_plan.py
@@ -22,9 +22,10 @@ Predicates and limits are pushed into Rust planning. The
normal pypaimon reader
still applies them while reading, so pushdown remains an optimization.
"""
+import re
from typing import List, Optional, Tuple
-from pypaimon.common.options.config import CatalogOptions
+from pypaimon.common.options.config import CatalogOptions, OssOptions
from pypaimon.common.options.core_options import CoreOptions
from pypaimon.common.options.options_utils import OptionsUtils
from pypaimon.common.predicate import Predicate
@@ -46,6 +47,22 @@ def native_runtime_available() -> bool:
return hasattr(PaimonCatalog, 'get_table') and hasattr(Split, 'serialize')
+def native_family_search_modes_available() -> bool:
+ """Whether Rust supports family-specific global-index search modes."""
+ if not native_runtime_available():
+ return False
+ try:
+ from importlib.metadata import PackageNotFoundError, version
+ except ImportError:
+ return False
+ try:
+ rust_version = version('pypaimon-rust')
+ except PackageNotFoundError:
+ return False
+ match = re.match(r'^(\d+)\.(\d+)', rust_version)
+ return match is not None and tuple(map(int, match.groups())) >= (0, 4)
+
+
def _partition_fields(table):
"""Ordered partition DataFields, used to decode the split partition
bytes."""
schema = table.table_schema
@@ -93,6 +110,14 @@ def _catalog_options(table) -> dict:
if metastore is None:
raise ValueError("native_plan requires an exact built-in catalog
loader")
normalized[CatalogOptions.METASTORE.key()] = metastore
+ if str(getattr(table, 'table_path', '')).startswith('oss://'):
+ from pypaimon.filesystem.jindo_file_system_handler import (
+ JINDO_AVAILABLE,
+ )
+ impl = normalized.get(OssOptions.OSS_IMPL.key())
+ if JINDO_AVAILABLE and (impl is None or impl.lower() == 'jindo'):
+ # This catalog is only used for Rust scan planning.
+ normalized[OssOptions.OSS_IMPL.key()] = 'jindo'
return normalized
@@ -108,7 +133,11 @@ def _read_options(table) -> dict:
for option in (
CoreOptions.SCAN_SNAPSHOT_ID,
CoreOptions.SCAN_TAG_NAME,
- CoreOptions.SCAN_TIMESTAMP_MILLIS):
+ CoreOptions.SCAN_TIMESTAMP_MILLIS,
+ CoreOptions.GLOBAL_INDEX_SEARCH_MODE,
+ CoreOptions.SCALAR_INDEX_SEARCH_MODE,
+ CoreOptions.VECTOR_INDEX_SEARCH_MODE,
+ CoreOptions.FULL_TEXT_INDEX_SEARCH_MODE):
if table_options.contains_key(option.key()):
options[option.key()] = _option_value_to_string(
table_options.get(option))
diff --git a/paimon-python/pypaimon/read/table_scan.py
b/paimon-python/pypaimon/read/table_scan.py
index fc50ece0f8..9dbcd04dd9 100755
--- a/paimon-python/pypaimon/read/table_scan.py
+++ b/paimon-python/pypaimon/read/table_scan.py
@@ -32,6 +32,14 @@ from pypaimon.read.scanner.file_scanner import FileScanner
logger = logging.getLogger(__name__)
+_NATIVE_FAMILY_SEARCH_MODE_OPTIONS = frozenset({
+ CoreOptions.SCALAR_INDEX_SEARCH_MODE.key(),
+ CoreOptions.VECTOR_INDEX_SEARCH_MODE.key(),
+ CoreOptions.FULL_TEXT_INDEX_SEARCH_MODE.key(),
+})
+_NATIVE_SEARCH_MODE_OPTIONS = _NATIVE_FAMILY_SEARCH_MODE_OPTIONS | {
+ CoreOptions.GLOBAL_INDEX_SEARCH_MODE.key(),
+}
# Options native forwards to Rust; any other copy() override is invisible to
Rust.
_NATIVE_FORWARDED_OPTIONS = frozenset({
CoreOptions.SCAN_NATIVE_PLAN_ENABLED.key(),
@@ -41,7 +49,7 @@ _NATIVE_FORWARDED_OPTIONS = frozenset({
CoreOptions.SCAN_TAG_NAME.key(),
CoreOptions.SCAN_TIMESTAMP.key(),
CoreOptions.SCAN_TIMESTAMP_MILLIS.key(),
-})
+}) | _NATIVE_SEARCH_MODE_OPTIONS
_NATIVE_TIME_TRAVEL_OPTIONS = frozenset({
CoreOptions.SCAN_SNAPSHOT_ID.key(),
CoreOptions.SCAN_TAG_NAME.key(),
@@ -152,6 +160,11 @@ class TableScan:
if self.table.bucket_mode() in (BucketMode.HASH_DYNAMIC,
BucketMode.CROSS_PARTITION):
return False
options = self.table.options.options
+ if (any(options.contains_key(key)
+ for key in _NATIVE_FAMILY_SEARCH_MODE_OPTIONS)):
+ from pypaimon.read.native_plan import
native_family_search_modes_available
+ if not native_family_search_modes_available():
+ return False
supported_time_travel = any(
options.contains_key(key) for key in _NATIVE_TIME_TRAVEL_OPTIONS)
# Time travel intentionally carries a historical schema; other stale
@@ -163,7 +176,8 @@ class TableScan:
# Rust cannot remove an option persisted in the catalog-loaded schema.
applied_options = getattr(self.table, '_applied_dynamic_options', {})
or {}
if (set(applied_options) - _NATIVE_FORWARDED_OPTIONS
- or any(key in _NATIVE_TIME_TRAVEL_OPTIONS and value is None
+ or any(key in (_NATIVE_TIME_TRAVEL_OPTIONS
+ | _NATIVE_SEARCH_MODE_OPTIONS) and value is None
for key, value in applied_options.items())):
return False
from pypaimon.snapshot.time_travel_util import SCAN_KEYS
diff --git a/paimon-python/pypaimon/tests/native_plan_integration_test.py
b/paimon-python/pypaimon/tests/native_plan_integration_test.py
index 5eb062afad..6ed414cd81 100644
--- a/paimon-python/pypaimon/tests/native_plan_integration_test.py
+++ b/paimon-python/pypaimon/tests/native_plan_integration_test.py
@@ -23,6 +23,7 @@ import pyarrow as pa
from pypaimon import CatalogFactory, Schema
from pypaimon.globalindex.global_index_result import GlobalIndexResult
+from pypaimon.read.native_plan import native_family_search_modes_available
from pypaimon.utils.range import Range
@@ -131,6 +132,27 @@ class NativePlanIntegrationTest(unittest.TestCase):
self._write('ap_t', [{'k': 3, 'v': 'c'}])
self._assert_matches('ap_t')
+ @unittest.skipUnless(native_family_search_modes_available(),
+ "pypaimon-rust 0.4+ required")
+ def test_dynamic_family_search_mode_uses_native_plan(self):
+ self.cat.create_table(
+ 'default.search_mode_t', Schema.from_pyarrow_schema(self.schema),
False)
+ self._write('search_mode_t', [{'k': 1, 'v': 'a'}, {'k': 2, 'v': 'b'}])
+
+ table = self.cat.get_table('default.search_mode_t').copy({
+ 'scan.native-plan.enabled': 'true',
+ 'scalar-index.search-mode': 'full',
+ })
+ builder = table.new_read_builder()
+ plan = builder.new_scan().plan()
+
+ self.assertEqual(
+ sorted(builder.new_read().to_arrow(plan.splits()).to_pylist(),
+ key=lambda row: row['k']),
+ [{'k': 1, 'v': 'a'}, {'k': 2, 'v': 'b'}],
+ )
+ self.assertTrue(builder.explain().native_planned)
+
def test_data_evolution_blob_projection_filter_limit(self):
schema = pa.schema([
('k', pa.int64()),
diff --git a/paimon-python/pypaimon/tests/native_plan_test.py
b/paimon-python/pypaimon/tests/native_plan_test.py
index 3f74a3fb86..eecbf2899f 100644
--- a/paimon-python/pypaimon/tests/native_plan_test.py
+++ b/paimon-python/pypaimon/tests/native_plan_test.py
@@ -35,6 +35,7 @@ from pypaimon.read.native_plan import (
_predicate_to_native,
_read_options,
_restore_python_partition_paths,
+ native_family_search_modes_available,
native_plan,
)
from pypaimon.read.scan_stats import ScanStats
@@ -328,6 +329,50 @@ class NativePlanTest(unittest.TestCase):
np.assert_not_called()
fs.scan.assert_called_once_with()
+ def test_family_search_modes_require_rust_0_4(self):
+ for available, expect_native in ((False, False), (True, True)):
+ with self.subTest(available=available):
+ fs = Mock(partition_key_predicate=None)
+ fs.scan.return_value = fallback = object()
+ scan = _scan(native_enabled=True, file_scanner=fs)
+ scan.table.options.options.contains_key.side_effect = (
+ lambda key: key == 'scalar-index.search-mode')
+ scan.table._applied_dynamic_options = {
+ 'scalar-index.search-mode': 'full',
+ }
+ split = Mock(partition=Mock(values=[]), snapshot_id=1)
+
+ with patch(
+ 'pypaimon.read.native_plan.'
+ 'native_family_search_modes_available',
+ return_value=available), patch(
+ 'pypaimon.read.native_plan.native_plan',
+ return_value=[split]) as native:
+ plan = scan.plan()
+
+ if expect_native:
+ self.assertEqual(plan.splits(), [split])
+ native.assert_called_once()
+ fs.scan.assert_not_called()
+ else:
+ self.assertIs(plan, fallback)
+ native.assert_not_called()
+ fs.scan.assert_called_once_with()
+
+ def test_removing_search_mode_falls_back(self):
+ fs = Mock(partition_key_predicate=None)
+ fs.scan.return_value = fallback = object()
+ scan = _scan(native_enabled=True, file_scanner=fs)
+ scan.table._applied_dynamic_options = {
+ 'scalar-index.search-mode': None,
+ }
+
+ with patch('pypaimon.read.native_plan.native_plan') as native:
+ self.assertIs(scan.plan(), fallback)
+
+ native.assert_not_called()
+ fs.scan.assert_called_once_with()
+
def test_plan_falls_back_when_native_plan_raises(self):
# A native planning failure (e.g. unsupported scheme) must fall back,
not crash.
fs = Mock(partition_key_predicate=None)
@@ -425,6 +470,43 @@ class NativePlanTest(unittest.TestCase):
'metastore': 'rest',
})
+ @patch(
+ 'pypaimon.filesystem.jindo_file_system_handler.JINDO_AVAILABLE', True)
+ def test_native_plan_prefers_installed_jindo_for_oss(self):
+ table = Mock(table_path='oss://bucket/table')
+ table.catalog_environment.catalog_loader = FileSystemCatalogLoader(
+ CatalogContext.create_from_options(Options({})))
+
+ self.assertEqual(_catalog_options(table), {
+ 'metastore': 'filesystem',
+ 'fs.oss.impl': 'jindo',
+ })
+
+ @patch(
+ 'pypaimon.filesystem.jindo_file_system_handler.JINDO_AVAILABLE', False)
+ def test_native_plan_uses_opendal_without_jindo(self):
+ table = Mock(table_path='oss://bucket/table')
+ table.catalog_environment.catalog_loader = FileSystemCatalogLoader(
+ CatalogContext.create_from_options(Options({})))
+
+ self.assertEqual(_catalog_options(table), {
+ 'metastore': 'filesystem',
+ })
+
+ @patch(
+ 'pypaimon.filesystem.jindo_file_system_handler.JINDO_AVAILABLE', True)
+ def test_native_plan_respects_explicit_legacy_oss(self):
+ table = Mock(table_path='oss://bucket/table')
+ table.catalog_environment.catalog_loader = FileSystemCatalogLoader(
+ CatalogContext.create_from_options(Options({
+ 'fs.oss.impl': 'legacy',
+ })))
+
+ self.assertEqual(_catalog_options(table), {
+ 'fs.oss.impl': 'legacy',
+ 'metastore': 'filesystem',
+ })
+
def test_catalog_options_reject_loader_subclass(self):
class RoutedFileSystemLoader(FileSystemCatalogLoader):
pass
@@ -454,13 +536,36 @@ class NativePlanTest(unittest.TestCase):
table.options.source_split_open_file_cost.return_value = 128
table.options.options = Options({
'scan.snapshot-id': '9',
+ 'global-index.search-mode': 'detail',
+ 'scalar-index.search-mode': 'full',
+ 'vector-index.search-mode': 'fast',
+ 'full-text-index.search-mode': 'fast',
})
self.assertEqual(_read_options(table), {
'source.split.target-size': '1024',
'source.split.open-file-cost': '128',
'scan.snapshot-id': '9',
+ 'global-index.search-mode': 'detail',
+ 'scalar-index.search-mode': 'full',
+ 'vector-index.search-mode': 'fast',
+ 'full-text-index.search-mode': 'fast',
})
+ @unittest.skipIf(sys.version_info < (3, 8),
+ "importlib.metadata requires Python 3.8")
+ def test_family_search_mode_version_gate(self):
+ cases = {
+ '0.3.0': False,
+ '0.4.0': True,
+ '0.4.0.dev20260808': True,
+ '1.0.0': True,
+ }
+ for version, expected in cases.items():
+ with self.subTest(version=version), patch(
+ 'importlib.metadata.version', return_value=version):
+ self.assertEqual(
+ native_family_search_modes_available(), expected)
+
def test_partition_path_prefers_existing_python_legacy_path(self):
table = Mock(partition_keys=['p'])
table.path_factory.return_value.bucket_path.return_value = (