blob: bf675d22379418860f41e921119532be51089f68 [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.
"""Share physical-position reads across primary-key vector query results."""
from copy import copy
import pyarrow as pa
from pypaimon.execution.python.deletionvectors.deletion_vector import DeletionVector
from pypaimon.execution.python.index.pk.primary_key_index_source_policy import should_read
from pypaimon.globalindex.indexed_split import IndexedSplit
from pypaimon.table.source.primary_key_scored_result import (
PrimaryKeyScoredResult, _partition_bytes, _ranges,
)
from pypaimon.utils.arrow_utils import zero_column_batch
def _take(rows, indices):
if not rows.num_columns:
return pa.Table.from_batches([zero_column_batch(len(indices), rows.schema.metadata)])
return rows.take(pa.array(indices, type=pa.int64()))
def read_primary_key_batch(query, results):
"""Return shared lookup tables, or None when position mapping is unavailable."""
table = query._table
if (not table.is_primary_key_table or query._predicate is not None
or table.options.query_auth_enabled or table.options.native_read_enabled()
or not all(isinstance(result, PrimaryKeyScoredResult) for result in results)
or len({result.snapshot_id for result in results}) != 1):
return None
projection = query._effective_projection()
if projection and not query._configured_read_builder()._output_fields():
return None
groups = {}
selections = []
for result in results:
selected = []
for split in result.splits:
if not isinstance(split, IndexedSplit) or len(split.files) != 1:
return None
data = split.data_split()
file = data.files[0]
if not should_read(file):
return None
key = (_partition_bytes(data.partition), data.bucket, file.file_name)
positions = {position for r in split.row_ranges()
for position in range(r.from_, r.to + 1)}
if key not in groups:
groups[key] = (data, set())
source, union = groups[key]
if source.files != data.files or source.data_deletion_files != data.data_deletion_files:
return None
union.update(positions)
selected.append((key, positions))
selections.append(selected)
# Preserve per-query read parallelism when every file is selected once.
if len(groups) == sum(len(selected) for selected in selections):
return None
lookup = copy(query)
# A shared file may contain more rows than any individual query's Top-K.
lookup._limit = None
reader = lookup._configured_read_builder().new_read()
shared = {}
for key, (data, positions) in groups.items():
ordered = sorted(positions)
deletion = data.data_deletion_files[0] if data.data_deletion_files else None
if deletion is not None:
dv = DeletionVector.read(table.file_io, deletion)
ordered = [position for position in ordered if not dv.is_deleted(position)]
rows = reader.to_arrow([IndexedSplit(data, _ranges(ordered))])
# Compacted source files are key-sorted. DVs preserve that physical
# order; a tombstone or merge that removes additional rows prevents a
# reliable ordinal mapping and requires the individual lookup path.
if rows.num_rows != len(ordered):
return None
shared[key] = (rows, {position: index for index, position in enumerate(ordered)})
empty = _take(next(iter(shared.values()))[0], [])
output = []
for selected in selections:
parts = []
for key, positions in selected:
rows, offsets = shared[key]
indices = [offsets[position] for position in sorted(positions) if position in offsets]
parts.append(_take(rows, indices))
if not parts:
rows = empty
elif not empty.num_columns:
rows = _take(empty, range(sum(part.num_rows for part in parts)))
else:
rows = pa.concat_tables(parts)
if query._limit is not None:
rows = (_take(rows, range(min(query._limit, rows.num_rows)))
if not rows.num_columns else rows.slice(0, query._limit))
output.append(rows)
return output