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 ab97a37e43 [python] Handle unsupported composite BTree indexes safely
(#10352)
ab97a37e43 is described below
commit ab97a37e43e0c7d13a44f48894dda8f24c4e63fe
Author: QuakeWang <[email protected]>
AuthorDate: Mon Oct 5 21:13:40 2026 +0800
[python] Handle unsupported composite BTree indexes safely (#10352)
---
.../data_evolution_global_index_scanner.py | 5 +-
.../tests/composite_btree_fallback_test.py | 182 +++++++++++++++++++++
.../pypaimon/tests/vector_search_filter_test.py | 103 +++++++-----
3 files changed, 246 insertions(+), 44 deletions(-)
diff --git
a/paimon-python/pypaimon/globalindex/data_evolution_global_index_scanner.py
b/paimon-python/pypaimon/globalindex/data_evolution_global_index_scanner.py
index 1dbbfb0154..1174df8919 100644
--- a/paimon-python/pypaimon/globalindex/data_evolution_global_index_scanner.py
+++ b/paimon-python/pypaimon/globalindex/data_evolution_global_index_scanner.py
@@ -371,9 +371,12 @@ def _resolve_snapshot(table, snapshot):
def is_supported_scalar_index(index_file):
+ meta = index_file.global_index_meta
return (
- index_file.global_index_meta is not None
+ meta is not None
and index_file.index_type in _SUPPORTED_SCALAR_INDEX_TYPES
+ # Composite BTree keys cannot use scalar readers or their coverage.
+ and not (index_file.index_type == 'btree' and meta.extra_field_ids)
)
diff --git a/paimon-python/pypaimon/tests/composite_btree_fallback_test.py
b/paimon-python/pypaimon/tests/composite_btree_fallback_test.py
new file mode 100644
index 0000000000..d94aab1cf4
--- /dev/null
+++ b/paimon-python/pypaimon/tests/composite_btree_fallback_test.py
@@ -0,0 +1,182 @@
+# 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.
+
+import unittest
+from dataclasses import replace
+from unittest.mock import patch
+
+import pyarrow as pa
+import pytest
+
+from pypaimon.common.predicate_builder import PredicateBuilder
+from pypaimon.globalindex.data_evolution_global_index_scanner import (
+ DataEvolutionGlobalIndexScanner,
+ is_supported_scalar_index,
+)
+from pypaimon.globalindex.global_index_meta import GlobalIndexMeta
+from pypaimon.globalindex.sorted_index_file_meta import SortedIndexFileMeta
+from pypaimon.index.index_file_handler import IndexFileHandler
+from pypaimon.index.index_file_meta import IndexFileMeta
+from pypaimon.manifest.index_manifest_entry import IndexManifestEntry
+from pypaimon.read.scanner.file_scanner import FileScanner
+from pypaimon.table.row.generic_row import GenericRow
+from pypaimon.tests.data_evolution_test_helpers import (
+ BatchModeMixin,
+ DataEvolutionTestBase,
+)
+from pypaimon.utils.range import Range
+
+
+def _composite_file():
+ # Java compacted keys (NULL, NULL) and (9, 1). Component nulls do not
+ # set the scalar has_nulls flag; treating this as a scalar index loses
rows.
+ metadata = SortedIndexFileMeta(
+ b'\x00\x03', b'\x00\x00\x09\x00\x00\x00\x01\x00\x00\x00', False)
+ return IndexFileMeta(
+ index_type='btree', file_name='unsupported-composite',
+ file_size=259, row_count=10,
+ global_index_meta=GlobalIndexMeta(0, 9, 0, [1], metadata.serialize()),
+ )
+
+
+class CompositeBTreeFallbackTest(
+ BatchModeMixin, DataEvolutionTestBase, unittest.TestCase):
+
+ pa_schema = pa.schema([('a', pa.int32()), ('b', pa.int32())])
+ table_options = {
+ 'row-tracking.enabled': 'true',
+ 'data-evolution.enabled': 'true',
+ 'global-index.enabled': 'true',
+ 'bucket': '-1',
+ 'file.format': 'parquet',
+ }
+
+ def _table_with_partial_index(self):
+ table = self._create_table()
+ self._write_arrow(table, pa.table({
+ 'a': [None, 1, 2, 3, 4], 'b': [None, 1, 0, 1, 0],
+ }, schema=self.pa_schema))
+ table.create_global_index('a')
+ self._write_arrow(table, pa.table({
+ 'a': [5, 6, 7, 8, 9], 'b': [1, 0, 1, 0, 1],
+ }, schema=self.pa_schema))
+ return table
+
+ def test_scalar_candidate_types(self):
+ composite = _composite_file()
+ self.assertFalse(is_supported_scalar_index(composite))
+ for extra_fields in (None, []):
+ single = replace(composite, global_index_meta=replace(
+ composite.global_index_meta, extra_field_ids=extra_fields))
+ self.assertTrue(is_supported_scalar_index(single))
+ self.assertTrue(is_supported_scalar_index(replace(single,
index_type='bitmap')))
+ self.assertFalse(is_supported_scalar_index(replace(composite,
global_index_meta=None)))
+ self.assertFalse(is_supported_scalar_index(replace(composite,
index_type='unknown')))
+ # Keep the existing policy for other index types unchanged.
+ self.assertTrue(is_supported_scalar_index(replace(composite,
index_type='bitmap')))
+
+ def test_explicit_files_and_constructor_share_usable_coverage(self):
+ table =
self._table_with_partial_index().copy({'scalar-index.search-mode': 'full'})
+ snapshot = table.snapshot_manager().get_latest_snapshot()
+ files = [e.index_file for e in IndexFileHandler(table).scan(snapshot)]
+ composite = _composite_file()
+ predicate =
table.new_read_builder().new_predicate_builder().equal('a', 2)
+ self.assertIsNone(DataEvolutionGlobalIndexScanner.create(
+ table, index_files=[composite], snapshot=snapshot))
+ for ordered in (files + [composite], [composite] + files):
+ for use_factory in (True, False):
+ with self.subTest(factory=use_factory,
composite_first=ordered[0] is composite):
+ if use_factory:
+ scanner = DataEvolutionGlobalIndexScanner.create(
+ table, index_files=ordered, snapshot=snapshot)
+ else:
+ scanner = DataEvolutionGlobalIndexScanner(
+ table.fields, table.file_io,
+
table.path_factory().global_index_path_factory().index_path(),
+ ordered, options=table.options, table=table,
snapshot=snapshot)
+ with scanner:
+ evaluation = scanner.scan_with_coverage(predicate)
+ self.assertEqual([Range(2, 2)],
evaluation.result.results().to_range_list())
+ self.assertEqual(frozenset([0]),
evaluation.contributing_field_ids)
+ self.assertEqual([Range(5, 9)],
scanner.unindexed_ranges(
+ predicate,
contributing_field_ids=evaluation.contributing_field_ids))
+
+ @pytest.mark.python_plan
+ def test_queries_preserve_fallback_and_fast_mode(self):
+ table = self._table_with_partial_index()
+ snapshot = table.snapshot_manager().get_latest_snapshot()
+ scalar_entries = IndexFileHandler(table).scan(snapshot)
+ composite_entry = IndexManifestEntry(0, GenericRow([], []), 0,
_composite_file())
+ b = table.new_read_builder().new_predicate_builder()
+ cases = [
+ (b.equal('a', 2), True, [2]),
+ (b.equal('a', 7), True, []),
+ (b.greater_or_equal('a', 2), True, [2, 3, 4]),
+ (b.is_in('a', [2, 7]), True, [2]),
+ (b.is_null('a'), True, [None]),
+ (b.equal('b', 1), False, [1, 3, 5, 7, 9]),
+ (b.is_null('b'), False, [None]),
+ (PredicateBuilder.and_predicates([
+ b.greater_or_equal('a', 2), b.equal('b', 1)]), True, [3]),
+ (PredicateBuilder.or_predicates([
+ b.equal('a', 2), b.equal('b', 1)]), False, [1, 2, 3, 5, 7, 9]),
+ (PredicateBuilder.and_predicates([
+ b.greater_or_equal('a', 2), PredicateBuilder.or_predicates([
+ b.equal('a', 7), b.equal('b', 1)])]), True, [3]),
+ ]
+ original_eval = FileScanner._eval_global_index
+ for predicate, uses_scalar, fast_expected in cases:
+ baseline = self._query(table.copy({'global-index.enabled':
'false'}), predicate)
+ for coexist in (False, True):
+ entries = (scalar_entries if coexist else []) +
[composite_entry]
+ for reverse in (False, True):
+ ordered = list(reversed(entries)) if reverse else entries
+
+ # Model Java-produced manifests without changing Python's
write validation.
+ def scan_indexes(handler, snapshot, entry_filter=None):
+ return [e for e in ordered if entry_filter is None or
entry_filter(e)]
+
+ for mode in ('fast', 'full', 'detail'):
+ with self.subTest(predicate=predicate, coexist=coexist,
+ reverse=reverse, mode=mode):
+ plans = []
+
+ def observe(scanner, snapshot=None):
+ plan = original_eval(scanner, snapshot)
+ plans.append(plan)
+ return plan
+
+ view = table.copy({'scalar-index.search-mode':
mode})
+ with patch.object(IndexFileHandler, 'scan',
scan_indexes), patch.object(
+ FileScanner, '_eval_global_index',
observe):
+ actual = self._query(view, predicate)
+ indexed = coexist and uses_scalar
+ expected = fast_expected if mode == 'fast' and
indexed else baseline
+ self.assertEqual(expected, actual)
+ self.assertEqual(1, len(plans))
+ if indexed:
+ self.assertIsNotNone(plans[0])
+ self.assertEqual([] if mode == 'fast' else
[Range(5, 9)],
+ plans[0].unindexed_ranges)
+ else:
+ self.assertIsNone(plans[0])
+
+ @staticmethod
+ def _query(table, predicate):
+ builder = table.new_read_builder().with_filter(predicate)
+ rows = builder.new_read().to_arrow(builder.new_scan().plan().splits())
+ return sorted(rows.column('a').to_pylist(), key=lambda value: (value
is not None, value))
diff --git a/paimon-python/pypaimon/tests/vector_search_filter_test.py
b/paimon-python/pypaimon/tests/vector_search_filter_test.py
index 837e792ea7..f707189fae 100644
--- a/paimon-python/pypaimon/tests/vector_search_filter_test.py
+++ b/paimon-python/pypaimon/tests/vector_search_filter_test.py
@@ -1051,6 +1051,43 @@ class VectorSearchFilterTest(unittest.TestCase):
self.assertEqual([Range(0, 9)], raw[0].row_ranges)
self.assertEqual([], raw[0].scalar_index_files)
+ def test_composite_btree_does_not_cover_scalar_filter(self):
+ from pypaimon.table.source.vector_search_split import (
+ IndexVectorSearchSplit,
+ RawVectorSearchSplit,
+ )
+
+ self.table.fields.append(_field(2, "other"))
+ self.entries[2].index_file.global_index_meta.row_range_end = 4
+ composite = _entry(
+ None, field_id=0, index_type="btree", file_name="composite.index",
+ row_range_start=0, row_range_end=9)
+ composite.index_file.global_index_meta.extra_field_ids = [2]
+ self.entries.append(composite)
+ predicate = Predicate(method="equal", index=0, field="id",
literals=[7])
+
+ for mode in ("fast", "full", "detail"):
+ with self.subTest(mode=mode):
+ self.table.options = CoreOptions(Options({
+ "scalar-index.search-mode": mode,
+ "vector-index.search-mode": "fast",
+ }))
+
self.table.data_ranges_for_data_evolution_global_index_coverage = (
+ lambda snapshot, partition_filter: [Range(0, 9)])
+ splits =
self._builder(predicate).new_vector_search_scan().scan().splits()
+ indexed = [s for s in splits if isinstance(s,
IndexVectorSearchSplit)]
+ raw = [s for s in splits if isinstance(s,
RawVectorSearchSplit)]
+ self.assertEqual(2, len(indexed))
+ attached = [f.file_name for s in indexed for f in
s.scalar_index_files]
+ self.assertEqual(["id-btree-0.index"], attached)
+ if mode == "fast":
+ self.assertEqual([], raw)
+ else:
+ self.assertEqual(1, len(raw))
+ self.assertEqual([Range(5, 9)], raw[0].row_ranges)
+ self.assertNotIn("composite.index",
+ [f.file_name for f in
raw[0].scalar_index_files])
+
def test_read_threads_prefilter_bitmap_as_include_row_ids(self):
"""preFilter bitmap from scanner.scan(filter) must reach each split's
VectorSearch, offset-rebased to local coords by
OffsetGlobalIndexReader.
@@ -1766,7 +1803,7 @@ class VectorSearchFilterTest(unittest.TestCase):
self.assertEqual(1, len(raw))
self.assertEqual("ivf-flat", raw[0].index_type)
- def test_scan_attaches_scalar_index_when_filter_hits_extra_field(self):
+ def test_scan_excludes_composite_btree_when_filter_hits_extra_field(self):
id_name_index = _entry(None, field_id=2, index_type="btree",
file_name="name-id.index",
row_range_start=0,
@@ -1797,8 +1834,7 @@ class VectorSearchFilterTest(unittest.TestCase):
.splits()
)
- self.assertEqual(["name-id.index"],
- [f.file_name for f in splits[0].scalar_index_files])
+ self.assertEqual([], splits[0].scalar_index_files)
class VectorSearchMultiShardScalarTest(unittest.TestCase):
@@ -1886,7 +1922,7 @@ class VectorSearchMultiShardScalarTest(unittest.TestCase):
# Must not be empty despite shard_a being empty (no short-circuit).
self.assertEqual([7], hits)
- def test_primary_and_extra_field_indexes_share_coverage(self):
+ def test_composite_btree_does_not_extend_primary_coverage(self):
from pypaimon.globalindex.global_index_reader import GlobalIndexReader
from pypaimon.globalindex.data_evolution_global_index_scanner import (
DataEvolutionGlobalIndexScanner,
@@ -1948,9 +1984,8 @@ class VectorSearchMultiShardScalarTest(unittest.TestCase):
finally:
scanner.close()
- result = evaluation.result.or_(fallback)
- self.assertTrue(fallback.results().is_empty())
- self.assertEqual([1, 7], sorted(result.results()))
+ self.assertEqual([Range(5, 9)], fallback.results().to_range_list())
+ self.assertEqual([1], sorted(evaluation.result.results()))
def test_unsupported_extra_field_index_does_not_poison_primary(self):
from pypaimon.globalindex.global_index_reader import GlobalIndexReader
@@ -2024,11 +2059,12 @@ class
VectorSearchMultiShardScalarTest(unittest.TestCase):
b_field = _field(1, "b")
c_field = _field(2, "c")
- short = _entry(None, field_id=0, index_type="btree",
+ # Mocked non-BTree readers retain coverage of generic extra-field
padding.
+ short = _entry(None, field_id=0, index_type="bitmap",
file_name="a-c.index",
row_range_start=0, row_range_end=4).index_file
short.global_index_meta.extra_field_ids = [2]
- long = _entry(None, field_id=1, index_type="btree",
+ long = _entry(None, field_id=1, index_type="bitmap",
file_name="b-c.index",
row_range_start=0, row_range_end=9).index_file
long.global_index_meta.extra_field_ids = [2]
@@ -2087,15 +2123,16 @@ class
VectorSearchMultiShardScalarTest(unittest.TestCase):
b_field = _field(1, "b")
c_field = _field(2, "c")
- a_early = _entry(None, field_id=0, index_type="btree",
+ # Reader stubs test padding independently of composite BTree support.
+ a_early = _entry(None, field_id=0, index_type="bitmap",
file_name="a-c-early.index",
row_range_start=2, row_range_end=3).index_file
a_early.global_index_meta.extra_field_ids = [2]
- a_late = _entry(None, field_id=0, index_type="btree",
+ a_late = _entry(None, field_id=0, index_type="bitmap",
file_name="a-c-late.index",
row_range_start=7, row_range_end=9).index_file
a_late.global_index_meta.extra_field_ids = [2]
- b_full = _entry(None, field_id=1, index_type="btree",
+ b_full = _entry(None, field_id=1, index_type="bitmap",
file_name="b-c-full.index",
row_range_start=0, row_range_end=9).index_file
b_full.global_index_meta.extra_field_ids = [2]
@@ -2155,11 +2192,11 @@ class
VectorSearchMultiShardScalarTest(unittest.TestCase):
b_field = _field(1, "b", "STRING")
c_field = _field(2, "c", "STRING")
- short = _entry(None, field_id=0, index_type="btree",
+ short = _entry(None, field_id=0, index_type="bitmap",
file_name="a-c.index",
row_range_start=0, row_range_end=4).index_file
short.global_index_meta.extra_field_ids = [2]
- long = _entry(None, field_id=1, index_type="btree",
+ long = _entry(None, field_id=1, index_type="bitmap",
file_name="b-c.index",
row_range_start=0, row_range_end=9).index_file
long.global_index_meta.extra_field_ids = [2]
@@ -2337,7 +2374,7 @@ class VectorSearchMultiShardScalarTest(unittest.TestCase):
self.assertEqual([Range(5, 9)], result.results().to_range_list())
- def test_scanner_create_selects_extra_field_indexes(self):
+ def test_scanner_create_excludes_composite_btree_extra_fields(self):
from pypaimon.globalindex.data_evolution_global_index_scanner import (
DataEvolutionGlobalIndexScanner,
)
@@ -2358,35 +2395,15 @@ class
VectorSearchMultiShardScalarTest(unittest.TestCase):
)
_patch_snapshot(self, table._entries)
- class _StubBTreeReader:
- def __init__(self_inner, key_serializer, file_io, index_path,
- io_meta):
- pass
-
- def visit_equal(self_inner, literal):
- return GlobalIndexResult.create_empty()
-
- def close(self_inner):
- pass
-
with mock.patch(
-
"pypaimon.globalindex.btree.lazy_filtered_btree_reader.BTreeIndexReader",
- _StubBTreeReader):
- with mock.patch(
-
"pypaimon.globalindex.sorted_file_global_index_reader.SortedIndexFileMeta.deserialize",
- return_value=BTreeIndexMeta(first_key=b'',
last_key=b'zzzz', has_nulls=False)):
- scanner = DataEvolutionGlobalIndexScanner.create(
- table,
- predicate=Predicate(method="equal", index=1, field="id",
- literals=[3]),
- )
- try:
- self.assertIsNotNone(scanner)
- readers = scanner._evaluator._readers_function(id_field)
- self.assertTrue(readers)
- finally:
- if scanner is not None:
- scanner.close()
+ "pypaimon.globalindex.data_evolution_global_index_scanner."
+ "_create_inner_readers") as create_readers:
+ scanner = DataEvolutionGlobalIndexScanner.create(
+ table,
+ predicate=Predicate(method="equal", index=1, field="id",
literals=[3]),
+ )
+ self.assertIsNone(scanner)
+ create_readers.assert_not_called()
class VectorSearchPartitionedFilterTest(unittest.TestCase):