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 c0a939a96c [Python] Execute Ray bucket routing and postpone planning
in Rust (#10770)
c0a939a96c is described below
commit c0a939a96c252ac22abb62220879c7aa3815327b
Author: Jingsong Lee <[email protected]>
AuthorDate: Fri Oct 23 17:44:49 2026 +0800
[Python] Execute Ray bucket routing and postpone planning in Rust (#10770)
---
docs/docs/pypaimon/ray-data.md | 6 +
.../execution/native/write/input_routing.py | 73 ++++++++
.../execution/native/write/postpone_bucket.py | 102 +++++++++--
paimon-python/pypaimon/ray/shuffle.py | 20 +--
paimon-python/pypaimon/table/row/generic_row.py | 31 +++-
.../pypaimon/tests/native_input_routing_test.py | 192 +++++++++++++++++++++
.../pypaimon/tests/noncompact_null_row_test.py | 81 +++++++++
.../pypaimon/tests/test_ray_shuffle_helper.py | 38 ++--
.../tests/write/native_input_routing_test.py | 180 +++++++++++++++++++
.../write/postpone_execution_boundary_test.py | 44 ++++-
paimon-python/pypaimon/write/backend_dispatch.py | 16 ++
paimon-python/pypaimon/write/input_routing.py | 8 +
paimon-python/pypaimon/write/ray_datasink.py | 9 +-
13 files changed, 741 insertions(+), 59 deletions(-)
diff --git a/docs/docs/pypaimon/ray-data.md b/docs/docs/pypaimon/ray-data.md
index 5566ee9e36..135f83f654 100644
--- a/docs/docs/pypaimon/ray-data.md
+++ b/docs/docs/pypaimon/ray-data.md
@@ -278,6 +278,12 @@ including the default dynamic-bucket primary-key table
(`bucket = -1`).
Ray write tasks create independent Paimon writers, which can assign
overlapping buckets or sequence numbers for those modes.
+With `write.native.enabled=true`, fixed and postpone input bucket routing runs
+in Rust, including POSTPONE per-partition input statistics and bucket-count
+inference. Ray still materializes, groups, and schedules the input; workers
+reconstruct native routing state from the shared plan. The grouping and
+single-writer ownership rules above remain the same.
+
**Parameters:**
- `dataset`: the Ray Dataset to write.
- `table_identifier`: full table name, e.g. `"db_name.table_name"`.
diff --git a/paimon-python/pypaimon/execution/native/write/input_routing.py
b/paimon-python/pypaimon/execution/native/write/input_routing.py
new file mode 100644
index 0000000000..461cba52f1
--- /dev/null
+++ b/paimon-python/pypaimon/execution/native/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 transport for Rust's distributed input bucket routing."""
+
+import pyarrow as pa
+
+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):
+ if isinstance(data, pa.RecordBatch):
+ return data
+ if isinstance(data, pa.Table):
+ return 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')
+
+
+def create_native_input_router(table, bucket_plan=None):
+ if not native_runtime_available():
+ return None
+ native_table = create_native_write_table(table)
+ if native_table is None:
+ return None
+ router = NativeInputRouter(table, bucket_plan)
+ router._prepare(native_table)
+ return router
+
+
+class NativeInputRouter:
+ """Keep serializable configuration while Rust owns every input-row hash."""
+
+ def __init__(self, table, bucket_plan=None):
+ self.table = table
+ self._bucket_plan = bucket_plan
+ self._router = None
+
+ def _prepare(self, native_table=None):
+ from pypaimon_rust.datafusion import BucketRouter
+ if native_table is None:
+ native_table = create_native_write_table(self.table)
+ if native_table is None:
+ raise RuntimeError('Native input bucket routing is unavailable')
+ router = BucketRouter(native_table.schema())
+ if self._bucket_plan is not None:
+ router =
router.with_bucket_plan(self._bucket_plan.to_arrow(self.table))
+ self._router = router
+
+ def route(self, data) -> pa.Int32Array:
+ if self._router is None:
+ self._prepare()
+ return self._router.route(as_record_batch(data))
+
+ def __getstate__(self):
+ state = dict(self.__dict__)
+ state['_router'] = None
+ return state
diff --git a/paimon-python/pypaimon/execution/native/write/postpone_bucket.py
b/paimon-python/pypaimon/execution/native/write/postpone_bucket.py
index 2bb38944b5..acb3823aa4 100644
--- a/paimon-python/pypaimon/execution/native/write/postpone_bucket.py
+++ b/paimon-python/pypaimon/execution/native/write/postpone_bucket.py
@@ -15,32 +15,114 @@
# specific language governing permissions and limitations
# under the License.
-"""Read existing bucket layouts through Rust before distributed input work."""
+"""Arrow transport for Rust's postpone bucket statistics and planning."""
+
+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.native_commit import
create_native_write_table
-from pypaimon.write.bucket_planning import PostponeBucketPlanning
+from pypaimon.schema.data_types import PyarrowFieldParser
+from pypaimon.write.bucket_plan import PostponeBucketPlan
def create_native_postpone_bucket_planner(table, known_num_buckets=None):
if not native_runtime_available():
return None
- return NativePostponeBucketPlanner(table, known_num_buckets)
+ native_table = create_native_write_table(table)
+ if native_table is None:
+ return None
+ return NativePostponeBucketPlanner(table, known_num_buckets, native_table)
+
+
+def _plan_dict(plan):
+ # Both Core and Python use positional columns. Partition keys may be
+ # called total_buckets, row_count or data_size.
+ columns = [column.to_pylist() for column in plan.columns]
+ return {tuple(column[row] for column in columns[:-1]): columns[-1][row]
+ for row in range(plan.num_rows)}
+
+
+class NativePostponeBucketPlanner:
+ """Keep Python plans serializable while Rust evaluates all input rows."""
+
+ def __init__(self, table, known_num_buckets=None, native_table=None):
+ self.table = table
+ self._planner = None
+ # Preserve the attributes exposed by the existing public planner.
+ options = table.options
+ self.default_bucket_num = options.postpone_default_bucket_num()
+ self.target_row_num_per_bucket = None
+ self.target_size_per_bucket = None
+ if self.default_bucket_num is None:
+ self.max_num_buckets =
options.postpone_batch_write_fixed_bucket_max_parallelism()
+ self.target_row_num_per_bucket =
options.postpone_target_row_num_per_bucket()
+ if self.target_row_num_per_bucket is None:
+ self.target_size_per_bucket =
options.postpone_target_size_per_bucket()
+ if native_table is None:
+ native_table = create_native_write_table(table)
+ if native_table is None:
+ raise RuntimeError('Native postpone bucket planning is
unavailable')
+ if known_num_buckets is None:
+ known_num_buckets = self._load_bucket_metadata(table, native_table)
+ self._known_num_buckets = dict(known_num_buckets)
+ self._prepare(native_table)
+
+ def _prepare(self, native_table=None):
+ from pypaimon_rust.datafusion import PostponeBucketPlanner
+ if native_table is None:
+ native_table = create_native_write_table(self.table)
+ if native_table is None:
+ raise RuntimeError('Native postpone bucket planning is
unavailable')
+ self._planner = PostponeBucketPlanner(
+ native_table.schema(),
PostponeBucketPlan(self._known_num_buckets).to_arrow(self.table))
+ def _ensure_planner(self):
+ if self._planner is None:
+ self._prepare()
+ return self._planner
-class NativePostponeBucketPlanner(PostponeBucketPlanning):
- """Use Rust planning metadata and shared input-only bucket arithmetic."""
+ def current_plan(self):
+ self._known_num_buckets =
_plan_dict(self._ensure_planner().current_plan())
+ 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)}
+
+ def plan(self, partition_stats):
+ fields = [self.table.field_dict[name] for name in
self.table.partition_keys]
+ schema = PyarrowFieldParser.from_paimon_schema(fields)
+ schema = schema.append(pa.field('row_count', pa.int64(),
nullable=False))
+ schema = schema.append(pa.field('data_size', pa.int64(),
nullable=False))
+ partitions = list(partition_stats)
+ if any(len(partition) != len(fields) for partition in partitions):
+ raise ValueError('Input statistics partition arity does not match
the table schema')
+ columns = [pa.array([partition[i] for partition in partitions],
type=field.type)
+ for i, field in enumerate(list(schema)[:-2])]
+ columns.extend([
+ pa.array([partition_stats[partition][i] for partition in
partitions], type=pa.int64())
+ for i in range(2)])
+ plan = self._ensure_planner().plan(pa.RecordBatch.from_arrays(columns,
schema=schema))
+ self._known_num_buckets = _plan_dict(plan)
+ return PostponeBucketPlan(self._known_num_buckets)
+
+ def __getstate__(self):
+ state = dict(self.__dict__)
+ state['_planner'] = None
+ return state
@staticmethod
- def _load_bucket_metadata(table):
+ def _load_bucket_metadata(table, native_table=None):
# Writer restoration uses the catalog's write identity. Query plans
# would add SELECT rules and can combine different physical layouts.
- native_table = create_native_write_table(table)
+ if native_table is None:
+ native_table = create_native_write_table(table)
if native_table is None:
raise RuntimeError('Native bucket metadata planning is
unavailable')
plan =
native_table.new_postpone_fixed_bucket_write_builder().resolve_bucket_plan()
# A partition key may itself be called total_buckets. Keep the
# positional schema used by the Core plan and with_bucket_plan input.
- columns = [column.to_pylist() for column in plan.columns]
- return {tuple(column[row] for column in columns[:-1]): columns[-1][row]
- for row in range(plan.num_rows)}
+ return _plan_dict(plan)
diff --git a/paimon-python/pypaimon/ray/shuffle.py
b/paimon-python/pypaimon/ray/shuffle.py
index 4dfb63a379..71ea575adb 100644
--- a/paimon-python/pypaimon/ray/shuffle.py
+++ b/paimon-python/pypaimon/ray/shuffle.py
@@ -130,15 +130,15 @@ def maybe_apply_repartition(
def _group_by_partition_bucket(
dataset: "ray.data.Dataset",
table: "Table",
- extractor=None,
+ router=None,
):
partition_keys = list(table.table_schema.partition_keys or [])
- if extractor is None:
- from pypaimon.write.input_routing import FixedBucketInputRouter
- extractor = FixedBucketInputRouter(table.table_schema)
+ if router is None:
+ from pypaimon.write.backend_dispatch import create_input_router
+ router = create_input_router(table)
col_names = set(f.name for f in table.table_schema.fields)
bucket_col = _pick_bucket_col_name(col_names)
- bucket_udf = _make_bucket_udf(extractor, bucket_col)
+ bucket_udf = _make_bucket_udf(router, bucket_col)
ds_with_bucket = dataset.map_batches(
bucket_udf, batch_format="pyarrow", zero_copy_batch=True,
@@ -151,11 +151,10 @@ def _identity_batch(batch: pa.Table) -> pa.Table:
return batch
-def _make_bucket_udf(extractor, bucket_col):
+def _make_bucket_udf(router, bucket_col):
"""Build a map_batches UDF that appends a transient bucket column.
- The bucket value comes from ``extract_partition_bucket_batch`` so it
- matches the writer's bucket assignment for the same row exactly.
+ The prepared router uses the writer's bucket assignment for each row.
"""
def _udf(batch: pa.Table) -> pa.Table:
if batch.num_rows == 0:
@@ -163,9 +162,6 @@ def _make_bucket_udf(extractor, bucket_col):
bucket_col, pa.array([], type=pa.int32())
)
record_batch = batch.combine_chunks().to_batches()[0]
- _, buckets = extractor.extract_partition_bucket_batch(record_batch)
- return batch.append_column(
- bucket_col, pa.array(buckets, type=pa.int32())
- )
+ return batch.append_column(bucket_col, router.route(record_batch))
return _udf
diff --git a/paimon-python/pypaimon/table/row/generic_row.py
b/paimon-python/pypaimon/table/row/generic_row.py
index 07659ba503..31a7701edb 100644
--- a/paimon-python/pypaimon/table/row/generic_row.py
+++ b/paimon-python/pypaimon/table/row/generic_row.py
@@ -455,8 +455,11 @@ class GenericRowSerializer:
field_fixed_offset = null_bits_size_in_bytes + i * 8
if value is None:
- cls._set_null_bit(fixed_part, 0, i)
- struct.pack_into('<q', fixed_part, field_fixed_offset, 0)
+ null_data = cls._serialize_null_field(
+ fixed_part, field_fixed_offset, i, field.type,
+ fixed_part_size + current_variable_offset)
+ variable_part_data.append(null_data)
+ current_variable_offset += len(null_data)
continue
if not isinstance(field.type, AtomicType):
@@ -486,8 +489,11 @@ class GenericRowSerializer:
d = value if isinstance(value, Decimal) else
Decimal(str(value))
unscaled_value, overflow = _decimal_to_unscaled_with_check(d,
precision, scale)
if overflow:
- cls._set_null_bit(fixed_part, 0, i)
- struct.pack_into('<q', fixed_part, field_fixed_offset, 0)
+ null_data = cls._serialize_null_field(
+ fixed_part, field_fixed_offset, i, field.type,
+ fixed_part_size + current_variable_offset)
+ variable_part_data.append(null_data)
+ current_variable_offset += len(null_data)
continue
if is_non_compact_timestamp:
@@ -566,6 +572,23 @@ class GenericRowSerializer:
arity_prefix = struct.pack('>i', arity)
return arity_prefix + row_data
+ @classmethod
+ def _serialize_null_field(cls, fixed_part, field_offset, index, data_type,
variable_offset):
+ cls._set_null_bit(fixed_part, 0, index)
+ reserved_size = 0
+ if isinstance(data_type, AtomicType):
+ type_name = data_type.type.upper()
+ if type_name.startswith(('DECIMAL', 'NUMERIC')):
+ if _parse_type_precision_scale(data_type)[0] > 18:
+ reserved_size = 16
+ elif type_name.startswith('TIMESTAMP'):
+ if _parse_type_precision_scale(data_type)[0] > 3:
+ reserved_size = 8
+ # Java reserves updateable space even for NULL noncompact values and
+ # keeps its cursor in the fixed slot, with a zero length or nanos word.
+ struct.pack_into('<q', fixed_part, field_offset, variable_offset << 32
if reserved_size else 0)
+ return b'\x00' * reserved_size
+
@classmethod
def _calculate_bit_set_width_in_bytes(cls, arity: int) -> int:
return ((arity + 63 + cls.HEADER_SIZE_IN_BITS) // 64) * 8
diff --git a/paimon-python/pypaimon/tests/native_input_routing_test.py
b/paimon-python/pypaimon/tests/native_input_routing_test.py
new file mode 100644
index 0000000000..d0764b6409
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_input_routing_test.py
@@ -0,0 +1,192 @@
+# 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 input hashes and postpone statistics execute in the real SDK."""
+
+from decimal import Decimal
+from datetime import datetime, timezone
+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 NativeInputRouter
+from pypaimon.execution.native.write.postpone_bucket import
NativePostponeBucketPlanner
+from pypaimon.ray.shuffle import _make_bucket_udf
+from pypaimon.write.backend_dispatch import create_input_router,
create_postpone_bucket_planner
+from pypaimon.write.bucket_planning import PostponeBucketPlanning
+from pypaimon.write.input_routing import FixedBucketInputRouter,
PostponeFixedBucketInputRouter
+
+pytestmark = pytest.mark.native_plan
+
+
+def _table(catalog, schema, options, partition='p'):
+ table_options = {
+ 'file.format': 'parquet', 'write.native.enabled': 'true',
+ 'postpone.target-row-num-per-bucket': '2',
+ 'postpone.batch-write-fixed-bucket.max-parallelism': '8',
+ }
+ table_options.update(options)
+ table_options = {key: value for key, value in table_options.items() if
value is not None}
+ catalog.create_table('default.input_routing', Schema.from_pyarrow_schema(
+ schema, primary_keys=['id', partition], partition_keys=[partition],
options=table_options), False)
+ return catalog.get_table('default.input_routing')
+
+
[email protected]('bucket_function', ['default', 'mod'])
+def
test_native_ray_bucket_hashes_match_writer_for_reordered_subset(native_rest_catalog,
bucket_function):
+ schema = pa.schema([('id', pa.int64()), ('p', pa.string()), ('value',
pa.string())])
+ table = _table(native_rest_catalog, schema, {
+ 'bucket': '7', 'bucket-function.type': bucket_function})
+ batch = pa.record_batch([['west', 'east', 'west', 'east'], [-9, 0, 11, -(1
<< 63)]], names=['p', 'id'])
+ expected =
FixedBucketInputRouter(table.table_schema).extract_partition_bucket_batch(batch)[1]
+ with patch.object(FixedBucketInputRouter, '_extract_partitions_batch',
+ side_effect=AssertionError('Python partitions')), \
+ patch.object(FixedBucketInputRouter, '_binary_row_hash_code',
side_effect=AssertionError('Python hashes')):
+ router = create_input_router(table)
+ assert isinstance(router, NativeInputRouter)
+ restored = pickle.loads(pickle.dumps(router))
+ chunked = pa.concat_tables([pa.Table.from_batches([batch.slice(0, 2)]),
+ pa.Table.from_batches([batch.slice(2,
2)])])
+ result = _make_bucket_udf(restored, '__bucket__')(chunked)
+ assert result.column('__bucket__').to_pylist() == expected
+ assert result.column('__bucket__').type == pa.int32()
+ assert result.drop_columns(['__bucket__']).equals(chunked)
+ with pytest.raises(Exception, match='id|routing|column|field'):
+ restored.route(pa.record_batch([['west']], names=['p']))
+
+
+def
test_native_postpone_statistics_inference_and_routing_skip_python(native_rest_catalog):
+ schema = pa.schema([
+ ('id', pa.int64()), ('row_count', pa.string()), ('value', pa.string()),
+ ('items', pa.list_(pa.int64())), ('attrs', pa.map_(pa.string(),
pa.int64())),
+ ('nested', pa.struct([('label', pa.string()), ('count', pa.int64())])),
+ ('amount', pa.decimal128(24, 3)),
+ ])
+ table = _table(native_rest_catalog, schema, {
+ 'bucket': '-2', 'postpone.target-size-per-bucket': '64',
+ 'postpone.target-row-num-per-bucket': None}, partition='row_count')
+ rows = [dict(id=i, row_count='known' if i == 0 else 'west' if i < 4 else
'east',
+ value='abcdefghi' * i, items=[1, None, i], attrs=[('key', i)],
+ nested={'label': 'nested-value', 'count': i},
amount=Decimal('12345678901234567890.123'))
+ for i in range(6)]
+ batch = pa.RecordBatch.from_pylist(rows, schema=schema)
+ reference = PostponeBucketPlanning(table, {('known',): 3})
+ expected_stats = reference.input_partition_stats(batch)
+ expected = reference.plan(expected_stats)
+ with patch.object(PostponeBucketPlanning, 'input_partition_stats',
+ side_effect=AssertionError('Python row sizes')), \
+ patch.object(PostponeBucketPlanning, 'plan',
side_effect=AssertionError('Python bucket inference')):
+ planner = create_postpone_bucket_planner(table, {('known',): 3})
+ assert isinstance(planner, NativePostponeBucketPlanner)
+ restored = pickle.loads(pickle.dumps(planner))
+ stats =
restored.input_partition_stats(pa.Table.from_batches([batch.slice(0, 2),
batch.slice(2)]))
+ assert stats == expected_stats
+ plan = restored.plan(stats)
+ assert plan.as_dict() == expected.as_dict()
+ assert restored.current_plan().as_dict() == expected.as_dict()
+ assert restored.input_partition_stats(batch) == {}
+ reference_router = PostponeFixedBucketInputRouter(table, expected)
+ expected_buckets =
reference_router.extract_partition_bucket_batch(batch)[1]
+ with patch.object(PostponeFixedBucketInputRouter,
'extract_partition_bucket_batch',
+ side_effect=AssertionError('Python postpone hashes')):
+ router = create_input_router(table, plan)
+ assert isinstance(router, NativeInputRouter)
+ assert router.route(batch).to_pylist() == expected_buckets
+ # A worker reconstructs from the immutable plan without SDK pickling.
+ assert pickle.loads(pickle.dumps(router)).route(batch).to_pylist() ==
expected_buckets
+
+
+def
test_native_postpone_known_empty_skips_metadata_and_stats_failure_is_terminal(native_rest_catalog):
+ table = _table(native_rest_catalog, pa.schema([('id', pa.int64()), ('p',
pa.string())]), {'bucket': '-2'})
+ with patch.object(NativePostponeBucketPlanner, '_load_bucket_metadata',
side_effect=AssertionError('metadata')):
+ planner = create_postpone_bucket_planner(table, {})
+ assert isinstance(planner, NativePostponeBucketPlanner)
+ assert planner.current_plan().as_dict() == {}
+ assert planner.plan({('west',): (9, 0)}).as_dict() == {('west',): 8}
+ with patch.object(PostponeBucketPlanning, 'input_partition_stats',
side_effect=AssertionError('Python replay')):
+ with pytest.raises(Exception, match='p|partition|column|field'):
+ planner.input_partition_stats(pa.record_batch([[1]], names=['id']))
+
+
[email protected]('key_type,value,null_bucket', [
+ (pa.decimal128(19, 2), Decimal('123.40'), 1),
+ (pa.timestamp('us'), datetime(2024, 1, 2, 3, 4, 5, 123456), 28),
+ (pa.timestamp('us', tz='UTC'), datetime(2024, 1, 2, 3, 4, 5, 123456,
tzinfo=timezone.utc), 28),
+])
+def
test_native_nullable_noncompact_bucket_keys_match_java(native_rest_catalog,
key_type, value, null_bucket):
+ schema = pa.schema([('id', key_type), ('label', pa.string()), ('p',
pa.string())])
+ native_rest_catalog.create_table('default.null_bucket',
Schema.from_pyarrow_schema(
+ schema, primary_keys=['id', 'label', 'p'], partition_keys=['p'],
options={
+ 'bucket': '100', 'primary-key.nullable': 'true',
'write.native.enabled': 'true',
+ }), False)
+ table = native_rest_catalog.get_table('default.null_bucket')
+ batch = pa.RecordBatch.from_arrays([
+ pa.array([None, value, None], type=key_type),
+ pa.array(['abcdefghijk'] * 3), pa.array(['west'] * 3),
+ ], schema=schema)
+ expected =
FixedBucketInputRouter(table.table_schema).extract_partition_bucket_batch(batch)[1]
+ # Current Java hashes [NULL, "abcdefghijk"] as 1558048601 for DECIMAL
+ # and -684602128 for noncompact TIMESTAMP/LTZ; Java abs(hash % 100)
follows.
+ assert expected[0] == expected[2] == null_bucket
+ with patch.object(FixedBucketInputRouter, '_binary_row_hash_code',
side_effect=AssertionError('Python hashes')):
+ router = create_input_router(table)
+ assert isinstance(router, NativeInputRouter)
+ assert router.route(batch).to_pylist() == expected
+
+
+def
test_native_postpone_fixed_binary_partition_slices_match_python(native_rest_catalog):
+ schema = pa.schema([('id', pa.int64()), ('p', pa.binary(4)), ('value',
pa.string())])
+ table = _table(native_rest_catalog, schema, {
+ 'bucket': '-2', 'partition.legacy-name': 'false',
'primary-key.nullable': 'true',
+ 'postpone.target-row-num-per-bucket': None,
'postpone.target-size-per-bucket': '128',
+ })
+ assert table.field_dict['p'].type.type == 'BINARY(4)'
+ payloads = ['payload-' * 4] * 9
+ payloads[3] = None
+ source = pa.RecordBatch.from_arrays([
+ pa.array(range(9), type=pa.int64()),
+ pa.array([b'pad!', b'abcd', None, b'abcd', b'abcd', None, b'efgh',
b'efgh', b'end!'], type=pa.binary(4)),
+ pa.array(payloads),
+ ], schema=schema)
+ batch = source.slice(1, 7)
+ assert batch.column(1).offset == 1
+ chunked = pa.Table.from_batches([batch.slice(0, 2), batch.slice(2)])
+ reference = PostponeBucketPlanning(table, {})
+ expected_stats = reference.input_partition_stats(batch)
+ assert expected_stats == {(b'abcd',): (3, 160), (None,): (2, 128),
(b'efgh',): (2, 128)}
+ expected_plan = reference.plan(expected_stats)
+ assert expected_plan.as_dict() == {(b'abcd',): 2, (None,): 1, (b'efgh',):
1}
+ expected_buckets = PostponeFixedBucketInputRouter(table,
expected_plan).extract_partition_bucket_batch(batch)[1]
+
+ with patch.object(PostponeBucketPlanning, 'input_partition_stats',
+ side_effect=AssertionError('Python statistics')), \
+ patch.object(PostponeBucketPlanning, 'plan',
side_effect=AssertionError('Python bucket inference')):
+ planner = create_postpone_bucket_planner(table, {})
+ assert isinstance(planner, NativePostponeBucketPlanner)
+ assert planner.input_partition_stats(batch) == expected_stats
+ assert planner.input_partition_stats(chunked) == expected_stats
+ plan = planner.plan(expected_stats)
+ assert plan.as_dict() == expected_plan.as_dict()
+ assert planner.input_partition_stats(chunked) == {}
+ with patch.object(PostponeFixedBucketInputRouter,
'extract_partition_bucket_batch',
+ side_effect=AssertionError('Python bucket hashing')):
+ router = create_input_router(table, plan)
+ assert isinstance(router, NativeInputRouter)
+ assert router.route(batch).to_pylist() == expected_buckets
+ assert router.route(chunked).to_pylist() == expected_buckets
diff --git a/paimon-python/pypaimon/tests/noncompact_null_row_test.py
b/paimon-python/pypaimon/tests/noncompact_null_row_test.py
new file mode 100644
index 0000000000..24a0181dc7
--- /dev/null
+++ b/paimon-python/pypaimon/tests/noncompact_null_row_test.py
@@ -0,0 +1,81 @@
+# 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.
+
+"""NULL noncompact fields retain Java's updateable BinaryRow layout."""
+
+from decimal import Decimal
+import struct
+
+import pyarrow as pa
+import pytest
+
+from pypaimon.schema.data_types import AtomicType, DataField,
PyarrowFieldParser
+from pypaimon.schema.table_schema import TableSchema
+from pypaimon.table.row.generic_row import GenericRow, GenericRowDeserializer,
GenericRowSerializer
+from pypaimon.write.input_routing import FixedBucketInputRouter,
_hash_bytes_by_words
+
+
+# Current Java InternalRowSerializer for [NULL, BinaryString("abcdefghijk")].
+# Java BinaryRow.hashCode() hashes these raw bytes without Python's arity
prefix.
+_COMPACT =
'000100000000000000000000000000000b000000180000006162636465666768696a6b0000000000'
+_DECIMAL = ('000100000000000000000000180000000b00000028000000'
+ '000000000000000000000000000000006162636465666768696a6b0000000000')
+_TIMESTAMP = ('000100000000000000000000180000000b00000020000000'
+ '00000000000000006162636465666768696a6b0000000000')
+
+
[email protected]('type_name,body,java_hash', [
+ ('DECIMAL(18, 2)', _COMPACT, 1425286886),
+ ('DECIMAL(19, 2)', _DECIMAL, 1558048601),
+ ('DECIMAL(20, 2)', _DECIMAL, 1558048601),
+ ('TIMESTAMP(3)', _COMPACT, 1425286886),
+ ('TIMESTAMP(4)', _TIMESTAMP, -684602128),
+ ('TIMESTAMP(9)', _TIMESTAMP, -684602128),
+ ('TIMESTAMP(3) WITH LOCAL TIME ZONE', _COMPACT, 1425286886),
+ ('TIMESTAMP(4) WITH LOCAL TIME ZONE', _TIMESTAMP, -684602128),
+ ('TIMESTAMP(9) WITH LOCAL TIME ZONE', _TIMESTAMP, -684602128),
+ ('TIMESTAMP_LTZ(6)', _TIMESTAMP, -684602128),
+])
+def
test_null_variable_field_bytes_hash_and_following_string_match_java(type_name,
body, java_hash):
+ fields = [DataField(0, 'key', AtomicType(type_name)), DataField(1,
'label', AtomicType('STRING'))]
+ values = [None, 'abcdefghijk']
+ encoded = GenericRowSerializer.to_bytes(GenericRow(values, fields))
+ assert encoded == struct.pack('>i', 2) + bytes.fromhex(body)
+ assert _hash_bytes_by_words(encoded[4:]) == java_hash & 0xFFFFFFFF
+ assert GenericRowDeserializer.from_bytes(encoded, fields).values == values
+
+ # Real Python input routing must retain both the NULL slot and the next
+ # variable field's offset when hashing a nullable composite bucket key.
+ partition = DataField(2, 'p', AtomicType('STRING'))
+ schema = TableSchema(fields=fields + [partition], primary_keys=['key',
'label', 'p'], partition_keys=['p'],
+ options={'bucket': '100', 'primary-key.nullable':
'true'})
+ arrow_schema = PyarrowFieldParser.from_paimon_schema(schema.fields)
+ batch = pa.RecordBatch.from_arrays([
+ pa.array([None], type=arrow_schema.field('key').type),
+ pa.array(['abcdefghijk']), pa.array(['west']),
+ ], schema=arrow_schema)
+ partitions, buckets =
FixedBucketInputRouter(schema).extract_partition_bucket_batch(batch)
+ assert partitions == [('west',)]
+ assert buckets == [abs(java_hash) % 100]
+
+
[email protected]('type_name', ['DECIMAL(19, 2)', 'NUMERIC(19, 2)'])
+def test_decimal_overflow_uses_the_same_noncompact_null_layout(type_name):
+ fields = [DataField(0, 'key', AtomicType(type_name)), DataField(1,
'label', AtomicType('STRING'))]
+ encoded =
GenericRowSerializer.to_bytes(GenericRow([Decimal('99999999999999999.999'),
'abcdefghijk'], fields))
+ assert encoded == struct.pack('>i', 2) + bytes.fromhex(_DECIMAL)
+ assert _hash_bytes_by_words(encoded[4:]) == 1558048601
+ assert GenericRowDeserializer.from_bytes(encoded, fields).values == [None,
'abcdefghijk']
diff --git a/paimon-python/pypaimon/tests/test_ray_shuffle_helper.py
b/paimon-python/pypaimon/tests/test_ray_shuffle_helper.py
index 7401eb1e3a..b6535b15f2 100644
--- a/paimon-python/pypaimon/tests/test_ray_shuffle_helper.py
+++ b/paimon-python/pypaimon/tests/test_ray_shuffle_helper.py
@@ -19,7 +19,7 @@
"""Unit tests for the Ray pre-shuffle helper in pypaimon/ray/shuffle.py.
These tests exercise the helper in isolation: the bucket-key UDF (with a
-stub extractor), the collision-safe column name picker, the
+stub router), the collision-safe column name picker, the
large-type coercion, and the bucket-mode dispatch in
``maybe_apply_repartition``. Ray-based end-to-end behaviour is covered
in ``pypaimon/tests/ray_repartition_test.py``.
@@ -53,17 +53,14 @@ maybe_apply_repartition = _SHUFFLE.maybe_apply_repartition
class BucketUdfTest(unittest.TestCase):
"""The bucket-key UDF appends a deterministic int32 column."""
- def _make_extractor(self, buckets_per_row):
- extractor = MagicMock()
- extractor.extract_partition_bucket_batch.return_value = (
- [() for _ in buckets_per_row],
- list(buckets_per_row),
- )
- return extractor
+ def _make_router(self, buckets_per_row):
+ router = MagicMock(spec=["route"])
+ router.route.return_value = pa.array(buckets_per_row, type=pa.int32())
+ return router
def test_appends_int32_bucket_column(self):
- extractor = self._make_extractor([0, 1, 0])
- udf = _make_bucket_udf(extractor, BUCKET_KEY_COL)
+ router = self._make_router([0, 1, 0])
+ udf = _make_bucket_udf(router, BUCKET_KEY_COL)
batch = pa.table({"id": [10, 11, 12]})
out = udf(batch)
@@ -73,24 +70,24 @@ class BucketUdfTest(unittest.TestCase):
self.assertEqual(out.column(BUCKET_KEY_COL).to_pylist(), [0, 1, 0])
def test_empty_batch_appends_empty_column(self):
- extractor = self._make_extractor([])
- udf = _make_bucket_udf(extractor, BUCKET_KEY_COL)
+ router = self._make_router([])
+ udf = _make_bucket_udf(router, BUCKET_KEY_COL)
batch = pa.table({"id": pa.array([], type=pa.int32())})
out = udf(batch)
self.assertEqual(out.num_rows, 0)
self.assertEqual(out.column_names, ["id", BUCKET_KEY_COL])
- # The extractor is short-circuited on empty input — we don't pay
+ # The router is short-circuited on empty input — we don't pay
# the cost of combining empty chunks just to call into it.
- extractor.extract_partition_bucket_batch.assert_not_called()
+ router.route.assert_not_called()
- def test_multichunk_batch_combines_before_extracting(self):
+ def test_multichunk_batch_combines_before_routing(self):
# Two record batches in the same table — the UDF must combine
- # before calling the extractor, otherwise the extractor sees
+ # before calling the router, otherwise the router sees
# half the rows.
- extractor = self._make_extractor([0, 1, 2, 3])
- udf = _make_bucket_udf(extractor, BUCKET_KEY_COL)
+ router = self._make_router([0, 1, 2, 3])
+ udf = _make_bucket_udf(router, BUCKET_KEY_COL)
rb1 = pa.record_batch({"id": [1, 2]})
rb2 = pa.record_batch({"id": [3, 4]})
batch = pa.Table.from_batches([rb1, rb2])
@@ -99,8 +96,9 @@ class BucketUdfTest(unittest.TestCase):
self.assertEqual(out.num_rows, 4)
self.assertEqual(out.column(BUCKET_KEY_COL).to_pylist(), [0, 1, 2, 3])
- # Extractor is called exactly once with all four rows.
- call = extractor.extract_partition_bucket_batch.call_args
+ # Router is called exactly once with all four rows.
+ router.route.assert_called_once()
+ call = router.route.call_args
passed_batch = call.args[0]
self.assertEqual(passed_batch.num_rows, 4)
diff --git a/paimon-python/pypaimon/tests/write/native_input_routing_test.py
b/paimon-python/pypaimon/tests/write/native_input_routing_test.py
new file mode 100644
index 0000000000..da3fc398ea
--- /dev/null
+++ b/paimon-python/pypaimon/tests/write/native_input_routing_test.py
@@ -0,0 +1,180 @@
+# 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.
+
+"""Native input execution is chosen before data and remains native on
workers."""
+
+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.execution.native.write.input_routing import NativeInputRouter
+from pypaimon.execution.native.write.postpone_bucket import
NativePostponeBucketPlanner
+from pypaimon.write.backend_dispatch import create_input_router,
create_postpone_bucket_planner
+from pypaimon.write.bucket_plan import PostponeBucketPlan
+from pypaimon.write.bucket_planning import PostponeBucketPlanning
+from pypaimon.write.input_routing import FixedBucketInputRouter
+
+
+def _table(tmp_path, bucket='4', partition='p', 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()), (partition, pa.string()), ('value',
pa.string())]),
+ primary_keys=['id', partition], partition_keys=[partition], options={
+ 'bucket': bucket, 'write.native.enabled': str(native).lower(),
+ 'postpone.target-row-num-per-bucket': '2',
+ 'postpone.batch-write-fixed-bucket.max-parallelism': '8',
+ }), False)
+ return catalog.get_table('default.t')
+
+
[email protected]
+def sdk(monkeypatch):
+ module = SimpleNamespace(BucketRouter=Mock(), PostponeBucketPlanner=Mock())
+ monkeypatch.setitem(sys.modules, 'pypaimon_rust.datafusion', module)
+ return module
+
+
[email protected]('native', [False, True])
+def test_bucket_router_selection_finishes_before_input(tmp_path, native):
+ table = _table(tmp_path, native=native)
+ with
patch('pypaimon.execution.native.write.input_routing.create_native_input_router',
+ side_effect=RuntimeError('unsupported table environment')) as
prepare:
+ router = create_input_router(table)
+ assert isinstance(router, FixedBucketInputRouter)
+ assert prepare.call_count == int(native)
+
+
[email protected]('bucket', ['4', '-2'])
[email protected]('chunked', [False, True])
+def
test_python_router_returns_arrow_buckets_for_the_shared_route_interface(tmp_path,
bucket, chunked):
+ table = _table(tmp_path, bucket=bucket, native=False)
+ plan = PostponeBucketPlan({('west',): 3, ('east',): 2}) if bucket == '-2'
else None
+ router = create_input_router(table, plan)
+ batch = pa.record_batch([['west', 'east', 'west'], [1, 2, 3]], names=['p',
'id'])
+ expected = router.extract_partition_bucket_batch(batch)[1]
+ data = pa.Table.from_batches([batch.slice(0, 1), batch.slice(1)]) if
chunked else batch
+ buckets = router.route(data)
+ assert isinstance(buckets, pa.Int32Array)
+ assert buckets.to_pylist() == expected
+ assert buckets.null_count == 0
+ empty = router.route(data.slice(0, 0))
+ assert isinstance(empty, pa.Int32Array)
+ assert len(empty) == 0
+
+
+def test_native_router_passes_arrow_without_python_row_hashing(tmp_path, sdk):
+ table = _table(tmp_path)
+ batch = pa.record_batch([['west', 'east'], [1, 2]], names=['p', 'id'])
+ buckets = pa.array([2, 1], type=pa.int32())
+ sdk.BucketRouter.return_value.route.return_value = buckets
+ native_table = Mock()
+ 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(FixedBucketInputRouter, '_binary_row_hash_code',
+ side_effect=AssertionError('Python input hashing')):
+ router = create_input_router(table)
+ assert isinstance(router, NativeInputRouter)
+ assert router.route(batch) is buckets
+ sdk.BucketRouter.assert_called_once_with(native_table.schema.return_value)
+ sdk.BucketRouter.return_value.route.assert_called_once_with(batch)
+
+
+def test_native_router_failure_propagates_without_selecting_python(tmp_path,
sdk):
+ table = _table(tmp_path)
+ router = NativeInputRouter(table)
+ failure = ValueError('Input must contain a routing column')
+ router._router = Mock()
+ router._router.route.side_effect = failure
+ with patch.object(FixedBucketInputRouter, '__init__',
side_effect=AssertionError('Python replay')):
+ with pytest.raises(ValueError) as raised:
+ router.route(pa.record_batch([[1]], names=['value']))
+ assert raised.value is failure
+
+
+def test_native_router_pickle_discards_sdk_and_restores_bucket_plan(tmp_path,
sdk):
+ table = _table(tmp_path, bucket='-2')
+ plan = PostponeBucketPlan({('west',): 3})
+ router = NativeInputRouter(table, plan)
+ # An SDK object is deliberately not serializable.
+ router._router = lambda: None
+ restored = pickle.loads(pickle.dumps(router))
+ assert restored._router is None
+ native = sdk.BucketRouter.return_value
+ native.with_bucket_plan.return_value = native
+ native.route.return_value = pa.array([1], type=pa.int32())
+ with
patch('pypaimon.execution.native.write.input_routing.create_native_write_table',
return_value=Mock()):
+ assert restored.route(pa.record_batch([[7], ['west']], names=['id',
'p'])).to_pylist() == [1]
+ assert native.with_bucket_plan.call_args[0][0].equals(plan.to_arrow(table))
+
+
+def test_native_planner_transports_group_statistics_positionally(tmp_path,
sdk):
+ table = _table(tmp_path, bucket='-2', partition='row_count')
+ native_table = Mock()
+ core = sdk.PostponeBucketPlanner.return_value
+ core.input_statistics.return_value = pa.record_batch(
+ [['west'], pa.array([3], type=pa.int64()), pa.array([80],
type=pa.int64())],
+ names=['row_count', 'row_count', 'data_size'])
+ expected = PostponeBucketPlan({('west',): 2})
+ core.plan.return_value = expected.to_arrow(table)
+ with
patch('pypaimon.execution.native.write.postpone_bucket.native_runtime_available',
return_value=True), \
+
patch('pypaimon.execution.native.write.postpone_bucket.create_native_write_table',
+ return_value=native_table), \
+ patch.object(PostponeBucketPlanning, 'input_partition_stats',
+ side_effect=AssertionError('Python input
statistics')), \
+ patch.object(PostponeBucketPlanning, 'plan',
side_effect=AssertionError('Python bucket inference')):
+ planner = create_postpone_bucket_planner(table, known_num_buckets={})
+ assert isinstance(planner, NativePostponeBucketPlanner)
+ assert not isinstance(planner, PostponeBucketPlanning)
+ batch = pa.table({'id': [1, 2, 3], 'row_count': ['west'] * 3, 'value':
['data'] * 3})
+ stats = planner.input_partition_stats(batch)
+ assert stats == {('west',): (3, 80)}
+ assert planner.plan(stats).as_dict() == expected.as_dict()
+ native_table.new_postpone_fixed_bucket_write_builder.assert_not_called()
+ transported = core.input_statistics.call_args[0][0]
+ assert isinstance(transported, pa.RecordBatch) and transported.num_rows ==
3
+ grouped = core.plan.call_args[0][0]
+ assert grouped.schema.names == ['row_count', 'row_count', 'data_size']
+ assert [column.to_pylist() for column in grouped.columns] == [['west'],
[3], [80]]
+ assert grouped.column(1).type == pa.int64() and grouped.column(2).type ==
pa.int64()
+
+
+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
+ expected = PostponeBucketPlan({('west',): 7})
+ core.plan.return_value = expected.to_arrow(table)
+ planner = NativePostponeBucketPlanner(table, {}, Mock())
+ planner.plan({('west',): (13, 0)})
+ planner._planner = lambda: None
+ restored = pickle.loads(pickle.dumps(planner))
+ assert restored._planner is None
+ sdk.PostponeBucketPlanner.reset_mock()
+ failure = RuntimeError('worker statistics failed')
+ core.input_statistics.side_effect = failure
+ with
patch('pypaimon.execution.native.write.postpone_bucket.create_native_write_table',
return_value=Mock()), \
+ patch.object(PostponeBucketPlanning, 'input_partition_stats',
side_effect=AssertionError('Python replay')):
+ with pytest.raises(RuntimeError) as raised:
+ restored.input_partition_stats(pa.table({'id': [1], 'p': ['west'],
'value': ['x']}))
+ assert raised.value is failure
+ assert
sdk.PostponeBucketPlanner.call_args[0][1].equals(expected.to_arrow(table))
diff --git
a/paimon-python/pypaimon/tests/write/postpone_execution_boundary_test.py
b/paimon-python/pypaimon/tests/write/postpone_execution_boundary_test.py
index 8453fecbd5..447658a0df 100644
--- a/paimon-python/pypaimon/tests/write/postpone_execution_boundary_test.py
+++ b/paimon-python/pypaimon/tests/write/postpone_execution_boundary_test.py
@@ -20,6 +20,7 @@
from pathlib import Path
import subprocess
import sys
+from types import SimpleNamespace
from unittest.mock import Mock, patch
import pyarrow as pa
@@ -102,6 +103,26 @@ def _native_table(table):
return native
[email protected](autouse=True)
+def native_input_sdk(monkeypatch):
+ """These boundary tests supply SDK results without a native
installation."""
+ def planner(_schema, known):
+ core = Mock()
+ core.current_plan.return_value = known
+ core.plan.return_value = known
+ return core
+
+ def router(_schema):
+ core = Mock()
+ core.with_bucket_plan.return_value = core
+ core.route.side_effect = lambda batch: pa.array([0] * batch.num_rows,
type=pa.int32())
+ return core
+
+ sdk = SimpleNamespace(PostponeBucketPlanner=Mock(side_effect=planner),
BucketRouter=Mock(side_effect=router))
+ monkeypatch.setitem(sys.modules, 'pypaimon_rust.datafusion', sdk)
+ return sdk
+
+
@pytest.fixture
def forbid_python_execution():
with
patch('pypaimon.execution.python.write.postpone_bucket.PythonPostponeBucketPlanner.__init__',
@@ -116,32 +137,37 @@ def forbid_python_execution():
@pytest.mark.parametrize('overwrite', [False, True])
-def test_native_ray_planning_uses_native_metadata_and_shared_input_routing(
+def test_native_ray_planning_uses_native_metadata_and_input_routing(
tmp_path, forbid_python_execution, overwrite):
pytest.importorskip('ray')
table = _table(tmp_path)
core = Mock()
writer = NativePostponeFixedBucketTableWrite(table, 'user', None, None,
core)
dataset = Mock()
+ expected = {('existing',): 1 if overwrite else 3, ('new',): 8,
('pending',): 1}
+ native_table = _native_table(table)
with
patch('pypaimon.execution.native.write.postpone_bucket.native_runtime_available',
return_value=True), \
patch('pypaimon.execution.native.write.postpone_bucket.create_native_write_table',
- return_value=_native_table(table)) as plan, \
+ return_value=native_table), \
+
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.native.write.postpone_bucket.NativePostponeBucketPlanner.plan',
+ return_value=PostponeBucketPlan(expected)), \
patch('pypaimon.write.ray_datasink._collect_partition_stats',
return_value=(
dataset, {('existing',): (2, 0), ('new',): (9, 0),
('pending',): (1, 0)})), \
patch('pypaimon.write.ray_datasink._write_primary_key_groups') as
write:
writer.write_ray(dataset, overwrite=overwrite)
args = write.call_args[1]
- expected = {('existing',): 1 if overwrite else 3, ('new',): 8,
('pending',): 1}
assert args['postpone_bucket_plan'].as_dict() == expected
batch = pa.record_batch([[1, 2], ['existing', 'new'], [10, 20]],
names=['id', 'p', 'value'])
- partitions, buckets =
args['bucket_extractor'].extract_partition_bucket_batch(batch)
- assert partitions == [('existing',), ('new',)]
+ buckets = args['bucket_router'].route(batch).to_pylist()
assert 0 <= buckets[0] < expected[('existing',)]
assert 0 <= buckets[1] < 8
if overwrite:
- plan.assert_not_called()
+
native_table.new_postpone_fixed_bucket_write_builder.assert_not_called()
else:
- plan.assert_called_once_with(table)
+
native_table.new_postpone_fixed_bucket_write_builder.return_value.resolve_bucket_plan.assert_called_once()
assert writer._python_writer is None
core.write_arrow.assert_not_called()
writer.close()
@@ -153,13 +179,15 @@ def
test_native_ray_explicit_plan_does_not_load_metadata(tmp_path, forbid_python
explicit = PostponeBucketPlan({('existing',): 7})
writer = NativePostponeFixedBucketTableWrite(table, 'user', None,
explicit, Mock())
dataset = Mock()
+ native_table = _native_table(table)
with
patch('pypaimon.execution.native.write.postpone_bucket.native_runtime_available',
return_value=True), \
patch('pypaimon.execution.native.write.postpone_bucket.create_native_write_table',
- side_effect=AssertionError('explicit bucket plan must not
read metadata')), \
+ return_value=native_table), \
patch('pypaimon.write.ray_datasink._collect_partition_stats',
return_value=(dataset, {})), \
patch('pypaimon.write.ray_datasink._write_primary_key_groups') as
write:
writer.write_ray(dataset)
assert write.call_args[1]['postpone_bucket_plan'].as_dict() ==
explicit.as_dict()
+
native_table.new_postpone_fixed_bucket_write_builder.assert_not_called()
writer.close()
diff --git a/paimon-python/pypaimon/write/backend_dispatch.py
b/paimon-python/pypaimon/write/backend_dispatch.py
index 5961d946a2..f62e553fc2 100644
--- a/paimon-python/pypaimon/write/backend_dispatch.py
+++ b/paimon-python/pypaimon/write/backend_dispatch.py
@@ -204,6 +204,22 @@ def create_postpone_bucket_planner(table,
known_num_buckets=None):
return PythonPostponeBucketPlanner(table,
known_num_buckets=known_num_buckets)
+def create_input_router(table, bucket_plan=None):
+ """Select bucket routing before a distributed task receives input rows."""
+ if table.options.native_write_enabled():
+ def prepare():
+ from pypaimon.execution.native.write.input_routing import
create_native_input_router
+ return create_native_input_router(table, bucket_plan)
+ native = try_prepare_native(prepare)
+ if native is not None:
+ return native
+ if bucket_plan is not None:
+ from pypaimon.write.input_routing import PostponeFixedBucketInputRouter
+ return PostponeFixedBucketInputRouter(table, bucket_plan)
+ from pypaimon.write.input_routing import FixedBucketInputRouter
+ return FixedBucketInputRouter(table.table_schema)
+
+
def load_postpone_bucket_metadata(table):
"""Select metadata planning before public bucket arithmetic consumes
input."""
if table.options.native_write_enabled():
diff --git a/paimon-python/pypaimon/write/input_routing.py
b/paimon-python/pypaimon/write/input_routing.py
index b8e700e840..d2d0496902 100644
--- a/paimon-python/pypaimon/write/input_routing.py
+++ b/paimon-python/pypaimon/write/input_routing.py
@@ -167,6 +167,10 @@ class
PostponeFixedBucketInputRouter(PartitionKeyExtractor):
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]]:
@@ -229,6 +233,10 @@ class FixedBucketInputRouter(PartitionKeyExtractor):
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)
diff --git a/paimon-python/pypaimon/write/ray_datasink.py
b/paimon-python/pypaimon/write/ray_datasink.py
index 4005798649..0da5eeb383 100644
--- a/paimon-python/pypaimon/write/ray_datasink.py
+++ b/paimon-python/pypaimon/write/ray_datasink.py
@@ -326,8 +326,7 @@ def write_paimon_dataset(
)
)
):
- from pypaimon.write.backend_dispatch import
create_postpone_bucket_planner
- from pypaimon.write.input_routing import PostponeFixedBucketInputRouter
+ from pypaimon.write.backend_dispatch import create_input_router,
create_postpone_bucket_planner
planner = (
postpone_bucket_planner
@@ -351,7 +350,7 @@ def write_paimon_dataset(
static_partition=static_partition,
concurrency=concurrency,
ray_remote_args=ray_remote_args,
- bucket_extractor=PostponeFixedBucketInputRouter(table, plan),
+ bucket_router=create_input_router(table, plan),
postpone_bucket_plan=plan,
)
@@ -489,7 +488,7 @@ def _write_primary_key_groups(
static_partition: Optional[Dict[str, Any]],
concurrency: Optional[int],
ray_remote_args: Optional[Dict[str, Any]],
- bucket_extractor=None,
+ bucket_router=None,
postpone_bucket_plan=None,
) -> Optional[PaimonWriteResult]:
import pickle
@@ -499,7 +498,7 @@ def _write_primary_key_groups(
)
grouped, bucket_col = _group_by_partition_bucket(
- dataset, table, extractor=bucket_extractor
+ dataset, table, router=bucket_router
)
message_col = "__paimon_commit_messages__"
error_col = "__paimon_write_error__"