blob: 0d1c233ebe57df71684e420f223e0b0166143413 [file]
# 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))