blob: 61cf33a70483d4d9ea2dbbaed70f79856a33fdd5 [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.
################################################################################
"""Python dynamic-bucket allocation and persistent HASH index execution."""
import random
import struct
import uuid
from dataclasses import dataclass
from typing import Dict, Iterator, List, Set, Tuple
from pypaimon.index.dynamic_bucket import (
HASH_INDEX, MAX_DYNAMIC_BUCKETS, to_signed_int32, validate_bucket_id, validate_max_buckets,
)
from pypaimon.index.index_file_handler import IndexFileHandler
from pypaimon.index.index_file_meta import IndexFileMeta
from pypaimon.manifest.index_manifest_entry import IndexManifestEntry
from pypaimon.table.row.generic_row import GenericRow
_ADD = 0
_SNAPSHOT_UNSET = object()
def _java_remainder(value: int, divisor: int) -> int:
remainder = abs(value) % divisor
return -remainder if value < 0 else remainder
def compute_assigner(
partition_hash: int,
key_hash: int,
num_channels: int,
num_assigners: int,
) -> int:
"""Mirror Java ``BucketAssigner.computeAssigner``."""
if num_channels <= 0 or num_assigners <= 0:
raise ValueError("num_channels and num_assigners must be positive")
start = abs(_java_remainder(to_signed_int32(partition_hash), num_channels))
assigner = abs(_java_remainder(to_signed_int32(key_hash), num_assigners))
return _java_remainder(to_signed_int32(start + assigner), num_channels)
def is_my_bucket(bucket: int, num_assigners: int, assign_id: int) -> bool:
return bucket % num_assigners == assign_id % num_assigners
def _iter_hashes(table, entry: IndexManifestEntry) -> Iterator[int]:
meta = entry.index_file
path = table.path_factory().bucket_index_path(
tuple(entry.partition.values), entry.bucket, meta
)
with table.file_io.new_input_stream(path) as stream:
remainder = b""
while True:
payload = stream.read(64 * 1024)
if not payload:
break
payload = remainder + payload
aligned_size = len(payload) - len(payload) % 4
for value in struct.iter_unpack(">i", payload[:aligned_size]):
yield value[0]
remainder = payload[aligned_size:]
if remainder:
raise RuntimeError(
f"Corrupt dynamic-bucket HASH index {path}: "
"expected a multiple of 4 bytes"
)
class _PartitionIndex:
def __init__(
self,
hash_to_bucket: Dict[int, int],
bucket_information: Dict[int, int],
target_bucket_row_number: int,
) -> None:
self.hash_to_bucket = hash_to_bucket
self.non_full_bucket_information = bucket_information
self.total_bucket_set = set(bucket_information)
self.total_bucket_list = list(bucket_information)
self.target_bucket_row_number = target_bucket_row_number
self._bucket_upper_bound_reached = False
def assign(
self,
key_hash: int,
bucket_filter,
max_buckets_num: int,
max_bucket_id: int,
) -> int:
return self.assign_with_status(
key_hash, bucket_filter, max_buckets_num, max_bucket_id
)[0]
def assign_with_status(
self,
key_hash: int,
bucket_filter,
max_buckets_num: int,
max_bucket_id: int,
) -> Tuple[int, bool]:
validate_max_buckets(max_buckets_num)
if key_hash in self.hash_to_bucket:
return self.hash_to_bucket[key_hash], False
for bucket, row_count in list(self.non_full_bucket_information.items()):
if row_count < self.target_bucket_row_number:
self.non_full_bucket_information[bucket] = row_count + 1
self.hash_to_bucket[key_hash] = bucket
return bucket, True
del self.non_full_bucket_information[bucket]
global_max_bucket_id = (
MAX_DYNAMIC_BUCKETS if max_buckets_num == -1 else max_buckets_num
) - 1
# Java's occupancy and bucket limit are partition-local. A larger
# bucket restored from another partition cannot hide a free local ID.
if not self._bucket_upper_bound_reached:
for bucket in range(global_max_bucket_id + 1):
if bucket_filter(bucket) and bucket not in self.total_bucket_set:
self.non_full_bucket_information[bucket] = 1
self.total_bucket_set.add(bucket)
self.total_bucket_list.append(bucket)
self.hash_to_bucket[key_hash] = bucket
return bucket, True
if max_buckets_num == -1:
raise RuntimeError(
"No dynamic bucket id remains below Java Short.MAX_VALUE. "
"Increase dynamic-bucket.target-row-num."
)
# The assigner's ownership and limit are fixed for this partition,
# so Java remembers exhaustion and reuses an owned bucket directly.
self._bucket_upper_bound_reached = True
if not self.total_bucket_list:
raise RuntimeError(
"No dynamic bucket is available for this assigner. Reduce the "
"assigner parallelism or increase dynamic-bucket.max-buckets."
)
bucket = random.choice(self.total_bucket_list)
self.hash_to_bucket[key_hash] = bucket
return bucket, True
class DynamicBucketAssigner:
"""Restore dynamic-bucket mappings from HASH indexes before assigning."""
def __init__(
self,
table,
num_channels: int,
num_assigners: int,
assign_id: int,
target_bucket_row_number: int,
max_buckets_num: int,
ignore_existing: bool = False,
snapshot=_SNAPSHOT_UNSET,
) -> None:
validate_max_buckets(max_buckets_num)
if not 0 <= assign_id < num_channels:
raise ValueError(
f"assign_id must be in [0, {num_channels}), got {assign_id}"
)
self.table = table
self.num_channels = num_channels
self.num_assigners = num_assigners
self.assign_id = assign_id
self.target_bucket_row_number = target_bucket_row_number
self.max_buckets_num = max_buckets_num
self.ignore_existing = ignore_existing
self.snapshot = (
table.snapshot_manager().get_latest_snapshot()
if snapshot is _SNAPSHOT_UNSET
else snapshot
)
self.max_bucket_id = 0
self._partition_indexes: Dict[Tuple, _PartitionIndex] = {}
def assign(self, partition: Tuple, partition_hash: int, key_hash: int) -> int:
return self.assign_with_status(partition, partition_hash, key_hash)[0]
def assign_with_status(
self, partition: Tuple, partition_hash: int, key_hash: int
) -> Tuple[int, bool]:
result = self.assign_batch(
[partition], [partition_hash], [key_hash]
)
return result[0]
def assign_batch(
self,
partitions: List[Tuple],
partition_hashes: List[int],
key_hashes: List[int],
) -> List[Tuple[int, bool]]:
if not (
len(partitions) == len(partition_hashes) == len(key_hashes)
):
raise ValueError(
"Partition, partition-hash, and key-hash counts must match"
)
requested_by_partition: Dict[Tuple, Set[int]] = {}
normalized_hashes = [to_signed_int32(value) for value in key_hashes]
for partition, partition_hash, key_hash in zip(
partitions, partition_hashes, normalized_hashes
):
self._validate_assigner(partition_hash, key_hash)
requested_by_partition.setdefault(partition, set()).add(key_hash)
for partition, requested in requested_by_partition.items():
index = self._partition_indexes.get(partition)
if index is None:
index = self._load_partition(
partition,
requested,
)
self._partition_indexes[partition] = index
else:
missing = requested.difference(index.hash_to_bucket)
if missing:
self._restore_requested_hashes(
partition,
missing,
index.hash_to_bucket,
)
results = []
for partition, key_hash in zip(partitions, normalized_hashes):
index = self._partition_indexes[partition]
bucket, is_new = index.assign_with_status(
key_hash,
lambda value: is_my_bucket(
value, self.num_assigners, self.assign_id
),
self.max_buckets_num,
self.max_bucket_id,
)
self.max_bucket_id = max(self.max_bucket_id, bucket)
results.append((bucket, is_new))
return results
def _validate_assigner(self, partition_hash: int, key_hash: int) -> None:
expected_assigner = compute_assigner(
partition_hash,
key_hash,
self.num_channels,
self.num_assigners,
)
if expected_assigner != self.assign_id:
raise ValueError(
f"Record assigner {expected_assigner} does not match writer "
f"assigner {self.assign_id}"
)
def _load_partition(
self, partition: Tuple, requested_hashes: Set[int]
) -> _PartitionIndex:
if self.ignore_existing:
return _PartitionIndex({}, {}, self.target_bucket_row_number)
snapshot = self.snapshot
if snapshot is None:
return _PartitionIndex({}, {}, self.target_bucket_row_number)
entries = IndexFileHandler(self.table).scan(
snapshot,
lambda entry: entry.index_file.index_type == HASH_INDEX
and tuple(entry.partition.values) == partition,
)
entries_by_bucket = {}
for entry in entries:
validate_bucket_id(entry.bucket)
previous = entries_by_bucket.get(entry.bucket)
if previous is not None:
raise RuntimeError(
"Found multiple dynamic-bucket HASH indexes for partition "
f"{partition}, bucket {entry.bucket}: "
f"{[previous.index_file.file_name, entry.index_file.file_name]}"
)
entries_by_bucket[entry.bucket] = entry
data_buckets = self._active_data_buckets(partition, snapshot)
missing_buckets = data_buckets.difference(entries_by_bucket)
if missing_buckets:
raise RuntimeError(
f"Dynamic-bucket partition {partition} has data files but no "
"complete HASH index for buckets "
f"{sorted(missing_buckets)}. Rewrite the partition before "
"performing incremental writes."
)
hash_to_bucket: Dict[int, int] = {}
bucket_information: Dict[int, int] = {}
for entry in entries:
self.max_bucket_id = max(self.max_bucket_id, entry.bucket)
if is_my_bucket(
entry.bucket, self.num_assigners, self.assign_id
):
bucket_information[entry.bucket] = entry.index_file.row_count
remaining = set(requested_hashes)
for entry in entries:
if not remaining:
break
for key_hash in _iter_hashes(self.table, entry):
if key_hash in remaining:
hash_to_bucket[key_hash] = entry.bucket
remaining.remove(key_hash)
if not remaining:
break
return _PartitionIndex(
hash_to_bucket,
bucket_information,
self.target_bucket_row_number,
)
def _restore_requested_hashes(
self,
partition: Tuple,
requested_hashes: Set[int],
hash_to_bucket: Dict[int, int],
) -> None:
if self.ignore_existing or not requested_hashes:
return
snapshot = self.snapshot
if snapshot is None:
return
entries = IndexFileHandler(self.table).scan(
snapshot,
lambda entry: entry.index_file.index_type == HASH_INDEX
and tuple(entry.partition.values) == partition,
)
remaining = set(requested_hashes)
for entry in entries:
validate_bucket_id(entry.bucket)
for key_hash in _iter_hashes(self.table, entry):
if key_hash not in remaining:
continue
hash_to_bucket[key_hash] = entry.bucket
remaining.remove(key_hash)
if not remaining:
return
def _active_data_buckets(self, partition: Tuple, snapshot) -> Set[int]:
if snapshot is None:
return set()
from pypaimon.execution.python.write.bucket_metadata import dynamic_bucket_data_buckets
return dynamic_bucket_data_buckets(self.table, partition, snapshot)
@dataclass
class DynamicBucketIndexChanges:
additions: List[IndexManifestEntry]
deletions: List[IndexManifestEntry]
class DynamicBucketIndexMaintainer:
"""Maintain one complete HASH index file for every modified bucket."""
def __init__(
self,
table,
ignore_existing: bool = False,
snapshot=_SNAPSHOT_UNSET,
) -> None:
self.table = table
self.ignore_existing = ignore_existing
self.snapshot = (
table.snapshot_manager().get_latest_snapshot()
if snapshot is _SNAPSHOT_UNSET
else snapshot
)
self.base_snapshot_id = (
self.snapshot.id if self.snapshot is not None else 0
)
self._states: Dict[
Tuple[Tuple, int], Tuple[Set[int], bool]
] = {}
self._new_paths: List[str] = []
def notify_new_record(
self, partition: Tuple, bucket: int, key_hash: int
) -> None:
key = (partition, bucket)
state = self._states.get(key)
if state is None:
state = (self._load_bucket(partition, bucket), False)
hashes, modified = state
previous_size = len(hashes)
hashes.add(to_signed_int32(key_hash))
self._states[key] = (hashes, modified or len(hashes) != previous_size)
def prepare_commit(self) -> Dict[Tuple[Tuple, int], DynamicBucketIndexChanges]:
changes: Dict[Tuple[Tuple, int], DynamicBucketIndexChanges] = {}
prepared_states = {}
for key, (hashes, modified) in self._states.items():
if not modified:
continue
partition, bucket = key
new_entry = self._write_index(partition, bucket, hashes)
changes[key] = DynamicBucketIndexChanges(
additions=[new_entry],
# Java sends only the full new HASH file. BucketedCombiner
# replaces the previous entry by partition and bucket.
deletions=[],
)
prepared_states[key] = (hashes, False)
# A failed bucket must not make a retry forget earlier additions.
self._states.update(prepared_states)
return changes
def release_prepared(self) -> None:
"""Release files after their commit messages leave writer ownership."""
self._new_paths.clear()
def abort(self) -> None:
for path in self._new_paths:
self.table.file_io.delete_quietly(path)
self._new_paths.clear()
def _load_bucket(
self, partition: Tuple, bucket: int
) -> Set[int]:
"""Load a complete bucket index for full-file replacement.
This matches Java's HASH index lifecycle. A modified bucket therefore
incurs O(bucket size) read memory and IO until an incremental index
format is introduced.
"""
if self.ignore_existing:
return set()
snapshot = self.snapshot
if snapshot is None:
return set()
entries = IndexFileHandler(self.table).scan(
snapshot,
lambda entry: entry.index_file.index_type == HASH_INDEX
and tuple(entry.partition.values) == partition
and entry.bucket == bucket,
)
if len(entries) > 1:
raise RuntimeError(
"Found multiple dynamic-bucket HASH indexes for partition "
f"{partition}, bucket {bucket}: "
f"{[entry.index_file.file_name for entry in entries]}"
)
if not entries:
return set()
return set(_iter_hashes(self.table, entries[0]))
def _write_index(
self, partition: Tuple, bucket: int, hashes: Set[int]
) -> IndexManifestEntry:
file_name = f"index-{uuid.uuid4()}-0"
path, external = self.table.path_factory().new_bucket_index_path(
partition, bucket, file_name
)
self.table.file_io.check_or_mkdirs(path.rsplit("/", 1)[0])
payload = b"".join(struct.pack(">i", value) for value in sorted(hashes))
try:
with self.table.file_io.new_output_stream(path) as stream:
stream.write(payload)
except Exception:
self.table.file_io.delete_quietly(path)
raise
self._new_paths.append(path)
meta = IndexFileMeta(
index_type=HASH_INDEX,
file_name=path.rsplit("/", 1)[-1],
file_size=self.table.file_io.get_file_size(path),
row_count=len(hashes),
external_path=path if external else None,
)
return IndexManifestEntry(
kind=_ADD,
partition=GenericRow(
list(partition), self.table.partition_keys_fields
),
bucket=bucket,
index_file=meta,
)