blob: a4f10752e843b7845efb9963ea43bb9cb8b138da [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 input routing must never construct a Python file executor."""
from contextlib import ExitStack
from unittest.mock import Mock, patch
import pyarrow as pa
import pytest
from pypaimon import CatalogFactory, Schema
from pypaimon.execution.python.write.dynamic_bucket import DynamicBucketAssigner
from pypaimon.read.plan import Plan
from pypaimon.read.split_serializer import deserialize_split_v1, serialize_split_v1
from pypaimon.execution.python.write.input_routing import DynamicBucketInputRouter, FixedBucketInputRouter
def _table(tmp_path, bucket):
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()), ('value', pa.string()), ('p', pa.string())]),
partition_keys=['p'], primary_keys=['id', 'p'], options={
'bucket': bucket, 'dynamic-bucket.target-row-num': '1',
'write.native.enabled': 'true',
}), False)
return catalog.get_table('default.t')
@pytest.fixture
def forbid_python_execution():
with ExitStack() as stack:
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.SimpleDynamicBucketAssigner.__init__',
'execution.python.read.scanner.file_scanner.FileScanner.__init__',
'execution.python.write.table_write.PythonTableWrite.__init__',
'execution.python.write.dynamic_bucket.DynamicBucketIndexMaintainer.__init__',
]:
stack.enter_context(patch('pypaimon.' + target, side_effect=AssertionError(target)))
yield
def test_ray_fixed_grouping_executes_input_router_without_python_extractor(tmp_path, forbid_python_execution):
from pypaimon.ray.shuffle import _group_by_partition_bucket
table = _table(tmp_path, '4')
dataset = Mock()
_group_by_partition_bucket(dataset, table)
udf = dataset.map_batches.call_args[0][0]
rows = pa.table({'id': [1, 2, 1], 'value': ['a', 'b', 'c'], 'p': ['p', 'p', 'q']})
actual = udf(rows)
assert actual.column_names[:3] == rows.column_names
assert actual.column(3).to_pylist() == FixedBucketInputRouter(table.table_schema).extract_partition_bucket_batch(
rows.to_batches()[0])[1]
assert dataset.map_batches.return_value.groupby.call_args[0][0] == ['p', actual.column_names[3]]
def test_daft_fixed_and_dynamic_routing_do_not_construct_python_extractors(tmp_path, forbid_python_execution):
daft = pytest.importorskip('daft')
from pypaimon.daft.daft_datasink import make_dynamic_routing_udf, make_fixed_bucket_udf
from pypaimon.index.dynamic_bucket import to_signed_int32
from pypaimon.execution.python.write.dynamic_bucket import compute_assigner
table = _table(tmp_path, '4')
rows = {'id': [1, 2, 1], 'value': ['a', 'b', 'c'], 'p': ['p', 'p', 'q']}
columns = [daft.col(name) for name in table.field_names]
fixed = daft.from_pydict(rows).with_column('bucket', make_fixed_bucket_udf(table)(*columns)).to_pydict()
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 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)
routed = daft.from_pydict(rows).select(
*columns, make_dynamic_routing_udf(dynamic, 2, 2, 'assigner', 'hash')(*columns)).to_pydict()
assert routed['hash'] == [to_signed_int32(value) for value in key_hashes]
assert routed['assigner'] == [compute_assigner(p, k, 2, 2) for p, k in zip(partition_hashes, key_hashes)]
def _seed_snapshot(table):
# Seed real data and complete HASH indexes before guarding every Pure
# executor constructor, so an empty-table shortcut cannot hide the leak.
pure = table.copy({'write.native.enabled': 'false', 'commit.native.enabled': 'false'})
builder = pure.new_batch_write_builder()
writer = builder.new_write()
writer.write_arrow(pa.table({'id': [1], 'value': ['old'], 'p': ['p']}))
commit = builder.new_commit()
commit.commit(writer.prepare_commit())
writer.close()
commit.close()
snapshot = table.snapshot_manager().get_latest_snapshot()
splits = pure.new_read_builder().new_scan().plan_all().splits()
plan = Plan([deserialize_split_v1(
serialize_split_v1(split), table.partition_keys_fields,
table.trimmed_primary_keys_fields) for split in splits])
assert plan.splits()[0].partition.values == ['p']
return snapshot, plan
@pytest.mark.parametrize('ignore_existing', [False, True])
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, _ = _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.SimpleDynamicBucketAssigner.__init__',
'execution.python.read.scanner.file_scanner.FileScanner.__init__',
'execution.python.write.dynamic_bucket.DynamicBucketIndexMaintainer.__init__',
'execution.python.write.input_routing.DynamicBucketInputRouter.__init__',
'execution.python.write.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', 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)
# 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'] == [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_selected_python_dynamic_assigner_validates_real_live_buckets(tmp_path):
table = _table(tmp_path, '-1').copy({
'scan.native-plan.enabled': 'false', 'read.native.enabled': 'false'})
snapshot, _ = _seed_snapshot(table)
# A real data file without its HASH index must still fail validation. The
# selected Python assigner obtains complete live metadata from its own
# scanner even though the original table requests Native writes.
with patch('pypaimon.execution.python.write.dynamic_bucket.IndexFileHandler') as indexes, \
patch('pypaimon.execution.native.read.plan.native_plan',
side_effect=AssertionError('Selected Python assigner entered Native')) as native_plan:
indexes.return_value.scan.return_value = []
assigner = DynamicBucketAssigner(table, 1, 1, 0, 1, -1, snapshot=snapshot)
with pytest.raises(RuntimeError, match='no complete HASH index.*0'):
assigner.assign(('p',), 0, 123)
native_plan.assert_not_called()