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 65c5dd7fb8 [Python] Use scalar indexes for upsert key lookup (#10745)
65c5dd7fb8 is described below

commit 65c5dd7fb82f5f51b38f3378310ceb2f2dd646e0
Author: Jingsong Lee <[email protected]>
AuthorDate: Thu Oct 22 19:10:01 2026 +0800

    [Python] Use scalar indexes for upsert key lookup (#10745)
---
 .../python/globalindex/create_global_index.py      |  10 +-
 .../python/globalindex/index_freshness.py          | 174 +++++++
 .../execution/python/write/table_upsert_by_key.py  |  86 +++-
 .../pypaimon/tests/upsert_btree_lookup_test.py     | 536 +++++++++++++++++++++
 4 files changed, 789 insertions(+), 17 deletions(-)

diff --git 
a/paimon-python/pypaimon/execution/python/globalindex/create_global_index.py 
b/paimon-python/pypaimon/execution/python/globalindex/create_global_index.py
index 40dacc4f68..026b573c38 100644
--- a/paimon-python/pypaimon/execution/python/globalindex/create_global_index.py
+++ b/paimon-python/pypaimon/execution/python/globalindex/create_global_index.py
@@ -49,6 +49,7 @@ from 
pypaimon.execution.python.globalindex.vindex.vindex_vector_index_writer imp
     VindexVectorIndexWriter,
 )
 from pypaimon.index.index_file_meta import IndexFileMeta
+from pypaimon.index.data_evolution_index_source_meta import 
DataEvolutionIndexSourceMeta
 from pypaimon.manifest.index_manifest_entry import IndexManifestEntry
 from pypaimon.table.row.generic_row import GenericRow
 from pypaimon.table.special_fields import SpecialFields
@@ -114,7 +115,8 @@ class PythonGlobalIndexBuilder:
 
         if self._index_type in _SORTED_INDEX_IDENTIFIERS:
             return self._build_sorted_index(
-                splits, unindexed_ranges, index_field, table_read, index_path)
+                splits, unindexed_ranges, index_field, table_read, index_path,
+                DataEvolutionIndexSourceMeta(snapshot.id).serialize() if 
snapshot else None)
         if execution == "ray":
             from pypaimon.ray.vector_index_build import build_vector_index
             return build_vector_index(
@@ -131,7 +133,8 @@ class PythonGlobalIndexBuilder:
         return snapshot_manager.get_latest_snapshot()
 
     def _build_sorted_index(
-        self, splits, unindexed_ranges, index_field, table_read, index_path: 
str
+        self, splits, unindexed_ranges, index_field, table_read, index_path: 
str,
+        source_meta=None,
     ) -> List[CommitMessage]:
         key_serializer = create_serializer(index_field.type)
         configured_records_per_range = (
@@ -174,6 +177,7 @@ class PythonGlobalIndexBuilder:
                         index_field.id,
                         self._index_type,
                         result_entries,
+                        source_meta=source_meta,
                     )
                 )
             if index_adds:
@@ -479,6 +483,7 @@ def _to_index_manifest_entries(
     index_field_id: int,
     index_type: str,
     result_entries,
+    source_meta=None,
 ) -> List[IndexManifestEntry]:
     path_factory = table.path_factory().global_index_path_factory()
     entries = []
@@ -497,6 +502,7 @@ def _to_index_manifest_entries(
                 index_field_id=index_field_id,
                 extra_field_ids=None,
                 index_meta=result.meta,
+                source_meta=source_meta,
             ),
             external_path=external_path,
         )
