blob: 58efed762dfc581f47e03e85e5846478f0aa3409 [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""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.execution.python.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')
@pytest.fixture
def sdk(monkeypatch):
module = SimpleNamespace(BucketRouter=Mock(), PostponeBucketPlanner=Mock())
monkeypatch.setitem(sys.modules, 'pypaimon_rust.datafusion', module)
return module
@pytest.mark.parametrize('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)
@pytest.mark.parametrize('bucket', ['4', '-2'])
@pytest.mark.parametrize('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 _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)
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_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
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))