blob: 99156528f06137f26efa957061a7b574b0117dab [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.
from dataclasses import dataclass
from pypaimon.common.options.options import Options
from pypaimon.common.options.core_options import CoreOptions
from pypaimon.index.index_file_handler import IndexFileHandler
from pypaimon.index.pk.primary_key_index_source_meta import PrimaryKeyIndexSourceMeta
from pypaimon.index.pk.primary_key_index_source_policy import (
should_read as _should_read_source)
from pypaimon.read.query_auth_split import QueryAuthSplit
from pypaimon.read.split import DataSplit
from pypaimon.globalindex.indexed_split import IndexedSplit
from pypaimon.deletionvectors.deletion_vector import DeletionVector
from pypaimon.snapshot.time_travel_util import TimeTravelUtil
from pypaimon.table.source.vector_search_scan import VectorSearchScan, VectorSearchScanPlan
from pypaimon.table.row.generic_row import GenericRow
from pypaimon.utils.range import Range
@dataclass(frozen=True)
class PrimaryKeyVectorSearchSplit:
data_split: DataSplit
payloads: tuple
uncovered_data_files: tuple
row_ranges_by_file: dict
class PrimaryKeyVectorScan(VectorSearchScan):
"""Plan source-backed vector payloads with their current PK data files."""
def __init__(self, table, vector_column, filter_=None,
partition_filter=None, options=None, index_type=None):
self._table = table
self._vector_column = vector_column
self._filter = filter_
self._partition_filter = partition_filter
self._options = dict(options or {})
self._index_type = index_type
def scan(self):
snapshot = TimeTravelUtil.try_travel_to_snapshot(
Options(self._table.table_schema.options), self._table.tag_manager(),
self._table.snapshot_manager())
if snapshot is None:
snapshot = self._table.snapshot_manager().get_latest_snapshot()
if snapshot is None:
return PrimaryKeyVectorScanPlan(0, [])
pin_options = {
CoreOptions.SCAN_MODE.key(): "from-snapshot",
CoreOptions.SCAN_SNAPSHOT_ID.key(): str(snapshot.id)}
for option in (CoreOptions.SCAN_TAG_NAME,
CoreOptions.SCAN_WATERMARK,
CoreOptions.SCAN_TIMESTAMP,
CoreOptions.SCAN_TIMESTAMP_MILLIS):
if option.key() in self._table.table_schema.options:
pin_options[option.key()] = None
scan_table = self._table.copy(pin_options)
builder = scan_table.new_read_builder()
if self._partition_filter is not None:
builder = builder.with_partition_filter(self._partition_filter)
if self._filter is not None:
builder = builder.with_filter(self._filter)
source_splits = []
for split in builder.new_scan().plan().splits():
split = split.split if isinstance(split, QueryAuthSplit) else split
if isinstance(split, IndexedSplit):
data_split = split.data_split()
if len(data_split.files) != 1:
raise ValueError("Primary-key pre-filter split must contain one data file.")
source_splits.append((data_split, {
data_split.files[0].file_name: tuple(split.row_ranges())}))
elif isinstance(split, DataSplit):
source_splits.append((split, {}))
if self._filter is not None:
source_splits = [
(split, _residual_row_ranges(
scan_table, self._filter, split, ranges))
for split, ranges in source_splits
]
index_type = self._index_type
if index_type is None:
index_type = CoreOptions(
Options(dict(scan_table.table_schema.options))
).primary_key_vector_index_type(self._vector_column.name)
entries = IndexFileHandler(table=scan_table).scan(
snapshot,
lambda entry: _matches(entry, self._vector_column.id, index_type,
self._partition_filter))
return PrimaryKeyVectorScanPlan(
snapshot.id, _bucket_splits(source_splits, entries))
class PrimaryKeyVectorScanPlan(VectorSearchScanPlan):
def __init__(self, snapshot_id, splits):
super().__init__(splits)
self.snapshot_id = snapshot_id
def _matches(entry, field_id, index_type, partition_filter):
meta = entry.index_file.global_index_meta
return (entry.kind == 0 and entry.index_file.index_type == index_type
and meta is not None and meta.source_meta is not None
and meta.index_field_id == field_id
and (partition_filter is None or partition_filter.test(entry.partition)))
def _bucket_splits(source_splits, entries):
payloads_by_bucket = {}
for entry in entries:
key = (_partition_key(entry.partition), entry.bucket)
payloads_by_bucket.setdefault(key, []).append(entry.index_file)
combined = {}
ranges_by_bucket = {}
for split, ranges in source_splits:
key = (_partition_key(split.partition), split.bucket)
ranges_by_bucket.setdefault(key, {}).update(ranges)
previous = combined.get(key)
if previous is None:
combined[key] = split
continue
files = list(previous.files) + list(split.files)
deletions = None
if (previous.data_deletion_files is not None
or split.data_deletion_files is not None):
deletions = list(previous.data_deletion_files or [None] * len(previous.files))
deletions.extend(split.data_deletion_files or [None] * len(split.files))
combined[key] = DataSplit(files, previous.partition, previous.bucket,
False, deletions)
result = []
for split in combined.values():
active = {data_file.file_name: data_file for data_file in split.files
if _should_read_source(data_file)}
sources_by_level = {}
for data_file in active.values():
sources_by_level.setdefault(data_file.level, []).append(
(data_file.file_name, data_file.row_count))
for sources in sources_by_level.values():
sources.sort(key=lambda source: source[0])
payloads_by_level = {}
for payload in payloads_by_bucket.get(
(_partition_key(split.partition), split.bucket), []):
try:
source_meta = PrimaryKeyIndexSourceMeta.from_index_file(payload)
sources = [(source.file_name, source.row_count)
for source in source_meta.source_files]
source_row_count = sum(source.row_count
for source in source_meta.source_files)
global_meta = payload.global_index_meta
if (payload.row_count == source_row_count
and global_meta.row_range_start == 0
and global_meta.row_range_end == source_row_count - 1
and sources_by_level.get(source_meta.data_level) == sources):
payloads_by_level.setdefault(
source_meta.data_level, []).append(payload)
except (ValueError, OverflowError, TypeError, AttributeError):
pass
current = []
covered = set()
for level in sorted(payloads_by_level):
level_payloads = payloads_by_level[level]
if len(level_payloads) != 1:
continue
current.append(level_payloads[0])
covered.update(name for name, _ in sources_by_level[level])
key = (_partition_key(split.partition), split.bucket)
result.append(PrimaryKeyVectorSearchSplit(
split, tuple(current), tuple(name for name in active if name not in covered),
dict(ranges_by_bucket.get(key, {}))))
return result
def _residual_row_ranges(table, predicate, split, candidate_ranges):
"""Evaluate the residual predicate on physical rows before ANN search."""
from pypaimon.read.push_down_utils import (
predicate_field_names, rewrite_predicate_indices)
field_names = predicate_field_names(predicate)
fields = [field for field in table.fields if field.name in field_names]
residual = rewrite_predicate_indices(predicate, fields)
reader = table.new_read_builder().with_projection(
[field.name for field in fields]).new_read()
result = {}
deletions = split.data_deletion_files or []
for index, data_file in enumerate(split.files):
ranges = candidate_ranges.get(data_file.file_name)
if ranges is not None:
ranges = tuple(Range.sort_and_merge_overlap(list(ranges), True))
if not ranges:
result[data_file.file_name] = tuple()
continue
deletion_file = deletions[index] if index < len(deletions) else None
deleted = set()
if deletion_file is not None:
deleted = set(DeletionVector.read(
table.file_io, deletion_file).bit_map())
def physical_positions():
range_index = 0
for position in range(data_file.row_count):
while (ranges is not None and range_index < len(ranges)
and ranges[range_index].to < position):
range_index += 1
if position in deleted:
continue
if (ranges is None or (range_index < len(ranges)
and ranges[range_index].contains(position))):
yield position
single = DataSplit(
[data_file], split.partition, split.bucket, True,
[deletion_file] if deletion_file is not None else None)
read_split = IndexedSplit(single, list(ranges), None) \
if ranges is not None else single
positions = physical_positions()
matched = []
for batch in reader.to_arrow([read_split]).to_batches():
for row in batch.to_pylist():
try:
position = next(positions)
except StopIteration:
raise ValueError(
"Residual filter row count does not match physical positions.")
if residual.test(GenericRow(
[row[field.name] for field in fields], fields)):
if matched and matched[-1].to + 1 == position:
matched[-1] = Range(matched[-1].from_, position)
else:
matched.append(Range(position, position))
try:
next(positions)
raise ValueError(
"Residual filter row count does not match physical positions.")
except StopIteration:
pass
result[data_file.file_name] = tuple(matched)
return result
def _partition_key(partition):
return repr(tuple(getattr(partition, "values", ())))