blob: 3ae8540a5d3d515d0de74ab4d538d2a56450d4fb [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.
"""Scanner for shard-based global indexes on data-evolution tables."""
from concurrent.futures import ThreadPoolExecutor
import threading
from typing import Collection, List, Optional
from pypaimon.execution.python.globalindex.global_index_evaluator import (
GlobalIndexEvaluation,
GlobalIndexEvaluator,
)
from pypaimon.globalindex.global_index_meta import GlobalIndexIOMeta
from pypaimon.globalindex.global_index_reader import GlobalIndexReader
from pypaimon.globalindex.global_index_result import GlobalIndexResult
from pypaimon.common.options.core_options import CoreOptions
from pypaimon.common.options.options import Options
from pypaimon.common.predicate import Predicate
from pypaimon.execution.python.globalindex.data_evolution_global_index_coverage import DataEvolutionGlobalIndexCoverage
from pypaimon.read.push_down_utils import _get_all_fields
from pypaimon.schema.data_types import DataField
from pypaimon.utils.range import Range
_SUPPORTED_SCALAR_INDEX_TYPES = frozenset(('btree', 'bitmap'))
class CompositeIndexReadError(RuntimeError):
"""A selected composite index is unreadable; falling back may hide corruption."""
class DataEvolutionGlobalIndexScanner:
"""Scanner for shard-based global indexes."""
def __init__(
self,
fields: list,
file_io,
index_path: str,
index_files: Collection['IndexFileMeta'],
thread_num: Optional[int] = None,
options: Optional[CoreOptions] = None,
table=None,
snapshot=None,
partition_filter=None,
composite_files=None,
):
index_files = _supported_scalar_index_files(index_files)
self._options = options or CoreOptions(Options.from_none())
self._executor = ThreadPoolExecutor(
max_workers=thread_num or 32
)
self._fields = fields
self._file_io = file_io
self._index_path = index_path
self._composite_groups = _group_composite_files(composite_files or [])
self._composite_readers = {}
self._composite_reader_lock = threading.Lock()
self._composite_reader_locks = {}
self._coverage = (
DataEvolutionGlobalIndexCoverage(table, snapshot, partition_filter, index_files)
if table is not None
else None
)
self._evaluator = self._create_evaluator(
fields, file_io, index_path, index_files
)
def _create_evaluator(self, fields, file_io, index_path, index_files):
index_metas = {}
extra_index_metas = {}
for index_file in index_files:
global_index_meta = index_file.global_index_meta
if global_index_meta is None:
continue
index_type = index_file.index_type
index_field_id = global_index_meta.index_field_id
field_ids = [index_field_id]
if global_index_meta.extra_field_ids is not None:
field_ids.extend(global_index_meta.extra_field_ids)
io_meta = GlobalIndexIOMeta(
file_name=index_file.file_name,
file_size=index_file.file_size,
metadata=global_index_meta.index_meta,
external_path=index_file.external_path,
)
range_key = Range(
global_index_meta.row_range_start,
global_index_meta.row_range_end)
group = index_metas.get(index_field_id)
if group is None:
group = _IndexMetaFileGroup(index_field_id, field_ids)
index_metas[index_field_id] = group
for extra_field_id in field_ids[1:]:
extra_index_metas.setdefault(extra_field_id, []).append(group)
elif group.field_ids != tuple(field_ids):
raise ValueError(
"Primary field %s owns multiple indexes with different "
"columns %s and %s; a primary column can own at most one "
"index." % (index_field_id, list(group.field_ids), field_ids)
)
group.add_file(index_type, range_key, io_meta)
executor = self._executor
options = self._options
def readers_function(field: DataField) -> Collection[GlobalIndexReader]:
from pypaimon.execution.python.globalindex.union_global_index_reader import UnionGlobalIndexReader
groups = []
group = index_metas.get(field.id)
if group is not None:
groups.append(group)
extra_groups = extra_index_metas.get(field.id)
if extra_groups:
groups.extend(
extra_group
for extra_group in extra_groups
if extra_group not in groups
)
if not groups:
return []
# Like Java, union every reader for the field. Missing coverage
# belongs to raw fallback, not to the matches returned by an index.
readers = []
for group in groups:
readers.extend(
_create_readers(
file_io, index_path, group.metas, field, executor, options))
return [UnionGlobalIndexReader(readers)]
return GlobalIndexEvaluator(fields, readers_function)
@staticmethod
def create(table, index_files=None, partition_filter=None, predicate=None,
snapshot=None, allow_composite=False) -> Optional['DataEvolutionGlobalIndexScanner']:
"""Create a DataEvolutionGlobalIndexScanner.
Can be called in two ways:
1. create(table, index_files) - with explicit index files
2. create(table, partition_filter=..., predicate=..., snapshot=...) -
scan index files from snapshot. ``snapshot`` may be passed in by the
caller to avoid a duplicate ``get_latest_snapshot`` REST round-trip
(the caller usually already fetched it for manifest scanning).
"""
from pypaimon.index.index_file_handler import IndexFileHandler
if index_files is not None:
index_files = list(index_files)
scalar_files = _supported_scalar_index_files(index_files)
composite_files = [file for file in index_files
if allow_composite and _is_composite_btree(file)]
if not scalar_files and not composite_files:
return None
core_options = _core_options(table)
return DataEvolutionGlobalIndexScanner(
fields=table.fields,
file_io=table.file_io,
index_path=table.path_factory().global_index_path_factory().index_path(),
index_files=scalar_files,
composite_files=composite_files,
thread_num=core_options.global_index_thread_num(),
options=core_options,
table=table,
snapshot=_resolve_snapshot(table, snapshot),
partition_filter=partition_filter,
)
# Scan index files from snapshot using partition_filter and predicate
filter_field_names = _get_all_fields(predicate)
filter_field_ids = set()
if predicate is not None:
for field_item in table.fields:
if field_item.name in filter_field_names:
filter_field_ids.add(field_item.id)
def index_file_filter(entry):
if partition_filter is not None:
if not partition_filter.test(entry.partition):
return False
if not (is_supported_scalar_index(entry.index_file)
or allow_composite and _is_composite_btree(entry.index_file)):
return False
global_index_meta = entry.index_file.global_index_meta
if global_index_meta is None:
return False
if global_index_meta.index_field_id in filter_field_ids:
return True
if global_index_meta.extra_field_ids is not None:
return any(
field_id in filter_field_ids
for field_id in global_index_meta.extra_field_ids
)
return False
if snapshot is None:
snapshot = _resolve_snapshot(table, None)
index_file_handler = IndexFileHandler(table=table)
entries = index_file_handler.scan(snapshot, index_file_filter)
scanned_index_files = [entry.index_file for entry in entries]
if len(scanned_index_files) == 0:
return None
core_options = _core_options(table)
return DataEvolutionGlobalIndexScanner(
fields=table.fields,
file_io=table.file_io,
index_path=table.path_factory().global_index_path_factory().index_path(),
index_files=scanned_index_files,
composite_files=[file for file in scanned_index_files
if allow_composite and _is_composite_btree(file)],
thread_num=core_options.global_index_thread_num(),
options=core_options,
table=table,
snapshot=snapshot,
partition_filter=partition_filter,
)
def scan(self, predicate: Optional[Predicate]) -> Optional[GlobalIndexResult]:
"""Scan the global index with the given predicate."""
evaluation = self.scan_with_coverage(predicate)
return evaluation.result if evaluation is not None else None
def scan_with_coverage(
self, predicate: Optional[Predicate]
) -> Optional[GlobalIndexEvaluation]:
if not self._composite_groups or predicate is None:
return self._evaluator.evaluate_with_contributing_fields(predicate)
return self._evaluate(predicate)
def _evaluate(self, predicate):
from pypaimon.execution.python.globalindex.btree.composite_btree import (
_conjuncts,
plan_composite,
)
fields_by_id = {field.id: field for field in self._fields}
candidates = []
for field_ids, files in self._composite_groups.items():
if any(field_id not in fields_by_id for field_id in field_ids):
continue
fields = [fields_by_id[field_id] for field_id in field_ids]
plan = plan_composite(fields, predicate)
if plan is None:
continue
if plan.bound_columns == 1 and self._coverage is not None:
composite_ranges = Range.sort_and_merge_overlap(list(files), True)
scalar_ranges = self._coverage.indexed_ranges([field_ids[0]])
if Range.and_(composite_ranges, scalar_ranges) == composite_ranges:
continue
try:
selected = self._select_composite_files(files, fields, plan)
except Exception as error:
raise CompositeIndexReadError("Cannot read composite index metadata") from error
if selected is None:
continue
candidates.append((plan.bound_columns, plan.equal_columns,
-len(field_ids), field_ids, fields, files, selected, plan))
if candidates:
_, _, _, field_ids, fields, files, selected, plan = max(
candidates, key=lambda candidate: candidate[:3])
if predicate.method == 'and':
key_fields = {field.name for field in fields}
remaining = []
for child in _conjuncts(predicate):
if any(child is used for used in plan.covered_predicates):
continue
referenced_fields = _get_all_fields(child)
# Key residuals stay in the final row filter.
if referenced_fields and referenced_fields <= key_fields:
continue
remaining.append(child)
if remaining:
rest = (remaining[0] if len(remaining) == 1
else Predicate('and', None, None, remaining))
if not self._may_use_composite(rest):
rest_future = self._evaluator._visit_async(rest)
try:
composite = self._read_composite(
field_ids, fields, files, selected, plan)
except BaseException:
try:
rest_future.result()
except BaseException:
pass
raise
return self._combine_evaluations(
[composite, rest_future.result()], 'and')
composite = self._read_composite(
field_ids, fields, files, selected, plan)
return self._combine_evaluations(
[composite, self._evaluate(rest)], 'and')
return self._read_composite(field_ids, fields, files, selected, plan)
if predicate.method in ('and', 'or'):
scalar_children = []
composite_children = []
for child in predicate.literals:
if self._may_use_composite(child):
composite_children.append(child)
else:
scalar_children.append(child)
if not composite_children:
return self._evaluator.evaluate_with_contributing_fields(predicate)
scalar_future = None
if scalar_children:
scalar = (scalar_children[0] if len(scalar_children) == 1
else Predicate(predicate.method, None, None, scalar_children))
scalar_future = self._evaluator._visit_async(scalar)
evaluations = [self._evaluate(child) for child in composite_children]
if scalar_future is not None:
evaluations.append(scalar_future.result())
return self._combine_evaluations(evaluations, predicate.method)
return self._evaluator.evaluate_with_contributing_fields(predicate)
def _may_use_composite(self, predicate):
from pypaimon.execution.python.globalindex.btree.composite_btree import plan_composite
fields_by_id = {field.id: field for field in self._fields}
predicate_fields = _get_all_fields(predicate)
for field_ids in self._composite_groups:
if (all(field_id in fields_by_id for field_id in field_ids)
and fields_by_id[field_ids[0]].name in predicate_fields):
fields = [fields_by_id[field_id] for field_id in field_ids]
if plan_composite(fields, predicate) is not None:
return True
return (predicate.method in ('and', 'or')
and any(self._may_use_composite(child) for child in predicate.literals))
def _combine_evaluations(self, evaluations, method):
if method == 'or' and any(item is None for item in evaluations):
return None
present = [item for item in evaluations if item is not None]
if not present:
return None
result = present[0].result
ranges = self._evaluation_ranges(present[0])
field_ids = set(present[0].contributing_field_ids)
for item in present[1:]:
result = (result.or_(item.result) if method == 'or'
else result.and_(item.result))
ranges = Range.and_(ranges, self._evaluation_ranges(item))
field_ids.update(item.contributing_field_ids)
if len(present) != len(evaluations):
result = GlobalIndexResult.create(result.results(), is_exact=False)
return GlobalIndexEvaluation(result, frozenset(field_ids), tuple(ranges))
def _evaluation_ranges(self, evaluation):
if evaluation.indexed_ranges is not None:
return list(evaluation.indexed_ranges)
if self._coverage is None:
return []
return self._coverage.indexed_ranges(evaluation.contributing_field_ids)
def _select_composite_files(self, files, fields, plan):
from pypaimon.execution.python.globalindex.btree.composite_btree import (
compare_key_bound,
)
from pypaimon.execution.python.globalindex.btree.composite_key import (
CompositeKeySerializer,
)
from pypaimon.globalindex.sorted_index_file_meta import SortedIndexFileMeta
if not plan.intervals:
return []
serializer = CompositeKeySerializer(fields)
selected = []
budget = self._options.btree_index_fallback_scan_max_size()
for row_range, metas in files.items():
shard = []
for meta in metas:
if meta.metadata is None:
return None
index_meta = SortedIndexFileMeta.deserialize(meta.metadata)
if index_meta.first_key is not None and plan.intervals:
first = serializer.deserialize(index_meta.first_key)
last = serializer.deserialize(index_meta.last_key)
if not any(compare_key_bound(last, lower, serializer._comparators) > 0
and compare_key_bound(first, upper,
serializer._comparators) < 0
for lower, upper in plan.intervals):
continue
if plan.intervals:
shard.append((row_range, meta))
if not plan.point_lookup and sum(meta.file_size for _, meta in shard) > budget:
return None
selected.extend(shard)
return selected
def _read_composite(self, field_ids, fields, files, selected, plan):
from pypaimon.execution.python.globalindex.btree.composite_btree import (
CompositeBTreeIndexReader,
)
from pypaimon.execution.python.globalindex.btree.composite_key import (
CompositeKeySerializer,
)
serializer = CompositeKeySerializer(fields)
futures = []
for row_range, meta in selected:
def read(range_=row_range, io_meta=meta):
key = (field_ids, io_meta.external_path or io_meta.file_name)
with self._composite_reader_lock:
reader = self._composite_readers.get(key)
reader_lock = (self._composite_reader_locks.setdefault(
key, threading.Lock()) if reader is None else None)
if reader is None:
with reader_lock:
with self._composite_reader_lock:
reader = self._composite_readers.get(key)
if reader is None:
reader = CompositeBTreeIndexReader(
serializer, self._file_io, self._index_path, io_meta)
with self._composite_reader_lock:
self._composite_readers[key] = reader
return reader.query(plan).offset(range_.from_)
futures.append(self._executor.submit(read))
result = GlobalIndexResult.create_empty()
failure = None
for future in futures:
try:
result = result.or_(future.result())
except Exception as error:
if failure is None:
failure = error
if failure is not None:
raise CompositeIndexReadError("Cannot read composite BTree file") from failure
result = GlobalIndexResult.create(result.results(), is_exact=False)
return GlobalIndexEvaluation(
result, frozenset(field_ids[:plan.bound_columns]),
tuple(Range.sort_and_merge_overlap(list(files), True)))
def unindexed_rows(self, predicate: Optional[Predicate],
search_mode=None,
contributing_field_ids=None, indexed_ranges=None) -> GlobalIndexResult:
"""Return coarse row ids not covered by global indexes."""
return GlobalIndexResult.from_ranges(self.unindexed_ranges(
predicate,
search_mode=search_mode,
contributing_field_ids=contributing_field_ids,
indexed_ranges=indexed_ranges,
))
def unindexed_ranges(self, predicate: Optional[Predicate],
search_mode=None,
contributing_field_ids=None, indexed_ranges=None) -> List[Range]:
"""Return row ranges not covered by global indexes."""
if self._coverage is None:
return []
if indexed_ranges is not None:
return self._coverage.unindexed_ranges_from_indexed(
indexed_ranges, search_mode=search_mode)
if contributing_field_ids is not None:
return self._coverage.unindexed_ranges(
contributing_field_ids, search_mode=search_mode)
return self._coverage.unindexed_ranges(
self._fields, predicate, search_mode=search_mode)
def close(self):
"""Close the scanner and release resources."""
self._evaluator.close()
for reader in self._composite_readers.values():
reader.close()
self._executor.shutdown(wait=False)
def __enter__(self) -> 'DataEvolutionGlobalIndexScanner':
return self
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
self.close()
class _IndexMetaFileGroup:
def __init__(self, index_field_id, field_ids):
self.index_field_id = index_field_id
self.field_ids = tuple(field_ids)
self.metas = {}
def add_file(self, index_type, range_key, io_meta):
self.metas.setdefault(index_type, {}).setdefault(range_key, []).append(io_meta)
def _resolve_snapshot(table, snapshot):
if snapshot is not None:
return snapshot
if hasattr(table, "_read_snapshot"):
return table._read_snapshot
snapshot_manager = table.snapshot_manager()
if snapshot_manager is None:
return None
try:
from pypaimon.snapshot.time_travel_util import TimeTravelUtil
table_options = getattr(table.table_schema, "options", {})
scan_keys = getattr(TimeTravelUtil, "SCAN_KEYS", None)
if scan_keys is None:
from pypaimon.snapshot.time_travel_util import SCAN_KEYS as scan_keys
has_time_travel = any(key in table_options for key in scan_keys)
resolved = TimeTravelUtil.try_travel_to_snapshot(
Options(table.table_schema.options),
table.tag_manager(),
snapshot_manager,
)
if resolved is not None and (
has_time_travel or getattr(resolved, "next_row_id", None) is not None):
return resolved
except Exception:
pass
return snapshot_manager.get_latest_snapshot()
def is_supported_scalar_index(index_file):
meta = index_file.global_index_meta
return (
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)
)
def _supported_scalar_index_files(index_files):
return [index_file for index_file in index_files
if is_supported_scalar_index(index_file)]
def _is_composite_btree(index_file):
meta = index_file.global_index_meta
return (index_file.index_type == 'btree' and meta is not None
and bool(meta.extra_field_ids)
and len(set((meta.index_field_id,) + tuple(meta.extra_field_ids)))
== 1 + len(meta.extra_field_ids))
def _group_composite_files(index_files):
groups = {}
for index_file in index_files:
if not _is_composite_btree(index_file):
continue
meta = index_file.global_index_meta
field_ids = (meta.index_field_id,) + tuple(meta.extra_field_ids)
row_range = Range(meta.row_range_start, meta.row_range_end)
io_meta = GlobalIndexIOMeta(
file_name=index_file.file_name,
file_size=index_file.file_size,
metadata=meta.index_meta,
external_path=index_file.external_path,
)
groups.setdefault(field_ids, {}).setdefault(row_range, []).append(io_meta)
return groups
def _core_options(table):
options = getattr(table, "options", None)
if options is None:
return CoreOptions(Options.from_none())
return options
def _create_readers(
file_io, index_path, index_type_metas, field, executor=None,
options=None):
"""Create readers for a specific field, dispatched by index_type.
Unknown indexTypes raise — a silent skip would make
``DataEvolutionVectorRead._pre_filter`` return ``None`` and the vector search
would then return unfiltered results that violate the user predicate.
"""
if index_type_metas is None:
return []
from pypaimon.execution.python.globalindex.offset_global_index_reader import (
OffsetGlobalIndexReader,
)
from pypaimon.execution.python.globalindex.union_global_index_reader import (
UnionGlobalIndexReader,
)
readers = []
for index_type, range_metas in index_type_metas.items():
offset_readers = []
for range_key, io_metas in range_metas.items():
inner_readers = _create_inner_readers(
index_type, file_io, index_path, field, io_metas, executor, options)
for inner in inner_readers:
offset_readers.append(
OffsetGlobalIndexReader(
inner, range_key.from_, range_key.to))
if offset_readers:
readers.append(UnionGlobalIndexReader(offset_readers))
return readers
def _create_inner_readers(
index_type, file_io, index_path, field, io_metas, executor=None, options=None):
"""Build per-file (or per-shard) readers for a single indexType/range."""
core_options = options or CoreOptions(Options.from_none())
core_options = CoreOptions(core_options.global_index_options(index_type, field.name))
if index_type == 'btree':
from pypaimon.execution.python.globalindex.btree.lazy_filtered_btree_reader import LazyFilteredBTreeReader
from pypaimon.execution.python.globalindex.key_serializer import create_serializer
key_serializer = create_serializer(field.type)
return [LazyFilteredBTreeReader(
key_serializer=key_serializer,
file_io=file_io,
index_path=index_path,
io_metas=io_metas,
executor=executor,
fallback_scan_max_size=core_options.btree_index_fallback_scan_max_size(),
)]
if index_type == 'bitmap':
from pypaimon.execution.python.globalindex.bitmap.lazy_filtered_bitmap_reader import LazyFilteredBitmapReader
from pypaimon.execution.python.globalindex.key_serializer import create_serializer
key_serializer = create_serializer(field.type)
return [LazyFilteredBitmapReader(
key_serializer=key_serializer,
file_io=file_io,
index_path=index_path,
io_metas=io_metas,
executor=executor,
fallback_scan_max_size=core_options.bitmap_index_fallback_scan_max_size(),
)]
raise ValueError(
"Unsupported global-index type in scanner: '%s'" % index_type)