blob: 937defe5386db94189fde492b1dfa0d57cdae897 [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.
"""Execution selection must not allocate resources for an unused backend."""
from types import SimpleNamespace
from unittest.mock import Mock, patch
import pytest
from pypaimon.snapshot.snapshot import BATCH_COMMIT_IDENTIFIER
from pypaimon.write.commit_message import CommitMessage
from pypaimon.write.table_commit import BatchTableCommit, StreamTableCommit
def _table():
options = Mock()
options.native_commit_enabled.return_value = True
options.snapshot_ignore_empty_commit.return_value = False
return SimpleNamespace(options=options, identifier='test', new_snapshot_commit=Mock())
def _message():
return CommitMessage((), 0, [], deleted_files=[Mock()])
@pytest.fixture
def native_bridge():
native = Mock()
with patch('pypaimon.execution.native.write.native_commit.create_native_commit', return_value=native), \
patch('pypaimon.execution.native.write.native_commit.native_messages_supported', return_value=True), \
patch('pypaimon.execution.native.write.native_commit.to_native_commit_messages',
return_value=['wire-message']):
yield native
@pytest.mark.parametrize('operation', ['commit', 'overwrite', 'abort', 'truncate', 'partitions', 'close'])
def test_native_commit_never_constructs_python_resources(native_bridge, operation):
table = _table()
table.new_snapshot_commit.side_effect = AssertionError('Python snapshot committer was constructed')
callback = Mock()
with patch('pypaimon.execution.python.write.file_store_commit.FileStoreCommit',
side_effect=AssertionError('Python file committer was constructed')):
commit = BatchTableCommit(table, 'user', {} if operation == 'overwrite' else None)
commit.add_commit_callback(callback)
if operation in ('commit', 'overwrite'):
commit.commit([_message()])
if operation == 'overwrite':
native_bridge.commit.assert_called_once_with(['wire-message'])
else:
native_bridge.commit.assert_called_once_with(BATCH_COMMIT_IDENTIFIER, ['wire-message'])
elif operation == 'abort':
commit.abort([_message()])
native_bridge.abort.assert_called_once_with(['wire-message'])
elif operation == 'truncate':
commit.truncate_table()
native_bridge.truncate_table.assert_called_once_with()
elif operation == 'partitions':
commit.truncate_partitions([{'pt': 'a'}])
native_bridge.truncate_partitions.assert_called_once_with([{'pt': 'a'}])
commit.close()
commit.close()
table.new_snapshot_commit.assert_not_called()
callback.close.assert_called_once_with()
if operation != 'close':
native_bridge.close.assert_called_once_with()
def test_python_committer_is_created_once_only_when_fallback_is_selected(native_bridge):
table = _table()
pure = Mock()
callback = Mock()
with patch('pypaimon.execution.python.write.file_store_commit.FileStoreCommit', return_value=pure) as create:
commit = StreamTableCommit(table, 'user')
commit.add_commit_callback(callback)
create.assert_not_called()
table.new_snapshot_commit.assert_not_called()
commit.commit([_message()], 1)
create.assert_not_called()
with patch('pypaimon.execution.native.write.native_commit.native_messages_supported', return_value=False):
commit.commit([_message()], 2)
# Once Python was selected, later Core-compatible messages must not
# revisit Native selection or allocate another executor.
commit.commit([_message()], 3)
assert commit._python_commit.file_store_commit is pure
native_bridge.commit.assert_called_once()
create.assert_called_once()
table.new_snapshot_commit.assert_called_once_with()
assert create.call_args[1]['close_commit_callbacks'] is False
assert [call[1]['commit_identifier'] for call in pure.commit.call_args_list] == [2, 3]
commit.close()
commit.close()
callback.close.assert_called_once_with()
pure.close.assert_called_once_with()
native_bridge.close.assert_called_once_with()
@pytest.mark.parametrize('operation', ['commit', 'overwrite', 'truncate', 'partitions'])
def test_native_publication_failure_never_constructs_python_or_aborts(native_bridge, operation, tmp_path):
table = _table()
table.new_snapshot_commit.side_effect = AssertionError('Python fallback after publication')
prepared_file = tmp_path / 'prepared.parquet'
prepared_file.write_bytes(b'prepared data')
callback = Mock()
failure = TimeoutError('publication outcome is unknown')
method = {'overwrite': 'commit', 'truncate': 'truncate_table', 'partitions': 'truncate_partitions'}.get(
operation, 'commit')
getattr(native_bridge, method).side_effect = failure
with patch('pypaimon.execution.python.write.file_store_commit.FileStoreCommit',
side_effect=AssertionError('Python fallback after publication')):
commit = BatchTableCommit(table, 'user', {} if operation == 'overwrite' else None)
commit.add_commit_callback(callback)
with pytest.raises(TimeoutError) as raised:
if operation in ('commit', 'overwrite'):
commit.commit([_message()])
elif operation == 'truncate':
commit.truncate_table()
else:
commit.truncate_partitions([{'pt': 'a'}])
assert raised.value is failure
assert prepared_file.read_bytes() == b'prepared data'
native_bridge.abort.assert_not_called()
if operation != 'partitions':
with pytest.raises(RuntimeError, match='one-time'):
commit.commit([_message()])
commit.close()
table.new_snapshot_commit.assert_not_called()
callback.close.assert_called_once_with()
def test_ignored_empty_commit_and_close_do_not_allocate_either_backend():
table = _table()
table.options.snapshot_ignore_empty_commit.return_value = True
table.new_snapshot_commit.side_effect = AssertionError('unused Python snapshot')
callback = Mock()
with patch('pypaimon.execution.native.write.native_commit.create_native_commit',
side_effect=AssertionError('unused Native committer')):
commit = BatchTableCommit(table, 'user', None)
commit.add_commit_callback(callback)
commit.commit([])
commit.close()
commit.close()
table.new_snapshot_commit.assert_not_called()
callback.close.assert_called_once_with()
def test_commit_executor_attribute_inspection_does_not_allocate_a_backend():
table = _table()
with patch('pypaimon.execution.native.write.native_commit.create_native_commit',
side_effect=AssertionError('attribute inspection selected Native')), \
patch('pypaimon.execution.python.write.file_store_commit.FileStoreCommit',
side_effect=AssertionError('attribute inspection selected Python')):
commit = BatchTableCommit(table, 'user', None)
assert not hasattr(commit, 'file_store_commit')
with pytest.raises(AttributeError):
_ = commit.file_store_commit
assert commit._python_commit is None
table.new_snapshot_commit.assert_not_called()
commit.close()
def test_callback_closure_is_owned_once_even_if_native_close_fails(native_bridge):
table = _table()
callback = Mock()
native_bridge.close.side_effect = OSError('Native close failed')
commit = BatchTableCommit(table, 'user', None)
commit.add_commit_callback(callback)
commit.commit([_message()])
with pytest.raises(OSError, match='Native close failed'):
commit.close()
commit.close()
callback.close.assert_called_once_with()
table.new_snapshot_commit.assert_not_called()
def _data_evolution_table(tmp_path):
import pyarrow as pa
from pypaimon import CatalogFactory, Schema
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.int64())]), options={
'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true',
'write.native.enabled': 'true', 'commit.native.enabled': 'true',
}), False)
return catalog.get_table('default.t')
def _core_builder():
table = Mock()
builder = table.new_batch_write_builder.return_value
builder.with_commit_user.return_value = builder
return table, builder
@pytest.mark.parametrize('native', [False, True])
@pytest.mark.parametrize('option', [
'commit.max-retries', 'commit.timeout', 'commit.min-retry-wait',
'commit.max-retry-wait', 'manifest.target-file-size',
'data-evolution.row-id-conflict-rewrite.max-size', 'manifest.compression',
])
def test_invalid_commit_configuration_fails_before_allocating_either_backend(
tmp_path, native, option):
table = _data_evolution_table(tmp_path).copy({
option: 'typo', 'commit.native.enabled': str(native).lower()})
with patch.object(table, 'new_snapshot_commit',
side_effect=AssertionError('Python snapshot committer was constructed')) as snapshot, \
patch('pypaimon.execution.python.write.file_store_commit.FileStoreCommit',
side_effect=AssertionError('Python file committer was constructed')) as pure, \
patch('pypaimon.execution.native.write.native_commit.create_native_commit',
side_effect=AssertionError('Native committer was constructed')) as core:
with pytest.raises(ValueError):
table.new_batch_write_builder().new_commit()
snapshot.assert_not_called()
pure.assert_not_called()
core.assert_not_called()
def test_native_writer_does_not_construct_python_writer_or_file_index(
tmp_path):
import pyarrow as pa
from pypaimon.write.write_dispatch import NativeTableWrite
table = _data_evolution_table(tmp_path)
native_table, builder = _core_builder()
expected = [_message()]
with patch('pypaimon.execution.native.write.native_write.native_runtime_available', return_value=True), \
patch('pypaimon.execution.native.write.native_write.create_native_write_table',
return_value=native_table), \
patch('pypaimon.execution.native.write.native_write.from_native_commit_messages', return_value=expected), \
patch('pypaimon.execution.python.write.table_write.PythonTableWrite.__init__',
side_effect=AssertionError('Python data writer was constructed')), \
patch('pypaimon.execution.python.write.row_id_file_index.RowIdFileIndex.from_table',
side_effect=AssertionError('Python file index was built')):
writer = table.new_batch_write_builder().new_write()
assert isinstance(writer, NativeTableWrite)
writer.write_arrow_batch(pa.record_batch([[1], [10]], names=['id', 'value']))
assert writer.prepare_commit() is expected
writer.close()
builder.new_write.return_value.write_arrow.assert_called_once()
builder.new_write.return_value.close.assert_called_once_with()
def test_native_row_id_updater_does_not_construct_python_updater_or_file_index(tmp_path):
import pyarrow as pa
from pypaimon.write.table_update_by_row_id import TableUpdateByRowId
table = _data_evolution_table(tmp_path)
native_table, builder = _core_builder()
expected = [_message()]
with patch('pypaimon.execution.native.write.native_update._native_row_id_table', return_value=native_table), \
patch('pypaimon.execution.native.write.native_update.from_native_commit_messages', return_value=expected), \
patch('pypaimon.execution.python.write.table_update_by_row_id.PythonTableUpdateByRowId.initialize',
side_effect=AssertionError('Python row-id updater was constructed')), \
patch('pypaimon.execution.python.write.row_id_file_index.RowIdFileIndex.from_table',
side_effect=AssertionError('Python file index was built')):
updater = table.new_batch_write_builder().new_update().new_update_by_row_id()
assert isinstance(updater, TableUpdateByRowId)
updates = pa.table({'_ROW_ID': [0], 'value': [11]})
assert updater.update_columns(updates, ['value']) is expected
builder.new_update.return_value.new_update_by_row_id.return_value.update_columns.assert_called_once_with(
updates, ['value'])
def test_native_merge_does_not_construct_python_writer_or_file_index(tmp_path):
import pyarrow as pa
from pypaimon.write.merge_types import WhenMatched, source_col
table = _data_evolution_table(tmp_path)
native_table, builder = _core_builder()
expected = [_message()]
encoded_clause = object()
with patch('pypaimon.execution.native.write.native_merge_into._native_row_id_table', return_value=native_table), \
patch('pypaimon.execution.native.write.native_merge_into.encode_merge_clause',
return_value=encoded_clause), \
patch('pypaimon.execution.native.write.native_merge_into.from_native_commit_messages',
return_value=expected), \
patch('pypaimon.execution.python.write.table_write.PythonTableWrite.__init__',
side_effect=AssertionError('Python writer was constructed')), \
patch('pypaimon.execution.python.write.row_id_file_index.RowIdFileIndex.from_table',
side_effect=AssertionError('Python file index was built')):
result = table.new_batch_write_builder().new_update().merge_into(
pa.table({'id': [1], 'value': [11]}), on=['id'],
when_matched=[WhenMatched.update({'value': source_col('value')})])
assert result is expected
builder.new_update.return_value.merge_into.assert_called_once()
assert builder.new_update.return_value.merge_into.call_args[1]['when_matched'] == [encoded_clause]
def test_failed_python_construction_closes_snapshot_and_preserves_original_error():
table = _table()
table.options.native_commit_enabled.return_value = False
snapshot = table.new_snapshot_commit.return_value
snapshot.close.side_effect = OSError('cleanup failed')
callback = Mock()
failure = ValueError('Python constructor failed')
with patch('pypaimon.execution.python.write.file_store_commit.FileStoreCommit', side_effect=failure):
commit = BatchTableCommit(table, 'user', None)
commit.add_commit_callback(callback)
table.new_snapshot_commit.assert_not_called()
with pytest.raises(ValueError) as caught:
commit.commit([_message()])
assert caught.value is failure
snapshot.close.assert_called_once_with()
callback.close.assert_not_called()
commit.close()
commit.close()
callback.close.assert_called_once_with()
def test_python_close_failure_still_closes_callbacks_once():
table = _table()
table.options.native_commit_enabled.return_value = False
pure = Mock()
pure.close.side_effect = OSError('Python close failed')
callback = Mock()
with patch('pypaimon.execution.python.write.file_store_commit.FileStoreCommit', return_value=pure):
commit = StreamTableCommit(table, 'user')
commit.add_commit_callback(callback)
commit.commit([_message()], 1)
with pytest.raises(OSError, match='Python close failed'):
commit.close()
commit.close()
pure.close.assert_called_once_with()
callback.close.assert_called_once_with()
def test_native_abort_error_does_not_repeat_cleanup_through_python(native_bridge):
table = _table()
table.new_snapshot_commit.side_effect = AssertionError('Python cleanup was constructed')
failure = OSError('native cleanup failed after deleting some files')
native_bridge.abort.side_effect = failure
commit = StreamTableCommit(table, 'user')
with pytest.raises(OSError) as caught:
commit.abort([_message()])
assert caught.value is failure
table.new_snapshot_commit.assert_not_called()
native_bridge.abort.assert_called_once_with(['wire-message'])
commit.close()
def test_native_callback_error_keeps_publication_outside_fallback_and_closes_once(native_bridge):
from pypaimon.common.json_util import JSON
from pypaimon.snapshot.snapshot import Snapshot
table = _table()
table.new_snapshot_commit.side_effect = AssertionError('callback failure must not select Python')
callback = Mock()
failure = LookupError('notification failed after publication')
callback.call.side_effect = failure
follower = Mock()
follower.close.side_effect = RuntimeError('callback close failed')
snapshot = Snapshot(
version=3, id=7, schema_id=0, base_manifest_list='', delta_manifest_list='',
total_record_count=0, delta_record_count=0, commit_user='user',
commit_identifier=BATCH_COMMIT_IDENTIFIER, commit_kind='APPEND', time_millis=0)
context = SimpleNamespace(
commit_entries=[], identifier=BATCH_COMMIT_IDENTIFIER,
_snapshot_json=lambda: JSON.to_json(snapshot))
def publish(*args, **kwargs):
native_bridge.add_commit_callback.call_args[0][0](context)
native_bridge.commit.side_effect = publish
commit = BatchTableCommit(table, 'user', None)
commit.add_commit_callback(callback)
commit.add_commit_callback(follower)
with pytest.raises(LookupError) as caught:
commit.commit([_message()])
assert caught.value is failure
assert callback.call.call_args[0][0].snapshot.id == 7
callback.call.assert_called_once()
follower.call.assert_not_called()
native_bridge.abort.assert_not_called()
table.new_snapshot_commit.assert_not_called()
commit.close()
commit.close()
callback.close.assert_called_once_with()
follower.close.assert_called_once_with()
@pytest.mark.parametrize('stream', [False, True])
@pytest.mark.parametrize('written', [False, True])
def test_native_writer_attribute_queries_never_select_python(tmp_path, stream, written):
import pyarrow as pa
from pypaimon.write.write_dispatch import NativeTableWrite
from pypaimon.write.table_write import TableWrite
table = _data_evolution_table(tmp_path)
core = Mock()
writer = NativeTableWrite(table, 'user', None, stream, core, 7)
with patch('pypaimon.execution.python.write.table_write.PythonTableWrite.__init__',
side_effect=AssertionError('attribute query constructed Python writer')):
if written:
writer.write_arrow_batch(pa.record_batch([[1], [2]], names=['id', 'value']))
for name in ('flush', 'missing_attribute', 'file_store_write', 'row_key_extractor',
'table_pyarrow_schema', 'restore_snapshot_id', 'batch_committed'):
assert not hasattr(writer, name)
with pytest.raises(AttributeError):
getattr(writer, name)
# Every declared TableWrite method is explicit on the Native adapter.
for name in dir(TableWrite):
if not name.startswith('_') and callable(getattr(TableWrite, name)):
assert callable(getattr(writer, name))
assert writer._python_writer is None
core.close.assert_not_called()
writer.close()
core.close.assert_called_once_with()
def test_native_batch_preparation_failure_retains_execution_lifecycle_state_without_python(tmp_path):
from pypaimon.write.write_dispatch import NativeTableWrite
core = Mock()
failure = OSError('batch preparation failed')
core.prepare_commit.side_effect = failure
writer = NativeTableWrite(_data_evolution_table(tmp_path), 'user', None, False, core)
with patch('pypaimon.execution.python.write.table_write.PythonTableWrite.__init__',
side_effect=AssertionError('lifecycle query constructed Python writer')):
assert writer._execution._batch_committed is False
with pytest.raises(OSError) as raised:
writer.prepare_commit()
assert raised.value is failure
assert writer._execution._batch_committed is True
assert writer._python_writer is None
writer.close()
@pytest.mark.parametrize('failed', [False, True])
def test_native_preparation_is_one_attempt_without_exposing_executor_state(tmp_path, failed):
from pypaimon.write.write_dispatch import NativeTableWrite
core = Mock()
if failed:
core.prepare_commit.side_effect = OSError('batch preparation failed')
else:
core.prepare_commit.return_value = []
writer = NativeTableWrite(_data_evolution_table(tmp_path), 'user', None, False, core)
with patch('pypaimon.execution.native.write.native_write.from_native_commit_messages', return_value=[]):
if failed:
with pytest.raises(OSError, match='batch preparation failed'):
writer.prepare_commit()
else:
assert writer.prepare_commit() == []
assert writer._execution._batch_committed is True
assert writer._python_writer is None
for attribute in ('file_store_write', 'row_key_extractor', 'batch_committed'):
with pytest.raises(AttributeError):
getattr(writer, attribute)
assert writer._python_writer is None
with pytest.raises(RuntimeError, match='one-time committing'):
writer.prepare_commit()
core.prepare_commit.assert_called_once_with()
core.close.assert_not_called()
writer.close()
core.close.assert_called_once_with()
def test_importing_native_write_does_not_initialize_python_group_by_kernels():
import os
import subprocess
import sys
from pathlib import Path
script = '''
from unittest.mock import patch
import pyarrow as pa
with patch.object(pa, 'table', wraps=pa.table) as arrow_table:
import pypaimon.execution.native.write.native_write
import pypaimon.execution.python.write.row_key_extractor as pure
assert pure._ARROW_GROUP_BY_SUPPORTED is None
arrow_table.assert_not_called()
'''
environment = os.environ.copy()
environment['PYTHONPATH'] = str(Path(__file__).resolve().parents[3])
result = subprocess.run([sys.executable, '-c', script], env=environment,
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
universal_newlines=True, timeout=30)
assert result.returncode == 0, result.stdout + result.stderr
@pytest.mark.parametrize('supported', [False, True])
def test_python_grouping_probes_capability_only_when_selected_and_caches_result(supported):
from pypaimon.execution.python.write import row_key_extractor
with patch.object(row_key_extractor, '_ARROW_GROUP_BY_SUPPORTED', None), \
patch.object(row_key_extractor, '_probe_arrow_group_by', return_value=supported) as probe:
probe.assert_not_called()
assert row_key_extractor._arrow_group_by_supported() is supported
assert row_key_extractor._arrow_group_by_supported() is supported
probe.assert_called_once_with()