This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new b8af4bf2b1 [python] Use native Daft bucket routing and assignment
(#10775)
b8af4bf2b1 is described below
commit b8af4bf2b1a1bd6d2e1808174f669825eb6d88b1
Author: Jingsong Lee <[email protected]>
AuthorDate: Fri Oct 23 19:34:17 2026 +0800
[python] Use native Daft bucket routing and assignment (#10775)
---
docs/docs/pypaimon/daft.md | 7 +
paimon-python/pypaimon/daft/daft_datasink.py | 81 ++----
paimon-python/pypaimon/daft/daft_paimon.py | 2 +-
.../execution/native/write/input_routing.py | 124 +++++++-
.../execution/native/write/postpone_bucket.py | 16 +-
.../read/reader/outer_projection_record_reader.py | 24 +-
.../execution/python/write/input_routing.py | 73 +++++
.../execution/python/write/row_key_extractor.py | 8 +-
paimon-python/pypaimon/index/dynamic_bucket.py | 12 +-
.../pypaimon/read/reader/field_indices.py | 17 +-
.../tests/daft/daft_pk_distributed_write_test.py | 2 +-
.../dynamic_bucket_execution_boundary_test.py | 4 +-
.../tests/native_dynamic_input_routing_test.py | 151 ++++++++++
.../write/distributed_input_execution_test.py | 43 +--
.../pypaimon/tests/write/dynamic_bucket_test.py | 28 +-
.../tests/write/dynamic_input_routing_test.py | 317 +++++++++++++++++++++
.../tests/write/native_input_routing_test.py | 51 ++++
...t.py => simple_dynamic_bucket_assigner_test.py} | 18 +-
paimon-python/pypaimon/write/backend_dispatch.py | 31 ++
19 files changed, 880 insertions(+), 129 deletions(-)
diff --git a/docs/docs/pypaimon/daft.md b/docs/docs/pypaimon/daft.md
index d219912b87..000c2c23a8 100644
--- a/docs/docs/pypaimon/daft.md
+++ b/docs/docs/pypaimon/daft.md
@@ -174,6 +174,13 @@ write_paimon(
`write_paimon` opens its own catalog, resolves the table, and commits the
write through Daft's DataSink API.
+With `write.native.enabled=true`, primary-key writes use Rust for fixed-bucket
+routing and for both stages of dynamic-bucket assignment: input hashing and
+restoring HASH indexes from the captured writer snapshot. Daft still schedules
+and groups the input so one writer owns each partition/bucket. Overwrite starts
+with an empty bucket-assignment state; append restores the snapshot captured
+before dispatch. These write-side operations do not request SELECT permission.
+
**Overwrite mode:**
```python
diff --git a/paimon-python/pypaimon/daft/daft_datasink.py
b/paimon-python/pypaimon/daft/daft_datasink.py
index 675276efff..59dd539fd9 100644
--- a/paimon-python/pypaimon/daft/daft_datasink.py
+++ b/paimon-python/pypaimon/daft/daft_datasink.py
@@ -438,19 +438,16 @@ def make_fixed_bucket_udf(table: FileStoreTable):
from daft import DataType, Series
from pypaimon.schema.data_types import PyarrowFieldParser
- from pypaimon.write.input_routing import FixedBucketInputRouter
+ from pypaimon.write.backend_dispatch import create_input_router
schema = PyarrowFieldParser.from_paimon_schema(table.fields)
- extractor = FixedBucketInputRouter(table.table_schema)
+ router = create_input_router(table)
@daft.func.batch(return_dtype=DataType.int32())
def _fixed_bucket(*columns) -> Series:
arrow_table = _series_to_arrow_table(columns, schema)
- buckets = []
- for batch in arrow_table.to_batches():
- _, batch_buckets = extractor.extract_partition_bucket_batch(batch)
- buckets.extend(batch_buckets)
- return Series.from_arrow(pa.array(buckets, type=pa.int32()))
+ buckets = [router.route(batch) for batch in arrow_table.to_batches()]
+ return Series.from_arrow(pa.concat_arrays(buckets) if buckets else
pa.array([], type=pa.int32()))
return _fixed_bucket
@@ -466,12 +463,11 @@ def make_dynamic_routing_udf(
import daft
from daft import DataType, Series
- from pypaimon.index.dynamic_bucket import compute_assigner, to_signed_int32
from pypaimon.schema.data_types import PyarrowFieldParser
- from pypaimon.write.input_routing import DynamicBucketInputRouter
+ from pypaimon.write.backend_dispatch import
create_dynamic_bucket_assigner_router
schema = PyarrowFieldParser.from_paimon_schema(table.fields)
- extractor = DynamicBucketInputRouter(table.table_schema)
+ router = create_dynamic_bucket_assigner_router(table, num_channels,
num_assigners)
return_fields = {
assigner_column: DataType.int32(),
key_hash_column: DataType.int32(),
@@ -486,25 +482,14 @@ def make_dynamic_routing_udf(
assigners = []
key_hashes = []
for batch in arrow_table.to_batches():
- _, partition_hashes, batch_key_hashes = (
- extractor.extract_hashes_batch(batch)
- )
- assigners.extend([
- compute_assigner(
- partition_hash, key_hash, num_channels, num_assigners
- )
- for partition_hash, key_hash in zip(
- partition_hashes, batch_key_hashes
- )
- ])
- key_hashes.extend(
- to_signed_int32(value) for value in batch_key_hashes
- )
+ routed = router.route(batch)
+ assigners.append(routed.column(0))
+ key_hashes.append(routed.column(1))
return Series.from_arrow(
pa.StructArray.from_arrays(
[
- pa.array(assigners, type=pa.int32()),
- pa.array(key_hashes, type=pa.int32()),
+ pa.concat_arrays(assigners) if assigners else pa.array([],
type=pa.int32()),
+ pa.concat_arrays(key_hashes) if key_hashes else
pa.array([], type=pa.int32()),
],
names=[assigner_column, key_hash_column],
)
@@ -528,7 +513,7 @@ def make_dynamic_bucket_assignment_udf(
from daft import DataType, Series
from pypaimon.schema.data_types import PyarrowFieldParser
- from pypaimon.write.input_routing import DynamicBucketInputRouter
+ from pypaimon.write.backend_dispatch import create_dynamic_bucket_assigner
catalog_options = _extract_catalog_options(table)
table_identifier = _extract_identifier(table)
@@ -556,9 +541,10 @@ def make_dynamic_bucket_assignment_udf(
unnest=True,
)
def _assign_dynamic_bucket(*columns) -> Series:
- assigner_values = columns[data_column_count].to_pylist()
- key_hash_values = columns[data_column_count + 1].to_pylist()
- if not assigner_values:
+ import pyarrow.compute as pc
+
+ assigner_values = columns[data_column_count].to_arrow()
+ if len(assigner_values) == 0:
return Series.from_arrow(
pa.array([], type=pa.struct([
pa.field(field.name, field.type) for field in output_fields
@@ -567,8 +553,8 @@ def make_dynamic_bucket_assignment_udf(
pa.field(base_snapshot_column, pa.int64()),
]))
)
- assign_id = assigner_values[0]
- if any(value != assign_id for value in assigner_values):
+ assign_id = assigner_values[0].as_py()
+ if assigner_values.null_count or
pc.count_distinct(assigner_values).as_py() != 1:
raise RuntimeError(
"A dynamic-bucket group contained multiple assigners"
)
@@ -582,36 +568,17 @@ def make_dynamic_bucket_assignment_udf(
table_path,
schema_token,
)
- from pypaimon.index.dynamic_bucket import HashBucketAssigner,
resolve_bucket_snapshot
-
- router = DynamicBucketInputRouter(worker_table.table_schema)
- snapshot = resolve_bucket_snapshot(worker_table, base_snapshot_id)
- options = worker_table.options
- assigner = HashBucketAssigner(
- worker_table,
- num_channels=num_channels,
- num_assigners=num_assigners,
- assign_id=assign_id,
- ignore_existing=ignore_existing,
- target_bucket_row_number=options.dynamic_bucket_target_row_num(),
- max_buckets_num=options.dynamic_bucket_max_buckets(),
- snapshot=snapshot,
- )
- buckets = []
- offset = 0
- for batch in arrow_table.to_batches():
- batch_hashes = key_hash_values[offset:offset + batch.num_rows]
- partitions, partition_hashes =
router.extract_partition_hashes_batch(batch)
- buckets.extend(bucket for bucket, _ in assigner.assign_batch(
- partitions, partition_hashes, batch_hashes))
- offset += batch.num_rows
+ assigner = create_dynamic_bucket_assigner(
+ worker_table, num_channels, num_assigners, assign_id,
+ ignore_existing=ignore_existing, base_snapshot_id=base_snapshot_id)
+ buckets = assigner.assign(arrow_table,
key_hashes=columns[data_column_count + 1].to_arrow())
arrays = [
arrow_table.column(field.name).combine_chunks()
for field in output_fields
]
- arrays.append(pa.array(buckets, type=pa.int32()))
+ arrays.append(buckets)
arrays.append(pa.array(
- [snapshot.id if snapshot is not None else 0] *
arrow_table.num_rows,
+ [assigner.restore_snapshot_id] * arrow_table.num_rows,
type=pa.int64(),
))
return Series.from_arrow(
diff --git a/paimon-python/pypaimon/daft/daft_paimon.py
b/paimon-python/pypaimon/daft/daft_paimon.py
index 765c009a33..ec69834946 100644
--- a/paimon-python/pypaimon/daft/daft_paimon.py
+++ b/paimon-python/pypaimon/daft/daft_paimon.py
@@ -296,7 +296,7 @@ def _write_table(
return prepared.write_sink(commit_sink)
if bucket_mode == BucketMode.HASH_DYNAMIC:
- latest_snapshot = table.snapshot_manager().get_latest_snapshot()
+ latest_snapshot = None if mode == 'overwrite' else
table.snapshot_manager().get_latest_snapshot()
base_snapshot_id = (
latest_snapshot.id if latest_snapshot is not None else 0
)
diff --git a/paimon-python/pypaimon/execution/native/write/input_routing.py
b/paimon-python/pypaimon/execution/native/write/input_routing.py
index 461cba52f1..b5c9ff45bb 100644
--- a/paimon-python/pypaimon/execution/native/write/input_routing.py
+++ b/paimon-python/pypaimon/execution/native/write/input_routing.py
@@ -23,12 +23,15 @@ from pypaimon.execution.native.runtime import
native_runtime_available
from pypaimon.execution.native.write.native_commit import
create_native_write_table
-def as_record_batch(data):
+def input_batches(data):
if isinstance(data, pa.RecordBatch):
- return data
+ return [data]
if isinstance(data, pa.Table):
- return pa.RecordBatch.from_arrays(
- [column.combine_chunks() for column in data.columns],
schema=data.schema)
+ # Binary columns can retain multiple chunks even after combine_chunks.
+ # Preserve every batch and its row count, including zero-column input.
+ batches = data.to_batches()
+ return batches if batches else [pa.RecordBatch.from_arrays(
+ [column.combine_chunks() for column in data.columns],
schema=data.schema)]
raise TypeError('Input routing requires a pyarrow Table or RecordBatch')
@@ -65,9 +68,120 @@ class NativeInputRouter:
def route(self, data) -> pa.Int32Array:
if self._router is None:
self._prepare()
- return self._router.route(as_record_batch(data))
+ buckets = [self._router.route(batch) for batch in input_batches(data)]
+ return buckets[0] if len(buckets) == 1 else pa.concat_arrays(buckets)
def __getstate__(self):
state = dict(self.__dict__)
state['_router'] = None
return state
+
+
+def create_native_dynamic_bucket_assigner_router(table, num_channels,
num_assigners):
+ if not native_runtime_available():
+ return None
+ native_table = create_native_write_table(table)
+ if native_table is None:
+ return None
+ router = NativeDynamicBucketAssignerRouter(table, num_channels,
num_assigners)
+ router._prepare(native_table)
+ return router
+
+
+class NativeDynamicBucketAssignerRouter:
+ """Route input rows to dynamic bucket assigners using Rust's row hashes."""
+
+ def __init__(self, table, num_channels, num_assigners):
+ self.table = table
+ self._num_channels = num_channels
+ self._num_assigners = num_assigners
+ self._router = None
+
+ def _prepare(self, native_table=None):
+ from pypaimon_rust.datafusion import DynamicBucketAssignerRouter
+ if native_table is None:
+ native_table = create_native_write_table(self.table)
+ if native_table is None:
+ raise RuntimeError('Native dynamic bucket assigner routing is
unavailable')
+ self._router = DynamicBucketAssignerRouter(native_table.schema(),
self._num_channels, self._num_assigners)
+
+ def route(self, data) -> pa.RecordBatch:
+ if self._router is None:
+ self._prepare()
+ routed = [self._router.route(batch) for batch in input_batches(data)]
+ if len(routed) == 1:
+ return routed[0]
+ return pa.RecordBatch.from_arrays([
+ pa.concat_arrays([batch.column(index) for batch in routed]) for
index in range(2)
+ ], schema=routed[0].schema)
+
+ def __getstate__(self):
+ state = dict(self.__dict__)
+ state['_router'] = None
+ return state
+
+
+def create_native_dynamic_bucket_assigner(table, num_channels, num_assigners,
assign_id,
+ ignore_existing=False,
base_snapshot_id=None):
+ if not native_runtime_available():
+ return None
+ native_table = create_native_write_table(table)
+ if native_table is None:
+ return None
+ assigner = NativeDynamicBucketAssigner(
+ table, num_channels, num_assigners, assign_id, ignore_existing,
base_snapshot_id)
+ assigner._prepare(native_table)
+ return assigner
+
+
+class NativeDynamicBucketAssigner:
+
+ def __init__(self, table, num_channels, num_assigners, assign_id,
+ ignore_existing=False, base_snapshot_id=None):
+ self.table = table
+ self._num_channels = num_channels
+ self._num_assigners = num_assigners
+ self._assign_id = assign_id
+ self._ignore_existing = ignore_existing
+ self._base_snapshot_id = base_snapshot_id
+ self._assigner = None
+
+ def _prepare(self, native_table=None):
+ from pypaimon_rust.datafusion import DynamicBucketAssigner
+ if native_table is None:
+ native_table = create_native_write_table(self.table)
+ if native_table is None:
+ raise RuntimeError('Native dynamic bucket assignment is
unavailable')
+ self._assigner = DynamicBucketAssigner(
+ native_table, self._num_channels, self._num_assigners,
self._assign_id,
+ restore_snapshot_id=self._base_snapshot_id,
overwrite=self._ignore_existing)
+ # Core captures the restore point once. Final worker writers must
+ # restore that same snapshot even if another commit finishes later.
+ self._base_snapshot_id = self._assigner.restore_snapshot_id
+
+ @property
+ def restore_snapshot_id(self):
+ if self._assigner is None:
+ self._prepare()
+ return self._base_snapshot_id
+
+ def assign(self, data, key_hashes=None) -> pa.Int32Array:
+ batches = input_batches(data)
+ if isinstance(key_hashes, pa.ChunkedArray):
+ key_hashes = key_hashes.combine_chunks()
+ if key_hashes is not None:
+ if not isinstance(key_hashes, pa.Int32Array):
+ raise TypeError('Dynamic bucket key hashes must be an
Int32Array')
+ # Validate the whole input before the stateful assigner sees any
+ # batch, including a null or missing hash in a later chunk.
+ 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')
+ if self._assigner is None:
+ self._prepare()
+ buckets = []
+ offset = 0
+ for batch in batches:
+ hashes = None if key_hashes is None else key_hashes.slice(offset,
batch.num_rows)
+ buckets.append(self._assigner.assign(batch, key_hashes=hashes))
+ offset += batch.num_rows
+ return buckets[0] if len(buckets) == 1 else pa.concat_arrays(buckets)
diff --git a/paimon-python/pypaimon/execution/native/write/postpone_bucket.py
b/paimon-python/pypaimon/execution/native/write/postpone_bucket.py
index acb3823aa4..98837940bc 100644
--- a/paimon-python/pypaimon/execution/native/write/postpone_bucket.py
+++ b/paimon-python/pypaimon/execution/native/write/postpone_bucket.py
@@ -20,7 +20,7 @@
import pyarrow as pa
from pypaimon.execution.native.runtime import native_runtime_available
-from pypaimon.execution.native.write.input_routing import as_record_batch
+from pypaimon.execution.native.write.input_routing import input_batches
from pypaimon.execution.native.write.native_commit import
create_native_write_table
from pypaimon.schema.data_types import PyarrowFieldParser
from pypaimon.write.bucket_plan import PostponeBucketPlan
@@ -87,10 +87,16 @@ class NativePostponeBucketPlanner:
return PostponeBucketPlan(self._known_num_buckets)
def input_partition_stats(self, data):
- stats = self._ensure_planner().input_statistics(as_record_batch(data))
- columns = [column.to_pylist() for column in stats.columns]
- return {tuple(column[row] for column in columns[:-2]):
- (columns[-2][row], columns[-1][row]) for row in
range(stats.num_rows)}
+ planner = self._ensure_planner()
+ partition_stats = {}
+ for batch in input_batches(data):
+ stats = planner.input_statistics(batch)
+ columns = [column.to_pylist() for column in stats.columns]
+ for row in range(stats.num_rows):
+ partition = tuple(column[row] for column in columns[:-2])
+ count, size = partition_stats.get(partition, (0, 0))
+ partition_stats[partition] = (count + columns[-2][row], size +
columns[-1][row])
+ return partition_stats
def plan(self, partition_stats):
fields = [self.table.field_dict[name] for name in
self.table.partition_keys]
diff --git
a/paimon-python/pypaimon/execution/python/read/reader/outer_projection_record_reader.py
b/paimon-python/pypaimon/execution/python/read/reader/outer_projection_record_reader.py
index e2e6d0f5c0..fa4d710917 100644
---
a/paimon-python/pypaimon/execution/python/read/reader/outer_projection_record_reader.py
+++
b/paimon-python/pypaimon/execution/python/read/reader/outer_projection_record_reader.py
@@ -25,9 +25,8 @@ partial-update / aggregation see the original sub-structure),
and this
wrapper extracts the user-visible flat columns afterwards.
"""
-from typing import Any, List, Optional
+from typing import Any, Iterable, List, Optional, Sequence, Set
-from pypaimon.read.reader.field_indices import project_top_level_field_indices
from pypaimon.read.reader.iface.record_iterator import RecordIterator
from pypaimon.read.reader.iface.record_reader import RecordReader
from pypaimon.table.row.internal_row import InternalRow
@@ -62,11 +61,11 @@ class
OuterProjectionRecordReader(RecordReader[InternalRow]):
self._inner = inner
self._flat_arity = len(name_paths)
self._file_io = file_io
- self._blob_field_indices = project_top_level_field_indices(
+ self._blob_field_indices = _project_top_level_field_indices(
blob_field_indices, self._specs)
- self._descriptor_field_indices = project_top_level_field_indices(
+ self._descriptor_field_indices = _project_top_level_field_indices(
descriptor_field_indices, self._specs)
- self._vector_field_indices = project_top_level_field_indices(
+ self._vector_field_indices = _project_top_level_field_indices(
vector_field_indices, self._specs)
self.file_io = self._file_io
self.blob_field_indices = self._blob_field_indices
@@ -135,6 +134,21 @@ class _PathSpec:
self.sub_names = sub_names
+def _project_top_level_field_indices(
+ source_indices: Optional[Iterable[int]],
+ path_specs: Sequence[_PathSpec],
+) -> Optional[Set[int]]:
+ """Carry top-level field markers through this reader's resolved paths."""
+ if source_indices is None:
+ return None
+ source = set(source_indices)
+ return {
+ proj_pos
+ for proj_pos, spec in enumerate(path_specs)
+ if not spec.sub_names and spec.top_idx in source
+ }
+
+
def _extract(row: InternalRow, spec: _PathSpec) -> Any:
cur = row.get_field(spec.top_idx)
for name in spec.sub_names:
diff --git a/paimon-python/pypaimon/execution/python/write/input_routing.py
b/paimon-python/pypaimon/execution/python/write/input_routing.py
new file mode 100644
index 0000000000..133e3f2378
--- /dev/null
+++ b/paimon-python/pypaimon/execution/python/write/input_routing.py
@@ -0,0 +1,73 @@
+# 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.
+
+"""Arrow contracts for Python distributed dynamic-bucket execution."""
+
+import pyarrow as pa
+
+from pypaimon.index.dynamic_bucket import (
+ DynamicBucketAssigner, compute_assigner, resolve_bucket_snapshot,
to_signed_int32,
+)
+from pypaimon.write.input_routing import DynamicBucketInputRouter
+
+
+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())
diff --git a/paimon-python/pypaimon/execution/python/write/row_key_extractor.py
b/paimon-python/pypaimon/execution/python/write/row_key_extractor.py
index 76a65d0555..996f440c72 100644
--- a/paimon-python/pypaimon/execution/python/write/row_key_extractor.py
+++ b/paimon-python/pypaimon/execution/python/write/row_key_extractor.py
@@ -281,7 +281,7 @@ class _SimplePartitionIndex:
)
-class SimpleHashBucketAssigner:
+class SimpleDynamicBucketAssigner:
def __init__(self, num_assigners, assign_id, target_bucket_row_number,
max_buckets_num):
validate_max_buckets(max_buckets_num)
self.num_assigners = num_assigners
@@ -330,7 +330,7 @@ class
DynamicBucketRowKeyExtractor(DynamicBucketInputRouter, RowKeyExtractor):
max_buckets_num = opts.dynamic_bucket_max_buckets()
if table is None:
- self._assigner = SimpleHashBucketAssigner(
+ self._assigner = SimpleDynamicBucketAssigner(
num_assigners=num_assigners,
assign_id=assign_id,
target_bucket_row_number=target_bucket_row_number,
@@ -339,12 +339,12 @@ class
DynamicBucketRowKeyExtractor(DynamicBucketInputRouter, RowKeyExtractor):
self._index_maintainer = None
else:
from pypaimon.execution.python.write.dynamic_bucket import
DynamicBucketIndexMaintainer
- from pypaimon.index.dynamic_bucket import HashBucketAssigner
+ from pypaimon.index.dynamic_bucket import DynamicBucketAssigner
from pypaimon.index.dynamic_bucket import resolve_bucket_snapshot
snapshot = resolve_bucket_snapshot(table, base_snapshot_id)
self.base_snapshot_id = snapshot.id if snapshot is not None else 0
- self._assigner = HashBucketAssigner(
+ self._assigner = DynamicBucketAssigner(
table=table,
num_channels=num_channels,
num_assigners=num_assigners,
diff --git a/paimon-python/pypaimon/index/dynamic_bucket.py
b/paimon-python/pypaimon/index/dynamic_bucket.py
index a72d600b79..ad338d0065 100644
--- a/paimon-python/pypaimon/index/dynamic_bucket.py
+++ b/paimon-python/pypaimon/index/dynamic_bucket.py
@@ -83,7 +83,7 @@ def compute_assigner(
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 (start + assigner) % num_channels
+ return _java_remainder(to_signed_int32(start + assigner), num_channels)
def is_my_bucket(bucket: int, num_assigners: int, assign_id: int) -> bool:
@@ -141,6 +141,7 @@ class _PartitionIndex:
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,
@@ -174,7 +175,9 @@ class _PartitionIndex:
global_max_bucket_id = (
MAX_DYNAMIC_BUCKETS if max_buckets_num == -1 else max_buckets_num
) - 1
- if not self.total_bucket_set or max_bucket_id < global_max_bucket_id:
+ # 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
@@ -187,6 +190,9 @@ class _PartitionIndex:
"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(
@@ -198,7 +204,7 @@ class _PartitionIndex:
return bucket, True
-class HashBucketAssigner:
+class DynamicBucketAssigner:
"""Restore dynamic-bucket mappings from HASH indexes before assigning."""
def __init__(
diff --git a/paimon-python/pypaimon/read/reader/field_indices.py
b/paimon-python/pypaimon/read/reader/field_indices.py
index b5a6690a6b..7392aeafa4 100644
--- a/paimon-python/pypaimon/read/reader/field_indices.py
+++ b/paimon-python/pypaimon/read/reader/field_indices.py
@@ -17,7 +17,7 @@
"""Helpers for carrying typed field metadata through projection wrappers."""
-from typing import Any, Iterable, List, Optional, Sequence, Set
+from typing import Iterable, List, Set
from pypaimon.schema.data_types import DataField, VectorType
@@ -56,18 +56,3 @@ def descriptor_field_indices_for_table(table, fields:
List[DataField]) -> Set[in
def vector_field_indices(fields: List[DataField]) -> Set[int]:
return {i for i, f in enumerate(fields) if isinstance(f.type, VectorType)}
-
-
-def project_top_level_field_indices(
- source_indices: Optional[Iterable[int]],
- path_specs: Sequence[Any],
-) -> Optional[Set[int]]:
- """Map top-level field indices through path specs with
top_idx/sub_names."""
- if source_indices is None:
- return None
- source = set(source_indices)
- return {
- proj_pos
- for proj_pos, spec in enumerate(path_specs)
- if not spec.sub_names and spec.top_idx in source
- }
diff --git
a/paimon-python/pypaimon/tests/daft/daft_pk_distributed_write_test.py
b/paimon-python/pypaimon/tests/daft/daft_pk_distributed_write_test.py
index a9a0fb58c3..e5472cac25 100644
--- a/paimon-python/pypaimon/tests/daft/daft_pk_distributed_write_test.py
+++ b/paimon-python/pypaimon/tests/daft/daft_pk_distributed_write_test.py
@@ -289,7 +289,7 @@ def
test_hash_dynamic_pk_restores_bucket_mapping_across_commits(catalog):
_write_table(
daft.from_pydict(
{
- # A fresh task-local SimpleHashBucketAssigner puts id=2 in
+ # A fresh task-local SimpleDynamicBucketAssigner puts id=2 in
# bucket 0 and incorrectly moves the existing id=1 to bucket 1.
"id": [2, 1],
"value": ["other", "new"],
diff --git
a/paimon-python/pypaimon/tests/dynamic_bucket_execution_boundary_test.py
b/paimon-python/pypaimon/tests/dynamic_bucket_execution_boundary_test.py
index 49c169dda5..76c47ffbf2 100644
--- a/paimon-python/pypaimon/tests/dynamic_bucket_execution_boundary_test.py
+++ b/paimon-python/pypaimon/tests/dynamic_bucket_execution_boundary_test.py
@@ -62,7 +62,7 @@ class BlockPythonExecution(importlib.abc.MetaPathFinder):
sys.meta_path.insert(0, BlockPythonExecution())
from pypaimon import CatalogFactory
-from pypaimon.index.dynamic_bucket import HashBucketAssigner,
resolve_bucket_snapshot
+from pypaimon.index.dynamic_bucket import DynamicBucketAssigner,
resolve_bucket_snapshot
from pypaimon.write.input_routing import DynamicBucketInputRouter
catalog = CatalogFactory.create({'warehouse': sys.argv[1]})
@@ -77,7 +77,7 @@ partitions, partition_hashes, key_hashes =
router.extract_hashes_batch(rows)
# actual nonempty HASH file produced above and restores the original key.
plan = SimpleNamespace(splits=lambda: [SimpleNamespace(bucket=0)])
with patch('pypaimon.execution.native.read.plan.native_plan',
return_value=plan) as native_plan:
- assigner = HashBucketAssigner(table, 1, 1, 0, 1, -1, snapshot=snapshot)
+ assigner = DynamicBucketAssigner(table, 1, 1, 0, 1, -1, snapshot=snapshot)
assert assigner.assign_batch(partitions, partition_hashes, key_hashes) ==
[(1, True), (0, False)]
assert assigner.assign(partitions[1], partition_hashes[1], key_hashes[1])
== 0
native_plan.assert_called_once()
diff --git a/paimon-python/pypaimon/tests/native_dynamic_input_routing_test.py
b/paimon-python/pypaimon/tests/native_dynamic_input_routing_test.py
new file mode 100644
index 0000000000..c23bb48144
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_dynamic_input_routing_test.py
@@ -0,0 +1,151 @@
+# 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.
+
+"""Daft distributed input execution uses the real SDK for routing and
restoration."""
+
+from contextlib import ExitStack
+import pickle
+from unittest.mock import patch
+
+import pyarrow as pa
+import pytest
+
+from pypaimon import Schema
+from pypaimon.execution.native.write.input_routing import
NativeDynamicBucketAssigner, NativeDynamicBucketAssignerRouter
+from pypaimon.index.dynamic_bucket import DynamicBucketAssigner
+from pypaimon.tests.rest.rest_server import RESTCatalogServer
+from pypaimon.write.backend_dispatch import create_dynamic_bucket_assigner,
create_dynamic_bucket_assigner_router
+from pypaimon.write.input_routing import DynamicBucketInputRouter
+
+pytestmark = pytest.mark.native_plan
+
+
+def _table(catalog, bucket='-1', partitioned=True):
+ fields = [('id', pa.int64()), ('value', pa.string())]
+ if partitioned:
+ fields.append(('p', pa.string()))
+ catalog.create_table('default.dynamic_input', Schema.from_pyarrow_schema(
+ pa.schema(fields), primary_keys=['id', 'p'] if partitioned else ['id'],
+ partition_keys=['p'] if partitioned else [], options={
+ 'bucket': bucket, 'file.format': 'parquet',
'dynamic-bucket.target-row-num': '1',
+ 'write.native.enabled': 'true', 'commit.native.enabled': 'true',
+ 'read.native.enabled': 'true', 'scan.native-plan.enabled': 'true',
+ 'query-auth.enabled': 'true',
+ }), False)
+ return catalog.get_table('default.dynamic_input')
+
+
+def _commit(table, rows):
+ builder = table.new_batch_write_builder()
+ writer, commit = builder.new_write(), builder.new_commit()
+ try:
+ writer.write_arrow(pa.table(rows))
+ commit.commit(writer.prepare_commit())
+ finally:
+ writer.close()
+ commit.close()
+
+
+def _forbid_python_input_execution():
+ stack = ExitStack()
+ for target in [
+ 'pypaimon.write.input_routing.FixedBucketInputRouter.__init__',
+ 'pypaimon.write.input_routing.DynamicBucketInputRouter.__init__',
+ 'pypaimon.index.dynamic_bucket.DynamicBucketAssigner.__init__',
+
'pypaimon.execution.python.write.table_write.PythonTableWrite.__init__',
+ ]:
+ stack.enter_context(patch(target, side_effect=AssertionError('Python
input execution: ' + target)))
+ return stack
+
+
+def
test_native_dynamic_arrow_route_and_assignment_keep_one_restore_snapshot(native_rest_catalog):
+ table = _table(native_rest_catalog)
+ _commit(table, {'id': [1], 'value': ['old'], 'p': ['west']})
+ snapshot_id = table.snapshot_manager().get_latest_snapshot().id
+ batch = pa.record_batch([['west', 'west', 'east'], [2, 1, 2]], names=['p',
'id'])
+ chunked_input = pa.Table.from_batches([batch.slice(0, 1), batch.slice(1)])
+ reference = DynamicBucketInputRouter(table.table_schema)
+ _, partition_hashes, hashes = reference.extract_hashes_batch(batch)
+ reference_assigner = DynamicBucketAssigner(
+ table, 1, 1, 0, 1, -1,
snapshot=table.snapshot_manager().get_snapshot_by_id(snapshot_id))
+ expected = [bucket for bucket, _ in reference_assigner.assign_batch(
+ reference._extract_partitions_batch(batch), partition_hashes, hashes)]
+ with _forbid_python_input_execution(), \
+ patch.object(RESTCatalogServer, '_table_query_auth_handle',
+ side_effect=AssertionError('Bucket restoration must
not request SELECT')) as query:
+ router = create_dynamic_bucket_assigner_router(table, 1, 1)
+ assert isinstance(router, NativeDynamicBucketAssignerRouter)
+ routed = pickle.loads(pickle.dumps(router)).route(chunked_input)
+ assert routed.schema.names == ['assigner', 'key_hash']
+ assert routed.column(0).to_pylist() == [0] * batch.num_rows
+ assert routed.column(1).to_pylist() == [value - (1 << 32) if value >=
1 << 31 else value for value in hashes]
+ assigner = create_dynamic_bucket_assigner(table, 1, 1, 0,
base_snapshot_id=snapshot_id)
+ assert isinstance(assigner, NativeDynamicBucketAssigner)
+ assert assigner.restore_snapshot_id == snapshot_id
+ assert assigner.assign(chunked_input, routed.column(1)).to_pylist() ==
expected
+ assert assigner.assign(chunked_input).to_pylist() == expected
+ # Overwrite ignores existing mappings and exposes an empty restore
point.
+ overwritten = create_dynamic_bucket_assigner(table, 1, 1, 0, True,
snapshot_id)
+ assert overwritten.restore_snapshot_id == 0
+ assert overwritten.assign(chunked_input, routed.column(1)).to_pylist()
== [0, 1, 0]
+ query.assert_not_called()
+ assert table.snapshot_manager().get_latest_snapshot().id == snapshot_id
+
+
[email protected]('partitioned', [False, True])
+def
test_native_dynamic_precomputed_hashes_need_only_partition_input(native_rest_catalog,
partitioned):
+ table = _table(native_rest_catalog, partitioned=partitioned)
+ source = {'id': [1, 2, 1]}
+ if partitioned:
+ source['p'] = ['west'] * 3
+ source = pa.table(source)
+ with _forbid_python_input_execution():
+ routed = create_dynamic_bucket_assigner_router(table, 1,
1).route(source)
+ assigner = create_dynamic_bucket_assigner(table, 1, 1, 0,
base_snapshot_id=0)
+ partitions = source.select(['p'] if partitioned else [])
+ assert partitions.num_rows == 3
+ hashes = pa.chunked_array([routed.column(1).slice(0, 1),
routed.column(1).slice(1)])
+ assert assigner.assign(partitions, hashes).to_pylist() == [0, 1, 0]
+ assert assigner.restore_snapshot_id == 0
+ assert table.snapshot_manager().get_latest_snapshot() is None
+
+
[email protected]('bucket', ['4', '-1'])
[email protected]('partitioned', [False, True])
+def
test_native_daft_fixed_and_dynamic_grouped_writes_skip_python_input_execution(
+ native_rest_catalog, bucket, partitioned):
+ daft = pytest.importorskip('daft')
+ from pypaimon.daft.daft_paimon import _write_table
+ from pypaimon.write.write_dispatch import NativeTableWrite
+
+ table = _table(native_rest_catalog, bucket, partitioned)
+ first = {'id': [1, 2], 'value': ['old', 'keep']}
+ second = {'id': [1, 3], 'value': ['new', 'added']}
+ overwritten = {'id': [1, 4], 'value': ['replaced', 'last']}
+ if partitioned:
+ first['p'], second['p'], overwritten['p'] = ['a', 'b'], ['a', 'c'],
['a', 'd']
+ with _forbid_python_input_execution(), \
+ patch.object(RESTCatalogServer, '_table_query_auth_handle',
+ side_effect=AssertionError('Distributed writes must
not request SELECT')) as query, \
+ patch.object(NativeTableWrite, '_switch_to_python',
side_effect=AssertionError('Writer replay')):
+ for rows, mode in [(first, 'append'), (second, 'append'),
(overwritten, 'overwrite')]:
+ assert _write_table(daft.from_pydict(rows), table,
mode).to_pydict()['rows']
+ query.assert_not_called()
+ builder = table.new_read_builder()
+ result =
builder.new_read().to_arrow(builder.new_scan().plan().splits()).sort_by([('id',
'ascending')])
+ expected = overwritten if not partitioned else {
+ 'id': [1, 2, 3, 4], 'value': ['replaced', 'keep', 'added', 'last'],
'p': ['a', 'b', 'c', 'd']}
+ assert result.to_pydict() == expected
diff --git
a/paimon-python/pypaimon/tests/write/distributed_input_execution_test.py
b/paimon-python/pypaimon/tests/write/distributed_input_execution_test.py
index d1ed56bdce..d45a511ac5 100644
--- a/paimon-python/pypaimon/tests/write/distributed_input_execution_test.py
+++ b/paimon-python/pypaimon/tests/write/distributed_input_execution_test.py
@@ -24,7 +24,7 @@ import pyarrow as pa
import pytest
from pypaimon import CatalogFactory, Schema
-from pypaimon.index.dynamic_bucket import HashBucketAssigner
+from pypaimon.index.dynamic_bucket import DynamicBucketAssigner
from pypaimon.read.plan import Plan
from pypaimon.read.split import DataSplit
from pypaimon.read.split_serializer import deserialize_split_v1,
serialize_split_v1
@@ -49,7 +49,7 @@ def forbid_python_execution():
for target in [
'execution.python.write.row_key_extractor.FixedBucketRowKeyExtractor.__init__',
'execution.python.write.row_key_extractor.DynamicBucketRowKeyExtractor.__init__',
-
'execution.python.write.row_key_extractor.SimpleHashBucketAssigner.__init__',
+
'execution.python.write.row_key_extractor.SimpleDynamicBucketAssigner.__init__',
'execution.python.read.scanner.file_scanner.FileScanner.__init__',
'execution.python.write.table_write.PythonTableWrite.__init__',
'execution.python.write.dynamic_bucket.DynamicBucketIndexMaintainer.__init__',
@@ -85,7 +85,7 @@ def
test_daft_fixed_and_dynamic_routing_do_not_construct_python_extractors(tmp_p
batch = pa.table(rows).to_batches()[0]
assert fixed['bucket'] ==
FixedBucketInputRouter(table.table_schema).extract_partition_bucket_batch(batch)[1]
# Only input hashing belongs to this stage; it must not construct even a
- # transient SimpleHashBucketAssigner or persistent HASH-index maintainer.
+ # transient SimpleDynamicBucketAssigner or persistent HASH-index
maintainer.
dynamic = _table(tmp_path / 'dynamic', '-1')
router = DynamicBucketInputRouter(dynamic.table_schema)
_, partition_hashes, key_hashes = router.extract_hashes_batch(batch)
@@ -116,41 +116,48 @@ def _seed_snapshot(table):
@pytest.mark.parametrize('ignore_existing', [False, True])
-def
test_daft_dynamic_assignment_restores_pinned_native_metadata_without_python_executor(tmp_path,
ignore_existing):
+def
test_daft_dynamic_assignment_dispatches_pinned_snapshot_and_arrow_hashes(tmp_path,
ignore_existing):
daft = pytest.importorskip('daft')
from pypaimon.daft.daft_datasink import make_dynamic_bucket_assignment_udf
table = _table(tmp_path, '-1')
- snapshot, plan = _seed_snapshot(table)
+ snapshot, _ = _seed_snapshot(table)
rows = {'id': [2, 1], 'value': ['other', 'new'], 'p': ['p', 'p']}
router = DynamicBucketInputRouter(table.table_schema)
_, _, hashes = router.extract_hashes_batch(pa.table(rows).to_batches()[0])
rows.update(assigner=[0, 0], hash=hashes)
+ execution = Mock()
+ execution.restore_snapshot_id = 0 if ignore_existing else snapshot.id
+ execution.assign.return_value = pa.array([0, 1] if ignore_existing else
[1, 0], type=pa.int32())
with ExitStack() as stack:
for target in [
'execution.python.write.row_key_extractor.DynamicBucketRowKeyExtractor.__init__',
-
'execution.python.write.row_key_extractor.SimpleHashBucketAssigner.__init__',
+
'execution.python.write.row_key_extractor.SimpleDynamicBucketAssigner.__init__',
'execution.python.read.scanner.file_scanner.FileScanner.__init__',
'execution.python.write.dynamic_bucket.DynamicBucketIndexMaintainer.__init__',
+ 'write.input_routing.DynamicBucketInputRouter.__init__',
+ 'index.dynamic_bucket.DynamicBucketAssigner.__init__',
]:
stack.enter_context(patch('pypaimon.' + target,
side_effect=AssertionError(target)))
stack.enter_context(patch('pypaimon.daft.daft_datasink._load_worker_table',
return_value=table))
+ factory = stack.enter_context(patch(
+ 'pypaimon.write.backend_dispatch.create_dynamic_bucket_assigner',
return_value=execution))
native_plan = stack.enter_context(patch(
- 'pypaimon.execution.native.read.plan.native_plan',
return_value=plan))
+ 'pypaimon.execution.native.read.plan.native_plan',
side_effect=AssertionError('Read authorization')))
udf = make_dynamic_bucket_assignment_udf(
table, 1, 1, 'bucket', 'hash', 'base_snapshot',
ignore_existing=ignore_existing, base_snapshot_id=snapshot.id)
- result = daft.from_pydict(rows).select(udf(*[daft.col(name) for name
in rows])).to_pydict()
+ # Match the Int32 transport emitted by the first routing stage.
+ frame = pa.table(rows).set_column(4, 'hash', pa.array(hashes,
type=pa.int32()))
+ result = daft.from_arrow(frame).select(udf(*[daft.col(name) for name
in rows])).to_pydict()
assert result['bucket'] == ([0, 1] if ignore_existing else [1, 0])
- assert result['base_snapshot'] == [snapshot.id, snapshot.id]
- if ignore_existing:
- native_plan.assert_not_called()
- else:
- native_plan.assert_called_once()
- assert native_plan.call_args[0][0].options.scan_snapshot_id() ==
snapshot.id
- assert native_plan.call_args[1]['all_files'] is True
- predicate = native_plan.call_args[1]['predicate']
- assert predicate.field == 'p' and predicate.literals == ['p']
+ assert result['base_snapshot'] == [0 if ignore_existing else snapshot.id]
* 2
+ native_plan.assert_not_called()
+ factory.assert_called_once_with(
+ table, 1, 1, 0, ignore_existing=ignore_existing,
base_snapshot_id=snapshot.id)
+ transported = execution.assign.call_args[0][0]
+ assert transported.to_pydict() == {key: rows[key] for key in ('id',
'value', 'p')}
+ assert execution.assign.call_args[1]['key_hashes'].equals(pa.array(hashes,
type=pa.int32()))
def
test_native_dynamic_metadata_preserves_missing_hash_index_validation(tmp_path):
@@ -161,7 +168,7 @@ def
test_native_dynamic_metadata_preserves_missing_hash_index_validation(tmp_pat
with patch('pypaimon.execution.native.read.plan.native_plan',
return_value=plan), \
patch('pypaimon.execution.python.read.scanner.file_scanner.FileScanner.__init__',
side_effect=AssertionError('Pure scanner')):
- assigner = HashBucketAssigner(table, 1, 1, 0, 1, -1, snapshot=snapshot)
+ assigner = DynamicBucketAssigner(table, 1, 1, 0, 1, -1,
snapshot=snapshot)
with pytest.raises(RuntimeError, match='no complete HASH index.*7'):
assigner.assign(('p',), 0, 123)
diff --git a/paimon-python/pypaimon/tests/write/dynamic_bucket_test.py
b/paimon-python/pypaimon/tests/write/dynamic_bucket_test.py
index fc5530b6e7..8db7e598f2 100644
--- a/paimon-python/pypaimon/tests/write/dynamic_bucket_test.py
+++ b/paimon-python/pypaimon/tests/write/dynamic_bucket_test.py
@@ -27,7 +27,7 @@ import pytest
from pypaimon import CatalogFactory, Schema
from pypaimon.index.dynamic_bucket import (
- HashBucketAssigner,
+ DynamicBucketAssigner,
_PartitionIndex,
_iter_hashes,
compute_assigner,
@@ -122,6 +122,28 @@ class DynamicBucketTest(unittest.TestCase):
min_int = -(2 ** 31)
self.assertEqual(compute_assigner(min_int, 0, 5, 5), 3)
self.assertEqual(compute_assigner(2, min_int, 5, 5), 0)
+ # Java int addition wraps before the final remainder.
+ self.assertEqual(compute_assigner(max_int - 1, max_int - 1, max_int,
max_int), -4)
+
+ def test_global_maximum_does_not_hide_an_unused_partition_bucket(self):
+ index = _PartitionIndex({10: 0}, {0: 1}, 1)
+ # A different partition already restored the global maximum bucket.
+ # Java PartitionIndex still allocates the unused local bucket 1.
+ self.assertEqual(index.assign(20, lambda _: True, 2, 1), 1)
+ self.assertEqual(index.assign(20, lambda _: True, 2, 1), 1)
+
+ def
test_reached_partition_bucket_limit_is_cached_and_hash_assignments_stay_stable(self):
+ index = _PartitionIndex({10: 1}, {1: 1}, 1)
+ owns_odd = Mock(side_effect=lambda bucket: bucket % 2 == 1)
+ self.assertEqual(index.assign(20, owns_odd, 4, 3), 3)
+ first_overflow = index.assign(30, owns_odd, 4, 3)
+ self.assertIn(first_overflow, (1, 3))
+ owns_odd.reset_mock()
+ for key in range(31, 50):
+ bucket = index.assign(key, owns_odd, 4, 3)
+ self.assertIn(bucket, (1, 3))
+ self.assertEqual(index.assign(key, owns_odd, 4, 3), bucket)
+ owns_odd.assert_not_called()
def test_binary_row_hash_matches_java_for_bucket_key_types(self):
# Generated with Java
InternalRowSerializer.toBinaryRow(...).hashCode().
@@ -226,7 +248,7 @@ class DynamicBucketTest(unittest.TestCase):
validate_bucket_id(32768)
def test_restore_rejects_bucket_id_above_java_short_range(self):
- assigner = HashBucketAssigner(
+ assigner = DynamicBucketAssigner(
table=Mock(),
num_channels=1,
num_assigners=1,
@@ -260,7 +282,7 @@ class DynamicBucketTest(unittest.TestCase):
def test_assigner_rejects_record_owned_by_another_writer(self):
with tempfile.TemporaryDirectory() as root:
table = self._create_table(root, 'wrong_assigner')
- assigner = HashBucketAssigner(
+ assigner = DynamicBucketAssigner(
table=table,
num_channels=2,
num_assigners=2,
diff --git a/paimon-python/pypaimon/tests/write/dynamic_input_routing_test.py
b/paimon-python/pypaimon/tests/write/dynamic_input_routing_test.py
new file mode 100644
index 0000000000..24ec5c9e88
--- /dev/null
+++ b/paimon-python/pypaimon/tests/write/dynamic_input_routing_test.py
@@ -0,0 +1,317 @@
+# 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.
+
+"""Distributed dynamic routing keeps Native errors and Arrow batches intact."""
+
+import pickle
+import sys
+from types import SimpleNamespace
+from unittest.mock import Mock, patch
+
+import pyarrow as pa
+import pytest
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.catalog.catalog_exception import TableNoPermissionException
+from pypaimon.execution.native.write.input_routing import (
+ NativeDynamicBucketAssigner, NativeDynamicBucketAssignerRouter,
NativeInputRouter, input_batches,
+)
+from pypaimon.execution.python.write.input_routing import (
+ PythonDynamicBucketAssigner, PythonDynamicBucketAssignerRouter,
+)
+from pypaimon.write.backend_dispatch import create_dynamic_bucket_assigner,
create_dynamic_bucket_assigner_router
+
+
[email protected]('row_count', [0, 3])
+def
test_arrow_transport_preserves_rows_when_all_columns_are_projected_out(row_count):
+ data = pa.table({'id': list(range(row_count))}).select([])
+ batches = input_batches(data)
+ assert sum(batch.num_rows for batch in batches) == row_count
+ assert all(batch.num_columns == 0 for batch in batches)
+
+
+def test_empty_arrow_transport_preserves_routing_schema_and_metadata():
+ schema = pa.schema([pa.field('id', pa.int64(), nullable=False)],
metadata={b'input': b'empty'})
+ data = pa.Table.from_batches([], schema=schema)
+ batches = input_batches(data)
+ assert len(batches) == 1
+ assert batches[0].num_rows == 0
+ assert batches[0].schema.equals(schema, check_metadata=True)
+
+
[email protected]('mode', ['dense', 'sparse'])
+def test_empty_arrow_transport_preserves_union_payload_columns(mode):
+ type_ids = pa.array([], type=pa.int8())
+ children = [pa.array([], type=pa.int64()), pa.array([], type=pa.string())]
+ payload = (pa.UnionArray.from_dense(type_ids, pa.array([],
type=pa.int32()), children)
+ if mode == 'dense' else pa.UnionArray.from_sparse(type_ids,
children))
+ # An ignored payload still travels with the batch; constructing it with
+ # pa.array([], type=union_type) is unsupported by Arrow.
+ data = pa.table({'id': pa.array([], type=pa.int64()), 'payload': payload})
+ batches = input_batches(data)
+ assert len(batches) == 1
+ assert batches[0].num_rows == 0
+ assert batches[0].schema.equals(data.schema, check_metadata=True)
+
+
[email protected]('operation', ['fixed', 'dynamic', 'assign'])
+def test_empty_input_is_still_validated_by_the_sdk(tmp_path, operation):
+ table = _table(tmp_path)
+ core = Mock()
+ if operation == 'fixed':
+ execution = NativeInputRouter(table)
+ execution._router = core
+ method = 'route'
+ elif operation == 'dynamic':
+ execution = NativeDynamicBucketAssignerRouter(table, 1, 1)
+ execution._router = core
+ method = 'route'
+ else:
+ execution = NativeDynamicBucketAssigner(table, 1, 1, 0)
+ execution._assigner = core
+ method = 'assign'
+ failure = ValueError('Missing routing column p')
+ getattr(core, method).side_effect = failure
+ schema = pa.schema([('id', pa.int64())], metadata={b'input': b'empty'})
+ data = pa.Table.from_batches([], schema=schema)
+ with pytest.raises(ValueError) as raised:
+ getattr(execution, method)(data)
+ assert raised.value is failure
+ transported = getattr(core, method).call_args.args[0]
+ assert transported.num_rows == 0
+ assert transported.schema.equals(schema, check_metadata=True)
+
+
+def _uncombined_table(batch):
+ chunked = pa.Table.from_batches([batch.slice(0, 1), batch.slice(1)])
+ data = Mock(spec=pa.Table)
+ data.schema = chunked.schema
+ data.num_rows = chunked.num_rows
+ data.to_batches.return_value = chunked.to_batches()
+ data.combine_chunks.return_value = chunked
+ return data
+
+
+def _table(tmp_path, native=True):
+ catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+ catalog.create_database('default', True)
+ catalog.create_table('default.t', Schema.from_pyarrow_schema(
+ pa.schema([('id', pa.int64()), ('p', pa.string()), ('value',
pa.string())]),
+ primary_keys=['id', 'p'], partition_keys=['p'], options={
+ 'bucket': '-1', 'write.native.enabled': str(native).lower(),
+ 'dynamic-bucket.target-row-num': '1',
+ }), False)
+ return catalog.get_table('default.t')
+
+
[email protected]
+def sdk(monkeypatch):
+ module = SimpleNamespace(DynamicBucketAssignerRouter=Mock(),
DynamicBucketAssigner=Mock())
+ monkeypatch.setitem(sys.modules, 'pypaimon_rust.datafusion', module)
+ return module
+
+
+def test_python_dynamic_router_and_assigner_share_arrow_contract(tmp_path):
+ table = _table(tmp_path, False)
+ batch = pa.table({'p': ['west', 'west', 'east'], 'id': [1, 2, 1]})
+ router = create_dynamic_bucket_assigner_router(table, 1, 1)
+ assert isinstance(router, PythonDynamicBucketAssignerRouter)
+ routed = router.route(batch)
+ assert routed.schema == pa.schema([('assigner', pa.int32()), ('key_hash',
pa.int32())])
+ assert routed.column(0).to_pylist() == [0, 0, 0]
+ assigner = create_dynamic_bucket_assigner(table, 1, 1, 0,
base_snapshot_id=0)
+ assert isinstance(assigner, PythonDynamicBucketAssigner)
+ assert assigner.restore_snapshot_id == 0
+ assert assigner.assign(batch, routed.column(1)).to_pylist() == [0, 1, 0]
+ assert assigner.assign(batch).to_pylist() == [0, 1, 0]
+
+
[email protected]('hashes,error', [
+ (pa.array([1], type=pa.int32()), ValueError),
+ (pa.array([1, None], type=pa.int32()), ValueError),
+ (pa.array([1, 2], type=pa.int64()), TypeError),
+])
+def test_python_dynamic_hash_transport_rejects_invalid_arrays(tmp_path,
hashes, error):
+ assigner = create_dynamic_bucket_assigner(_table(tmp_path, False), 1, 1,
0, base_snapshot_id=0)
+ with pytest.raises(error):
+ assigner.assign(pa.table({'id': [1, 2], 'p': ['west', 'west']}),
hashes)
+
+
+def
test_native_dynamic_router_pickle_rebuilds_without_python_hashing(tmp_path,
sdk):
+ table = _table(tmp_path)
+ native_table = Mock()
+ batch = pa.record_batch([[1, 2], ['west', 'west']], names=['id', 'p'])
+ expected = pa.record_batch([pa.array([0, 1], type=pa.int32()),
pa.array([42, -3], type=pa.int32())],
+ names=['assigner', 'key_hash'])
+ sdk.DynamicBucketAssignerRouter.return_value.route.side_effect =
[expected.slice(0, 1), expected.slice(1)]
+ with
patch('pypaimon.execution.native.write.input_routing.native_runtime_available',
return_value=True), \
+
patch('pypaimon.execution.native.write.input_routing.create_native_write_table',
+ return_value=native_table), \
+
patch('pypaimon.write.input_routing.DynamicBucketInputRouter.__init__',
+ side_effect=AssertionError('Python hashing')):
+ router = create_dynamic_bucket_assigner_router(table, 2, 2)
+ assert isinstance(router, NativeDynamicBucketAssignerRouter)
+ router._router = lambda: None
+ restored = pickle.loads(pickle.dumps(router))
+ assert restored._router is None
+ assert restored.route(pa.Table.from_batches([batch.slice(0, 1),
batch.slice(1)])).equals(expected)
+ assert sdk.DynamicBucketAssignerRouter.call_count == 2
+
sdk.DynamicBucketAssignerRouter.assert_called_with(native_table.schema.return_value,
2, 2)
+ assert [call.args[0].to_pydict() for call in
sdk.DynamicBucketAssignerRouter.return_value.route.call_args_list] == [
+ batch.slice(0, 1).to_pydict(), batch.slice(1).to_pydict()]
+
+
+def
test_native_dynamic_routing_preserves_all_batches_and_sdk_output_schema(tmp_path,
sdk):
+ router = NativeDynamicBucketAssignerRouter(_table(tmp_path), 2, 2)
+ router._router = sdk.DynamicBucketAssignerRouter.return_value
+ schema = pa.schema([
+ pa.field('assigner', pa.int32(), nullable=False),
+ pa.field('key_hash', pa.int32(), nullable=False),
+ ], metadata={b'output': b'routing'})
+ expected = pa.record_batch([[0, 1, 0], [42, -3, 42]], schema=schema)
+ router._router.route.side_effect = [expected.slice(0, 1),
expected.slice(1)]
+ data = _uncombined_table(pa.record_batch([[1, 2, 1]], names=['id']))
+ actual = router.route(data)
+ assert actual.equals(expected, check_metadata=True)
+ assert [call.args[0].num_rows for call in
router._router.route.call_args_list] == [1, 2]
+ data.combine_chunks.assert_not_called()
+
+
[email protected]('precomputed', [False, True])
+def
test_native_assignment_keeps_state_and_slices_hashes_across_input_batches(tmp_path,
precomputed):
+ assigner = NativeDynamicBucketAssigner(_table(tmp_path), 1, 1, 0)
+ core = assigner._assigner = Mock()
+ core.assign.side_effect = [pa.array([0], type=pa.int32()), pa.array([1,
0], type=pa.int32())]
+ batch = pa.record_batch([['west', 'west', 'west'], [1, 2, 1]], names=['p',
'id'])
+ data = _uncombined_table(batch)
+ hashes = pa.chunked_array([pa.array([42, -3], type=pa.int32()),
pa.array([42], type=pa.int32())])
+ actual = assigner.assign(data, hashes if precomputed else None)
+ assert actual.to_pylist() == [0, 1, 0]
+ assert [call.args[0].num_rows for call in core.assign.call_args_list] ==
[1, 2]
+ actual_hashes = [call.kwargs['key_hashes'] for call in
core.assign.call_args_list]
+ if precomputed:
+ assert [values.to_pylist() for values in actual_hashes] == [[42], [-3,
42]]
+ else:
+ assert actual_hashes == [None, None]
+ data.combine_chunks.assert_not_called()
+
+
[email protected]('hashes,error', [
+ (pa.chunked_array([pa.array([1], type=pa.int32()), pa.array([2, None],
type=pa.int32())]), ValueError),
+ (pa.array([1, 2], type=pa.int32()), ValueError),
+ (pa.array([1, 2, 3, 4], type=pa.int32()), ValueError),
+ (pa.array([1, 2, 3], type=pa.int64()), TypeError),
+ ([1, 2, 3], TypeError),
+])
+def
test_native_assignment_validates_all_hashes_before_accepting_any_input(tmp_path,
hashes, error):
+ assigner = NativeDynamicBucketAssigner(_table(tmp_path), 1, 1, 0)
+ core = assigner._assigner = Mock()
+ data = _uncombined_table(pa.record_batch([[1, 2, 3]], names=['id']))
+ with pytest.raises(error):
+ assigner.assign(data, hashes)
+ core.assign.assert_not_called()
+
+
[email protected]('operation', ['route', 'assign'])
+def test_native_dynamic_later_batch_errors_are_terminal(tmp_path, sdk,
operation):
+ if operation == 'route':
+ execution = NativeDynamicBucketAssignerRouter(_table(tmp_path), 1, 1)
+ core = execution._router = sdk.DynamicBucketAssignerRouter.return_value
+ first = pa.record_batch([pa.array([0], type=pa.int32()),
pa.array([42], type=pa.int32())],
+ names=['assigner', 'key_hash'])
+ else:
+ execution = NativeDynamicBucketAssigner(_table(tmp_path), 1, 1, 0)
+ core = execution._assigner = Mock()
+ first = pa.array([0], type=pa.int32())
+ failure = ValueError('later input batch failed')
+ getattr(core, operation).side_effect = [first, failure]
+ data = _uncombined_table(pa.record_batch([[1, 2, 3]], names=['id']))
+ with
patch('pypaimon.execution.python.write.input_routing.PythonDynamicBucketAssignerRouter.__init__',
+ side_effect=AssertionError('Python replay')), \
+
patch('pypaimon.execution.python.write.input_routing.PythonDynamicBucketAssigner.__init__',
+ side_effect=AssertionError('Python replay')):
+ with pytest.raises(ValueError) as raised:
+ getattr(execution, operation)(data)
+ assert raised.value is failure
+
+
[email protected]('overwrite', [False, True])
[email protected]('base_snapshot_id', [None, 0, 7])
+def
test_native_assignment_uses_dedicated_constructor_and_precomputed_arrow_hashes(
+ tmp_path, sdk, overwrite, base_snapshot_id):
+ table = _table(tmp_path)
+ native_table = Mock()
+ core = sdk.DynamicBucketAssigner.return_value
+ core.restore_snapshot_id = 0 if overwrite or base_snapshot_id == 0 else 7
+ core.assign.return_value = pa.array([1, 0], type=pa.int32())
+ batch = pa.record_batch([[2, 1], ['west', 'west']], names=['id', 'p'])
+ hashes = pa.chunked_array([pa.array([42], type=pa.int32()), pa.array([-3],
type=pa.int32())])
+ with
patch('pypaimon.execution.native.write.input_routing.native_runtime_available',
return_value=True), \
+
patch('pypaimon.execution.native.write.input_routing.create_native_write_table',
+ return_value=native_table), \
+ patch.object(table, 'new_batch_write_builder',
side_effect=AssertionError('Python write builder')), \
+
patch('pypaimon.index.dynamic_bucket.DynamicBucketAssigner.__init__',
+ side_effect=AssertionError('Python index restoration')):
+ assigner = create_dynamic_bucket_assigner(table, 2, 2, 1, overwrite,
base_snapshot_id)
+ assert isinstance(assigner, NativeDynamicBucketAssigner)
+ assert assigner.restore_snapshot_id == core.restore_snapshot_id
+ assert assigner.assign(batch, hashes) is core.assign.return_value
+ native_table.new_batch_write_builder.assert_not_called()
+ sdk.DynamicBucketAssigner.assert_called_once_with(
+ native_table, 2, 2, 1, restore_snapshot_id=base_snapshot_id,
overwrite=overwrite)
+ assert core.assign.call_args[0][0] is batch
+ assert
core.assign.call_args[1]['key_hashes'].equals(hashes.combine_chunks())
+
+
[email protected]('operation', ['route', 'assign'])
[email protected]('phase', ['prepare', 'execute'])
+def test_native_dynamic_errors_never_replay_python(tmp_path, sdk, operation,
phase):
+ table = _table(tmp_path)
+ failure = TableNoPermissionException(table.identifier)
+ native_table = Mock()
+ core = (sdk.DynamicBucketAssignerRouter.return_value if operation ==
'route' else
+ sdk.DynamicBucketAssigner.return_value)
+ core.restore_snapshot_id = 0
+ if phase == 'prepare':
+ if operation == 'route':
+ sdk.DynamicBucketAssignerRouter.side_effect = failure
+ else:
+ sdk.DynamicBucketAssigner.side_effect = failure
+ else:
+ getattr(core, operation).side_effect = failure
+ with
patch('pypaimon.execution.native.write.input_routing.native_runtime_available',
return_value=True), \
+
patch('pypaimon.execution.native.write.input_routing.create_native_write_table',
+ return_value=native_table), \
+
patch('pypaimon.execution.python.write.input_routing.PythonDynamicBucketAssignerRouter.__init__',
+ side_effect=AssertionError('Python replay')), \
+
patch('pypaimon.execution.python.write.input_routing.PythonDynamicBucketAssigner.__init__',
+ side_effect=AssertionError('Python replay')):
+ with pytest.raises(TableNoPermissionException) as raised:
+ execution = (create_dynamic_bucket_assigner_router(table, 1, 1) if
operation == 'route' else
+ create_dynamic_bucket_assigner(table, 1, 1, 0))
+ getattr(execution, operation)(pa.record_batch([[1], ['west']],
names=['id', 'p']))
+ assert raised.value is failure
+
+
[email protected]('operation', ['route', 'assign'])
+def test_missing_native_environment_selects_python_before_input(tmp_path,
operation):
+ table = _table(tmp_path)
+ with
patch('pypaimon.execution.native.write.input_routing.native_runtime_available',
return_value=False):
+ execution = (create_dynamic_bucket_assigner_router(table, 1, 1) if
operation == 'route' else
+ create_dynamic_bucket_assigner(table, 1, 1, 0,
base_snapshot_id=0))
+ expected_type = PythonDynamicBucketAssignerRouter if operation == 'route'
else PythonDynamicBucketAssigner
+ assert isinstance(execution, expected_type)
diff --git a/paimon-python/pypaimon/tests/write/native_input_routing_test.py
b/paimon-python/pypaimon/tests/write/native_input_routing_test.py
index da3fc398ea..fdb5e70bec 100644
--- a/paimon-python/pypaimon/tests/write/native_input_routing_test.py
+++ b/paimon-python/pypaimon/tests/write/native_input_routing_test.py
@@ -100,6 +100,41 @@ def
test_native_router_passes_arrow_without_python_row_hashing(tmp_path, sdk):
sdk.BucketRouter.return_value.route.assert_called_once_with(batch)
+def _uncombined_table(batch):
+ # Arrow may keep multiple binary chunks to avoid a buffer overflow. Model
+ # that contract with small batches instead of allocating several GiB.
+ chunked = pa.Table.from_batches([batch.slice(0, 1), batch.slice(1)])
+ data = Mock(spec=pa.Table)
+ data.schema = chunked.schema
+ data.num_rows = chunked.num_rows
+ data.to_batches.return_value = chunked.to_batches()
+ data.combine_chunks.return_value = chunked
+ return data
+
+
+def test_native_router_preserves_every_batch_of_binary_input(tmp_path, sdk):
+ batch = pa.record_batch([['west', 'east', 'west'], [1, 2, 3]], names=['p',
'id'])
+ router = NativeInputRouter(_table(tmp_path))
+ router._router = sdk.BucketRouter.return_value
+ router._router.route.side_effect = [pa.array([2], type=pa.int32()),
pa.array([1, 3], type=pa.int32())]
+ data = _uncombined_table(batch)
+ assert router.route(data).to_pylist() == [2, 1, 3]
+ assert [call.args[0].num_rows for call in
router._router.route.call_args_list] == [1, 2]
+ data.combine_chunks.assert_not_called()
+
+
+def test_native_router_later_batch_failure_is_terminal(tmp_path, sdk):
+ router = NativeInputRouter(_table(tmp_path))
+ router._router = sdk.BucketRouter.return_value
+ failure = ValueError('later routing batch failed')
+ router._router.route.side_effect = [pa.array([0], type=pa.int32()),
failure]
+ data = _uncombined_table(pa.record_batch([[1, 2, 3]], names=['id']))
+ with patch.object(FixedBucketInputRouter, '__init__',
side_effect=AssertionError('Python replay')):
+ with pytest.raises(ValueError) as raised:
+ router.route(data)
+ assert raised.value is failure
+
+
def test_native_router_failure_propagates_without_selecting_python(tmp_path,
sdk):
table = _table(tmp_path)
router = NativeInputRouter(table)
@@ -159,6 +194,22 @@ def
test_native_planner_transports_group_statistics_positionally(tmp_path, sdk):
assert grouped.column(1).type == pa.int64() and grouped.column(2).type ==
pa.int64()
+def
test_native_planner_combines_partition_statistics_from_all_input_batches(tmp_path,
sdk):
+ table = _table(tmp_path, bucket='-2')
+ planner = NativePostponeBucketPlanner(table, {}, Mock())
+ schema = pa.schema([('p', pa.string()), ('row_count', pa.int64()),
('data_size', pa.int64())])
+ sdk.PostponeBucketPlanner.return_value.input_statistics.side_effect = [
+ pa.record_batch([['west'], [1], [10]], schema=schema),
+ pa.record_batch([['west', 'east'], [1, 1], [20, 30]], schema=schema),
+ ]
+ batch = pa.record_batch([['west', 'west', 'east'], [1, 2, 3]], names=['p',
'id'])
+ data = _uncombined_table(batch)
+ assert planner.input_partition_stats(data) == {('west',): (2, 30),
('east',): (1, 30)}
+ assert [call.args[0].num_rows
+ for call in
sdk.PostponeBucketPlanner.return_value.input_statistics.call_args_list] == [1,
2]
+ data.combine_chunks.assert_not_called()
+
+
def
test_native_planner_pickle_keeps_resolved_counts_and_worker_failures(tmp_path,
sdk):
table = _table(tmp_path, bucket='-2')
core = sdk.PostponeBucketPlanner.return_value
diff --git
a/paimon-python/pypaimon/tests/write/simple_hash_bucket_assigner_test.py
b/paimon-python/pypaimon/tests/write/simple_dynamic_bucket_assigner_test.py
similarity index 88%
rename from
paimon-python/pypaimon/tests/write/simple_hash_bucket_assigner_test.py
rename to
paimon-python/pypaimon/tests/write/simple_dynamic_bucket_assigner_test.py
index ee9405658c..d68c925f53 100644
--- a/paimon-python/pypaimon/tests/write/simple_hash_bucket_assigner_test.py
+++ b/paimon-python/pypaimon/tests/write/simple_dynamic_bucket_assigner_test.py
@@ -18,13 +18,13 @@
import unittest
from unittest.mock import patch
-from pypaimon.execution.python.write.row_key_extractor import
SimpleHashBucketAssigner
+from pypaimon.execution.python.write.row_key_extractor import
SimpleDynamicBucketAssigner
-class SimpleHashBucketAssignerTest(unittest.TestCase):
+class SimpleDynamicBucketAssignerTest(unittest.TestCase):
def test_assign(self):
- assigner = SimpleHashBucketAssigner(2, 0, 100, -1)
+ assigner = SimpleDynamicBucketAssigner(2, 0, 100, -1)
partition = ()
buckets = [assigner.assign(partition, h) for h in range(301)]
for b in buckets[:100]:
@@ -34,7 +34,7 @@ class SimpleHashBucketAssignerTest(unittest.TestCase):
self.assertEqual(buckets[200], 4)
def test_assign_with_upper_bound(self):
- assigner = SimpleHashBucketAssigner(1, 0, 100, 3)
+ assigner = SimpleDynamicBucketAssigner(1, 0, 100, 3)
partition = ()
buckets = [assigner.assign(partition, h) for h in range(400)]
for b in buckets[:100]:
@@ -49,7 +49,7 @@ class SimpleHashBucketAssignerTest(unittest.TestCase):
def test_assign_with_same_hash(self):
for max_buckets in [-1, 1, 2]:
with self.subTest(max_buckets=max_buckets):
- assigner = SimpleHashBucketAssigner(1, 0, 100, max_buckets)
+ assigner = SimpleDynamicBucketAssigner(1, 0, 100, max_buckets)
partition = ()
hashes = list(range(100)) + list(range(100))
buckets = [assigner.assign(partition, h) for h in hashes]
@@ -59,7 +59,7 @@ class SimpleHashBucketAssignerTest(unittest.TestCase):
def test_maximum_bucket_id(self):
for max_buckets in [-1, 32768]:
with self.subTest(max_buckets=max_buckets):
- assigner = SimpleHashBucketAssigner(
+ assigner = SimpleDynamicBucketAssigner(
32768, 32767, 1, max_buckets)
self.assertEqual(assigner.assign((), 1), 32767)
self.assertEqual(assigner.assign((), 1), 32767)
@@ -69,7 +69,7 @@ class SimpleHashBucketAssignerTest(unittest.TestCase):
ValueError,
"'dynamic-bucket.max-buckets' must be -1 or between 1 and 32768",
):
- SimpleHashBucketAssigner(1, 0, 100, 40000)
+ SimpleDynamicBucketAssigner(1, 0, 100, 40000)
def test_register_each_bucket_once(self):
for num_assigners, assign_id, max_buckets, expected in [
@@ -80,14 +80,14 @@ class SimpleHashBucketAssignerTest(unittest.TestCase):
]:
with self.subTest(num_assigners=num_assigners, assign_id=assign_id,
max_buckets=max_buckets):
- assigner = SimpleHashBucketAssigner(num_assigners, assign_id,
2, max_buckets)
+ assigner = SimpleDynamicBucketAssigner(num_assigners,
assign_id, 2, max_buckets)
for h, expected_bucket in enumerate(expected):
self.assertEqual(assigner.assign((), h), expected_bucket)
index = assigner._partition_index[()]
self.assertCountEqual(index.bucket_list,
index.bucket_information)
def test_overflow_uses_all_registered_buckets(self):
- assigner = SimpleHashBucketAssigner(1, 0, 2, 3)
+ assigner = SimpleDynamicBucketAssigner(1, 0, 2, 3)
initial = [assigner.assign((), h) for h in range(6)]
self.assertEqual(initial, [0, 0, 1, 1, 2, 2])
index = assigner._partition_index[()]
diff --git a/paimon-python/pypaimon/write/backend_dispatch.py
b/paimon-python/pypaimon/write/backend_dispatch.py
index f62e553fc2..f6f20f269e 100644
--- a/paimon-python/pypaimon/write/backend_dispatch.py
+++ b/paimon-python/pypaimon/write/backend_dispatch.py
@@ -220,6 +220,37 @@ def create_input_router(table, bucket_plan=None):
return FixedBucketInputRouter(table.table_schema)
+def create_dynamic_bucket_assigner_router(table, num_channels, num_assigners):
+ """Select routing to dynamic bucket assigners before a task receives
rows."""
+ if table.options.native_write_enabled():
+ from pypaimon.execution.native.write.input_routing import
create_native_dynamic_bucket_assigner_router
+ try:
+ native = create_native_dynamic_bucket_assigner_router(table,
num_channels, num_assigners)
+ except NotImplementedError:
+ native = None
+ if native is not None:
+ return native
+ from pypaimon.execution.python.write.input_routing import
PythonDynamicBucketAssignerRouter
+ return PythonDynamicBucketAssignerRouter(table, num_channels,
num_assigners)
+
+
+def create_dynamic_bucket_assigner(table, num_channels, num_assigners,
assign_id,
+ ignore_existing=False,
base_snapshot_id=None):
+ """Select restoration before assignment; authorization failures are
terminal."""
+ if table.options.native_write_enabled():
+ from pypaimon.execution.native.write.input_routing import
create_native_dynamic_bucket_assigner
+ # Resolving the writer snapshot can authorize and read HASH metadata.
+ # Only an explicitly unsupported environment returns None; never turn
+ # an authorization/restoration exception into another backend attempt.
+ native = create_native_dynamic_bucket_assigner(
+ table, num_channels, num_assigners, assign_id, ignore_existing,
base_snapshot_id)
+ if native is not None:
+ return native
+ from pypaimon.execution.python.write.input_routing import
PythonDynamicBucketAssigner
+ return PythonDynamicBucketAssigner(
+ table, num_channels, num_assigners, assign_id, ignore_existing,
base_snapshot_id)
+
+
def load_postpone_bucket_metadata(table):
"""Select metadata planning before public bucket arithmetic consumes
input."""
if table.options.native_write_enabled():