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 778d640423 [python] Use Native planning for primary-key sorted indexes
(#10473)
778d640423 is described below
commit 778d640423a65cf27f8763950f8311ed6778b3d8
Author: Jingsong Lee <[email protected]>
AuthorDate: Fri Oct 9 19:55:28 2026 +0800
[python] Use Native planning for primary-key sorted indexes (#10473)
---
paimon-python/pypaimon/read/table_scan.py | 13 +-
.../pypaimon/tests/native_pk_sorted_index_test.py | 178 +++++++++++++++++++++
.../pypaimon/tests/native_plan_expanded_test.py | 9 +-
3 files changed, 185 insertions(+), 15 deletions(-)
diff --git a/paimon-python/pypaimon/read/table_scan.py
b/paimon-python/pypaimon/read/table_scan.py
index 879072a12f..eb9c06d7ef 100755
--- a/paimon-python/pypaimon/read/table_scan.py
+++ b/paimon-python/pypaimon/read/table_scan.py
@@ -210,11 +210,9 @@ class TableScan:
self.file_scanner.partition_key_predicate,
) if predicate is not None
])
- # Append positions and Python sorted-index refinement still precede
- # LIMIT. Ordinary PK shards are selected inside Rust before LIMIT.
- defer_limit = has_distribution and (
- not self.table.is_primary_key_table
- or self.table.options.global_index_enabled())
+ # Append positions still precede LIMIT. Rust owns PK shards and
+ # source-backed sorted-index refinement before applying LIMIT.
+ defer_limit = has_distribution and not
self.table.is_primary_key_table
plan = native_plan(
self.table,
predicate=native_predicate,
@@ -240,11 +238,6 @@ class TableScan:
splits = [s for s in splits
if getattr(s, 'partition', None) is None
or partition_predicate.test(s.partition)]
- if (self.table.is_primary_key_table and not fs.is_streaming and
self.predicate is not None
- and self.table.options.global_index_enabled()
- and plan.snapshot_id is not None):
- snapshot =
self.table.snapshot_manager().get_snapshot_by_id(plan.snapshot_id)
- splits = fs._apply_primary_key_sorted_indexes(splits, snapshot)
if chunk_shuffle is None and defer_limit:
# A partial IndexedSplit plus a file-wide DV cardinality cannot
# reveal how many deleted rows lie inside the selected range.
diff --git a/paimon-python/pypaimon/tests/native_pk_sorted_index_test.py
b/paimon-python/pypaimon/tests/native_pk_sorted_index_test.py
new file mode 100644
index 0000000000..9932dc20e6
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_pk_sorted_index_test.py
@@ -0,0 +1,178 @@
+# 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.
+
+"""Java source-backed scalar indexes through the REST Native planner."""
+
+import shutil
+from pathlib import Path
+from unittest.mock import patch
+
+import pyarrow as pa
+import pytest
+
+from pypaimon import Schema
+from pypaimon.globalindex.indexed_split import IndexedSplit
+from pypaimon.read.native_plan import native_plan, native_runtime_available
+from pypaimon.tests import native_plan_rest_test,
primary_key_global_index_golden_test
+
+rest_catalog = native_plan_rest_test.rest_catalog
+golden_catalog = primary_key_global_index_golden_test.catalog
+
+pytestmark = [pytest.mark.native_plan, pytest.mark.skipif(
+ not native_runtime_available(), reason='Rust main required')]
+
+
[email protected]
+def indexed_table(rest_catalog, golden_catalog):
+ catalog, server = rest_catalog
+ golden = golden_catalog.get_table('default.test_pk_global_index_golden')
+ schema = golden.table_schema
+ catalog.create_table('default.indexed', Schema(
+ schema.fields, schema.partition_keys, schema.primary_keys,
schema.options), False)
+ source = Path(golden.table_path)
+ destination = Path(server.data_path) / server.warehouse / 'default' /
'indexed'
+ for directory in ('bucket-0', 'index', 'manifest', 'snapshot'):
+ shutil.copytree(str(source / directory), str(destination / directory))
+ return catalog.get_table('default.indexed')
+
+
[email protected]('column,value,ids', [
+ ('name', 'name-4', [4]), ('category', 'odd', [1, 3, 5]),
+ ('name', 'absent', []),
+])
+def test_java_scalar_indexes_are_planned_in_rust(indexed_table, column, value,
ids):
+ table = indexed_table.copy({'scan.native-plan.enabled': 'true',
'read.native.enabled': 'true'})
+ predicate = table.new_read_builder().new_predicate_builder().equal(column,
value)
+ plan = native_plan(table, predicate=predicate)
+ assert plan.snapshot_id == 6
+ # Java's fixture also contains an uncovered APPEND file. Keep it as an
+ # ordinary split; only the compacted source can use index positions.
+ indexed = [split for split in plan.splits() if isinstance(split,
IndexedSplit)]
+ assert len(indexed) == (1 if ids else 0)
+ assert all(not split.raw_convertible for split in indexed)
+ assert all(file.file_source != 1 for split in plan.splits()
+ if not isinstance(split, IndexedSplit) for file in split.files)
+ with patch('pypaimon.index.index_file_handler.IndexFileHandler.scan',
+ side_effect=AssertionError('Python index planning must not
run')):
+ builder = table.new_read_builder().with_filter(predicate)
+ scan_plan = builder.new_scan().plan()
+ result = builder.new_read().to_arrow(scan_plan.splits())
+ assert sorted(result.column('id').to_pylist()) == ids
+
+
[email protected]('mode,ids', [('and', [3]), ('or', [1, 3, 4, 5]),
('unsupported-or', [1, 2])])
+def test_native_compound_predicates_keep_residuals(indexed_table, mode, ids):
+ from pypaimon.common.predicate_builder import PredicateBuilder
+ table = indexed_table.copy({'scan.native-plan.enabled': 'true',
'read.native.enabled': 'true'})
+ pb = table.new_read_builder().new_predicate_builder()
+ if mode == 'and':
+ predicate = PredicateBuilder.and_predicates([pb.equal('name',
'name-3'), pb.equal('category', 'odd')])
+ elif mode == 'or':
+ predicate = PredicateBuilder.or_predicates([pb.equal('name',
'name-4'), pb.equal('category', 'odd')])
+ else:
+ predicate = PredicateBuilder.or_predicates([pb.equal('name',
'name-1'), pb.equal('id', 2)])
+ with patch('pypaimon.index.index_file_handler.IndexFileHandler.scan',
+ side_effect=AssertionError('Python index planning must not
run')):
+ builder = table.new_read_builder().with_filter(predicate)
+ result =
builder.new_read().to_arrow(builder.new_scan().plan().splits())
+ assert sorted(result.column('id').to_pylist()) == ids
+
+
[email protected]('version', [2, 4, 6])
+def test_native_index_uses_selected_snapshot(indexed_table, version):
+ base = indexed_table.copy({'scan.version': str(version),
'read.native.enabled': 'true'})
+ predicate =
base.new_read_builder().new_predicate_builder().equal('category', 'odd')
+ classic = base.copy({'scan.native-plan.enabled': 'false',
'read.native.enabled': 'false'})
+ classic = classic.new_read_builder().with_filter(predicate)
+ expected =
classic.new_read().to_arrow(classic.new_scan().plan().splits()).to_pylist()
+ with patch('pypaimon.index.index_file_handler.IndexFileHandler.scan',
+ side_effect=AssertionError('Python index planning must not
run')):
+ builder = base.copy({'scan.native-plan.enabled':
'true'}).new_read_builder().with_filter(predicate)
+ plan = builder.new_scan().plan()
+ rows = builder.new_read().to_arrow(plan.splits()).to_pylist()
+ assert plan.snapshot_id == version
+ assert sorted(rows, key=lambda row: row['id']) == sorted(expected,
key=lambda row: row['id'])
+
+
[email protected]('index_type,column,value,ids', [
+ ('btree', 'name', 'name-4', [4]), ('bitmap', 'category', 'odd', [1, 3, 5]),
+])
+def test_native_index_read_failure_keeps_data(indexed_table, index_type,
column, value, ids):
+ from pypaimon.index.index_file_handler import IndexFileHandler
+ table = indexed_table.copy({'scan.native-plan.enabled': 'true',
'read.native.enabled': 'true'})
+ entries =
IndexFileHandler(table).scan(table.snapshot_manager().get_latest_snapshot())
+ entry = next(entry for entry in entries if entry.index_file.index_type ==
index_type)
+ table.file_io.delete(table.table_path + '/index/' +
entry.index_file.file_name)
+ predicate = table.new_read_builder().new_predicate_builder().equal(column,
value)
+ with patch('pypaimon.index.index_file_handler.IndexFileHandler.scan',
+ side_effect=AssertionError('Python index planning must not
run')):
+ builder = table.new_read_builder().with_filter(predicate)
+ plan = builder.new_scan().plan()
+ assert all(not isinstance(split, IndexedSplit) for split in
plan.splits())
+ rows = builder.new_read().to_arrow(plan.splits())
+ assert sorted(rows.column('id').to_pylist()) == ids
+
+
+def
test_native_pk_index_positions_are_file_local_with_row_tracking(indexed_table):
+ from copy import deepcopy
+ table = indexed_table.copy({'read.native.enabled': 'true'})
+ predicate = table.new_read_builder().new_predicate_builder().equal('name',
'name-4')
+ plan = native_plan(table, predicate=predicate)
+ splits = deepcopy(plan.splits())
+ for split in splits:
+ for file in split.files:
+ file.first_row_id = 100
+ builder = table.new_read_builder().with_filter(predicate)
+ assert builder.new_read().to_arrow(splits).column('id').to_pylist() == [4]
+
+
[email protected]('disabled', [False, True], ids=['indexed',
'index-disabled'])
+def test_native_sorted_index_limit_and_shards(indexed_table, disabled):
+ table = indexed_table.copy({'scan.native-plan.enabled': 'true',
'read.native.enabled': 'true',
+ 'global-index.enabled': str(not
disabled).lower()})
+ predicate =
table.new_read_builder().new_predicate_builder().equal('category', 'odd')
+ ids = []
+ with patch('pypaimon.index.index_file_handler.IndexFileHandler.scan',
+ side_effect=AssertionError('Python index planning must not
run')):
+ for shard in range(2):
+ builder =
table.new_read_builder().with_filter(predicate).with_limit(1)
+ plan = builder.new_scan().with_shard(shard, 2).plan()
+ if disabled:
+ assert all(not isinstance(split, IndexedSplit) for split in
plan.splits())
+ rows = builder.new_read().to_arrow(plan.splits())
+ assert rows.num_rows <= 1
+ ids.extend(rows.column('id').to_pylist())
+ assert len(ids) == 1
+ assert ids[0] in [1, 3, 5]
+
+
+def test_native_sorted_index_does_not_resurrect_an_old_version(indexed_table):
+ from pypaimon.tests.native_plan_expanded_test import write
+ table = indexed_table.copy({
+ 'deletion-vectors.enabled': 'false', 'write-only': 'true',
+ 'write.native.enabled': 'false', 'commit.native.enabled': 'false',
+ 'scan.native-plan.enabled': 'true', 'read.native.enabled': 'true',
+ })
+ write(table, [{'id': 4, 'name': 'changed', 'category': 'even'}],
+ pa.schema([('id', pa.int32()), ('name', pa.string()), ('category',
pa.string())]))
+ predicate = table.new_read_builder().new_predicate_builder().equal('name',
'name-4')
+ with patch('pypaimon.index.index_file_handler.IndexFileHandler.scan',
+ side_effect=AssertionError('Python index planning must not
run')):
+ builder = table.new_read_builder().with_filter(predicate)
+ plan = builder.new_scan().plan()
+ assert any(not split.raw_convertible and len(split.files) > 1 for
split in plan.splits())
+ assert builder.new_read().to_arrow(plan.splits()).num_rows == 0
diff --git a/paimon-python/pypaimon/tests/native_plan_expanded_test.py
b/paimon-python/pypaimon/tests/native_plan_expanded_test.py
index 244ab1cfa1..f2bd2b7b7f 100644
--- a/paimon-python/pypaimon/tests/native_plan_expanded_test.py
+++ b/paimon-python/pypaimon/tests/native_plan_expanded_test.py
@@ -199,7 +199,8 @@ def
test_scored_de_preserves_score_mapping_and_selection(catalog, native, dv):
@pytest.mark.parametrize('covered', [True, False])
-def test_pk_sorted_index_refines_native_splits_at_selected_snapshot(catalog,
native, covered):
[email protected]_plan
+def test_python_pk_sorted_index_refines_splits_at_selected_snapshot(catalog,
covered):
from pypaimon.globalindex.global_index_meta import GlobalIndexMeta
from pypaimon.globalindex.indexed_split import IndexedSplit
from pypaimon.index.index_file_meta import IndexFileMeta
@@ -234,19 +235,17 @@ def
test_pk_sorted_index_refines_native_splits_at_selected_snapshot(catalog, nat
with patch('pypaimon.index.index_file_handler.IndexFileHandler.scan',
return_value=[entry]) as scan_index, \
patch('pypaimon.table.source.primary_key_sorted_index_scan.reader_factory',
return_value=lambda *args: _Reader([Range(1, 1)])):
- plan, rows = read(table.copy({'scan.version': '1'}), native, predicate)
+ plan, rows = read(table.copy({'scan.version': '1'}), False, predicate)
assert rows == [{'id': 2, 'v': 20}]
assert plan.snapshot_id == 1
assert scan_index.call_args[0][0].id == 1
assert any(isinstance(split, IndexedSplit) for split in plan.splits()) ==
covered
- if native:
- assert all(split.snapshot_id == 1 for split in plan.splits())
# The latest snapshot includes a newer version. Index pruning must not
# resurrect the old matching version from a merge-required split.
with patch('pypaimon.index.index_file_handler.IndexFileHandler.scan',
return_value=[entry]), \
patch('pypaimon.table.source.primary_key_sorted_index_scan.reader_factory',
return_value=lambda *args: _Reader([Range(1, 1)])):
- assert read(table, native, predicate)[1] == []
+ assert read(table, False, predicate)[1] == []
def test_scored_result_requires_a_score_for_each_selected_row(catalog, native):