| # 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() |