blob: f5764744603269a763805ec0b4296a5f3b1e3048 [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.
"""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.execution.python.write.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.execution.python.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.execution.python.write.input_routing.FixedBucketInputRouter.__init__',
'pypaimon.execution.python.write.input_routing.DynamicBucketInputRouter.__init__',
'pypaimon.execution.python.write.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
@pytest.mark.parametrize('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
@pytest.mark.parametrize('bucket', ['4', '-1'])
@pytest.mark.parametrize('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