blob: bd9d34a05a7a4375c287122503adb90da38f0220 [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.
"""Build global index files from Python."""
from functools import cmp_to_key
from typing import Dict, List, Optional, Sequence, Union
import pyarrow as pa
from pypaimon.common.options.core_options import CoreOptions
from pypaimon.common.options.options import Options
from pypaimon.common.predicate import Predicate
from pypaimon.globalindex.btree.btree_index_writer import (
BTREE_IDENTIFIER,
BTreeIndexWriter,
)
from pypaimon.globalindex.bitmap.bitmap_index_writer import (
BITMAP_IDENTIFIER,
BitmapIndexWriter,
)
from pypaimon.globalindex.build_plan import (
filter_non_indexable_splits as _filter_non_indexable_splits,
split_by_contiguous_unindexed_row_range as _split_by_contiguous_unindexed_row_range,
split_by_global_index_shard as _split_by_global_index_shard,
unindexed_row_ranges as _unindexed_row_ranges,
)
from pypaimon.globalindex.global_index_meta import GlobalIndexMeta
from pypaimon.globalindex.key_serializer import create_serializer
from pypaimon.globalindex.full_text.native_full_text_global_index_reader import (
FULL_TEXT_IDENTIFIER,
)
from pypaimon.globalindex.full_text.native_full_text_index_writer import (
NativeFullTextIndexWriter,
)
from pypaimon.globalindex.vindex.vindex_vector_global_index_reader import (
VINDEX_IDENTIFIERS,
)
from pypaimon.globalindex.vindex.vindex_vector_index_writer import (
VindexVectorIndexWriter,
)
from pypaimon.index.index_file_meta import IndexFileMeta
from pypaimon.manifest.index_manifest_entry import IndexManifestEntry
from pypaimon.table.row.generic_row import GenericRow
from pypaimon.table.special_fields import SpecialFields
from pypaimon.utils.range import Range
from pypaimon.write.commit_message import CommitMessage
def create_global_index(
table,
index_column: Union[str, Sequence[str]],
index_type: str = BTREE_IDENTIFIER,
partition_filter: Optional[Predicate] = None,
partitions: Optional[Union[Dict[str, object], Sequence[Dict[str, object]]]] = None,
options: Optional[Dict[str, object]] = None,
) -> int:
"""Build and commit global index files for a table.
Returns the number of index files added to the table snapshot.
"""
builder = GlobalIndexBuilder(
table,
index_column,
index_type=index_type,
partition_filter=partition_filter,
partitions=partitions,
options=options,
)
messages = builder.build()
if not messages:
return 0
write_builder = table.new_batch_write_builder()
commit = write_builder.new_commit()
try:
commit.commit(messages)
finally:
commit.close()
return sum(len(message.index_adds) for message in messages)
_SORTED_INDEX_IDENTIFIERS = (BTREE_IDENTIFIER, BITMAP_IDENTIFIER)
_GENERIC_INDEX_IDENTIFIERS = tuple(VINDEX_IDENTIFIERS) + (
FULL_TEXT_IDENTIFIER,
)
_SORTED_INDEX_RECORDS_PER_RANGE_FLOATING = 1.2
class GlobalIndexBuilder:
"""Small Python builder for global indexes."""
def __init__(
self,
table,
index_column: Union[str, Sequence[str]],
index_type: str = BTREE_IDENTIFIER,
partition_filter: Optional[Predicate] = None,
partitions: Optional[Union[Dict[str, object], Sequence[Dict[str, object]]]] = None,
options: Optional[Dict[str, object]] = None,
):
self._table = table
self._index_columns = _normalize_index_columns(index_column)
self._index_type = index_type.lower().strip()
self._partition_filter = partition_filter
self._partitions = partitions
self._options = _merged_options(table, options)
self._core_options = CoreOptions(self._options)
if (
self._index_type not in _SORTED_INDEX_IDENTIFIERS
and self._index_type not in _GENERIC_INDEX_IDENTIFIERS
):
raise ValueError(
"Python global index build currently supports %s and %s, got '%s'."
% (
_SORTED_INDEX_IDENTIFIERS,
_GENERIC_INDEX_IDENTIFIERS,
index_type,
)
)
if len(self._index_columns) != 1:
raise ValueError(
"Python global index build currently supports one column, got %s."
% self._index_columns
)
if not self._table.options.row_tracking_enabled():
raise ValueError(
"Table '%s' must enable 'row-tracking.enabled=true' before "
"creating global index." % self._table.identifier
)
for column in self._index_columns:
if column not in self._table.field_dict:
raise ValueError(
"Column '%s' does not exist in table '%s'."
% (column, self._table.identifier)
)
if self._index_type in _GENERIC_INDEX_IDENTIFIERS:
self._validate_generic_index_table()
def build(self) -> List[CommitMessage]:
read_builder = self._table.new_read_builder()
partition_filter = self._resolve_partition_filter(read_builder)
if partition_filter is not None:
read_builder = read_builder.with_partition_filter(partition_filter)
scan = read_builder.new_scan()
plan = scan.plan_for_write()
splits = plan.splits()
if not splits:
return []
index_field = self._table.field_dict[self._index_columns[0]]
snapshot = self._snapshot_for_plan(plan)
unindexed_ranges = _unindexed_row_ranges(
self._table,
snapshot,
partition_filter,
index_field.id,
self._index_type,
)
if not unindexed_ranges:
return []
if self._index_type in _GENERIC_INDEX_IDENTIFIERS:
splits = _filter_non_indexable_splits(
self._table, splits, self._index_columns)
if not splits:
return []
read_type = [index_field, SpecialFields.ROW_ID]
from pypaimon.read.table_read import TableRead
table_read = TableRead(
table=self._table,
predicate=None,
read_type=read_type,
)
index_path_factory = self._table.path_factory().global_index_path_factory()
index_path = index_path_factory.global_index_root_path()
if self._index_type in _SORTED_INDEX_IDENTIFIERS:
return self._build_sorted_index(
splits, unindexed_ranges, index_field, table_read, index_path)
return self._build_generic_index(
splits, unindexed_ranges, index_field, table_read, index_path)
def _snapshot_for_plan(self, plan):
snapshot_id = getattr(plan, "snapshot_id", None)
snapshot_manager = self._table.snapshot_manager()
if snapshot_id is not None:
return snapshot_manager.get_snapshot_by_id(snapshot_id)
return snapshot_manager.get_latest_snapshot()
def _build_sorted_index(
self, splits, unindexed_ranges, index_field, table_read, index_path: str
) -> List[CommitMessage]:
key_serializer = create_serializer(index_field.type)
configured_records_per_range = (
self._core_options.sorted_index_records_per_range())
if configured_records_per_range <= 0:
raise ValueError("sorted-index.records-per-range must be positive.")
records_per_range = int(
configured_records_per_range
* _SORTED_INDEX_RECORDS_PER_RANGE_FLOATING
)
messages = []
for split, row_range in _split_by_contiguous_unindexed_row_range(
splits, unindexed_ranges
):
table = table_read.to_arrow([split])
if table is None or table.num_rows == 0:
continue
rows = _extract_sorted_rows(
table,
self._index_columns[0],
SpecialFields.ROW_ID.name,
key_serializer,
row_range,
)
if not rows:
continue
index_adds = []
for chunk in _chunks(rows, records_per_range):
writer = self._create_sorted_index_writer(
index_path, key_serializer)
for key, row_id in chunk:
writer.write(key, row_id - row_range.from_)
result_entries = writer.finish()
index_adds.extend(
_to_index_manifest_entries(
self._table,
split.partition,
row_range,
index_field.id,
self._index_type,
result_entries,
)
)
if index_adds:
messages.append(
CommitMessage(
partition=tuple(split.partition.values),
bucket=0,
new_files=[],
index_adds=index_adds,
)
)
return messages
def _create_sorted_index_writer(self, index_path: str, key_serializer):
if self._index_type == BTREE_IDENTIFIER:
return BTreeIndexWriter(
self._table.file_io,
index_path,
key_serializer,
block_size=self._core_options.btree_index_block_size(),
)
if self._index_type == BITMAP_IDENTIFIER:
return BitmapIndexWriter(
self._table.file_io,
index_path,
key_serializer,
dictionary_block_size=(
self._core_options.bitmap_index_dictionary_block_size()),
compression=self._core_options.bitmap_index_compression(),
)
raise ValueError("Unsupported sorted global index type: %s" % self._index_type)
def _build_generic_index(
self, splits, unindexed_ranges, index_field, table_read, index_path: str
) -> List[CommitMessage]:
rows_per_shard = self._core_options.global_index_row_count_per_shard()
if rows_per_shard <= 0:
raise ValueError(
"Option 'global-index.row-count-per-shard' must be greater than 0."
)
messages = []
for index_split, index_range in _split_by_global_index_shard(
splits, rows_per_shard, unindexed_ranges
):
table = table_read.to_arrow([index_split])
if table is None or table.num_rows == 0:
continue
writer = self._create_generic_index_writer(index_path, index_field)
try:
for value, row_id in _extract_index_rows(
table,
self._index_columns[0],
SpecialFields.ROW_ID.name,
index_range,
):
writer.write(value, row_id - index_range.from_)
index_adds = _to_index_manifest_entries(
self._table,
index_split.partition,
index_range,
index_field.id,
self._index_type,
writer.finish(),
)
finally:
writer.close()
if index_adds:
messages.append(
CommitMessage(
partition=tuple(index_split.partition.values),
bucket=0,
new_files=[],
index_adds=index_adds,
)
)
return messages
def _create_generic_index_writer(self, index_path: str, index_field):
if self._index_type in VINDEX_IDENTIFIERS:
return VindexVectorIndexWriter(
self._table.file_io,
index_path,
index_field.type,
self._index_type,
self._options.to_map(),
index_field.name,
)
if self._index_type == FULL_TEXT_IDENTIFIER:
return NativeFullTextIndexWriter(
self._table.file_io,
index_path,
index_field.type,
self._options.to_map(),
)
raise ValueError("Unsupported generic global index type: %s" % self._index_type)
def _validate_generic_index_table(self) -> None:
bucket = self._core_options.bucket()
if bucket != -1:
raise ValueError(
"Generic global index only supports unaware-bucket tables "
"(bucket = -1), but table '%s' has bucket = %s."
% (self._table.identifier, bucket)
)
if self._core_options.deletion_vectors_enabled():
raise ValueError(
"Generic global index does not support tables with deletion "
"vectors enabled. Table '%s' has "
"'deletion-vectors.enabled' = true."
% self._table.identifier
)
def _resolve_partition_filter(self, read_builder) -> Optional[Predicate]:
if self._partition_filter is not None:
return self._partition_filter
if self._partitions is None:
return None
partitions = self._partitions
if isinstance(partitions, dict):
partitions = [partitions]
predicate_builder = read_builder.new_predicate_builder()
partition_predicates = []
for partition in partitions:
sub_predicates = []
for key, value in partition.items():
if key not in self._table.partition_keys:
raise ValueError(
"Partition spec key '%s' is not a partition column. "
"Partition keys are: %s"
% (key, list(self._table.partition_keys))
)
if value is None:
sub_predicates.append(predicate_builder.is_null(key))
else:
sub_predicates.append(predicate_builder.equal(key, value))
if sub_predicates:
partition_predicates.append(
predicate_builder.and_predicates(sub_predicates))
return predicate_builder.or_predicates(partition_predicates)
def _normalize_index_columns(index_column: Union[str, Sequence[str]]) -> List[str]:
if isinstance(index_column, str):
return [c.strip() for c in index_column.split(",") if c.strip()]
return [str(c).strip() for c in index_column if str(c).strip()]
def _merged_options(table, options: Optional[Dict[str, object]]) -> Options:
merged = dict(table.options.options.to_map())
if options:
merged.update(options)
return Options(merged)
def _extract_sorted_rows(
table: pa.Table,
index_column: str,
row_id_column: str,
key_serializer,
row_range: Optional[Range] = None,
):
keys = table.column(index_column).to_pylist()
row_ids = table.column(row_id_column).to_pylist()
rows = []
for key, row_id in zip(keys, row_ids):
if row_id is None:
raise ValueError("Cannot build global index because _ROW_ID is null.")
row_id = int(row_id)
if row_range is not None and not row_range.contains(row_id):
continue
rows.append((key, row_id))
comparator = key_serializer.create_comparator()
def compare(left, right):
left_key = left[0]
right_key = right[0]
if left_key is None and right_key is None:
return 0
if left_key is None:
return -1
if right_key is None:
return 1
return comparator(left_key, right_key)
return sorted(rows, key=cmp_to_key(compare))
def _extract_index_rows(
table: pa.Table,
index_column: str,
row_id_column: str,
row_range: Optional[Range] = None,
):
values = table.column(index_column).to_pylist()
row_ids = table.column(row_id_column).to_pylist()
rows = []
for value, row_id in zip(values, row_ids):
if row_id is None:
raise ValueError("Cannot build global index because _ROW_ID is null.")
row_id = int(row_id)
if row_range is not None and not row_range.contains(row_id):
continue
rows.append((value, row_id))
return rows
def _chunks(rows, size):
for start in range(0, len(rows), size):
yield rows[start:start + size]
def _to_index_manifest_entries(
table,
partition: GenericRow,
row_range: Range,
index_field_id: int,
index_type: str,
result_entries,
) -> List[IndexManifestEntry]:
path_factory = table.path_factory().global_index_path_factory()
entries = []
for result in result_entries:
file_path = path_factory.to_path(result.file_name)
file_size = table.file_io.get_file_size(file_path)
external_path = file_path if path_factory.is_external_path() else None
index_file = IndexFileMeta(
index_type=index_type,
file_name=result.file_name,
file_size=file_size,
row_count=result.row_count,
global_index_meta=GlobalIndexMeta(
row_range_start=row_range.from_,
row_range_end=row_range.to,
index_field_id=index_field_id,
extra_field_ids=None,
index_meta=result.meta,
),
external_path=external_path,
)
entries.append(
IndexManifestEntry(
kind=0,
partition=partition,
bucket=0,
index_file=index_file,
)
)
return entries