blob: e5170c9ad34fdad5f1ae60a423102c81d147e1b1 [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 merge capabilities must never bypass Python fallback validation."""
import sys
from unittest.mock import patch
import pyarrow as pa
import pytest
from pypaimon import CatalogFactory, Schema
from pypaimon.execution.native.runtime import (native_runtime_available)
from pypaimon.execution.native.write.native_commit import (
create_native_write_table, from_native_commit_messages)
_SCHEMA = pa.schema([(name, pa.int64()) for name in ('id', 'seq', 'value')])
_ADVANCED = {
'merge-engine': 'partial-update',
'fields.seq.sequence-group': 'value',
'fields.value.aggregate-function': 'sum',
}
def _table(tmp_path, options=None):
catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
catalog.create_database('default', True)
opts = {'bucket': '1', 'file.format': 'parquet', 'read.native.enabled': 'true',
'write.native.enabled': 'false', 'commit.native.enabled': 'false'}
opts.update(options or {})
catalog.create_table('default.t', Schema.from_pyarrow_schema(
_SCHEMA, primary_keys=['id'], options=opts), False)
return catalog.get_table('default.t')
@pytest.mark.native_plan
@pytest.mark.skipif(not native_runtime_available(), reason='native reader required')
@pytest.mark.parametrize('options', [_ADVANCED, {
'merge-engine': 'partial-update', 'ignore-delete': 'true',
}])
@pytest.mark.parametrize('streaming', [False, True])
def test_native_merge_reads_configurations_outside_python_subset(tmp_path, options, streaming):
table = _table(tmp_path, options)
# Separate commits require the reader to merge, even if the writer has
# already combined all rows within each file.
for seq, value in [(2, 10), (1, 3), (3, 7)]:
writer = create_native_write_table(table).new_batch_write_builder().new_write()
commit = table.new_batch_write_builder().new_commit()
try:
writer.write_arrow(pa.record_batch([[1], [seq], [value]], schema=_SCHEMA))
messages = from_native_commit_messages(table, writer.prepare_commit())
commit.commit(messages)
finally:
writer.close()
commit.close()
builder = table.new_read_builder()
splits = builder.new_scan().plan().splits()
read = builder.new_read()
with patch(
'pypaimon.execution.python.read.table_read.PythonTableRead._create_split_read',
side_effect=AssertionError('Python fallback')
):
if streaming:
batches = read.to_arrow_batch_reader(splits)
try:
actual = batches.read_all()
finally:
batches.close()
else:
actual = read.to_arrow(splits)
assert actual.to_pylist() == [
{'id': 1, 'seq': 3, 'value': 20 if options is _ADVANCED else 7}]
@pytest.mark.python_plan
@pytest.mark.python_write
@pytest.mark.parametrize('backend', ['setup_failure', 'missing_binding'])
@pytest.mark.parametrize('api', ['arrow', 'streaming', 'iterator'])
@pytest.mark.parametrize('parallelism', [1, 2])
def test_unsupported_python_merge_fails_before_raw_split_dispatch(
tmp_path, backend, api, parallelism):
table = _table(tmp_path)
builder = table.new_batch_write_builder()
writer = builder.new_write()
commit = builder.new_commit()
try:
writer.write_arrow(pa.table([[1], [1], [10]], schema=_SCHEMA))
commit.commit(writer.prepare_commit())
finally:
writer.close()
commit.close()
splits = table.new_read_builder().new_scan().plan().splits()
assert splits and all(split.raw_convertible for split in splits)
# Keep the raw plan: its Python dispatch would bypass the merge reader.
read = table.copy(_ADVANCED).new_read_builder().new_read()
splits = splits * parallelism
for split in splits:
split._native_split = object()
if backend == 'missing_binding':
failure = patch.dict(sys.modules, {
'pypaimon_rust': None, 'pypaimon_rust.datafusion': None})
else:
failure = patch('pypaimon.execution.native.read.table_read._prepare_native_read',
side_effect=RuntimeError('native setup failed'))
with failure, patch(
'pypaimon.execution.python.read.table_read.PythonTableRead._create_split_read'
) as dispatch:
with pytest.raises(NotImplementedError, match='sequence-group'):
if api == 'arrow':
read.to_arrow(splits, parallelism=parallelism)
elif api == 'streaming':
read.to_arrow_batch_reader(splits, parallelism=parallelism).read_all()
else:
list(read.to_iterator(splits))
dispatch.assert_not_called()
@pytest.mark.python_read
@pytest.mark.parametrize('native', [False, True])
@pytest.mark.parametrize('options,match', [
({'sequence.field': 'missing'}, 'can not be found'),
({'sequence.field': 'seq,seq'}, 'defined repeatedly'),
({'sequence.field': 'seq', 'fields.seq.aggregate-function': 'sum'},
'Should not define aggregation'),
({'sequence.field': 'seq', 'merge-engine': 'first-row'}, 'FIRST_ROW'),
])
def test_invalid_sequence_configuration_is_rejected_before_dispatch(tmp_path, native, options, match):
table = _table(tmp_path).copy(dict(options, **{'read.native.enabled': str(native).lower()}))
with pytest.raises(ValueError, match=match):
table.new_read_builder().new_read()
@pytest.mark.python_read
def test_python_only_reader_retains_eager_merge_validation(tmp_path):
table = _table(tmp_path, dict(_ADVANCED, **{'read.native.enabled': 'false'}))
with pytest.raises(NotImplementedError, match='sequence-group'):
table.new_read_builder().new_read()