diff --git 
a/paimon-python/pypaimon/execution/python/globalindex/index_freshness.py 
b/paimon-python/pypaimon/execution/python/globalindex/index_freshness.py
new file mode 100644
index 0000000000..0d1c233ebe
--- /dev/null
+++ b/paimon-python/pypaimon/execution/python/globalindex/index_freshness.py
@@ -0,0 +1,174 @@
+# 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.
+
+"""Freshness checks for global indexes on data-evolution tables."""
+
+import logging
+
+from pypaimon.common.options.core_options import CoreOptions, 
GlobalIndexColumnUpdateAction
+from pypaimon.table.special_fields import SpecialFields
+
+logger = logging.getLogger(__name__)
+
+
+def key_indexes_are_fresh(table, snapshot, match_keys, partition_predicate):
+    """Check whether IGNORE may have left indexed match keys stale."""
+    if table.options.global_index_column_update_action() != 
GlobalIndexColumnUpdateAction.IGNORE:
+        return True
+    if snapshot is None or not match_keys:
+        return True
+    from 
pypaimon.execution.python.globalindex.data_evolution_global_index_scanner 
import (
+        is_supported_scalar_index,
+    )
+    from pypaimon.index.data_evolution_index_source_meta import 
DataEvolutionIndexSourceMeta
+    from pypaimon.index.index_file_handler import IndexFileHandler
+    from pypaimon.read.push_down_utils import trim_and_transform_predicate
+
+    try:
+        key_ids = {table.field_dict[key].id for key in match_keys}
+        partition_filter = trim_and_transform_predicate(
+            partition_predicate, table.field_names, table.partition_keys)
+        native_planning = table.options.native_read_enabled() or 
table.options.native_plan_enabled()
+
+        def relevant_index(entry):
+            meta = entry.index_file.global_index_meta
+            # Native planning can use more index types, including composite
+            # BTree, even when Python performs the final key matching.
+            supported = (is_supported_scalar_index(entry.index_file)
+                         or (native_planning and meta is not None
+                             and entry.index_file.index_type.lower() in 
('btree', 'bitmap', 'fm', 'multivalue')))
+            return (supported
+                    and bool(key_ids.intersection(
+                        [meta.index_field_id] + (meta.extra_field_ids or [])))
+                    and (partition_filter is None or 
partition_filter.test(entry.partition)))
+
+        indexes = IndexFileHandler(table).scan(snapshot, relevant_index)
+        if not indexes:
+            return True
+        baselines = []
+        for index in indexes:
+            meta = index.index_file.global_index_meta
+            source = DataEvolutionIndexSourceMeta.deserialize(meta.source_meta)
+            if source.scan_snapshot_id > snapshot.id or meta.row_range_start > 
meta.row_range_end:
+                return False
+            baselines.append((index, source.scan_snapshot_id))
+
+        # Inspect active file metadata before either key stats or an index can
+        # hide the files containing changed keys. Both plans use this snapshot.
+        builder = table.copy_without_time_travel({
+            CoreOptions.GLOBAL_INDEX_ENABLED.key(): 'false',
+        }).new_read_builder().with_filter(partition_predicate)
+        indexed_groups = {(tuple(index.partition.values), index.bucket) for 
index, _ in baselines}
+        files_by_group = {}
+        for split in builder.new_scan().plan_all().splits():
+            group = (tuple(split.partition.values), split.bucket)
+            if group in indexed_groups:
+                files = files_by_group.setdefault(group, {})
+                files.update((file.file_name, file) for file in split.files)
+        ranges_by_group = {}
+        for group, files in files_by_group.items():
+            ranges = []
+            for file in files.values():
+                row_range = file.row_id_range()
+                if row_range is None:
+                    return False
+                ranges.append((row_range.from_, row_range.to, file))
+            ranges_by_group[group] = _FileRangeIndex(ranges)
+
+        schema = table.table_schema
+        known_names = set(table.field_names) | SpecialFields.SYSTEM_FIELD_NAMES
+        for index, baseline in baselines:
+            meta = index.index_file.global_index_meta
+            indexed_keys = key_ids.intersection([meta.index_field_id] + 
(meta.extra_field_ids or []))
+            ranges = ranges_by_group.get((tuple(index.partition.values), 
index.bucket))
+            if ranges is None:
+                continue
+            for file in ranges.overlapping(meta.row_range_start, 
meta.row_range_end):
+                # Historical schemas are outside this optimization's proof.
+                if file.schema_id != schema.id:
+                    return False
+                if file.write_cols is not None and (
+                        any(name not in known_names for name in 
file.write_cols)
+                        or len(file.write_cols) != len(set(file.write_cols))):
+                    return False
+                fields = schema.data_file_fields(file.write_cols)
+                sequences = file.write_cols_sequences
+                for position, field in enumerate(fields):
+                    if field.id not in indexed_keys:
+                        continue
+                    sequence = (sequences[position]
+                                if sequences is not None and len(sequences) == 
len(fields)
+                                else file.max_sequence_number)
+                    if sequence <= 0 or sequence > file.max_sequence_number or 
sequence > baseline:
+                        return False
+        return True
+    except Exception:
+        logger.debug("Cannot verify upsert key index freshness; scanning 
without indexes.", exc_info=True)
+        return False
+
+
+class _FileRangeIndex:
+    """Centered interval tree for inclusive physical-file row ranges.
+
+    Build once per partition/bucket in O(F log F); each shard visits only
+    O(log F + overlapping files), including nested and long column files.
+    """
+
+    def __init__(self, ranges):
+        self._root = self._build(sorted(ranges, key=lambda item: item[0]))
+
+    @classmethod
+    def _build(cls, ranges):
+        if not ranges:
+            return None
+        center = ranges[len(ranges) // 2][0]
+        left, crossing, right = [], [], []
+        for item in ranges:
+            start, end, _ = item
+            if end < center:
+                left.append(item)
+            elif start > center:
+                right.append(item)
+            else:
+                crossing.append(item)
+        # Filtering preserves start order; each file belongs to one node only.
+        by_end = sorted(crossing, key=lambda item: item[1], reverse=True)
+        return center, crossing, by_end, cls._build(left), cls._build(right)
+
+    def overlapping(self, start, end):
+        pending = [self._root]
+        while pending:
+            node = pending.pop()
+            if node is None:
+                continue
+            center, by_start, by_end, left, right = node
+            if end < center:
+                for file_start, _, file in by_start:
+                    if file_start > end:
+                        break
+                    yield file
+                pending.append(left)
+            elif start > center:
+                for _, file_end, file in by_end:
+                    if file_end < start:
+                        break
+                    yield file
+                pending.append(right)
+            else:
+                for _, _, file in by_start:
+                    yield file
+                pending.extend((left, right))
diff --git 
a/paimon-python/pypaimon/execution/python/write/table_upsert_by_key.py 
b/paimon-python/pypaimon/execution/python/write/table_upsert_by_key.py
index 22e7803ccf..73171ed9da 100644
--- a/paimon-python/pypaimon/execution/python/write/table_upsert_by_key.py
+++ b/paimon-python/pypaimon/execution/python/write/table_upsert_by_key.py
@@ -16,11 +16,19 @@
 # under the License.
 
 import logging
+import struct
 from typing import Any, Dict, List, Optional, Tuple
 
 import pyarrow as pa
 
+from pypaimon.common.options.core_options import (
+    CoreOptions,
+    GlobalIndexSearchMode,
+)
+from pypaimon.execution.python.globalindex.index_freshness import 
key_indexes_are_fresh
+from pypaimon.execution.python.globalindex.key_serializer import 
create_serializer
 from pypaimon.read.table_read import TableRead
+from pypaimon.schema.data_types import AtomicType
 from pypaimon.table.row.blob import Blob
 from pypaimon.table.special_fields import SpecialFields
 from pypaimon.write.commit_message import CommitMessage
@@ -33,6 +41,34 @@ _KeyTuple = Tuple[Any, ...]
 logger = logging.getLogger(__name__)
 
 
+def _key_predicate(builder, field, values):
+    # Python equality treats signed zero as the same key, whereas index Bloom
+    # filters can distinguish the serialized floating-point representations.
+    if not isinstance(field.type, AtomicType) or field.type.type.upper() in 
('FLOAT', 'DOUBLE'):
+        return None
+    try:
+        serializer = create_serializer(field.type)
+        literals = []
+        for value in values:
+            if value is not None:
+                literal = serializer.deserialize(serializer.serialize(value))
+                # A lossy conversion or incompatible row-key type must keep
+                # the original scan, including all of this column's keys.
+                if literal != value:
+                    return None
+                literals.append(literal)
+    except (ArithmeticError, TypeError, ValueError, struct.error):
+        return None
+
+    predicates = []
+    if literals:
+        predicates.append(builder.equal(field.name, literals[0]) if 
len(literals) == 1
+                          else builder.is_in(field.name, literals))
+    if None in values:
+        predicates.append(builder.is_null(field.name))
+    return builder.or_predicates(predicates)
+
+
 class PythonTableUpsert:
     """Python partition matching, deduplication and file rewriting."""
 
@@ -223,9 +259,8 @@ class PythonTableUpsert:
             partition_data, input_key_tuples, partition_spec,
         )
 
-        # 3. Scan partition once, keeping key → [_ROW_ID, ...] for keys that
-        #    appear in the input (memory ∝ matched existing rows, not the
-        #    whole partition).
+        # 3. Look up candidate rows using key predicates and available indexes,
+        #    keeping only exact input-key matches.
         key_to_row_ids = context._build_key_to_row_ids_map(
             match_keys, partition_spec, set(input_key_tuples),
         )
@@ -297,11 +332,10 @@ class PythonTableUpsert:
         Scan the partition in batches and collect key → [_ROW_ID, ...] for
         rows whose composite key is in *input_key_set*.
 
-        The partition spec (if any) is pushed down as an ``and`` of per-key
-        equality predicates so non-matching partitions are pruned by the
-        scanner. The match-key filter itself is applied in Python on each
-        batch — this keeps memory proportional to the input key set rather
-        than the entire partition.
+        Partition and match-key predicates let the scanner prune files and
+        use available scalar indexes. Per-column candidate sets may include
+        extra composite-key combinations; exact tuple matching still happens
+        on each batch. Unindexed data is always included in the lookup.
 
         Args:
             match_keys:     Column names used as the composite match key
@@ -311,17 +345,39 @@ class PythonTableUpsert:
             input_key_set:   Set of composite key tuples from the input data.
         """
         context = self._context
-        read_builder = context.table.new_read_builder()
+        scan_options = {
+            CoreOptions.SCALAR_INDEX_SEARCH_MODE.key(): 
GlobalIndexSearchMode.FULL.value,
+        }
+        scan_table = context.table.copy_without_time_travel(scan_options)
+        read_builder = scan_table.new_read_builder()
+        predicate_builder = read_builder.new_predicate_builder()
+        sub_predicates = []
 
         if partition_spec:
-            predicate_builder = read_builder.new_predicate_builder()
-            sub_predicates = [
+            sub_predicates.extend([
                 (predicate_builder.is_null(k) if v is None
                  else predicate_builder.equal(k, v))
                 for k, v in partition_spec.items()
-            ]
-            partition_predicate = 
predicate_builder.and_predicates(sub_predicates)
-            read_builder = read_builder.with_filter(partition_predicate)
+            ])
+        if scan_table.options.global_index_enabled():
+            from pypaimon.snapshot.time_travel_util import TimeTravelUtil
+
+            snapshot = TimeTravelUtil.resolve_snapshot(scan_table)
+            scan_table = scan_table._copy_with_snapshot(snapshot)
+            if not key_indexes_are_fresh(
+                    scan_table, snapshot, match_keys,
+                    predicate_builder.and_predicates(sub_predicates)):
+                scan_table = scan_table.copy_without_time_travel({
+                    CoreOptions.GLOBAL_INDEX_ENABLED.key(): 'false',
+                })
+            read_builder = scan_table.new_read_builder()
+        for i, key in enumerate(match_keys):
+            predicate = _key_predicate(
+                predicate_builder, context.table.field_dict[key],
+                {key_tuple[i] for key_tuple in input_key_set})
+            if predicate is not None:
+                sub_predicates.append(predicate)
+        
read_builder.with_filter(predicate_builder.and_predicates(sub_predicates))
 
         scan = read_builder.new_scan()
         splits = scan.plan_all().splits()
@@ -333,7 +389,7 @@ class PythonTableUpsert:
         read_type = key_fields + [SpecialFields.ROW_ID]
 
         table_read = TableRead(
-            table=context.table, predicate=None, read_type=read_type
+            table=scan_table, predicate=None, read_type=read_type
         )
 
         # Stream batches and filter against input_key_set on-the-fly
diff --git a/paimon-python/pypaimon/tests/upsert_btree_lookup_test.py 
b/paimon-python/pypaimon/tests/upsert_btree_lookup_test.py
new file mode 100644
index 0000000000..f3b731acb8
--- /dev/null
+++ b/paimon-python/pypaimon/tests/upsert_btree_lookup_test.py
@@ -0,0 +1,536 @@
+# 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 datetime
+from dataclasses import replace
+from decimal import Decimal
+import unittest
+from unittest import mock
+
+import pyarrow as pa
+
+from pypaimon.index.data_evolution_index_source_meta import 
DataEvolutionIndexSourceMeta
+from pypaimon.index.index_file_handler import IndexFileHandler
+from pypaimon.manifest.schema.data_file_meta import DataFileMeta
+from pypaimon.execution.python.globalindex.index_freshness import 
_FileRangeIndex, key_indexes_are_fresh
+from pypaimon.read.plan import Plan
+from pypaimon.read.split import DataSplit
+from pypaimon.read.table_read import TableRead
+from pypaimon.read.table_scan import TableScan
+from pypaimon.table.row.generic_row import GenericRow
+from pypaimon.table.special_fields import SpecialFields
+from pypaimon.tests.data_evolution_test_helpers import BatchModeMixin, 
DataEvolutionTestBase
+from pypaimon.write.table_upsert_by_key import TableUpsertByKey
+
+
+class UpsertBTreeLookupTest(BatchModeMixin, DataEvolutionTestBase, 
unittest.TestCase):
+    pa_schema = pa.schema([('id', pa.int32()), ('payload', pa.string())])
+    table_options = {
+        **DataEvolutionTestBase.table_options,
+        'global-index.enabled': 'true',
+        'bucket': '-1',
+        'file.format': 'parquet',
+        'scan.native-plan.enabled': 'false',
+        'read.native.enabled': 'false',
+        'write.native.enabled': 'false',
+        'commit.native.enabled': 'false',
+    }
+
+    def _lookup(self, table, keys, key_set, partition=None):
+        return TableUpsertByKey(table, 'lookup', 1)._build_key_to_row_ids_map(
+            keys, partition or {}, key_set)
+
+    def _counted_lookup(self, table, keys, key_set):
+        read_batches = TableRead.to_arrow_batch_reader
+        read_row_ids = []
+
+        def counted_reader(reader, splits, *args, **kwargs):
+            for batch in read_batches(reader, splits, *args, **kwargs):
+                
read_row_ids.extend(batch.column(SpecialFields.ROW_ID.name).to_pylist())
+                yield batch
+
+        with mock.patch.object(TableRead, 'to_arrow_batch_reader', 
counted_reader):
+            found = self._lookup(table, keys, key_set)
+        return found, sorted(read_row_ids)
+
+    def _upsert(self, table, data, row_api=False):
+        builder = table.new_batch_write_builder()
+        update = builder.new_update().with_update_type(['payload'])
+        if row_api:
+            rows = [GenericRow(list(row.values()), table.fields) for row in 
data.to_pylist()]
+            messages = update.upsert_by_key(rows, ['id'])
+        else:
+            messages = update.upsert_by_arrow_with_key(data, ['id'])
+        commit = builder.new_commit()
+        try:
+            commit.commit(messages)
+        finally:
+            commit.close()
+
+    def test_btree_reads_only_matching_row_ids(self):
+        table = self._create_table()
+        ids = list(range(0, 256, 2)) + [14, None, 198]
+        self._write_arrow(table, pa.table({
+            'id': ids, 'payload': ['old'] * len(ids),
+        }, schema=self.pa_schema))
+        self.assertGreater(table.create_global_index('id', 
index_type='btree'), 0)
+        found, read_row_ids = self._counted_lookup(table, ['id'], {(14,), 
(198,), (None,)})
+        self.assertEqual(found, {(14,): [7, 128], (198,): [99, 130], (None,): 
[129]})
+        self.assertEqual(read_row_ids, [7, 99, 128, 129, 130])
+
+        # The missing value is inside the file's min/max range, so file
+        # statistics alone cannot eliminate this lookup.
+        with mock.patch.object(TableRead, 'to_arrow_batch_reader') as read:
+            self.assertEqual(self._lookup(table, ['id'], {(15,)}), {})
+        read.assert_not_called()
+
+    def test_partial_index_and_fallback_update_all_duplicates(self):
+        schema = self.pa_schema.append(pa.field('untouched', pa.int32()))
+        for mode in ('default', 'fast', 'missing', 'disabled'):
+            for row_api in (False, True):
+                with self.subTest(mode=mode, row_api=row_api):
+                    options = dict(self.table_options)
+                    if mode == 'fast':
+                        options['scalar-index.search-mode'] = 'fast'
+                    table = self._create_table(pa_schema=schema, 
options=options)
+                    self._write_arrow(table, pa.table({
+                        'id': [1, 2], 'payload': ['indexed', 'keep'],
+                        'untouched': [10, 20],
+                    }, schema=schema))
+                    if mode != 'missing':
+                        self.assertGreater(table.create_global_index('id', 
index_type='btree'), 0)
+                    self._write_arrow(table, pa.table({
+                        'id': [1, 3], 'payload': ['unindexed', 'last'],
+                        'untouched': [30, 40],
+                    }, schema=schema))
+                    if mode == 'disabled':
+                        table = table.copy({'global-index.enabled': 'false'})
+                    self.assertEqual(self._lookup(table, ['id'], {(1,), 
(3,)}), {
+                        (1,): [0, 2], (3,): [3],
+                    })
+                    self._upsert(table, pa.table({
+                        'id': [1, 3, 4], 'payload': ['new'] * 3,
+                        'untouched': [99] * 3,
+                    }, schema=schema), row_api)
+                    rows = self._read_all(table).to_pylist()
+                    self.assertEqual(sorted((r['id'], r['payload'], 
r['untouched']) for r in rows), [
+                        (1, 'new', 10), (1, 'new', 30), (2, 'keep', 20),
+                        (3, 'new', 40), (4, 'new', 99),
+                    ])
+
+    def test_composite_key_candidates_remain_exact_and_partition_scoped(self):
+        schema = pa.schema([
+            ('id', pa.int32()), ('name', pa.string()), ('region', pa.string()),
+        ])
+        table = self._create_table(pa_schema=schema, partition_keys=['region'])
+        self._write_arrow(table, pa.table({
+            'id': [1, 1, 2, 2, None, 2, 1, 1],
+            'name': ['x', 'y', 'x', 'y', 'x', None, 'x', 'x'],
+            'region': ['A'] * 6 + ['B', None],
+        }, schema=schema))
+        for column in ('id', 'name'):
+            self.assertGreater(table.create_global_index(column, 
index_type='btree'), 0)
+        keys = {(1, 'x'), (2, 'y'), (None, 'x'), (2, None)}
+        found = self._lookup(table, ['id', 'name'], keys, {'region': 'A'})
+        self.assertEqual(set(found), keys)
+        self.assertEqual([len(ids) for ids in found.values()], [1] * 4)
+        all_a = self._lookup(table, [], {()}, {'region': 'A'})
+        self.assertEqual(len(all_a[()]), 6)
+        self.assertTrue(all(row_id in all_a[()] for ids in found.values() for 
row_id in ids))
+        null_partition = self._lookup(table, ['id'], {(1,)}, {'region': None})
+        self.assertEqual(len(null_partition[(1,)]), 1)
+        self.assertTrue(set(null_partition[(1,)]).isdisjoint(all_a[()]))
+
+    def test_deleted_index_match_is_appended_with_a_new_row_id(self):
+        table = self._create_table(options={
+            **self.table_options, 'deletion-vectors.enabled': 'true',
+        })
+        self._write_arrow(table, pa.table({
+            'id': [1, 2], 'payload': ['deleted', 'keep'],
+        }, schema=self.pa_schema))
+        self.assertGreater(table.create_global_index('id', 
index_type='btree'), 0)
+        builder = table.new_batch_write_builder()
+        messages = builder.new_update().delete_by_row_id([0])
+        commit = builder.new_commit()
+        try:
+            commit.commit(messages)
+        finally:
+            commit.close()
+        self.assertEqual(self._lookup(table, ['id'], {(1,)}), {})
+        self._upsert(table, pa.table({
+            'id': [1], 'payload': ['new'],
+        }, schema=self.pa_schema))
+        self.assertEqual(self._lookup(table, ['id'], {(1,)}), {(1,): [2]})
+        self.assertEqual(sorted((r['id'], r['payload']) for r in 
self._read_all(table).to_pylist()), [
+            (1, 'new'), (2, 'keep'),
+        ])
+
+    def test_scalar_key_types_prune_with_btree(self):
+        cases = [
+            (pa.decimal128(8, 2), [Decimal('1.20'), Decimal('2.30'), 
Decimal('3.40')]),
+            (pa.date32(), [datetime.date(2025, 1, day) for day in (1, 2, 3)]),
+            (pa.time32('ms'), [datetime.time(hour, 0) for hour in (1, 2, 3)]),
+            (pa.timestamp('us'), [datetime.datetime(2025, 1, day) for day in 
(1, 2, 3)]),
+        ]
+        for data_type, values in cases:
+            with self.subTest(data_type=data_type):
+                schema = pa.schema([('id', data_type)])
+                table = self._create_table(pa_schema=schema)
+                self._write_arrow(table, pa.table({'id': values}, 
schema=schema))
+                self.assertGreater(table.create_global_index('id', 
index_type='btree'), 0)
+                key_set = {(values[1],)}
+                self.assertEqual(self._counted_lookup(table, ['id'], key_set), 
({
+                    (values[1],): [1],
+                }, [1]))
+
+    def test_unsafe_key_conversion_falls_back_for_the_whole_column(self):
+        table = self._create_table()
+        self._write_arrow(table, pa.table({
+            'id': [1, 2, 3], 'payload': ['a', 'b', 'c'],
+        }, schema=self.pa_schema))
+        self.assertGreater(table.create_global_index('id', 
index_type='btree'), 0)
+        for unsafe in ('incompatible', 1.5, 1 << 64):
+            with self.subTest(unsafe=unsafe):
+                self.assertEqual(self._counted_lookup(table, ['id'], {(1,), 
(unsafe,)}), (
+                    {(1,): [0]}, [0, 1, 2]))
+        schema = pa.schema([('id', pa.binary())])
+        table = self._create_table(pa_schema=schema)
+        self._write_arrow(table, pa.table({'id': [b'a', b'b']}, schema=schema))
+        self.assertEqual(self._counted_lookup(table, ['id'], {(b'a',)}), (
+            {(b'a',): [0]}, [0, 1]))
+
+    def test_signed_zero_falls_back_while_other_key_columns_use_btree(self):
+        for data_type in (pa.float32(), pa.float64()):
+            with self.subTest(data_type=data_type):
+                schema = pa.schema([('id', pa.int32()), ('value', data_type)])
+                table = self._create_table(pa_schema=schema)
+                self._write_arrow(table, pa.table({
+                    'id': [1, 2, 3], 'value': [-0.0, -0.0, 1.0],
+                }, schema=schema))
+                for column in ('id', 'value'):
+                    self.assertGreater(table.create_global_index(column, 
index_type='btree', options={
+                        'btree-index.bloom-filter.enabled': 'true',
+                    }), 0)
+                self.assertEqual(self._counted_lookup(table, ['value'], 
{(0.0,)}), (
+                    {(0.0,): [0, 1]}, [0, 1, 2]))
+                self.assertEqual(self._counted_lookup(table, ['id', 'value'], 
{(1, 0.0)}), (
+                    {(1, 0.0): [0]}, [0]))
+
+    def test_ignore_index_updates_still_match_changed_keys(self):
+        for row_api in (False, True):
+            with self.subTest(row_api=row_api):
+                table = self._create_table(options={
+                    **self.table_options, 'global-index.column-update-action': 
'IGNORE',
+                })
+                self._write_arrow(table, pa.table({
+                    'id': [1, 1, 2], 'payload': ['old', 'duplicate', 'keep'],
+                }, schema=self.pa_schema))
+                self.assertGreater(table.create_global_index('id', 
index_type='btree'), 0)
+                builder = table.new_batch_write_builder()
+                messages = 
builder.new_update().with_update_type(['id']).update_by_arrow_with_row_id(
+                    pa.table({'_ROW_ID': pa.array([0, 1], type=pa.int64()),
+                              'id': pa.array([3, 3], type=pa.int32())}))
+                commit = builder.new_commit()
+                try:
+                    commit.commit(messages)
+                finally:
+                    commit.close()
+                # IGNORE deliberately retains the old key values in the index.
+                # They cannot prove that the new key is absent from the table.
+                self.assertEqual(self._lookup(table, ['id'], {(3,)}), {(3,): 
[0, 1]})
+                self._upsert(table, pa.table({
+                    'id': [3], 'payload': ['new'],
+                }, schema=self.pa_schema), row_api)
+                rows = self._read_all(table).to_pylist()
+                self.assertEqual(sorted((r['id'], r['payload']) for r in 
rows), [
+                    (2, 'keep'), (3, 'new'), (3, 'new'),
+                ])
+
+    def test_ignore_index_updates_keep_fresh_key_pruning(self):
+        for payload_updated in (False, True):
+            with self.subTest(payload_updated=payload_updated):
+                table = self._create_table(options={
+                    **self.table_options, 'global-index.column-update-action': 
'IGNORE',
+                })
+                self._write_arrow(table, pa.table({
+                    'id': list(range(0, 32, 2)), 'payload': ['old'] * 16,
+                }, schema=self.pa_schema))
+                source_snapshot = 
table.snapshot_manager().get_latest_snapshot()
+                self.assertGreater(table.create_global_index('id', 
index_type='btree'), 0)
+                for entry in IndexFileHandler(table).scan(None):
+                    self.assertEqual(DataEvolutionIndexSourceMeta.deserialize(
+                        
entry.index_file.global_index_meta.source_meta).scan_snapshot_id,
+                        source_snapshot.id)
+                if payload_updated:
+                    builder = table.new_batch_write_builder()
+                    messages = 
builder.new_update().with_update_type(['payload']).update_by_arrow_with_row_id(
+                        pa.table({'_ROW_ID': pa.array([5], type=pa.int64()),
+                                  'payload': ['updated']}))
+                    commit = builder.new_commit()
+                    try:
+                        commit.commit(messages)
+                    finally:
+                        commit.close()
+                self.assertEqual(self._counted_lookup(table, ['id'], {(10,)}), 
(
+                    {(10,): [5]}, [5]))
+                # This absent key is inside the file's min/max range. A
+                # still-fresh index can establish absence without a data read.
+                self.assertEqual(self._counted_lookup(table, ['id'], {(11,)}), 
({}, []))
+
+    def test_untrusted_index_source_falls_back_only_for_ignore(self):
+        table = self._create_table()
+        self._write_arrow(table, pa.table({
+            'id': list(range(0, 32, 2)), 'payload': ['old'] * 16,
+        }, schema=self.pa_schema))
+        self.assertGreater(table.create_global_index('id', 
index_type='btree'), 0)
+        snapshot = table.snapshot_manager().get_latest_snapshot()
+        entries = IndexFileHandler(table).scan(snapshot)
+        self.assertTrue(entries)
+        sources = [None, b'unknown', b'DEIX',
+                   DataEvolutionIndexSourceMeta(snapshot.id + 1).serialize()]
+        for action in ('IGNORE', 'THROW_ERROR', 'DROP_PARTITION_INDEX'):
+            scan_table = table.copy({'global-index.column-update-action': 
action})
+            expected_row_ids = list(range(16)) if action == 'IGNORE' else [5]
+            for source in sources:
+                with self.subTest(action=action, source=source):
+                    untrusted_entries = [replace(
+                        entry,
+                        index_file=replace(
+                            entry.index_file,
+                            global_index_meta=replace(
+                                entry.index_file.global_index_meta, 
source_meta=source)))
+                        for entry in entries]
+                    with mock.patch.object(IndexFileHandler, 'scan', 
return_value=untrusted_entries):
+                        self.assertEqual(self._counted_lookup(scan_table, 
['id'], {(10,)}), (
+                            {(10,): [5]}, expected_row_ids))
+
+    def test_ignore_index_updates_use_compacted_column_sequences(self):
+        table = self._create_table(options={
+            **self.table_options, 'global-index.column-update-action': 
'IGNORE',
+        })
+        self._write_arrow(table, pa.table({
+            'id': [1, 2, 3], 'payload': ['old'] * 3,
+        }, schema=self.pa_schema))
+        baseline = table.snapshot_manager().get_latest_snapshot().id
+        table.create_global_index('id', index_type='btree')
+        snapshot = table.snapshot_manager().get_latest_snapshot()
+        table = table._copy_with_snapshot(snapshot)
+        split = table.new_read_builder().new_scan().plan_all().splits()[0]
+        original_file = split.files[0]
+        # Model a compaction which rewrote the full schema after a payload
+        # update. Its overall sequence is new, but the key sequence is old.
+        cases = [([baseline, snapshot.id], True), ([snapshot.id, snapshot.id], 
False),
+                 (None, False), ([baseline], False), ([0, snapshot.id], False),
+                 ([snapshot.id + 1, snapshot.id], False)]
+        for write_cols in (None, ['id', 'payload']):
+            for sequences, expected in cases:
+                with self.subTest(write_cols=write_cols, sequences=sequences):
+                    file = replace(original_file, 
max_sequence_number=snapshot.id,
+                                   write_cols=write_cols, 
write_cols_sequences=sequences)
+                    plan = Plan([DataSplit([file], split.partition, 
split.bucket)], snapshot.id)
+                    with mock.patch.object(TableScan, 'plan_all', 
return_value=plan):
+                        self.assertEqual(key_indexes_are_fresh(
+                            table, snapshot, ['id'], None), expected)
+        # Updates outside this index's row range cannot stale its key values.
+        disjoint = replace(original_file, first_row_id=3, 
max_sequence_number=snapshot.id)
+        plan = Plan([DataSplit([disjoint], split.partition, split.bucket)], 
snapshot.id)
+        with mock.patch.object(TableScan, 'plan_all', return_value=plan):
+            self.assertTrue(key_indexes_are_fresh(table, snapshot, ['id'], 
None))
+
+    def _freshness_metadata(self):
+        table = self._create_table(options={
+            **self.table_options, 'global-index.column-update-action': 
'IGNORE',
+        })
+        self._write_arrow(table, pa.table({
+            'id': [1, 2, 3], 'payload': ['old'] * 3,
+        }, schema=self.pa_schema))
+        table.create_global_index('id', index_type='btree')
+        snapshot = table.snapshot_manager().get_latest_snapshot()
+        table = table._copy_with_snapshot(snapshot)
+        split = table.new_read_builder().new_scan().plan_all().splits()[0]
+        index = IndexFileHandler(table).scan(snapshot)[0]
+        return table, snapshot, split, index
+
+    def test_ignore_freshness_extracts_each_file_range_once(self):
+        table, snapshot, split, original_index = self._freshness_metadata()
+        size = 1024
+        files = [replace(split.files[0], file_name=f'file-{i}', first_row_id=i 
* 10)
+                 for i in range(size)]
+        indexes = [replace(original_index, index_file=replace(
+            original_index.index_file, file_name=f'index-{i}', 
global_index_meta=replace(
+                original_index.index_file.global_index_meta,
+                row_range_start=i * 10, row_range_end=i * 10 + 2)))
+            for i in reversed(range(size))]
+        # The same physical file can occur in more than one split.
+        plan = Plan([DataSplit(list(reversed(files)), split.partition, 
split.bucket),
+                     DataSplit(files[:1], split.partition, split.bucket)], 
snapshot.id)
+        calls = 0
+        original_row_range = DataFileMeta.row_id_range
+
+        def counted_row_range(file):
+            nonlocal calls
+            calls += 1
+            return original_row_range(file)
+
+        with mock.patch.object(IndexFileHandler, 'scan', 
return_value=indexes), \
+                mock.patch.object(TableScan, 'plan_all', return_value=plan), \
+                mock.patch.object(DataFileMeta, 'row_id_range', 
counted_row_range):
+            self.assertTrue(key_indexes_are_fresh(table, snapshot, ['id'], 
None))
+        self.assertEqual(calls, size)
+
+    def test_ignore_freshness_checks_native_composite_indexes(self):
+        from pypaimon.manifest.index_manifest_file import IndexManifestFile
+
+        table, snapshot, split, original_index = self._freshness_metadata()
+        original_meta = original_index.index_file.global_index_meta
+        baseline = 
DataEvolutionIndexSourceMeta.deserialize(original_meta.source_meta).scan_snapshot_id
+        key_id = table.field_dict['id'].id
+        payload_id = table.field_dict['payload'].id
+        for flag in (None, 'read.native.enabled', 'scan.native-plan.enabled'):
+            scan_table = table.copy_without_time_travel({flag: 'true'} if flag 
else {})
+            for first_id, extra_ids in ((key_id, [payload_id]), (payload_id, 
[key_id])):
+                for sequence in (baseline, snapshot.id):
+                    for source in (original_meta.source_meta, None):
+                        with self.subTest(flag=flag, first_id=first_id,
+                                          sequence=sequence, source=source):
+                            file = replace(split.files[0], 
max_sequence_number=snapshot.id,
+                                           write_cols=['id', 'payload'],
+                                           write_cols_sequences=[sequence, 
baseline])
+                            plan = Plan([DataSplit([file], split.partition, 
split.bucket)], snapshot.id)
+                            index = replace(original_index, index_file=replace(
+                                original_index.index_file, 
global_index_meta=replace(
+                                    original_meta, index_field_id=first_id,
+                                    extra_field_ids=extra_ids, 
source_meta=source)))
+                            # Keep the real IndexFileHandler.scan callback so
+                            # unsupported-index filtering cannot be bypassed.
+                            with mock.patch.object(IndexManifestFile, 'read', 
return_value=[index]), \
+                                    mock.patch.object(TableScan, 'plan_all', 
return_value=plan):
+                                self.assertEqual(key_indexes_are_fresh(
+                                    scan_table, snapshot, ['id'], None),
+                                    flag is None or (source is not None and 
sequence <= baseline))
+
+    def test_ignore_freshness_checks_native_scalar_index_types(self):
+        from pypaimon.manifest.index_manifest_file import IndexManifestFile
+
+        table, snapshot, split, original_index = self._freshness_metadata()
+        baseline = DataEvolutionIndexSourceMeta.deserialize(
+            
original_index.index_file.global_index_meta.source_meta).scan_snapshot_id
+        # A nullable string key can use an FM index for IS NULL lookups.
+        file = replace(split.files[0], max_sequence_number=snapshot.id,
+                       write_cols=['id', 'payload'], 
write_cols_sequences=[baseline, snapshot.id])
+        plan = Plan([DataSplit([file], split.partition, split.bucket)], 
snapshot.id)
+        for flag in (None, 'read.native.enabled', 'scan.native-plan.enabled'):
+            scan_table = table.copy_without_time_travel({flag: 'true'} if flag 
else {})
+            for index_type in ('bitmap', 'BITMAP', 'fm', 'FM', 'multivalue', 
'MULTIVALUE'):
+                with self.subTest(flag=flag, index_type=index_type):
+                    index = replace(original_index, index_file=replace(
+                        original_index.index_file, index_type=index_type, 
global_index_meta=replace(
+                            original_index.index_file.global_index_meta,
+                            index_field_id=table.field_dict['payload'].id)))
+                    with mock.patch.object(IndexManifestFile, 'read', 
return_value=[index]), \
+                            mock.patch.object(TableScan, 'plan_all', 
return_value=plan):
+                        self.assertEqual(key_indexes_are_fresh(
+                            scan_table, snapshot, ['payload'], None),
+                            flag is None and index_type != 'bitmap')
+
+    def test_ignore_freshness_checks_nested_ranges_and_group_boundaries(self):
+        table, snapshot, split, original_index = self._freshness_metadata()
+        original_file = split.files[0]
+        index = replace(original_index, index_file=replace(
+            original_index.index_file, global_index_meta=replace(
+                original_index.index_file.global_index_meta, 
row_range_start=100, row_range_end=199)))
+        # An unchanged outer column file overlaps many small disjoint files.
+        outer = replace(original_file, file_name='outer', first_row_id=0, 
row_count=1000)
+        disjoint = [replace(original_file, file_name=f'old-{i}', 
first_row_id=i * 3)
+                    for i in range(30)]
+        stale = replace(original_file, file_name='stale', 
max_sequence_number=snapshot.id)
+        cases = [
+            (replace(stale, first_row_id=99), split.bucket, False),
+            (replace(stale, first_row_id=199), split.bucket, False),
+            (replace(stale, first_row_id=200), split.bucket, True),
+            (replace(stale, first_row_id=150), split.bucket + 1, True),
+            (replace(stale, first_row_id=None), split.bucket, False),
+            (replace(stale, first_row_id=None), split.bucket + 1, True),
+            # Malformed metadata outside the indexed range is irrelevant.
+            (replace(stale, first_row_id=200, write_cols=['unknown']), 
split.bucket, True),
+        ]
+        for file, bucket, expected in cases:
+            with self.subTest(first_row_id=file.first_row_id, bucket=bucket):
+                plan = Plan([DataSplit(disjoint + [outer], split.partition, 
split.bucket),
+                             DataSplit([file], split.partition, bucket)], 
snapshot.id)
+                with mock.patch.object(IndexFileHandler, 'scan', 
return_value=[index]), \
+                        mock.patch.object(TableScan, 'plan_all', 
return_value=plan):
+                    self.assertEqual(key_indexes_are_fresh(table, snapshot, 
['id'], None), expected)
+
+    def test_ignore_index_rebuild_restores_key_pruning(self):
+        table = self._create_table(options={
+            **self.table_options, 'global-index.column-update-action': 
'IGNORE',
+        })
+        self._write_arrow(table, pa.table({
+            'id': list(range(0, 32, 2)), 'payload': ['old'] * 16,
+        }, schema=self.pa_schema))
+        table.create_global_index('id', index_type='btree')
+        builder = table.new_batch_write_builder()
+        messages = 
builder.new_update().with_update_type(['id']).update_by_arrow_with_row_id(
+            pa.table({'_ROW_ID': pa.array([5], type=pa.int64()),
+                      'id': pa.array([99], type=pa.int32())}))
+        commit = builder.new_commit()
+        try:
+            commit.commit(messages)
+        finally:
+            commit.close()
+        self.assertEqual(self._counted_lookup(table, ['id'], {(99,)}), (
+            {(99,): [5]}, list(range(16))))
+        table.drop_global_index('id', index_type='btree')
+        table.create_global_index('id', index_type='btree')
+        self.assertEqual(self._counted_lookup(table, ['id'], {(99,)}), 
({(99,): [5]}, [5]))
+
+
+class FileRangeIndexTest(unittest.TestCase):
+    def test_inclusive_overlaps_match_full_scan(self):
+        ranges = [(0, 1000, 'outer'), (20, 30, 'nested'), (20, 25, 
'same-start'),
+                  (20, 30, 'duplicate'), (40, 40, 'point'), (1001, 1005, 
'adjacent')]
+        index = _FileRangeIndex(list(reversed(ranges)))
+        for start, end in [(-10, -1), (0, 0), (19, 20), (25, 30), (31, 39),
+                           (40, 40), (1000, 1001), (1005, 1006), (1006, 2000), 
(-10, 2000)]:
+            with self.subTest(start=start, end=end):
+                expected = sorted(file for lo, hi, file in ranges if lo <= end 
and hi >= start)
+                self.assertEqual(sorted(index.overlapping(start, end)), 
expected)
+        self.assertEqual(list(_FileRangeIndex([]).overlapping(0, 1)), [])
+
+    def test_sparse_queries_do_not_scan_all_files_with_one_long_range(self):
+        class CountedInt(int):
+            comparisons = 0
+
+            def __lt__(self, other):
+                CountedInt.comparisons += 1
+                return int.__lt__(self, other)
+
+            def __gt__(self, other):
+                CountedInt.comparisons += 1
+                return int.__gt__(self, other)
+
+        size = 5000
+        ranges = [(CountedInt(i * 10), CountedInt(i * 10 + 2), i) for i in 
range(size)]
+        ranges.append((CountedInt(0), CountedInt(size * 10), 'outer'))
+        index = _FileRangeIndex(list(reversed(ranges)))
+        CountedInt.comparisons = 0
+        for i in range(size):
+            self.assertCountEqual(index.overlapping(i * 10, i * 10 + 2), [i, 
'outer'])
+        # A cached list scan or prefix-max window still takes 25 million
+        # comparisons here. Assert work rather than wall-clock timing.
+        self.assertLess(CountedInt.comparisons, size * 80)

Reply via email to