blob: d6cfe3d74cbb4c826c815f03d8beaa9fa3a6b7ff [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.
"""Bound scan/read executors share an explicit protocol and live configuration."""
from inspect import signature
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.read.plan import NativeTableScan
from pypaimon.execution.native.read.table_read import NativeTableRead
from pypaimon.execution.python.read.table_read import PythonTableRead
from pypaimon.execution.python.read.table_scan import PythonTableScan
from pypaimon.read.plan import Plan
from pypaimon.read.scan_request import ScanRequest
pytestmark = [pytest.mark.python_plan, pytest.mark.python_read,
pytest.mark.python_write, pytest.mark.python_commit]
@pytest.mark.parametrize('method', ['read_rows', 'read_batches'])
def test_bound_read_executors_have_the_same_call_contract(method):
assert signature(getattr(PythonTableRead, method)) == signature(getattr(NativeTableRead, method))
@pytest.mark.parametrize('method', ['plan', 'plan_all', 'scan_with_stats'])
def test_bound_scan_executors_have_the_same_call_contract(method):
assert signature(getattr(PythonTableScan, method)) == signature(getattr(NativeTableScan, method))
@pytest.fixture
def table(tmp_path):
catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
catalog.create_database('default', True)
catalog.create_table('default.protocol', Schema.from_pyarrow_schema(
pa.schema([('id', pa.int32())]), options={
'scan.native-plan.enabled': 'false', 'read.native.enabled': 'false',
'write.native.enabled': 'false', 'commit.native.enabled': 'false',
}), False)
table = catalog.get_table('default.protocol')
builder = table.new_batch_write_builder()
writer, commit = builder.new_write(), builder.new_commit()
try:
writer.write_arrow(pa.table({'id': pa.array([1, 2, 3], type=pa.int32())}))
commit.commit(writer.prepare_commit())
finally:
writer.close()
commit.close()
return table
@pytest.mark.parametrize('backend', ['python', 'native'])
@pytest.mark.parametrize('method', ['read_rows', 'read_batches'])
def test_bound_read_uses_live_configuration_and_selected_executor(table, backend, method):
splits = table.new_read_builder().new_scan().plan().splits()
if backend == 'native':
table = table.copy({'read.native.enabled': 'true'})
for split in splits:
split._native_split = object()
read = table.new_read_builder().new_read()
execution = (NativeTableRead if backend == 'native' else PythonTableRead)(read)
read.limit = 2
schema = read._output_arrow_schema(splits)
closed = []
def native_batches(*args, **kwargs):
values = [1, 2, 3][:kwargs['limit']]
batch = pa.record_batch([pa.array(values, type=pa.int32())], names=['id'])
if kwargs.get('include_row_kind'):
batch = pa.record_batch(
[pa.array(['+I'] * len(values)), batch.column(0)], names=['rowkind', 'id'])
def batches():
try:
yield batch
finally:
closed.append(True)
return batches()
with patch('pypaimon.execution.native.read.table_read.native_read', side_effect=native_batches) as native, \
patch.object(PythonTableRead, '_create_reader_for_split', autospec=True,
side_effect=PythonTableRead._create_reader_for_split) as python:
if method == 'read_rows':
values = [row.get_field(0) for row in execution.read_rows(splits)]
else:
values = [value for batch in execution.read_batches(splits, schema, parallelism=1)
for value in batch.column('id').to_pylist()]
assert values == [1, 2]
assert read.limit == 2 and not read.include_row_kind and read._row_kind_column == '_row_kind'
if backend == 'native':
native.assert_called_once()
assert native.call_args[1]['limit'] == 2
python.assert_not_called()
assert closed == [True]
else:
native.assert_not_called()
python.assert_called_once()
@pytest.mark.parametrize('method', ['read_rows', 'read_batches'])
def test_native_read_preparation_distinguishes_empty_results_from_unsupported(table, method):
read = table.copy({'read.native.enabled': 'true'}).new_read_builder().new_read()
execution = NativeTableRead(read)
schema = read._output_arrow_schema([])
with patch.object(read, '_try_native_batches', return_value=[]) as prepare:
if method == 'read_rows':
assert list(execution.read_rows([])) == []
else:
assert list(execution.read_batches([], schema)) == []
if method != 'read_batches':
prepare.assert_called_once()
read.table = table
if method == 'read_rows':
assert execution.read_rows([]) is None
else:
assert execution.read_batches([], schema) is None
@pytest.mark.parametrize('failure', [False, True])
def test_python_bound_all_files_restores_scanner_configuration_on_success_or_failure(failure):
scanner = SimpleNamespace(skip_level0=True)
expected = Plan([], 9)
def scan():
assert scanner.skip_level0 is False
if failure:
raise OSError('planning failed')
return expected
scanner.scan = scan
execution = PythonTableScan(SimpleNamespace(_python_file_scanner=scanner, _query_auth_fn=None))
if failure:
with pytest.raises(OSError, match='planning failed'):
execution.plan_all()
else:
assert execution.plan_all() is expected
assert scanner.skip_level0 is True
@pytest.mark.parametrize('method', ['plan', 'plan_all', 'scan_with_stats'])
def test_native_bound_scan_uses_live_configuration_without_creating_a_python_scanner(table, method):
scan = table.new_read_builder().new_scan()
execution = NativeTableScan(scan)
scan.limit = 1
expected = Plan([], 9)
with patch('pypaimon.execution.native.read.plan.native_plan', return_value=expected) as native, \
patch.object(scan, '_create_file_scanner', side_effect=AssertionError('Python scanner created')):
result = getattr(execution, method)()
assert (result[0] if method == 'scan_with_stats' else result).snapshot_id == 9
assert native.call_args[1]['limit'] == 1
assert bool(native.call_args[1].get('all_files')) is (method == 'plan_all')
assert scan._file_scanner is None
@pytest.mark.parametrize('method', ['plan', 'plan_all', 'scan_with_stats'])
def test_native_bound_scan_does_not_turn_preparation_failure_into_an_empty_plan(table, method):
scan = table.new_read_builder().new_scan()
execution = NativeTableScan(scan)
with patch('pypaimon.execution.native.read.plan.native_plan', side_effect=OSError('prepare failed')), \
patch.object(scan, '_create_file_scanner', side_effect=AssertionError('Python scanner created')):
assert getattr(execution, method)() is None
assert scan._file_scanner is None
@pytest.mark.parametrize('ordered', [False, True])
def test_native_bound_batches_preserve_the_requested_order_mode(table, ordered):
read = table.copy({'read.native.enabled': 'true'}).new_read_builder().new_read()
execution = NativeTableRead(read)
split = SimpleNamespace(files=[SimpleNamespace(file_name='data.parquet')], _native_split=object())
schema = read._output_arrow_schema([split])
with patch('pypaimon.execution.native.read.table_read.native_read', return_value=[]) as native:
assert list(execution.read_batches([split], schema, parallelism=3, ordered=ordered)) == []
assert native.call_args[1]['parallelism'] == 3
assert native.call_args[1]['ordered'] is ordered
def test_python_scanner_uses_private_cache_and_captured_configuration(table):
request = ScanRequest(idx_of_this_subtask=1, number_of_para_subtasks=3,
row_ranges=((7, 9),))
configure = Mock(side_effect=lambda scanner: setattr(scanner, 'skip_level0', False))
class PublicScanner:
_is_dispatch_file_scanner = True
_configure_python_scanner = staticmethod(configure)
class Scan:
_python_file_scanner = None
_file_scanner = PublicScanner()
_query_auth_fn = None
@property
def file_scanner(self):
raise AssertionError('Python execution entered public scanner dispatch')
def _scan_request(self):
return request
scan = Scan()
scan.table = table
scanner = Mock(skip_level0=True)
with patch.object(PythonTableScan, '_create_file_scanner', return_value=scanner) as create:
assert PythonTableScan(scan)._file_scanner() is scanner
assert PythonTableScan(scan)._scanner(None) is scanner
create.assert_called_once()
configure.assert_called_once_with(scanner)
scanner.with_shard.assert_called_once_with(1, 3)
ranges, = scanner.with_row_ranges.call_args.args
assert [(value.from_, value.to) for value in ranges] == [(7, 9)]
assert scan._python_file_scanner is scanner and not scanner.skip_level0