blob: 9210b2b65f90e8a0a62ebb2fccb0fb0319bf36aa [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.
"""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
@pytest.mark.parametrize('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)
@pytest.mark.parametrize('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)
@pytest.mark.parametrize('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')
@pytest.fixture
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]
@pytest.mark.parametrize('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.execution.python.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()
@pytest.mark.parametrize('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()
@pytest.mark.parametrize('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()
@pytest.mark.parametrize('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
@pytest.mark.parametrize('overwrite', [False, True])
@pytest.mark.parametrize('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.execution.python.write.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())
@pytest.mark.parametrize('operation', ['route', 'assign'])
@pytest.mark.parametrize('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
@pytest.mark.parametrize('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)