blob: e039b1d86ed2cbc8accae4e77e17d3f07daa4073 [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.
import bisect
import ctypes
import random
import struct
from abc import ABC, abstractmethod
from typing import List
class ExternalPathProvider(ABC):
"""Provider for external data paths."""
@abstractmethod
def get_next_external_data_path(self, file_name: str) -> str:
"""Get the next external data path for the given file name."""
@staticmethod
def create(strategy, external_table_paths, relative_bucket_path="", weights=None):
"""Factory method to create the appropriate ExternalPathProvider."""
from pypaimon.common.options.core_options import ExternalPathStrategy
if strategy is None:
return None
if isinstance(strategy, str):
strategy = ExternalPathStrategy(strategy)
if strategy == ExternalPathStrategy.NONE:
return None
elif strategy in (ExternalPathStrategy.ROUND_ROBIN, ExternalPathStrategy.SPECIFIC_FS):
return RoundRobinExternalPathProvider(external_table_paths, relative_bucket_path)
elif strategy == ExternalPathStrategy.ENTROPY_INJECT:
return EntropyInjectExternalPathProvider(external_table_paths, relative_bucket_path)
elif strategy == ExternalPathStrategy.WEIGHTED:
if len(external_table_paths) < 2 or not weights:
return RoundRobinExternalPathProvider(external_table_paths, relative_bucket_path)
return WeightedExternalPathProvider(external_table_paths, relative_bucket_path, weights)
else:
raise ValueError(f"Unsupported external path strategy: {strategy}")
class RoundRobinExternalPathProvider(ExternalPathProvider):
"""Provider for round-robin external data paths."""
def __init__(self, external_table_paths: List[str], relative_bucket_path: str = ""):
if not external_table_paths:
raise ValueError("external_table_paths must not be empty")
self._external_table_paths = external_table_paths
self._relative_bucket_path = relative_bucket_path
self._position = random.randint(0, len(external_table_paths) - 1)
def get_next_external_data_path(self, file_name: str) -> str:
self._position += 1
if self._position == len(self._external_table_paths):
self._position = 0
external_base = self._external_table_paths[self._position]
if self._relative_bucket_path:
return f"{external_base.rstrip('/')}/{self._relative_bucket_path.strip('/')}/{file_name}"
else:
return f"{external_base.rstrip('/')}/{file_name}"
class EntropyInjectExternalPathProvider(ExternalPathProvider):
"""Provider for entropy-injected external data paths.
Generates hash-based directory structures from filenames using murmur3_32.
Constants: 20-bit hash, depth=3 dirs of 4 bits each, 8-bit remainder.
"""
_HASH_BINARY_STRING_BITS = 20
_ENTROPY_DIR_LENGTH = 4
_ENTROPY_DIR_DEPTH = 3
def __init__(self, external_table_paths: List[str], relative_bucket_path: str = ""):
if not external_table_paths:
raise ValueError("external_table_paths must not be empty")
self._external_table_paths = external_table_paths
self._relative_bucket_path = relative_bucket_path
self._position = 0
def get_next_external_data_path(self, file_name: str) -> str:
hash_dirs = self._compute_hash(file_name)
if self._relative_bucket_path:
file_path_with_hash = f"{self._relative_bucket_path.strip('/')}/{hash_dirs}/{file_name}"
else:
file_path_with_hash = f"{hash_dirs}/{file_name}"
self._position += 1
if self._position == len(self._external_table_paths):
self._position = 0
external_base = self._external_table_paths[self._position]
return f"{external_base.rstrip('/')}/{file_path_with_hash}"
def _compute_hash(self, file_name: str) -> str:
hash_int = _murmur3_32(file_name.encode('utf-8'))
binary_string = format((hash_int & 0xFFFFFFFF) | 0x80000000, '032b')
hash_str = binary_string[32 - self._HASH_BINARY_STRING_BITS:]
parts = []
total_prefix = self._ENTROPY_DIR_DEPTH * self._ENTROPY_DIR_LENGTH
for i in range(0, total_prefix, self._ENTROPY_DIR_LENGTH):
end = min(i + self._ENTROPY_DIR_LENGTH, len(hash_str))
parts.append(hash_str[i:end])
if len(hash_str) > total_prefix:
parts.append(hash_str[total_prefix:])
return "/".join(parts)
class WeightedExternalPathProvider(ExternalPathProvider):
"""Provider for weighted external data paths.
Uses cumulative weights with binary search for path selection.
"""
def __init__(self, external_table_paths: List[str], relative_bucket_path: str, weights: List[int]):
if len(external_table_paths) != len(weights):
raise ValueError(
f"The number of external paths and weights should be the same. "
f"Paths: {len(external_table_paths)}, Weights: {len(weights)}"
)
self._external_table_paths = external_table_paths
self._relative_bucket_path = relative_bucket_path
self._total_weight = sum(weights)
self._cumulative_weights: List[int] = []
cumulative = 0
for w in weights:
cumulative += w
self._cumulative_weights.append(cumulative)
def get_next_external_data_path(self, file_name: str) -> str:
random_value = random.random() * self._total_weight
index = bisect.bisect_right(self._cumulative_weights, random_value)
if index >= len(self._external_table_paths):
index = len(self._external_table_paths) - 1
selected_base = self._external_table_paths[index]
if self._relative_bucket_path:
return f"{selected_base.rstrip('/')}/{self._relative_bucket_path.strip('/')}/{file_name}"
else:
return f"{selected_base.rstrip('/')}/{file_name}"
def _murmur3_32(data: bytes, seed: int = 0) -> int:
"""Pure-Python murmur3_32 hash, compatible with Guava Hashing.murmur3_32().
Returns a signed 32-bit integer identical to Java's int representation.
"""
c1 = 0xCC9E2D51
c2 = 0x1B873593
length = len(data)
h1 = seed & 0xFFFFFFFF
rounded_end = (length & 0xFFFFFFFC)
for i in range(0, rounded_end, 4):
k1 = struct.unpack_from('<I', data, i)[0]
k1 = (k1 * c1) & 0xFFFFFFFF
k1 = ((k1 << 15) | (k1 >> 17)) & 0xFFFFFFFF
k1 = (k1 * c2) & 0xFFFFFFFF
h1 ^= k1
h1 = ((h1 << 13) | (h1 >> 19)) & 0xFFFFFFFF
h1 = (h1 * 5 + 0xE6546B64) & 0xFFFFFFFF
k1 = 0
remaining = length & 3
if remaining >= 3:
k1 ^= data[rounded_end + 2] << 16
if remaining >= 2:
k1 ^= data[rounded_end + 1] << 8
if remaining >= 1:
k1 ^= data[rounded_end]
k1 = (k1 * c1) & 0xFFFFFFFF
k1 = ((k1 << 15) | (k1 >> 17)) & 0xFFFFFFFF
k1 = (k1 * c2) & 0xFFFFFFFF
h1 ^= k1
h1 ^= length
h1 ^= h1 >> 16
h1 = (h1 * 0x85EBCA6B) & 0xFFFFFFFF
h1 ^= h1 >> 13
h1 = (h1 * 0xC2B2AE35) & 0xFFFFFFFF
h1 ^= h1 >> 16
return ctypes.c_int32(h1).value