blob: 1f546986f0d05f252b3eb3e1736235489fbfc4cf [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 hashing and routing for distributed bucket execution."""
import math
import struct
from typing import Any, Dict, List, Tuple
import pyarrow as pa
from pypaimon.common.options.core_options import CoreOptions
from pypaimon.execution.python.write.dynamic_bucket import DynamicBucketAssigner, compute_assigner
from pypaimon.index.dynamic_bucket import resolve_bucket_snapshot, to_signed_int32
from pypaimon.schema.data_types import AtomicType
from pypaimon.schema.table_schema import TableSchema
from pypaimon.table.bucket_mode import BucketMode
from pypaimon.table.row.generic_row import GenericRow, GenericRowSerializer
from pypaimon.table.row.internal_row import RowKind
_MURMUR_C1 = 0xCC9E2D51
_MURMUR_C2 = 0x1B873593
_DEFAULT_SEED = 42
def _mix_k1(k1: int) -> int:
k1 = (k1 * _MURMUR_C1) & 0xFFFFFFFF
k1 = ((k1 << 15) | (k1 >> 17)) & 0xFFFFFFFF
k1 = (k1 * _MURMUR_C2) & 0xFFFFFFFF
return k1
def _mix_h1(h1: int, k1: int) -> int:
h1 = (h1 ^ k1) & 0xFFFFFFFF
h1 = ((h1 << 13) | (h1 >> 19)) & 0xFFFFFFFF
h1 = (h1 * 5 + 0xE6546B64) & 0xFFFFFFFF
return h1
def _fmix(h1: int, length: int) -> int:
h1 = (h1 ^ length) & 0xFFFFFFFF
h1 ^= h1 >> 16
h1 = (h1 * 0x85EBCA6B) & 0xFFFFFFFF
h1 ^= h1 >> 13
h1 = (h1 * 0xC2B2AE35) & 0xFFFFFFFF
h1 ^= h1 >> 16
return h1
def _hash_bytes_by_words(data: bytes, seed: int = _DEFAULT_SEED) -> int:
n = len(data)
length_aligned = n - (n % 4)
h1 = seed
for i in range(0, length_aligned, 4):
k1 = struct.unpack_from("<I", data, i)[0]
k1 = _mix_k1(k1)
h1 = _mix_h1(h1, k1)
return _fmix(h1, n)
def _bucket_from_hash(hash_unsigned: int, num_buckets: int) -> int:
if hash_unsigned >= 0x80000000:
hash_signed = hash_unsigned - 0x100000000
else:
hash_signed = hash_unsigned
rem = hash_signed - math.trunc(hash_signed / num_buckets) * num_buckets
return abs(rem)
class PartitionKeyExtractor:
"""Resolve input routing fields and hash their Java binary layout."""
def __init__(self, table_schema: TableSchema):
self.table_schema = table_schema
self.partition_indices = self._get_field_indices(table_schema.partition_keys)
def extract_partition_bucket_batch(self, data: pa.RecordBatch) -> Tuple[List[Tuple], List[int]]:
return self._extract_partitions_batch(data), self._extract_buckets_batch(data)
def extract_partition_bucket_row(
self, values_by_name: Dict[str, Any]) -> Tuple[Tuple, int]:
partition = tuple(
values_by_name[self.table_schema.fields[i].name]
for i in self.partition_indices
)
bucket = self._extract_bucket_row(values_by_name)
return partition, bucket
def _get_field_indices(self, field_names: List[str]) -> List[int]:
if not field_names:
return []
field_map = {field.name: i for i, field in enumerate(self.table_schema.fields)}
return [field_map[name] for name in field_names if name in field_map]
def _extract_partitions_batch(self, data: pa.RecordBatch) -> List[Tuple]:
if not self.partition_indices:
return [() for _ in range(data.num_rows)]
partition_columns = self._input_columns(data, self.partition_indices)
partitions = []
for row_idx in range(data.num_rows):
partition_values = tuple(col[row_idx].as_py() for col in partition_columns)
partitions.append(partition_values)
return partitions
def _input_columns(self, data: pa.RecordBatch, field_indices: List[int]):
"""Resolve routing fields in the actual write type, as Java's converter does."""
columns = []
for field_index in field_indices:
name = self.table_schema.fields[field_index].name
index = data.schema.get_field_index(name)
if index < 0:
raise ValueError(f"Input must contain exactly one routing column '{name}'.")
columns.append(data.column(index))
return columns
@staticmethod
def _binary_row_hash_code(values: Tuple, fields: List) -> int:
return _hash_bytes_by_words(
GenericRowSerializer.to_bytes(
GenericRow(list(values), fields, RowKind.INSERT)
)[4:]
)
class PostponeFixedBucketInputRouter(PartitionKeyExtractor):
"""Group distributed inputs by the same partition and bucket as writers."""
def __init__(self, table, bucket_plan):
super().__init__(table.table_schema)
if table.options.bucket() != BucketMode.POSTPONE_BUCKET.value:
raise ValueError(
"Postpone fixed bucket writes require bucket = -2, got {}".format(
table.options.bucket()
)
)
bucket_function = str(
table.table_schema.options.get("bucket-function.type", "default")
).strip().lower()
if bucket_function != "default":
raise ValueError(
"Postpone fixed bucket writes only support "
"bucket-function.type=default, got {}"
.format(bucket_function)
)
self.bucket_keys = table.table_schema.bucket_keys
self.bucket_key_indices = self._get_field_indices(self.bucket_keys)
self._bucket_key_fields = table.table_schema.logical_bucket_key_fields
self._bucket_plan = bucket_plan
def with_bucket_plan(self, bucket_plan) -> None:
self._bucket_plan = bucket_plan
def num_buckets(self, partition: Tuple) -> int:
return self._bucket_plan.num_buckets(partition)
def route(self, data) -> pa.Int32Array:
"""Return bucket IDs in input order, as the Native router does."""
return pa.array(self.extract_partition_bucket_batch(data)[1], type=pa.int32())
def extract_partition_bucket_batch(
self, data: pa.RecordBatch
) -> Tuple[List[Tuple], List[int]]:
partitions = self._extract_partitions_batch(data)
columns = self._input_columns(data, self.bucket_key_indices)
buckets = [
_bucket_from_hash(
self._binary_row_hash_code(
tuple(col[row_idx].as_py() for col in columns),
self._bucket_key_fields,
),
self.num_buckets(partition),
)
for row_idx, partition in enumerate(partitions)
]
return partitions, buckets
def _extract_buckets_batch(self, data: pa.RecordBatch) -> List[int]:
return self.extract_partition_bucket_batch(data)[1]
def _extract_bucket_row(self, values_by_name: Dict[str, Any]) -> int:
partition = tuple(
values_by_name[self.table_schema.fields[i].name]
for i in self.partition_indices
)
return _bucket_from_hash(
self._binary_row_hash_code(
tuple(values_by_name[name] for name in self.bucket_keys),
self._bucket_key_fields,
),
self.num_buckets(partition),
)
class FixedBucketInputRouter(PartitionKeyExtractor):
"""Route distributed inputs without constructing a Python file writer."""
def __init__(self, table_schema: TableSchema):
super().__init__(table_schema)
options = CoreOptions.from_dict(table_schema.options)
self.num_buckets = options.bucket()
if self.num_buckets <= 0:
raise ValueError(f"Fixed bucket mode requires bucket > 0, got {self.num_buckets}")
# Bucket-key resolution lives on TableSchema (mirrors Java
# ``TableSchema.bucketKeys()`` / ``logicalBucketKeyType()``); reuse
# it so any reader path that walks the same logic stays in sync.
self.bucket_keys = table_schema.bucket_keys
self.bucket_key_indices = self._get_field_indices(self.bucket_keys)
self._bucket_key_fields = table_schema.logical_bucket_key_fields
bucket_function = table_schema.options.get('bucket-function.type')
self.bucket_function = 'default' if bucket_function is None else str(bucket_function).lower()
if self.bucket_function == 'mod':
if len(self._bucket_key_fields) != 1:
raise ValueError('bucket key must have exactly one field in mod bucket function')
key_type = self._bucket_key_fields[0].type
if not isinstance(key_type, AtomicType) or key_type.type.upper() not in ('INT', 'INTEGER', 'BIGINT'):
raise ValueError(
'bucket key type must be INT or BIGINT in mod bucket function, but got %s' % key_type)
elif self.bucket_function not in ('default', 'hive'):
raise ValueError('Unsupported bucket function type: %s' % self.bucket_function)
def route(self, data) -> pa.Int32Array:
"""Return bucket IDs in input order, as the Native router does."""
return pa.array(self.extract_partition_bucket_batch(data)[1], type=pa.int32())
def _extract_buckets_batch(self, data: pa.RecordBatch) -> List[int]:
self._check_automatic_bucket_function()
columns = self._input_columns(data, self.bucket_key_indices)
if self.bucket_function == 'mod':
return [self._mod_bucket(value.as_py()) for value in columns[0]]
return [
_bucket_from_hash(
self._binary_row_hash_code(
tuple(col[row_idx].as_py() for col in columns),
self._bucket_key_fields,
),
self.num_buckets,
)
for row_idx in range(data.num_rows)
]
def _extract_bucket_row(self, values_by_name: Dict[str, Any]) -> int:
self._check_automatic_bucket_function()
if self.bucket_function == 'mod':
return self._mod_bucket(values_by_name[self.bucket_keys[0]])
return _bucket_from_hash(
self._binary_row_hash_code(
tuple(values_by_name[name] for name in self.bucket_keys),
self._bucket_key_fields,
),
self.num_buckets,
)
def _check_automatic_bucket_function(self):
# Explicit buckets do not use this method. A single bucket also keeps
# the existing zero result without needing the Hive hash algorithm.
if self.bucket_function == 'hive' and self.num_buckets > 1:
raise NotImplementedError(
'Hive bucket functions require the native writer; enable write.native.enabled')
def _mod_bucket(self, value):
# Java reads the zeroed fixed-width slot for NULL; Python's modulo
# with a positive divisor otherwise matches Math.floorMod, even for
# Long.MIN_VALUE. Avoid floating-point division for BIGINT values.
return 0 if value is None else value % self.num_buckets
class DynamicBucketInputRouter(PartitionKeyExtractor):
"""Hash dynamic-bucket input without assigning buckets or maintaining indexes."""
def __init__(self, table_schema: TableSchema):
super().__init__(table_schema)
num_buckets = int(table_schema.options.get(CoreOptions.BUCKET.key(), -1))
if num_buckets != -1:
raise ValueError(
"Only 'bucket' = '-1' is allowed for "
f"'DynamicBucketRowKeyExtractor', but found: {num_buckets}"
)
if table_schema.options.get(CoreOptions.BUCKET_KEY.key()) is not None:
raise ValueError("Cannot define 'bucket-key' in dynamic bucket mode")
self.bucket_keys = table_schema.bucket_keys
self.bucket_key_indices = self._get_field_indices(self.bucket_keys)
self._bucket_key_fields = table_schema.logical_bucket_key_fields
self._partition_fields = [
table_schema.fields[index] for index in self.partition_indices
]
def extract_hashes_batch(
self, data: pa.RecordBatch
) -> Tuple[List[Tuple], List[int], List[int]]:
"""Return partitions, BinaryRow partition hashes, and key hashes."""
partitions, partition_hashes = self.extract_partition_hashes_batch(data)
return partitions, partition_hashes, self._extract_key_hashes_batch(data)
def extract_partition_hashes_batch(self, data: pa.RecordBatch) -> Tuple[List[Tuple], List[int]]:
"""Hash partitions independently when key hashes arrived from upstream."""
partitions = self._extract_partitions_batch(data)
partition_hash_cache = {}
partition_hashes = []
for partition in partitions:
if partition not in partition_hash_cache:
partition_hash_cache[partition] = self._binary_row_hash_code(
partition, self._partition_fields
)
partition_hashes.append(partition_hash_cache[partition])
return partitions, partition_hashes
def _extract_key_hashes_batch(self, data: pa.RecordBatch) -> List[int]:
key_columns = self._input_columns(data, self.bucket_key_indices)
return [
self._binary_row_hash_code(
tuple(column[row_idx].as_py() for column in key_columns),
self._bucket_key_fields,
)
for row_idx in range(data.num_rows)
]
def extract_assigners_batch(
self, data: pa.RecordBatch, num_channels: int, num_assigners: int
) -> List[int]:
_, partition_hashes, key_hashes = self.extract_hashes_batch(data)
return [
compute_assigner(
partition_hash, key_hash, num_channels, num_assigners
)
for partition_hash, key_hash in zip(partition_hashes, key_hashes)
]
class PythonDynamicBucketAssignerRouter:
"""Route input rows to dynamic bucket assigners and carry their key hashes."""
def __init__(self, table, num_channels, num_assigners):
if num_channels <= 0 or num_assigners <= 0:
raise ValueError('num_channels and num_assigners must be positive')
self._router = DynamicBucketInputRouter(table.table_schema)
self._num_channels = num_channels
self._num_assigners = num_assigners
def route(self, data) -> pa.RecordBatch:
_, partition_hashes, key_hashes = self._router.extract_hashes_batch(data)
return pa.RecordBatch.from_arrays([
pa.array([compute_assigner(partition_hash, key_hash, self._num_channels, self._num_assigners)
for partition_hash, key_hash in zip(partition_hashes, key_hashes)], type=pa.int32()),
pa.array([to_signed_int32(key_hash) for key_hash in key_hashes], type=pa.int32()),
], names=['assigner', 'key_hash'])
class PythonDynamicBucketAssigner:
def __init__(self, table, num_channels, num_assigners, assign_id,
ignore_existing=False, base_snapshot_id=None):
self._router = DynamicBucketInputRouter(table.table_schema)
snapshot = None if ignore_existing else resolve_bucket_snapshot(table, base_snapshot_id)
self.restore_snapshot_id = snapshot.id if snapshot is not None else 0
self._assigner = DynamicBucketAssigner(
table, num_channels=num_channels, num_assigners=num_assigners, assign_id=assign_id,
target_bucket_row_number=table.options.dynamic_bucket_target_row_num(),
max_buckets_num=table.options.dynamic_bucket_max_buckets(),
ignore_existing=ignore_existing, snapshot=snapshot)
def assign(self, data, key_hashes=None) -> pa.Int32Array:
if key_hashes is None:
partitions, partition_hashes, hashes = self._router.extract_hashes_batch(data)
else:
if isinstance(key_hashes, pa.ChunkedArray):
key_hashes = key_hashes.combine_chunks()
if not isinstance(key_hashes, pa.Int32Array):
raise TypeError('Dynamic bucket key hashes must be an Int32Array')
if len(key_hashes) != data.num_rows or key_hashes.null_count:
raise ValueError('Dynamic bucket key hashes must be non-null and match the input row count')
partitions, partition_hashes = self._router.extract_partition_hashes_batch(data)
hashes = key_hashes.to_pylist()
return pa.array([bucket for bucket, _ in self._assigner.assign_batch(
partitions, partition_hashes, hashes)], type=pa.int32())