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 c9a36a81d7 [python] Use native scans for supplied primary-key index
results (#10591)
c9a36a81d7 is described below
commit c9a36a81d7ba761f544be5f07c9defcea6d4ffc2
Author: Jingsong Lee <[email protected]>
AuthorDate: Thu Oct 15 23:22:17 2026 +0800
[python] Use native scans for supplied primary-key index results (#10591)
---
paimon-python/pypaimon/read/native_plan.py | 39 ++-
.../pypaimon/read/scanner/file_scanner.py | 13 +-
paimon-python/pypaimon/read/table_scan.py | 32 +--
.../table/source/primary_key_scored_result.py | 16 +-
paimon-python/pypaimon/tests/global_index_test.py | 29 +-
.../tests/native_index_result_scan_test.py | 311 +++++++++++++++++++++
.../pypaimon/tests/native_plan_expanded_test.py | 6 +-
paimon-python/pypaimon/tests/native_plan_test.py | 16 +-
8 files changed, 416 insertions(+), 46 deletions(-)
diff --git a/paimon-python/pypaimon/read/native_plan.py
b/paimon-python/pypaimon/read/native_plan.py
index 0363fd8064..b3c8267598 100644
--- a/paimon-python/pypaimon/read/native_plan.py
+++ b/paimon-python/pypaimon/read/native_plan.py
@@ -452,7 +452,8 @@ def native_plan(
row_position_shard: Optional[Tuple[int, int]] = None,
chunk_shuffle: Optional[Tuple[int, int]] = None,
shard: Optional[Tuple[int, int]] = None,
- partition_predicate: Optional[Predicate] = None) -> Plan:
+ partition_predicate: Optional[Predicate] = None,
+ global_index_result=None) -> Plan:
"""Plan with pypaimon_rust, preserving snapshot metadata.
Native conversion or planning failures are handled by TableScan, which
@@ -469,15 +470,41 @@ def native_plan(
builder =
builder.with_partition_filter(_predicate_to_native(partition_predicate))
if row_ranges is not None:
builder = builder.with_row_ranges(row_ranges)
- if incremental_range is None:
+ from pypaimon.table.source.global_index_split_result import
GlobalIndexSplitResult
+ supplied_pk_splits = (isinstance(global_index_result,
GlobalIndexSplitResult)
+ and not table.options.query_auth_enabled)
+ if supplied_pk_splits or incremental_range is None:
scan = builder.new_scan()
elif incremental_mode == 'delta':
- # Keep the two-argument call compatible with runtimes predating the
- # explicit mode API. Non-delta modes require the new binding.
scan = builder.new_incremental_scan(*incremental_range)
else:
- scan = builder.new_incremental_scan(
- *incremental_range, incremental_mode)
+ scan = builder.new_incremental_scan(*incremental_range,
incremental_mode)
+ if global_index_result is not None and not
table.options.query_auth_enabled:
+ # A PK result already selected a snapshot and physical files. Java's
+ # PrimaryKeyBatchScan returns those selections before ordinary
planning.
+ from pypaimon_rust.datafusion import GlobalIndexResult, Split as
NativeSplit
+ from pypaimon.globalindex.vector_search_result import
ScoredGlobalIndexResult
+ from pypaimon.table.source.primary_key_scored_result import
PrimaryKeyScoredResult
+ if isinstance(global_index_result, GlobalIndexSplitResult):
+ # Searches and scans share the same core GlobalIndexResult.
+ result = (getattr(global_index_result, '_native_result', None)
+ if type(global_index_result) is PrimaryKeyScoredResult
else None)
+ # Java's PK result uses snapshot 0 for an uncommitted table,
+ # whereas the core search uses None. Keep the Python result's ID.
+ if result is not None and result.snapshot_id() is None:
+ result = None
+ if result is None:
+ result = GlobalIndexResult.from_splits(
+ global_index_result.snapshot_id,
+ [NativeSplit.deserialize(serialize_split_v1(split)) for
split in global_index_result.splits])
+ elif isinstance(global_index_result, ScoredGlobalIndexResult):
+ score = global_index_result.score_getter()
+ result = GlobalIndexResult.from_scored_row_ids({
+ row_id: score(row_id) for row_id in
global_index_result.results()})
+ else:
+ result = GlobalIndexResult.from_row_ranges([
+ (range_.from_, range_.to) for range_ in
global_index_result.results().to_range_list()])
+ scan = scan.with_global_index_result(result)
if row_position_slice is not None:
scan = scan.with_row_position_slice(*row_position_slice)
if row_position_shard is not None:
diff --git a/paimon-python/pypaimon/read/scanner/file_scanner.py
b/paimon-python/pypaimon/read/scanner/file_scanner.py
index b938a7f7d2..85c8061975 100755
--- a/paimon-python/pypaimon/read/scanner/file_scanner.py
+++ b/paimon-python/pypaimon/read/scanner/file_scanner.py
@@ -334,10 +334,13 @@ class FileScanner:
raise ValueError(
"Primary-key scan requires a GlobalIndexSplitResult,
but found %s."
% type(self._global_index_result).__name__)
- return Plan(
- list(self._global_index_result.splits),
- snapshot_id=self._global_index_result.snapshot_id,
- )
+ # Java ignores external raw-index results under query auth:
+ # masking can invalidate both their positions and their scores.
+ if not self.table.options.query_auth_enabled:
+ return Plan(
+ list(self._global_index_result.splits),
+ snapshot_id=self._global_index_result.snapshot_id,
+ )
# Create appropriate split generator based on table type
if self.chunk_shuffle is not None:
self._validate_chunk_shuffle_compat()
@@ -471,7 +474,7 @@ class FileScanner:
self._scanned_snapshot = snapshot
self._scanned_snapshot_id = snapshot.id if snapshot else None
- if row_ranges is None:
+ if row_ranges is None and not self.table.options.query_auth_enabled:
global_index_plan = self._global_index_result \
if self._global_index_result is not None \
else self._eval_global_index(snapshot)
diff --git a/paimon-python/pypaimon/read/table_scan.py
b/paimon-python/pypaimon/read/table_scan.py
index 40f0383d30..687d5c8690 100755
--- a/paimon-python/pypaimon/read/table_scan.py
+++ b/paimon-python/pypaimon/read/table_scan.py
@@ -104,7 +104,6 @@ class TableScan:
def _native_plan_supported_impl(self) -> bool:
"""Fall back to the Python scanner for scans native can't carry:
- primary-key global-index results,
a primary-key table whose trimmed PK is empty (PK equals the partition
key; Rust rejects this schema), unsupported time travel selectors,
query auth, a missing pypaimon-rust, or a catalog / identifier Rust
@@ -165,8 +164,13 @@ class TableScan:
result = self.file_scanner._global_index_result
if result is None:
return True
- if (self.table.is_primary_key_table
- or not self.file_scanner.data_evolution):
+ from pypaimon.read.native_plan import native_method_available
+ if not native_method_available('TableScan',
'with_global_index_result'):
+ return False
+ if self.table.is_primary_key_table:
+ from pypaimon.table.source.global_index_split_result import
GlobalIndexSplitResult
+ return isinstance(result, GlobalIndexSplitResult)
+ if not self.file_scanner.data_evolution:
return False
from pypaimon.globalindex.global_index_result import GlobalIndexResult
return isinstance(result, GlobalIndexResult)
@@ -175,11 +179,7 @@ class TableScan:
row_ranges = getattr(self.file_scanner, '_row_ranges', None)
if row_ranges is not None:
return [(range_.from_, range_.to) for range_ in row_ranges]
- result = self.file_scanner._global_index_result
- if result is None:
- return None
- return [(range_.from_, range_.to)
- for range_ in result.results().to_range_list()]
+ return None
def _try_native_plan(self) -> Optional[Plan]:
"""Plan via pypaimon_rust, then drop partitions the predicate rejects.
@@ -218,6 +218,8 @@ class TableScan:
extra_options['row_position_slice'] = (
fs.start_pos_of_this_subtask,
fs.end_pos_of_this_subtask)
row_ranges = self._native_row_ranges()
+ if fs._global_index_result is not None:
+ extra_options['global_index_result'] = fs._global_index_result
if self.partition_predicate is not None:
# Explicit partition selectors are scan-only. Keep the ordinary
# row predicate separate so core can apply Java's override and
@@ -238,6 +240,7 @@ class TableScan:
)
splits = plan.splits()
if (self.table.options.merge_engine() == 'first-row'
+ and (fs._global_index_result is None or
self.table.options.query_auth_enabled)
and not fs.skip_level0 and not fs.is_streaming
and any(file.level == 0 for split in splits for file in
split.files)
and any(not split.raw_convertible and any(file.level > 0
for file in split.files)
@@ -247,7 +250,9 @@ class TableScan:
# physical order need not match the PK merge comparator.
return None
partition_predicate = self.file_scanner.partition_key_predicate
- if partition_predicate is not None and not any(isinstance(split,
QueryAuthSplit) for split in splits):
+ if (partition_predicate is not None and not (
+ self.table.is_primary_key_table and
fs._global_index_result is not None)
+ and not any(isinstance(split, QueryAuthSplit) for split in
splits)):
# Authorized user predicates observe masked partition values;
# Rust has already applied only the safe raw partition tests.
splits = [s for s in splits
@@ -260,15 +265,6 @@ class TableScan:
if not (getattr(fs, 'deletion_vectors_enabled', False)
and not self.table.is_primary_key_table):
splits = fs._apply_push_down_limit(splits)
- # Attach scores to the row ranges retained by native planning.
- from pypaimon.globalindex.indexed_split import IndexedSplit,
scores_for_ranges
- from pypaimon.globalindex.vector_search_result import
ScoredGlobalIndexResult
- result = fs._global_index_result
- if fs._row_ranges is None and isinstance(result,
ScoredGlobalIndexResult):
- splits = [IndexedSplit(
- split.data_split(), split.row_ranges(),
- scores_for_ranges(result.score_getter(),
split.row_ranges()),
- ) for split in splits]
return Plan(splits, snapshot_id=plan.snapshot_id)
except Exception as e:
_raise_if_native_fork_safety_error(e)
diff --git a/paimon-python/pypaimon/table/source/primary_key_scored_result.py
b/paimon-python/pypaimon/table/source/primary_key_scored_result.py
index 73c370f297..6b80fcad60 100644
--- a/paimon-python/pypaimon/table/source/primary_key_scored_result.py
+++ b/paimon-python/pypaimon/table/source/primary_key_scored_result.py
@@ -56,6 +56,7 @@ class PrimaryKeyScoredResult(GlobalIndexSplitResult):
raise ValueError("Primary-key search result contains duplicate
physical positions.")
self._splits = tuple(_materialize(
source_splits, positions, snapshot_id))
+ self._native_result = None
@property
def snapshot_id(self):
@@ -67,11 +68,23 @@ class PrimaryKeyScoredResult(GlobalIndexSplitResult):
@property
def splits(self):
+ # Exposing mutable Python splits transfers metadata ownership to the
+ # caller. A later native scan must observe any edits to that view.
+ self._native_result = None
return self._splits
+ def __getstate__(self):
+ # GlobalIndexResult owns a live core table. Transport the materialized
+ # Python metadata. copy.copy also uses this protocol and shares the
+ # mutable splits, so the original must stop using its native cache too.
+ self._native_result = None
+ state = self.__dict__.copy()
+ state.pop('_native_result', None)
+ return state
+
@classmethod
def _from_native(cls, table, result):
- """Convert the selections already built by core without rebuilding
them."""
+ """Wrap core GlobalIndexResult; retain it for direct native scan
handoff."""
from pypaimon.read.native_plan import _partition_fields
from pypaimon.read.split_serializer import deserialize_split_v1
@@ -81,6 +94,7 @@ class PrimaryKeyScoredResult(GlobalIndexSplitResult):
value._splits = tuple(deserialize_split_v1(
split, _partition_fields(table), table.trimmed_primary_keys_fields)
for split in result.splits())
+ value._native_result = result
return value
diff --git a/paimon-python/pypaimon/tests/global_index_test.py
b/paimon-python/pypaimon/tests/global_index_test.py
index 044d08ae6c..c07833a70f 100644
--- a/paimon-python/pypaimon/tests/global_index_test.py
+++ b/paimon-python/pypaimon/tests/global_index_test.py
@@ -375,15 +375,26 @@ class GlobalIndexScalarFallbackTest(unittest.TestCase):
scanner.open_file_cost = 1
scanner._deletion_files_map = unittest.mock.MagicMock(return_value={})
- with unittest.mock.patch(
-
"pypaimon.read.scanner.file_scanner.DataEvolutionSplitGenerator"
- ) as split_generator:
- scanner._create_data_evolution_split_generator()
-
- self.assertEqual(
- [Range(1, 1), Range(10, 10 ** 12)],
- split_generator.call_args[0][4],
- )
+ for query_auth in (False, True):
+ with self.subTest(query_auth=query_auth):
+ scanner.table.options = CoreOptions(Options({
+ "query-auth.enabled": str(query_auth).lower(),
+ }))
+ scanner._eval_global_index.reset_mock()
+ with unittest.mock.patch(
+
"pypaimon.read.scanner.file_scanner.DataEvolutionSplitGenerator"
+ ) as split_generator:
+ scanner._create_data_evolution_split_generator()
+
+ self.assertEqual(
+ None if query_auth else [Range(1, 1), Range(10, 10 ** 12)],
+ split_generator.call_args[0][4],
+ )
+ if query_auth:
+ scanner._eval_global_index.assert_not_called()
+ else:
+ scanner._eval_global_index.assert_called_once_with(
+ scanner.manifest_scanner.return_value[1])
def test_eval_global_index_keeps_none_as_full_scan(self):
from pypaimon.read.scanner.file_scanner import FileScanner
diff --git a/paimon-python/pypaimon/tests/native_index_result_scan_test.py
b/paimon-python/pypaimon/tests/native_index_result_scan_test.py
new file mode 100644
index 0000000000..5b7065103a
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_index_result_scan_test.py
@@ -0,0 +1,311 @@
+# 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.
+
+"""Externally computed PK selections keep their snapshot and physical
addresses."""
+
+import copy
+import pickle
+from unittest.mock import patch
+
+import pytest
+
+from pypaimon import Schema
+from pypaimon.read.scanner.file_scanner import FileScanner
+from pypaimon.read.native_plan import _partition_fields
+from pypaimon.read.split_serializer import deserialize_split_v1,
serialize_split_v1
+from pypaimon.table.source.global_index_split_result import
GlobalIndexSplitResult
+from pypaimon.tests import native_hybrid_search_test as hybrid
+from pypaimon.tests import native_pk_full_text_search_test as text
+from pypaimon.tests import native_query_auth_test as auth
+from pypaimon.tests import native_vector_search_test as vectors
+
+rest_catalog = text.rest_catalog
+golden_catalog = text.golden_catalog
+source = text.source
+guarded_table = auth.guarded_table
+pk_vector_table = vectors.pk_vector_table
+vector_table = vectors.vector_table
+pytestmark = pytest.mark.native_plan
+
+
+class _SplitResult(GlobalIndexSplitResult):
+ def __init__(self, plan):
+ self._plan = plan
+
+ @property
+ def snapshot_id(self):
+ return self._plan.snapshot_id
+
+ @property
+ def splits(self):
+ return self._plan.splits()
+
+
[email protected]('native_read', ['true', 'false'])
[email protected]('snapshot', [1, 5, 8])
[email protected]('query', ['Paimon', 'no match'])
+def test_pk_supplied_results_use_native_scan_and_preserve_metadata(source,
native_read, snapshot, query):
+ table, _ = source
+ captured = table.copy({'scan.snapshot-id': str(snapshot),
'read.native.enabled': 'false'})
+ result = text._builder(captured, query).execute_local()
+ latest = table.copy({'scan.native-plan.enabled': 'true',
'read.native.enabled': native_read})
+ builder = latest.new_read_builder().with_projection(['id'])
+ with patch.object(FileScanner, 'scan', side_effect=AssertionError('Python
scan ran')):
+ plan = builder.new_scan().with_global_index_result(result).plan()
+ assert plan.snapshot_id == result.snapshot_id
+ assert [serialize_split_v1(s) for s in plan.splits()] ==
[serialize_split_v1(s) for s in result.splits]
+ expected =
captured.new_read_builder().with_projection(['id']).new_read().to_arrow(list(result.splits))
+ actual = builder.new_read().to_arrow(plan.splits())
+ assert actual.to_pylist() == expected.to_pylist()
+
+
[email protected]('hint', ['filter', 'limit', 'shard'])
+def test_supplied_pk_splits_precede_ordinary_planning_hints(source, hint):
+ table, _ = source
+ result = text._builder(table.copy({'read.native.enabled':
'false'})).execute_local()
+ builder = table.copy({'scan.native-plan.enabled':
'true'}).new_read_builder()
+ if hint == 'filter':
+ builder.with_filter(builder.new_predicate_builder().greater_than('id',
100))
+ elif hint == 'limit':
+ builder.with_limit(0)
+ scan = builder.new_scan().with_global_index_result(result)
+ if hint == 'shard':
+ scan.with_shard(1, 2)
+ with patch.object(FileScanner, 'scan', side_effect=AssertionError('Python
scan ran')):
+ plan = scan.plan()
+ assert plan.snapshot_id == result.snapshot_id
+ assert [serialize_split_v1(s) for s in plan.splits()] ==
[serialize_split_v1(s) for s in result.splits]
+ if hint in ('filter', 'limit'):
+ assert builder.new_read().to_arrow(plan.splits()).num_rows == 0
+
+
+def
test_core_search_result_passes_directly_to_scan_and_scored_splits_round_trip(source):
+ from pypaimon_rust.datafusion import GlobalIndexResult, Split
+ table, _ = source
+ native = text._native_table(table)
+ result = text._native(table).execute_local()
+ assert isinstance(result, GlobalIndexResult)
+ scan =
native.new_read_builder().with_projection(['id']).new_scan().with_global_index_result(result)
+ for plan in [scan.plan(), scan.plan()]:
+ assert plan.snapshot_id() == result.snapshot_id()
+ assert [s.serialize() for s in plan.splits()] == result.splits()
+ splits = [pickle.loads(pickle.dumps(Split.deserialize(s))) for s in
result.splits()]
+ carried = GlobalIndexResult.from_splits(result.snapshot_id(), iter(splits))
+ plan =
native.new_read_builder().new_scan().with_global_index_result(carried).plan()
+ assert [s.serialize() for s in plan.splits()] == result.splits()
+ reader = native.new_read_builder().with_projection(['id']).new_read()
+ assert sum(batch.num_rows for batch in reader.read(plan.splits())) == 2
+
+
[email protected]('family', ['full-text', 'vector'])
+def test_native_search_result_hands_core_selection_directly_to_scan(source,
pk_vector_table, family):
+ table = source[0] if family == 'full-text' else pk_vector_table
+ table = table.copy({'read.native.enabled': 'true',
'scan.native-plan.enabled': 'true'})
+ search = text._builder(table) if family == 'full-text' else
vectors._single(table, [1, 0, 0, 0], 2)
+ result = search.execute_local()
+ from pypaimon_rust.datafusion import GlobalIndexResult
+ assert isinstance(result._native_result, GlobalIndexResult)
+ expected = sorted(position.row_position for position in result.positions)
+ with patch('pypaimon.read.native_plan.serialize_split_v1',
+ side_effect=AssertionError('Native result round-tripped through
Python')), \
+ patch.object(FileScanner, 'scan',
side_effect=AssertionError('Python scan ran')):
+ plan =
table.new_read_builder().new_scan().with_global_index_result(result).plan()
+ assert plan.snapshot_id == result.snapshot_id
+ assert sorted(position for split in plan.splits() for r in
split.row_ranges()
+ for position in range(r.from_, r.to + 1)) == expected
+
+
+def
test_exposed_python_splits_and_pickled_results_use_current_metadata(source):
+ table, _ = source
+ table = table.copy({'read.native.enabled': 'true',
'scan.native-plan.enabled': 'true'})
+ result = text._builder(table).execute_local()
+ transported = pickle.loads(pickle.dumps(result))
+ builder = table.new_read_builder().with_projection(['id'])
+ plan = builder.new_scan().with_global_index_result(transported).plan()
+ assert
sorted(builder.new_read().to_arrow(plan.splits()).column('id').to_pylist()) ==
[1, 3]
+ # Accessing splits exposes mutable lists. Subsequent scans must respect
+ # the edited selections, including a deliberately empty one.
+ for split in result.splits:
+ split.row_ranges().clear()
+ split.scores().clear()
+ with patch.object(FileScanner, 'scan', side_effect=AssertionError('Python
scan ran')):
+ plan = builder.new_scan().with_global_index_result(result).plan()
+ assert plan.snapshot_id == result.snapshot_id
+ assert all(split.row_count == 0 and split.scores() == [] for split in
plan.splits())
+ assert builder.new_read().to_arrow(plan.splits()).num_rows == 0
+
+
+def test_empty_native_pk_search_preserves_java_snapshot_zero(source,
rest_catalog):
+ catalog, _ = rest_catalog
+ schema = source[0].table_schema
+ catalog.create_table('default.empty_text', Schema(
+ schema.fields, schema.partition_keys, schema.primary_keys,
schema.options), False)
+ table = catalog.get_table('default.empty_text').copy({
+ 'read.native.enabled': 'true', 'scan.native-plan.enabled': 'true'})
+ result = text._builder(table).execute_local()
+ assert result.snapshot_id == 0
+ with patch.object(FileScanner, 'scan', side_effect=AssertionError('Python
scan ran')):
+ plan =
table.new_read_builder().new_scan().with_global_index_result(result).plan()
+ assert plan.snapshot_id == 0
+ assert plan.splits() == []
+
+
+def
test_shallow_copy_split_edits_are_observed_when_scanning_the_original(source):
+ table = source[0].copy({'read.native.enabled': 'true',
'scan.native-plan.enabled': 'true'})
+ result = text._builder(table).execute_local()
+ alias = copy.copy(result)
+ for split in alias.splits:
+ split.row_ranges().clear()
+ split.scores().clear()
+ with patch.object(FileScanner, 'scan', side_effect=AssertionError('Python
scan ran')):
+ plan =
table.new_read_builder().new_scan().with_global_index_result(result).plan()
+ assert plan.snapshot_id == result.snapshot_id
+ assert all(split.row_count == 0 for split in plan.splits())
+
+
[email protected]('native_read', ['true', 'false'])
+def test_pk_vector_results_use_the_same_native_scan(pk_vector_table,
native_read):
+ source = pk_vector_table.copy({'read.native.enabled': 'false',
'scan.native-plan.enabled': 'false',
+ 'vector-index.search-mode': 'fast'})
+ result = vectors._single(source, [1, 0, 0, 0]).execute_local()
+ expected = vectors._rows(source, result).column('id').to_pylist()
+ table = pk_vector_table.copy({'read.native.enabled': native_read,
'scan.native-plan.enabled': 'true'})
+ builder = table.new_read_builder().with_projection(['id'])
+ with patch.object(FileScanner, 'scan', side_effect=AssertionError('Python
scan ran')):
+ plan = builder.new_scan().with_global_index_result(result).plan()
+ assert [serialize_split_v1(s) for s in plan.splits()] ==
[serialize_split_v1(s) for s in result.splits]
+ assert
sorted(builder.new_read().to_arrow(plan.splits()).column('id').to_pylist()) ==
sorted(expected)
+
+
[email protected]('native_read', ['true', 'false'])
+def test_nonindexed_pk_splits_are_preserved_too(source, native_read):
+ table, _ = source
+ original = table.copy({'scan.native-plan.enabled': 'false',
'read.native.enabled': 'false'})
+ original_builder = original.new_read_builder()
+ result = _SplitResult(original_builder.new_scan().plan())
+ expected = original_builder.new_read().to_arrow(result.splits).to_pylist()
+ builder = table.copy({'scan.native-plan.enabled': 'true',
'read.native.enabled': native_read}).new_read_builder()
+ with patch.object(FileScanner, 'scan', side_effect=AssertionError('Python
scan ran')):
+ plan = builder.new_scan().with_global_index_result(result).plan()
+ assert [serialize_split_v1(s) for s in plan.splits()] ==
[serialize_split_v1(s) for s in result.splits]
+ assert builder.new_read().to_arrow(plan.splits()).to_pylist() == expected
+
+
[email protected]('selection', ['all', 'slice', 'shard', 'empty'])
+def test_de_scored_results_align_scores_in_core(vector_table, selection):
+ from pypaimon.globalindex.vector_search_result import
DictBasedScoredIndexResult
+ table = vector_table.copy({'scan.native-plan.enabled': 'true',
'read.native.enabled': 'true'})
+ result = DictBasedScoredIndexResult({4: 0.2, 0: 0.9, 3: 0.7} if selection
!= 'empty' else {})
+ scan = table.new_read_builder().new_scan().with_global_index_result(result)
+ if selection == 'slice':
+ scan.with_slice(3, 5)
+ elif selection == 'shard':
+ scan.with_shard(1, 2)
+ with patch.object(FileScanner, 'scan', side_effect=AssertionError('Python
scan ran')):
+ plan = scan.plan()
+ expected = {0: 0.9, 3: 0.7, 4: 0.2}
+ if selection in ('slice', 'shard'):
+ expected.pop(0)
+ elif selection == 'empty':
+ expected = {}
+ actual = {}
+ for split in plan.splits():
+ for range_ in split.row_ranges():
+ for row_id in range(range_.from_, range_.to + 1):
+ actual[row_id] = split.get_score(row_id)
+ assert actual == pytest.approx(expected)
+
+
+def
test_de_supplied_result_preserves_explicit_incremental_window(vector_table):
+ from pypaimon.globalindex.vector_search_result import
DictBasedScoredIndexResult
+ from pypaimon.read.native_plan import native_plan
+ table = vector_table.copy({'read.native.enabled': 'true'})
+ start = table.snapshot_manager().get_latest_snapshot().id
+ vectors._write(table, [{'id': 7, 'embedding': [1, 0], 'pt': 1}])
+ end = table.snapshot_manager().get_latest_snapshot().id
+ plan = native_plan(table, incremental_range=(start, end),
+ global_index_result=DictBasedScoredIndexResult({0: 0.1,
6: 0.9}))
+ assert plan.snapshot_id == end
+ assert len(plan.splits()) == 1
+ assert plan.splits()[0].scores() == pytest.approx([0.9])
+ assert vectors._rows(table, DictBasedScoredIndexResult({6:
0.9})).column('id').to_pylist() == [7]
+ builder = table.new_read_builder().with_projection(['id'])
+ assert builder.new_read().to_arrow(plan.splits()).to_pylist() == [{'id':
7}]
+
+
[email protected]('guarded_table', [
+ ('primary-key', {'bucket': '1'}),
+ ('data-evolution', {'row-tracking.enabled': 'true',
'data-evolution.enabled': 'true'}),
+], indirect=True)
[email protected]('native', [False, True])
+def test_authorized_scans_ignore_external_raw_index_selections(guarded_table,
native):
+ from pypaimon.api.api_response import AuthTableQueryResponse
+ from pypaimon.globalindex.vector_search_result import
DictBasedScoredIndexResult
+ from pypaimon.read.plan import Plan
+ table, server = guarded_table
+ server.table_query_auth_store[table.identifier.get_full_name()] =
AuthTableQueryResponse(
+ filter=[auth._filter('id', 'GREATER_THAN', [1])])
+ result = _SplitResult(Plan([], 1)) if table.is_primary_key_table else
DictBasedScoredIndexResult({0: 0.9})
+ table = table.copy({'scan.native-plan.enabled': str(native).lower(),
'read.native.enabled': str(native).lower()})
+ builder = table.new_read_builder().with_projection(['id'])
+ scan = builder.new_scan().with_global_index_result(result)
+ if native:
+ with patch.object(FileScanner, 'scan',
side_effect=AssertionError('Python scan ran')):
+ plan = scan.plan()
+ else:
+ plan = scan.plan()
+ assert
sorted(builder.new_read().to_arrow(plan.splits()).column('id').to_pylist()) ==
[2, 3, 4]
+
+
[email protected]('family', ['vector', 'batch-vector', 'full-text',
'hybrid'])
+def test_all_core_searches_return_one_result_for_read_and_scan(rest_catalog,
family):
+ import pyarrow as pa
+ from pypaimon_rust.datafusion import GlobalIndexResult
+ table = hybrid._table(rest_catalog[0])
+ native = text._native_table(table)
+ if family == 'vector':
+ results =
[(native.new_vector_search_builder().with_vector_column('embedding')
+ .with_query_vector([0.,
0.]).with_limit(3).execute_local())]
+ elif family == 'batch-vector':
+ results =
(native.new_batch_vector_search_builder().with_vector_column('embedding')
+ .with_query_vectors([[0., 0.], [7.,
0.]]).with_limit(3).execute_batch_local())
+ assert len(results) == 2
+ elif family == 'full-text':
+ results = [text._native(table, 'paimon', 3).execute_local()]
+ else:
+ results = [(native.new_hybrid_search_builder().with_limit(3)
+ .add_vector_route('embedding', [0., 0.], 3, 1., {})
+ .add_full_text_route('content',
hybrid.match_query('paimon'), 3, 1., {})
+ .execute_local())]
+ for result in results:
+ assert isinstance(result, GlobalIndexResult)
+ assert result.snapshot_id() ==
table.snapshot_manager().get_latest_snapshot().id
+ expected = result.row_ids()
+ ranked =
pa.Table.from_batches(result.new_read_builder().with_projection(['id']).read())
+ assert ranked.column_names == ['id', '__paimon_search_score']
+ assert sorted(ranked.column('__paimon_search_score').to_pylist()) ==
pytest.approx(sorted(expected.values()))
+ builder = native.new_read_builder().with_projection(['id'])
+ plan = builder.new_scan().with_global_index_result(result).plan()
+ rows = pa.Table.from_batches(builder.new_read().read(plan.splits()))
+ assert sorted(rows.column('id').to_pylist()) ==
sorted(ranked.column('id').to_pylist())
+ carried_scores = {}
+ for selected in plan.splits():
+ split = deserialize_split_v1(selected.serialize(),
_partition_fields(table), [])
+ for range_ in split.row_ranges():
+ for row_id in range(range_.from_, range_.to + 1):
+ carried_scores[row_id] = split.get_score(row_id)
+ assert carried_scores == pytest.approx(expected)
diff --git a/paimon-python/pypaimon/tests/native_plan_expanded_test.py
b/paimon-python/pypaimon/tests/native_plan_expanded_test.py
index f2bd2b7b7f..af4874ae10 100644
--- a/paimon-python/pypaimon/tests/native_plan_expanded_test.py
+++ b/paimon-python/pypaimon/tests/native_plan_expanded_test.py
@@ -184,7 +184,11 @@ def
test_scored_de_preserves_score_mapping_and_selection(catalog, native, dv):
plan, actual = read(table, native, predicate, shard, result=result)
for split in plan.splits():
ids = [i for r in split.row_ranges() for i in range(r.from_,
r.to + 1)]
- assert split.scores() == [scores[i] for i in ids]
+ # Java IndexedSplit stores float[], including when a Native
+ # plan crosses the split wire format back into Python.
+ expected_scores = [pa.scalar(scores[i],
type=pa.float32()).as_py()
+ if native else scores[i] for i in ids]
+ assert split.scores() == expected_scores
python_rows = read(table, False, predicate, shard,
result=result)[1]
assert actual == python_rows
assert all(row['id'] in scores and (not dv or row['id'] != 1) for
row in actual)
diff --git a/paimon-python/pypaimon/tests/native_plan_test.py
b/paimon-python/pypaimon/tests/native_plan_test.py
index d2a522189e..f9b629d4e4 100644
--- a/paimon-python/pypaimon/tests/native_plan_test.py
+++ b/paimon-python/pypaimon/tests/native_plan_test.py
@@ -191,6 +191,7 @@ class NativePlanTest(unittest.TestCase):
fake_df.PaimonCatalog = type(
'PaimonCatalog', (), {'get_table': lambda self, name: None})
fake_df.Split = type('Split', (), {'serialize': lambda self: b''})
+ fake_df.TableScan = type('TableScan', (), {'with_global_index_result':
lambda self, result: self})
fake_mod = ModuleType('pypaimon_rust')
fake_mod.datafusion = fake_df
patcher = patch.dict(
@@ -381,7 +382,7 @@ class NativePlanTest(unittest.TestCase):
row_ranges=None,
)
- def test_plan_forwards_global_index_row_ranges(self):
+ def test_plan_forwards_global_index_result(self):
fs = Mock(partition_key_predicate=None)
scan = _scan(native_enabled=True, file_scanner=fs)
fs.data_evolution = True
@@ -397,7 +398,8 @@ class NativePlanTest(unittest.TestCase):
predicate=None,
limit=None,
projection=None,
- row_ranges=[(1, 2), (5, 5)],
+ row_ranges=None,
+ global_index_result=fs._global_index_result,
)
fs.scan.assert_not_called()
self.assertEqual(plan.splits(), [split])
@@ -416,12 +418,13 @@ class NativePlanTest(unittest.TestCase):
predicate=None,
limit=None,
projection=None,
- row_ranges=[],
+ row_ranges=None,
+ global_index_result=fs._global_index_result,
)
fs.scan.assert_not_called()
self.assertEqual(plan.splits(), [])
- def test_scored_global_index_result_uses_native_ranges(self):
+ def test_scored_global_index_result_retains_core_scores(self):
from pypaimon.globalindex.indexed_split import IndexedSplit
from pypaimon.read.split import DataSplit
@@ -430,10 +433,11 @@ class NativePlanTest(unittest.TestCase):
fs.data_evolution = True
bitmap = GlobalIndexResult.from_range(Range(1, 1)).results()
fs._global_index_result = ScoredGlobalIndexResult.create(bitmap,
lambda _: 0.75)
- split = IndexedSplit(DataSplit([], None, 0), [Range(1, 1)])
+ split = IndexedSplit(DataSplit([], None, 0), [Range(1, 1)], [0.75])
with patch('pypaimon.read.native_plan.native_plan',
return_value=Plan([split], 9)) as np:
plan = scan.plan()
- self.assertEqual(np.call_args[1]['row_ranges'], [(1, 1)])
+ self.assertIs(np.call_args[1]['global_index_result'],
fs._global_index_result)
+ self.assertIsNone(np.call_args[1]['row_ranges'])
self.assertEqual(plan.splits()[0].scores(), [0.75])
self.assertEqual(plan.snapshot_id, 9)
fs.scan.assert_not_called()