Skip to content

Commit 65c5dd7

Browse files
authored
[Python] Use scalar indexes for upsert key lookup (#10745)
1 parent 72f96f9 commit 65c5dd7

4 files changed

Lines changed: 789 additions & 17 deletions

File tree

‎paimon-python/pypaimon/execution/python/globalindex/create_global_index.py‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
VindexVectorIndexWriter,
5050
)
5151
from pypaimon.index.index_file_meta import IndexFileMeta
52+
from pypaimon.index.data_evolution_index_source_meta import DataEvolutionIndexSourceMeta
5253
from pypaimon.manifest.index_manifest_entry import IndexManifestEntry
5354
from pypaimon.table.row.generic_row import GenericRow
5455
from pypaimon.table.special_fields import SpecialFields
@@ -114,7 +115,8 @@ def build(self, partition_filter, *, execution="local", concurrency=None, ray_re
114115

115116
if self._index_type in _SORTED_INDEX_IDENTIFIERS:
116117
return self._build_sorted_index(
117-
splits, unindexed_ranges, index_field, table_read, index_path)
118+
splits, unindexed_ranges, index_field, table_read, index_path,
119+
DataEvolutionIndexSourceMeta(snapshot.id).serialize() if snapshot else None)
118120
if execution == "ray":
119121
from pypaimon.ray.vector_index_build import build_vector_index
120122
return build_vector_index(
@@ -131,7 +133,8 @@ def _snapshot_for_plan(self, plan):
131133
return snapshot_manager.get_latest_snapshot()
132134

133135
def _build_sorted_index(
134-
self, splits, unindexed_ranges, index_field, table_read, index_path: str
136+
self, splits, unindexed_ranges, index_field, table_read, index_path: str,
137+
source_meta=None,
135138
) -> List[CommitMessage]:
136139
key_serializer = create_serializer(index_field.type)
137140
configured_records_per_range = (
@@ -174,6 +177,7 @@ def _build_sorted_index(
174177
index_field.id,
175178
self._index_type,
176179
result_entries,
180+
source_meta=source_meta,
177181
)
178182
)
179183
if index_adds:
@@ -479,6 +483,7 @@ def _to_index_manifest_entries(
479483
index_field_id: int,
480484
index_type: str,
481485
result_entries,
486+
source_meta=None,
482487
) -> List[IndexManifestEntry]:
483488
path_factory = table.path_factory().global_index_path_factory()
484489
entries = []
@@ -497,6 +502,7 @@ def _to_index_manifest_entries(
497502
index_field_id=index_field_id,
498503
extra_field_ids=None,
499504
index_meta=result.meta,
505+
source_meta=source_meta,
500506
),
501507
external_path=external_path,
502508
)
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
18+
"""Freshness checks for global indexes on data-evolution tables."""
19+
20+
import logging
21+
22+
from pypaimon.common.options.core_options import CoreOptions, GlobalIndexColumnUpdateAction
23+
from pypaimon.table.special_fields import SpecialFields
24+
25+
logger = logging.getLogger(__name__)
26+
27+
28+
def key_indexes_are_fresh(table, snapshot, match_keys, partition_predicate):
29+
"""Check whether IGNORE may have left indexed match keys stale."""
30+
if table.options.global_index_column_update_action() != GlobalIndexColumnUpdateAction.IGNORE:
31+
return True
32+
if snapshot is None or not match_keys:
33+
return True
34+
from pypaimon.execution.python.globalindex.data_evolution_global_index_scanner import (
35+
is_supported_scalar_index,
36+
)
37+
from pypaimon.index.data_evolution_index_source_meta import DataEvolutionIndexSourceMeta
38+
from pypaimon.index.index_file_handler import IndexFileHandler
39+
from pypaimon.read.push_down_utils import trim_and_transform_predicate
40+
41+
try:
42+
key_ids = {table.field_dict[key].id for key in match_keys}
43+
partition_filter = trim_and_transform_predicate(
44+
partition_predicate, table.field_names, table.partition_keys)
45+
native_planning = table.options.native_read_enabled() or table.options.native_plan_enabled()
46+
47+
def relevant_index(entry):
48+
meta = entry.index_file.global_index_meta
49+
# Native planning can use more index types, including composite
50+
# BTree, even when Python performs the final key matching.
51+
supported = (is_supported_scalar_index(entry.index_file)
52+
or (native_planning and meta is not None
53+
and entry.index_file.index_type.lower() in ('btree', 'bitmap', 'fm', 'multivalue')))
54+
return (supported
55+
and bool(key_ids.intersection(
56+
[meta.index_field_id] + (meta.extra_field_ids or [])))
57+
and (partition_filter is None or partition_filter.test(entry.partition)))
58+
59+
indexes = IndexFileHandler(table).scan(snapshot, relevant_index)
60+
if not indexes:
61+
return True
62+
baselines = []
63+
for index in indexes:
64+
meta = index.index_file.global_index_meta
65+
source = DataEvolutionIndexSourceMeta.deserialize(meta.source_meta)
66+
if source.scan_snapshot_id > snapshot.id or meta.row_range_start > meta.row_range_end:
67+
return False
68+
baselines.append((index, source.scan_snapshot_id))
69+
70+
# Inspect active file metadata before either key stats or an index can
71+
# hide the files containing changed keys. Both plans use this snapshot.
72+
builder = table.copy_without_time_travel({
73+
CoreOptions.GLOBAL_INDEX_ENABLED.key(): 'false',
74+
}).new_read_builder().with_filter(partition_predicate)
75+
indexed_groups = {(tuple(index.partition.values), index.bucket) for index, _ in baselines}
76+
files_by_group = {}
77+
for split in builder.new_scan().plan_all().splits():
78+
group = (tuple(split.partition.values), split.bucket)
79+
if group in indexed_groups:
80+
files = files_by_group.setdefault(group, {})
81+
files.update((file.file_name, file) for file in split.files)
82+
ranges_by_group = {}
83+
for group, files in files_by_group.items():
84+
ranges = []
85+
for file in files.values():
86+
row_range = file.row_id_range()
87+
if row_range is None:
88+
return False
89+
ranges.append((row_range.from_, row_range.to, file))
90+
ranges_by_group[group] = _FileRangeIndex(ranges)
91+
92+
schema = table.table_schema
93+
known_names = set(table.field_names) | SpecialFields.SYSTEM_FIELD_NAMES
94+
for index, baseline in baselines:
95+
meta = index.index_file.global_index_meta
96+
indexed_keys = key_ids.intersection([meta.index_field_id] + (meta.extra_field_ids or []))
97+
ranges = ranges_by_group.get((tuple(index.partition.values), index.bucket))
98+
if ranges is None:
99+
continue
100+
for file in ranges.overlapping(meta.row_range_start, meta.row_range_end):
101+
# Historical schemas are outside this optimization's proof.
102+
if file.schema_id != schema.id:
103+
return False
104+
if file.write_cols is not None and (
105+
any(name not in known_names for name in file.write_cols)
106+
or len(file.write_cols) != len(set(file.write_cols))):
107+
return False
108+
fields = schema.data_file_fields(file.write_cols)
109+
sequences = file.write_cols_sequences
110+
for position, field in enumerate(fields):
111+
if field.id not in indexed_keys:
112+
continue
113+
sequence = (sequences[position]
114+
if sequences is not None and len(sequences) == len(fields)
115+
else file.max_sequence_number)
116+
if sequence <= 0 or sequence > file.max_sequence_number or sequence > baseline:
117+
return False
118+
return True
119+
except Exception:
120+
logger.debug("Cannot verify upsert key index freshness; scanning without indexes.", exc_info=True)
121+
return False
122+
123+
124+
class _FileRangeIndex:
125+
"""Centered interval tree for inclusive physical-file row ranges.
126+
127+
Build once per partition/bucket in O(F log F); each shard visits only
128+
O(log F + overlapping files), including nested and long column files.
129+
"""
130+
131+
def __init__(self, ranges):
132+
self._root = self._build(sorted(ranges, key=lambda item: item[0]))
133+
134+
@classmethod
135+
def _build(cls, ranges):
136+
if not ranges:
137+
return None
138+
center = ranges[len(ranges) // 2][0]
139+
left, crossing, right = [], [], []
140+
for item in ranges:
141+
start, end, _ = item
142+
if end < center:
143+
left.append(item)
144+
elif start > center:
145+
right.append(item)
146+
else:
147+
crossing.append(item)
148+
# Filtering preserves start order; each file belongs to one node only.
149+
by_end = sorted(crossing, key=lambda item: item[1], reverse=True)
150+
return center, crossing, by_end, cls._build(left), cls._build(right)
151+
152+
def overlapping(self, start, end):
153+
pending = [self._root]
154+
while pending:
155+
node = pending.pop()
156+
if node is None:
157+
continue
158+
center, by_start, by_end, left, right = node
159+
if end < center:
160+
for file_start, _, file in by_start:
161+
if file_start > end:
162+
break
163+
yield file
164+
pending.append(left)
165+
elif start > center:
166+
for _, file_end, file in by_end:
167+
if file_end < start:
168+
break
169+
yield file
170+
pending.append(right)
171+
else:
172+
for _, _, file in by_start:
173+
yield file
174+
pending.extend((left, right))

‎paimon-python/pypaimon/execution/python/write/table_upsert_by_key.py‎

Lines changed: 71 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,19 @@
1616
# under the License.
1717

1818
import logging
19+
import struct
1920
from typing import Any, Dict, List, Optional, Tuple
2021

2122
import pyarrow as pa
2223

24+
from pypaimon.common.options.core_options import (
25+
CoreOptions,
26+
GlobalIndexSearchMode,
27+
)
28+
from pypaimon.execution.python.globalindex.index_freshness import key_indexes_are_fresh
29+
from pypaimon.execution.python.globalindex.key_serializer import create_serializer
2330
from pypaimon.read.table_read import TableRead
31+
from pypaimon.schema.data_types import AtomicType
2432
from pypaimon.table.row.blob import Blob
2533
from pypaimon.table.special_fields import SpecialFields
2634
from pypaimon.write.commit_message import CommitMessage
@@ -33,6 +41,34 @@
3341
logger = logging.getLogger(__name__)
3442

3543

44+
def _key_predicate(builder, field, values):
45+
# Python equality treats signed zero as the same key, whereas index Bloom
46+
# filters can distinguish the serialized floating-point representations.
47+
if not isinstance(field.type, AtomicType) or field.type.type.upper() in ('FLOAT', 'DOUBLE'):
48+
return None
49+
try:
50+
serializer = create_serializer(field.type)
51+
literals = []
52+
for value in values:
53+
if value is not None:
54+
literal = serializer.deserialize(serializer.serialize(value))
55+
# A lossy conversion or incompatible row-key type must keep
56+
# the original scan, including all of this column's keys.
57+
if literal != value:
58+
return None
59+
literals.append(literal)
60+
except (ArithmeticError, TypeError, ValueError, struct.error):
61+
return None
62+
63+
predicates = []
64+
if literals:
65+
predicates.append(builder.equal(field.name, literals[0]) if len(literals) == 1
66+
else builder.is_in(field.name, literals))
67+
if None in values:
68+
predicates.append(builder.is_null(field.name))
69+
return builder.or_predicates(predicates)
70+
71+
3672
class PythonTableUpsert:
3773
"""Python partition matching, deduplication and file rewriting."""
3874

@@ -223,9 +259,8 @@ def _upsert_partition(
223259
partition_data, input_key_tuples, partition_spec,
224260
)
225261

226-
# 3. Scan partition once, keeping key → [_ROW_ID, ...] for keys that
227-
# appear in the input (memory ∝ matched existing rows, not the
228-
# whole partition).
262+
# 3. Look up candidate rows using key predicates and available indexes,
263+
# keeping only exact input-key matches.
229264
key_to_row_ids = context._build_key_to_row_ids_map(
230265
match_keys, partition_spec, set(input_key_tuples),
231266
)
@@ -297,11 +332,10 @@ def _build_key_to_row_ids_map(
297332
Scan the partition in batches and collect key → [_ROW_ID, ...] for
298333
rows whose composite key is in *input_key_set*.
299334
300-
The partition spec (if any) is pushed down as an ``and`` of per-key
301-
equality predicates so non-matching partitions are pruned by the
302-
scanner. The match-key filter itself is applied in Python on each
303-
batch — this keeps memory proportional to the input key set rather
304-
than the entire partition.
335+
Partition and match-key predicates let the scanner prune files and
336+
use available scalar indexes. Per-column candidate sets may include
337+
extra composite-key combinations; exact tuple matching still happens
338+
on each batch. Unindexed data is always included in the lookup.
305339
306340
Args:
307341
match_keys: Column names used as the composite match key
@@ -311,17 +345,39 @@ def _build_key_to_row_ids_map(
311345
input_key_set: Set of composite key tuples from the input data.
312346
"""
313347
context = self._context
314-
read_builder = context.table.new_read_builder()
348+
scan_options = {
349+
CoreOptions.SCALAR_INDEX_SEARCH_MODE.key(): GlobalIndexSearchMode.FULL.value,
350+
}
351+
scan_table = context.table.copy_without_time_travel(scan_options)
352+
read_builder = scan_table.new_read_builder()
353+
predicate_builder = read_builder.new_predicate_builder()
354+
sub_predicates = []
315355

316356
if partition_spec:
317-
predicate_builder = read_builder.new_predicate_builder()
318-
sub_predicates = [
357+
sub_predicates.extend([
319358
(predicate_builder.is_null(k) if v is None
320359
else predicate_builder.equal(k, v))
321360
for k, v in partition_spec.items()
322-
]
323-
partition_predicate = predicate_builder.and_predicates(sub_predicates)
324-
read_builder = read_builder.with_filter(partition_predicate)
361+
])
362+
if scan_table.options.global_index_enabled():
363+
from pypaimon.snapshot.time_travel_util import TimeTravelUtil
364+
365+
snapshot = TimeTravelUtil.resolve_snapshot(scan_table)
366+
scan_table = scan_table._copy_with_snapshot(snapshot)
367+
if not key_indexes_are_fresh(
368+
scan_table, snapshot, match_keys,
369+
predicate_builder.and_predicates(sub_predicates)):
370+
scan_table = scan_table.copy_without_time_travel({
371+
CoreOptions.GLOBAL_INDEX_ENABLED.key(): 'false',
372+
})
373+
read_builder = scan_table.new_read_builder()
374+
for i, key in enumerate(match_keys):
375+
predicate = _key_predicate(
376+
predicate_builder, context.table.field_dict[key],
377+
{key_tuple[i] for key_tuple in input_key_set})
378+
if predicate is not None:
379+
sub_predicates.append(predicate)
380+
read_builder.with_filter(predicate_builder.and_predicates(sub_predicates))
325381

326382
scan = read_builder.new_scan()
327383
splits = scan.plan_all().splits()
@@ -333,7 +389,7 @@ def _build_key_to_row_ids_map(
333389
read_type = key_fields + [SpecialFields.ROW_ID]
334390

335391
table_read = TableRead(
336-
table=context.table, predicate=None, read_type=read_type
392+
table=scan_table, predicate=None, read_type=read_type
337393
)
338394

339395
# Stream batches and filter against input_key_set on-the-fly

0 commit comments

Comments
 (0)