| # 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. | |
| from unittest.mock import Mock, patch | |
| import pyarrow as pa | |
| import pytest | |
| from pypaimon import CatalogFactory, Schema | |
| from pypaimon.catalog.catalog_environment import CatalogEnvironment | |
| from pypaimon.common.options.core_options import CoreOptions | |
| from pypaimon.common.options.options import Options | |
| from pypaimon.snapshot.snapshot import BATCH_COMMIT_IDENTIFIER | |
| from pypaimon.table.file_store_table import FileStoreTable | |
| from pypaimon.write.commit_message import CommitMessage | |
| from pypaimon.execution.native.write.native_commit import ( | |
| create_native_commit, | |
| native_runtime_available, | |
| native_messages_supported, | |
| ) | |
| from pypaimon.write.table_write import StreamTableWrite | |
| pytestmark = pytest.mark.python_write | |
| def requires_native(test): | |
| # Exercise the companion Rust release in the Native CI job. | |
| test = pytest.mark.native_plan(test) | |
| return pytest.mark.skipif( | |
| not native_runtime_available(), reason='pypaimon-rust not installed')(test) | |
| def _table(tmp_path, mode='append', backend='filesystem', catalog=None): | |
| options = {'warehouse': str(tmp_path / 'warehouse')} | |
| if backend == 'jdbc': | |
| options.update({'metastore': 'jdbc', 'uri': 'jdbc:sqlite:' + str(tmp_path / 'catalog.db')}) | |
| if catalog is None: | |
| catalog = CatalogFactory.create(options) | |
| catalog.create_database('default', True) | |
| options = {'file.format': 'parquet', 'commit.native.enabled': 'true'} | |
| if mode == 'pk': | |
| options['bucket'] = '1' | |
| elif mode == 'de': | |
| options.update({'data-evolution.enabled': 'true', 'row-tracking.enabled': 'true'}) | |
| catalog.create_table('default.t', Schema.from_pyarrow_schema( | |
| pa.schema([('id', pa.int64()), ('pt', pa.string())]), | |
| options=options, primary_keys=['id'] if mode == 'pk' else [], | |
| partition_keys=[] if mode in ('pk', 'unpartitioned') else ['pt']), False) | |
| table = catalog.get_table('default.t') | |
| if backend == 'path': | |
| table = FileStoreTable.from_path(table.table_path) | |
| if backend == 'jdbc': | |
| catalog.close() | |
| return table | |
| def _prepare(builder, rows, identifier=None): | |
| writer = builder.new_write() | |
| try: | |
| writer.write_arrow(pa.Table.from_pylist( | |
| rows, schema=pa.schema([('id', pa.int64()), ('pt', pa.string())]))) | |
| return writer.prepare_commit() if identifier is None else writer.prepare_commit(identifier) | |
| finally: | |
| writer.close() | |
| def _rows(table): | |
| builder = table.new_read_builder() | |
| return sorted(builder.new_read().to_arrow( | |
| builder.new_scan().plan().splits()).to_pylist(), key=lambda row: row['id']) | |
| def _must_not_fallback(commit, method='commit'): | |
| return patch.object(commit.file_store_commit, method, side_effect=AssertionError('Python fallback')) | |
| def _seed(table): | |
| builder = table.copy({'commit.native.enabled': 'false'}).new_batch_write_builder() | |
| commit = builder.new_commit() | |
| try: | |
| commit.commit(_prepare(builder, [ | |
| {'id': 1, 'pt': 'a'}, {'id': 2, 'pt': 'b'}, {'id': 3, 'pt': None}])) | |
| finally: | |
| commit.close() | |
| @pytest.mark.python_commit | |
| def test_native_commit_is_opt_in(): | |
| assert not CoreOptions(Options({})).native_commit_enabled() | |
| assert CoreOptions(Options({'commit.native.enabled': 'true'})).native_commit_enabled() | |
| @pytest.mark.parametrize('backend', ['filesystem', 'path', 'jdbc']) | |
| def test_non_rest_catalog_keeps_python_commit(tmp_path, backend): | |
| table = _table(tmp_path, backend=backend) | |
| assert create_native_commit(table, 'job') is None | |
| builder = table.new_batch_write_builder() | |
| commit = builder.new_commit() | |
| try: | |
| commit.commit(_prepare(builder, [{'id': 1, 'pt': 'a'}])) | |
| assert _rows(table) == [{'id': 1, 'pt': 'a'}] | |
| finally: | |
| commit.close() | |
| def test_overwrite_builder_api_is_batch_only(tmp_path): | |
| table = _table(tmp_path) | |
| batch = table.new_batch_write_builder() | |
| assert batch.overwrite({'pt': 'a'}) is batch | |
| assert batch.static_partition == {'pt': 'a'} | |
| stream = table.new_stream_write_builder() | |
| with pytest.raises(AttributeError): | |
| stream.overwrite({'pt': 'a'}) | |
| with pytest.raises(TypeError): | |
| StreamTableWrite(table, stream.commit_user, {'pt': 'a'}) | |
| @requires_native | |
| @pytest.mark.parametrize('mode', ['append', 'pk']) | |
| def test_native_batch_roundtrip_preserves_identity(tmp_path, native_rest_catalog, mode): | |
| table = _table(tmp_path, mode, catalog=native_rest_catalog) | |
| builder = table.new_batch_write_builder() | |
| rows = [{'id': 1, 'pt': 'a'}, {'id': 2, 'pt': None}] | |
| messages = _prepare(builder, rows) | |
| commit = builder.new_commit() | |
| try: | |
| with _must_not_fallback(commit): | |
| commit.commit(messages) | |
| snapshot = table.snapshot_manager().get_latest_snapshot() | |
| assert snapshot.commit_user == builder.commit_user | |
| assert snapshot.commit_identifier == BATCH_COMMIT_IDENTIFIER | |
| assert _rows(table) == rows | |
| with pytest.raises(RuntimeError, match='one-time'): | |
| commit.commit(messages) | |
| finally: | |
| commit.close() | |
| @requires_native | |
| def test_replaced_rest_table_rejects_stale_commit(tmp_path, native_rest_catalog): | |
| table = _table(tmp_path, catalog=native_rest_catalog) | |
| table.catalog_environment.uuid = 'stale-table-id' | |
| assert create_native_commit(table, 'job') is None | |
| @requires_native | |
| def test_native_stream_reuses_commit_user_and_identifiers(tmp_path, native_rest_catalog): | |
| table = _table(tmp_path, catalog=native_rest_catalog) | |
| builder = table.new_stream_write_builder() | |
| commit = builder.new_commit() | |
| try: | |
| with _must_not_fallback(commit): | |
| for identifier in (7, 8): | |
| commit.commit(_prepare(builder, [{'id': identifier, 'pt': None}], identifier), identifier) | |
| snapshot = table.snapshot_manager().get_latest_snapshot() | |
| assert snapshot.commit_user == builder.commit_user | |
| assert snapshot.commit_identifier == identifier | |
| assert _rows(table) == [{'id': 7, 'pt': None}, {'id': 8, 'pt': None}] | |
| finally: | |
| commit.close() | |
| @requires_native | |
| @pytest.mark.parametrize('mode,dynamic,spec', [ | |
| ('append', True, {'pt': 'ignored'}), | |
| ('append', False, {'pt': 'a'}), | |
| ('pk', True, {}), | |
| ('unpartitioned', True, {}), | |
| ]) | |
| def test_native_overwrite_replaces_only_target_rows( | |
| tmp_path, native_rest_catalog, mode, dynamic, spec): | |
| table = _table(tmp_path, mode, catalog=native_rest_catalog).copy({ | |
| 'dynamic-partition-overwrite': str(dynamic).lower(), 'commit.user-prefix': 'python'}) | |
| _seed(table) | |
| builder = table.new_batch_write_builder().overwrite(spec) | |
| commit = builder.new_commit() | |
| try: | |
| messages = _prepare(builder, [{'id': 4, 'pt': 'a'}]) | |
| with _must_not_fallback(commit, 'overwrite'): | |
| commit.commit(messages) | |
| expected = [{'id': 4, 'pt': 'a'}] | |
| if table.partition_keys: | |
| expected = [{'id': 2, 'pt': 'b'}, {'id': 3, 'pt': None}] + expected | |
| assert _rows(table) == expected | |
| snapshot = table.snapshot_manager().get_latest_snapshot() | |
| assert snapshot.commit_user == builder.commit_user | |
| assert snapshot.commit_identifier == BATCH_COMMIT_IDENTIFIER | |
| assert snapshot.commit_kind == 'OVERWRITE' | |
| with pytest.raises(RuntimeError, match='one-time'): | |
| commit.commit(messages) | |
| finally: | |
| commit.close() | |
| @requires_native | |
| @pytest.mark.parametrize('mode,dynamic,spec,remaining', [ | |
| ('append', True, {}, [1, 2, 3]), | |
| ('append', False, {'pt': 'a'}, [2, 3]), | |
| ('append', False, {'pt': None}, [1, 2]), | |
| ('append', False, {'pt': '__DEFAULT_PARTITION__'}, [1, 2]), | |
| ('append', False, {}, []), | |
| ('unpartitioned', True, {}, []), | |
| ('pk', True, {}, []), | |
| ]) | |
| def test_native_empty_overwrite_semantics( | |
| tmp_path, native_rest_catalog, mode, dynamic, spec, remaining): | |
| table = _table(tmp_path, mode, catalog=native_rest_catalog).copy({ | |
| 'dynamic-partition-overwrite': str(dynamic).lower()}) | |
| _seed(table) | |
| builder = table.new_batch_write_builder().overwrite(spec) | |
| commit = builder.new_commit() | |
| try: | |
| with _must_not_fallback(commit, 'overwrite'): | |
| commit.commit([]) | |
| assert [row['id'] for row in _rows(table)] == remaining | |
| snapshot = table.snapshot_manager().get_latest_snapshot() | |
| if remaining == [1, 2, 3]: | |
| assert snapshot.id == 1 | |
| else: | |
| assert snapshot.id == 2 | |
| assert snapshot.commit_user == builder.commit_user | |
| assert snapshot.commit_kind == 'OVERWRITE' | |
| finally: | |
| commit.close() | |
| @requires_native | |
| @pytest.mark.parametrize('value', ['off', '0', ' false ']) | |
| def test_native_overwrite_normalizes_python_boolean_option(tmp_path, native_rest_catalog, value): | |
| table = _table(tmp_path, catalog=native_rest_catalog).copy({ | |
| 'dynamic-partition-overwrite': value, 'snapshot.ignore-empty-commit': value}) | |
| _seed(table) | |
| commit = table.new_batch_write_builder().overwrite({'pt': 'a'}).new_commit() | |
| try: | |
| with _must_not_fallback(commit, 'overwrite'): | |
| commit.commit([]) | |
| assert [row['id'] for row in _rows(table)] == [2, 3] | |
| finally: | |
| commit.close() | |
| @pytest.mark.parametrize('native', [False, pytest.param(True, marks=pytest.mark.native_plan)]) | |
| @pytest.mark.parametrize('case', ['unpartitioned', 'static-empty', 'static-missing', 'dynamic-empty']) | |
| def test_empty_overwrite_records_java_snapshot(tmp_path, native_rest_catalog, native, case): | |
| if native and not native_runtime_available(): | |
| pytest.skip('pypaimon-rust not installed') | |
| table = _table(tmp_path, 'unpartitioned' if case == 'unpartitioned' else 'append', | |
| catalog=native_rest_catalog).copy({ | |
| 'commit.native.enabled': str(native).lower(), | |
| 'dynamic-partition-overwrite': str(case == 'dynamic-empty').lower(), | |
| }) | |
| if case == 'static-missing': | |
| _seed(table) | |
| builder = table.new_batch_write_builder().overwrite( | |
| {'pt': 'missing'} if case.startswith('static') else {}) | |
| commit = builder.new_commit() | |
| try: | |
| if native: | |
| with _must_not_fallback(commit, 'overwrite'): | |
| commit.commit([]) | |
| else: | |
| commit.commit([]) | |
| snapshot = table.snapshot_manager().get_latest_snapshot() | |
| if case == 'dynamic-empty': | |
| assert snapshot is None | |
| else: | |
| assert snapshot.id == (2 if case == 'static-missing' else 1) | |
| assert snapshot.commit_user == builder.commit_user | |
| assert snapshot.commit_kind == 'OVERWRITE' | |
| assert snapshot.total_record_count == (3 if case == 'static-missing' else 0) | |
| assert [row['id'] for row in _rows(table)] == ([1, 2, 3] if case == 'static-missing' else []) | |
| finally: | |
| commit.close() | |
| @requires_native | |
| @pytest.mark.parametrize('mode', ['batch', 'stream']) | |
| @pytest.mark.parametrize('ignore', [True, False]) | |
| def test_native_empty_commit_preserves_python_option( | |
| tmp_path, native_rest_catalog, mode, ignore): | |
| table = _table(tmp_path, catalog=native_rest_catalog).copy({ | |
| 'snapshot.ignore-empty-commit': str(ignore).lower()}) | |
| commit = getattr(table, 'new_' + mode + '_write_builder')().new_commit() | |
| try: | |
| with _must_not_fallback(commit): | |
| if mode == 'batch': | |
| commit.commit([]) | |
| else: | |
| commit.commit([], 7) | |
| snapshot = table.snapshot_manager().get_latest_snapshot() | |
| if ignore: | |
| assert snapshot is None | |
| else: | |
| assert snapshot.commit_identifier == (BATCH_COMMIT_IDENTIFIER if mode == 'batch' else 7) | |
| assert snapshot.total_record_count == 0 | |
| finally: | |
| commit.close() | |
| @requires_native | |
| @pytest.mark.parametrize('overwrite', [False, True]) | |
| def test_native_abort_removes_uncommitted_files(tmp_path, native_rest_catalog, overwrite): | |
| table = _table(tmp_path, catalog=native_rest_catalog) | |
| builder = table.new_batch_write_builder() | |
| if overwrite: | |
| builder.overwrite() | |
| messages = _prepare(builder, [{'id': 1, 'pt': None}]) | |
| files = list(tmp_path.rglob('data-*.parquet')) | |
| assert files | |
| commit = builder.new_commit() | |
| try: | |
| with patch.object(commit.file_store_commit, 'abort', side_effect=AssertionError('Python fallback')): | |
| commit.abort(messages) | |
| assert not any(path.exists() for path in files) | |
| assert table.snapshot_manager().get_latest_snapshot() is None | |
| finally: | |
| commit.close() | |
| @requires_native | |
| @pytest.mark.parametrize('serialized', [False, True]) | |
| def test_python_written_escaped_partition_supports_native_abort( | |
| tmp_path, native_rest_catalog, serialized): | |
| from pypaimon.write.commit_message_serializer import deserialize_commit_message, serialize_commit_message | |
| table = _table(tmp_path, catalog=native_rest_catalog) | |
| builder = table.new_batch_write_builder() | |
| messages = _prepare(builder, [{'id': 1, 'pt': 'a/b'}]) | |
| file = messages[0].new_files[0] | |
| assert table.file_io.exists(file.file_path) | |
| assert '/pt=a%2Fb/' in file.file_path | |
| if serialized: | |
| messages = [deserialize_commit_message(serialize_commit_message(message, table.partition_keys_fields), | |
| table.partition_keys_fields) for message in messages] | |
| assert native_messages_supported(table, messages) | |
| commit = builder.new_commit() | |
| try: | |
| with patch.object(commit.file_store_commit, 'abort', | |
| side_effect=AssertionError('Python fallback')): | |
| commit.abort(messages) | |
| assert not table.file_io.exists(file.file_path) | |
| assert table.snapshot_manager().get_latest_snapshot() is None | |
| finally: | |
| commit.close() | |
| @pytest.mark.parametrize('failure', ['missing', 'construction', 'conversion']) | |
| def test_preflight_failure_uses_python(tmp_path, failure): | |
| table = _table(tmp_path) | |
| builder = table.new_batch_write_builder() | |
| messages = _prepare(builder, [{'id': 1, 'pt': 'a'}]) | |
| commit = builder.new_commit() | |
| native = Mock() | |
| with patch('pypaimon.execution.native.write.native_commit.create_native_commit', return_value=native) as create: | |
| if failure == 'missing': | |
| create.return_value = None | |
| elif failure == 'construction': | |
| create.side_effect = RuntimeError('unsupported FileIO') | |
| with patch('pypaimon.execution.native.write.native_commit.to_native_commit_messages', | |
| side_effect=ValueError('unsupported payload')): | |
| commit.commit(messages) | |
| native.commit.assert_not_called() | |
| assert _rows(table) == [{'id': 1, 'pt': 'a'}] | |
| commit.close() | |
| @pytest.mark.parametrize('method', ['commit', 'abort']) | |
| @pytest.mark.parametrize('overwrite', [False, True]) | |
| def test_native_mutation_failure_never_falls_back_or_aborts(tmp_path, method, overwrite): | |
| table = _table(tmp_path) | |
| builder = table.new_batch_write_builder() | |
| if overwrite: | |
| builder.overwrite() | |
| messages = _prepare(builder, [{'id': 1, 'pt': 'a'}]) | |
| commit = builder.new_commit() | |
| native = Mock() | |
| getattr(native, method).side_effect = OSError('outcome unknown') | |
| with patch('pypaimon.execution.native.write.native_commit.create_native_commit', return_value=native), \ | |
| patch('pypaimon.execution.native.write.native_commit.to_native_commit_messages', return_value=['wire']), \ | |
| patch.object(commit.file_store_commit, 'commit') as fallback, \ | |
| patch.object(commit.file_store_commit, 'overwrite') as fallback_overwrite, \ | |
| patch.object(commit.file_store_commit, 'abort') as abort: | |
| with pytest.raises(OSError, match='outcome unknown'): | |
| getattr(commit, method)(messages) | |
| fallback.assert_not_called() | |
| fallback_overwrite.assert_not_called() | |
| abort.assert_not_called() | |
| if method == 'commit': | |
| native.abort.assert_not_called() | |
| with pytest.raises(RuntimeError, match='one-time'): | |
| commit.commit(messages) | |
| assert list(tmp_path.rglob('data-*.parquet')) | |
| commit.close() | |
| @requires_native | |
| @pytest.mark.parametrize('overwrite', [False, True]) | |
| def test_publication_response_loss_does_not_duplicate_or_delete_files( | |
| tmp_path, native_rest_catalog, overwrite): | |
| table = _table(tmp_path, catalog=native_rest_catalog) | |
| builder = table.new_batch_write_builder() | |
| if overwrite: | |
| builder.overwrite() | |
| messages = _prepare(builder, [{'id': 1, 'pt': None}]) | |
| native = create_native_commit(table, builder.commit_user, builder.static_partition) | |
| proxy = Mock(wraps=native) | |
| def publish_then_fail(*args): | |
| native.commit(*args) | |
| raise OSError('response lost') | |
| proxy.commit.side_effect = publish_then_fail | |
| commit = builder.new_commit() | |
| try: | |
| with patch('pypaimon.execution.native.write.native_commit.create_native_commit', return_value=proxy), \ | |
| _must_not_fallback(commit, 'overwrite' if overwrite else 'commit'), \ | |
| patch.object(commit.file_store_commit, 'abort', side_effect=AssertionError('abort')): | |
| with pytest.raises(OSError, match='response lost'): | |
| commit.commit(messages) | |
| assert table.snapshot_manager().get_latest_snapshot().id == 1 | |
| assert _rows(table) == [{'id': 1, 'pt': None}] | |
| proxy.abort.assert_not_called() | |
| finally: | |
| commit.close() | |
| @pytest.mark.parametrize('properties', [{}, {'source': 'python'}]) | |
| @pytest.mark.parametrize('overwrite', [False, True]) | |
| def test_non_rest_snapshot_properties_keep_python_commit(tmp_path, properties, overwrite): | |
| table = _table(tmp_path) | |
| builder = table.new_batch_write_builder() | |
| if overwrite: | |
| builder.overwrite() | |
| commit = builder.new_commit() | |
| with patch('pypaimon.execution.native.write.native_commit.create_native_commit', | |
| wraps=create_native_commit) as create: | |
| commit.commit(_prepare(builder, [{'id': 1, 'pt': None}]), snapshot_properties=properties) | |
| create.assert_called_once() | |
| assert commit._native_commit is None | |
| assert table.snapshot_manager().get_latest_snapshot().properties == (properties or None) | |
| commit.close() | |
| @requires_native | |
| @pytest.mark.parametrize('warmup', [False, True]) | |
| def test_callbacks_added_after_construction_use_native( | |
| tmp_path, native_rest_catalog, warmup): | |
| if warmup and not native_runtime_available(): | |
| pytest.skip('native warmup requires the commit bindings') | |
| table = _table(tmp_path, catalog=native_rest_catalog) | |
| builder = table.new_stream_write_builder() | |
| commit = builder.new_commit() | |
| if warmup: | |
| with _must_not_fallback(commit): | |
| commit.commit(_prepare(builder, [{'id': 6, 'pt': None}], 6), 6) | |
| callback = Mock() | |
| commit.add_commit_callback(callback) | |
| with _must_not_fallback(commit): | |
| commit.commit(_prepare(builder, [{'id': 1, 'pt': None}], 7), 7) | |
| callback.call.assert_called_once() | |
| context = callback.call.call_args[0][0] | |
| assert context.identifier == 7 | |
| assert context.snapshot.id == (2 if warmup else 1) | |
| assert sum(entry.file.row_count for entry in context.commit_entries) == 1 | |
| commit.close() | |
| callback.close.assert_called_once() | |
| def test_truncate_keeps_python_when_native_publication_is_unavailable(tmp_path): | |
| table = _table(tmp_path) | |
| _seed(table) | |
| with patch('pypaimon.execution.native.write.native_commit.create_native_commit', return_value=None) as create: | |
| for method, args in [('truncate_partitions', ([{'pt': 'a'}],)), ('truncate_table', ())]: | |
| commit = table.new_batch_write_builder().new_commit() | |
| try: | |
| getattr(commit, method)(*args) | |
| finally: | |
| commit.close() | |
| assert create.call_count == 2 | |
| assert _rows(table) == [] | |
| def test_overwrite_conversion_failure_preserves_partition_scope(tmp_path): | |
| table = _table(tmp_path).copy({'dynamic-partition-overwrite': 'false'}) | |
| _seed(table) | |
| builder = table.new_batch_write_builder().overwrite({'pt': 'a'}) | |
| commit = builder.new_commit() | |
| native = Mock() | |
| try: | |
| messages = _prepare(builder, [{'id': 4, 'pt': 'a'}]) | |
| with patch('pypaimon.execution.native.write.native_commit.create_native_commit', return_value=native), \ | |
| patch('pypaimon.execution.native.write.native_commit.to_native_commit_messages', | |
| side_effect=ValueError('unsupported payload')): | |
| commit.commit(messages) | |
| native.commit.assert_not_called() | |
| native.abort.assert_not_called() | |
| assert _rows(table) == [{'id': 2, 'pt': 'b'}, {'id': 3, 'pt': None}, {'id': 4, 'pt': 'a'}] | |
| assert table.snapshot_manager().get_latest_snapshot().commit_user == builder.commit_user | |
| finally: | |
| commit.close() | |
| def test_overwrite_callbacks_select_python(tmp_path): | |
| table = _table(tmp_path) | |
| builder = table.new_batch_write_builder().overwrite() | |
| commit = builder.new_commit() | |
| callback = Mock() | |
| commit.add_commit_callback(callback) | |
| with patch( | |
| 'pypaimon.execution.native.write.native_commit.create_native_commit', | |
| wraps=create_native_commit) as create: | |
| commit.commit(_prepare(builder, [{'id': 1, 'pt': 'a'}])) | |
| create.assert_called_once() | |
| assert commit._native_commit is None | |
| assert _rows(table) == [{'id': 1, 'pt': 'a'}] | |
| callback.call.assert_called_once() | |
| commit.close() | |
| callback.close.assert_called_once() | |
| def test_disabled_option_never_initializes_native(tmp_path): | |
| table = _table(tmp_path).copy({'commit.native.enabled': 'false'}) | |
| builder = table.new_batch_write_builder() | |
| commit = builder.new_commit() | |
| with patch('pypaimon.execution.native.write.native_commit.create_native_commit') as create: | |
| commit.commit(_prepare(builder, [{'id': 1, 'pt': None}])) | |
| create.assert_not_called() | |
| assert _rows(table) == [{'id': 1, 'pt': None}] | |
| commit.close() | |
| @pytest.mark.parametrize('kind', ['custom-env', 'custom-io']) | |
| def test_incompatible_publication_environment_is_not_reconstructed( | |
| tmp_path, native_rest_catalog, kind): | |
| table = _table(tmp_path, catalog=native_rest_catalog) | |
| if kind == 'custom-env': | |
| class CustomEnvironment(CatalogEnvironment): | |
| pass | |
| table.catalog_environment = CustomEnvironment() | |
| else: | |
| table.file_io = Mock() | |
| with patch('pypaimon.execution.native.write.native_commit.native_runtime_available', return_value=True), \ | |
| patch('pypaimon.execution.native.write.native_commit._native_rest_table') as resolve: | |
| assert create_native_commit(table, 'job') is None | |
| resolve.assert_not_called() | |
| @pytest.mark.parametrize('option', ['hadoop_conf', 'prefer_io_loader', 'fallback_io_loader']) | |
| def test_inactive_context_loader_fields_do_not_disable_native_commit(tmp_path, native_rest_catalog, option): | |
| from pypaimon.execution.native.write.native_commit import _rest_catalog_supported | |
| table = _table(tmp_path, catalog=native_rest_catalog) | |
| unused = Mock(side_effect=AssertionError('Inactive IO loader was invoked')) | |
| setattr(table.catalog_environment.catalog_loader.context(), option, unused) | |
| assert _rest_catalog_supported(table) | |
| unused.assert_not_called() | |
| def test_missing_runtime_falls_back_without_reconstructing_table( | |
| tmp_path, native_rest_catalog): | |
| table = _table(tmp_path, catalog=native_rest_catalog) | |
| with patch('pypaimon.execution.native.write.native_commit.native_runtime_available', return_value=False), \ | |
| patch('pypaimon.execution.native.write.native_commit._native_rest_table') as resolve: | |
| assert create_native_commit(table, 'job') is None | |
| resolve.assert_not_called() | |
| def test_preassigned_row_id_messages_use_native_validation( | |
| tmp_path, native_rest_catalog): | |
| table = _table(tmp_path, 'de', catalog=native_rest_catalog) | |
| assert native_messages_supported(table, [CommitMessage((), 0, [])]) | |
| assert native_messages_supported(table, [CommitMessage((), 0, [], check_from_snapshot=7)]) | |
| messages = _prepare(table.new_batch_write_builder(), [{'id': 1, 'pt': 'a'}]) | |
| messages[0].new_files[0].first_row_id = 1 | |
| messages[0].check_from_snapshot = 7 | |
| assert native_messages_supported(table, messages) | |
| assert not native_messages_supported(table, [CommitMessage((), 0, [], compact_after=[Mock()])]) | |
| @requires_native | |
| @pytest.mark.parametrize('mode', ['append', 'de']) | |
| def test_custom_manifest_target_uses_native_commit(tmp_path, native_rest_catalog, mode): | |
| from pypaimon.manifest.manifest_list_manager import ManifestListManager | |
| table = _table(tmp_path, mode, catalog=native_rest_catalog).copy({ | |
| 'manifest.target-file-size': '1 B'}) | |
| builder = table.new_batch_write_builder() | |
| commit = builder.new_commit() | |
| try: | |
| messages = _prepare(builder, [{'id': 4, 'pt': 'a'}, {'id': 5, 'pt': 'b'}]) | |
| with _must_not_fallback(commit): | |
| commit.commit(messages) | |
| assert _rows(table) == [{'id': 4, 'pt': 'a'}, {'id': 5, 'pt': 'b'}] | |
| snapshot = table.snapshot_manager().get_latest_snapshot() | |
| metas = ManifestListManager(table).read_delta(snapshot) | |
| assert len(metas) == 1 # Java checks only at multiples of 1000 records. | |
| assert metas[0].num_added_files == 2 | |
| finally: | |
| commit.close() | |
| def test_close_releases_python_resources_even_if_native_close_fails(tmp_path): | |
| commit = _table(tmp_path).new_batch_write_builder().new_commit() | |
| commit._native_commit = Mock() | |
| commit._native_commit.close.side_effect = OSError('close failure') | |
| with patch.object(commit.file_store_commit, 'close') as close: | |
| with pytest.raises(OSError, match='close failure'): | |
| commit.close() | |
| close.assert_called_once() | |
| @requires_native | |
| @pytest.mark.parametrize('mode', ['append', 'pk', 'de']) | |
| @pytest.mark.parametrize('overwrite', [False, True]) | |
| def test_native_callbacks_keep_python_context_and_allow_native_read( | |
| tmp_path, native_rest_catalog, mode, overwrite): | |
| from pypaimon.manifest.schema.manifest_entry import ManifestEntry | |
| from pypaimon.table.row.generic_row import GenericRow | |
| from pypaimon.write.commit_callback import CommitCallback, CommitCallbackContext | |
| table = _table(tmp_path, mode, catalog=native_rest_catalog) | |
| table = table.copy({ | |
| 'scan.native-plan.enabled': 'true', 'read.native.enabled': 'true', | |
| 'dynamic-partition-overwrite': 'false'}) | |
| builder = table.new_batch_write_builder() | |
| if overwrite: | |
| _seed(table) | |
| builder.overwrite() | |
| calls = [] | |
| class ReadingCallback(CommitCallback): | |
| def call(self, context): | |
| assert isinstance(context, CommitCallbackContext) | |
| assert all(isinstance(entry, ManifestEntry) for entry in context.commit_entries) | |
| assert all(isinstance(entry.partition, GenericRow) for entry in context.commit_entries) | |
| # Re-enter Rust's runtime from the Python callback after publication. | |
| with patch( | |
| 'pypaimon.execution.python.read.table_read.PythonTableRead._create_split_read', | |
| side_effect=AssertionError('callback read fell back') | |
| ): | |
| calls.append((context, _rows(table))) | |
| commit = builder.new_commit() | |
| callback = ReadingCallback() | |
| follower = Mock() | |
| commit.add_commit_callback(callback) | |
| commit.add_commit_callback(follower) | |
| rows = [{'id': 9, 'pt': 'a/b%'}] | |
| messages = _prepare(builder, rows) | |
| paths = [file.file_path for message in messages for file in message.new_files] | |
| try: | |
| with _must_not_fallback(commit, 'overwrite' if overwrite else 'commit'): | |
| commit.commit(messages, snapshot_properties={'source': 'callback-test'}) | |
| assert len(calls) == 1 | |
| context, actual_rows = calls[0] | |
| assert actual_rows == rows | |
| assert context.identifier == BATCH_COMMIT_IDENTIFIER | |
| assert context.snapshot.properties == {'source': 'callback-test'} | |
| assert context.snapshot == table.snapshot_manager().get_latest_snapshot() | |
| adds = [entry for entry in context.commit_entries if entry.kind == 0] | |
| assert [entry.file.file_path for entry in adds] == paths | |
| assert all(table.file_io.exists(file_path) for file_path in paths) | |
| if mode == 'de': | |
| assert all(entry.file.first_row_id is not None for entry in adds) | |
| assert all(entry.file.min_sequence_number == context.snapshot.id for entry in adds) | |
| if overwrite: | |
| deletes = [entry for entry in context.commit_entries if entry.kind == 1] | |
| assert deletes | |
| assert all(entry.file.file_path for entry in deletes) | |
| follower.call.assert_called_once_with(context) | |
| finally: | |
| commit.close() | |
| follower.close.assert_called_once() | |
| @requires_native | |
| @pytest.mark.parametrize('lose_response', [False, True]) | |
| @pytest.mark.parametrize('callback_fails', [False, True]) | |
| def test_native_callback_recovery_and_error_preserve_files( | |
| tmp_path, native_rest_catalog, lose_response, callback_fails): | |
| from pypaimon.tests.rest.rest_server import RESTCatalogServer | |
| table = _table(tmp_path, 'de', catalog=native_rest_catalog) | |
| table = table.copy({'commit.min-retry-wait': '0 ms', 'commit.max-retry-wait': '0 ms'}) | |
| builder = table.new_batch_write_builder() | |
| messages = _prepare(builder, [{'id': 1, 'pt': 'a/b%'}]) | |
| paths = [file.file_path for message in messages for file in message.new_files] | |
| commit = builder.new_commit() | |
| callback = Mock() | |
| if callback_fails: | |
| callback.call.side_effect = LookupError('notification failed') | |
| commit.add_commit_callback(callback) | |
| follower = Mock() | |
| commit.add_commit_callback(follower) | |
| publish = RESTCatalogServer._table_commit_handle | |
| attempts = [] | |
| def publish_then_lose_response(server, *args, **kwargs): | |
| result = publish(server, *args, **kwargs) | |
| attempts.append(result[1]) | |
| assert result[1] == 200 | |
| # A malformed successful response leaves publication outcome uncertain. | |
| return ('not-json', 200) if lose_response else result | |
| try: | |
| with patch.object(RESTCatalogServer, '_table_commit_handle', publish_then_lose_response), \ | |
| _must_not_fallback(commit), \ | |
| patch.object(commit.file_store_commit, 'abort', side_effect=AssertionError('abort')): | |
| if callback_fails: | |
| with pytest.raises(LookupError, match='notification failed'): | |
| commit.commit(messages) | |
| else: | |
| commit.commit(messages) | |
| assert attempts == [200], 'notification failure must not republish the snapshot' | |
| context = callback.call.call_args[0][0] | |
| assert context.snapshot.id == 1 | |
| assert context.snapshot.next_row_id == 1 | |
| assert context.commit_entries[0].file.first_row_id == 0 | |
| assert context.commit_entries[0].file.file_path == paths[0] | |
| assert tuple(context.commit_entries[0].partition.values) == ('a/b%',) | |
| assert _rows(table) == [{'id': 1, 'pt': 'a/b%'}] | |
| assert all(table.file_io.exists(file_path) for file_path in paths) | |
| callback.call.assert_called_once() | |
| if callback_fails: | |
| follower.call.assert_not_called() | |
| else: | |
| follower.call.assert_called_once_with(context) | |
| finally: | |
| commit.close() | |
| callback.close.assert_called_once() | |
| follower.close.assert_called_once() | |
| @requires_native | |
| def test_native_callback_empty_commit_and_prepublication_failure(tmp_path, native_rest_catalog): | |
| table = _table(tmp_path, catalog=native_rest_catalog).copy({'snapshot.ignore-empty-commit': 'false'}) | |
| builder = table.new_stream_write_builder() | |
| commit = builder.new_commit() | |
| callback = Mock() | |
| commit.add_commit_callback(callback) | |
| try: | |
| with _must_not_fallback(commit): | |
| commit.commit([], 1) | |
| context = callback.call.call_args[0][0] | |
| assert context.snapshot.id == 1 | |
| assert context.identifier == 1 | |
| assert context.commit_entries == [] | |
| bad = _prepare(builder, [{'id': 1, 'pt': None}], 2) | |
| # A repeated file entry must fail before publication and notification. | |
| with _must_not_fallback(commit), pytest.raises(ValueError, match='Duplicate'): | |
| commit.commit(bad + bad, 2) | |
| callback.call.assert_called_once() | |
| assert table.snapshot_manager().get_latest_snapshot().id == 1 | |
| finally: | |
| commit.close() | |
| @requires_native | |
| def test_native_callbacks_added_between_commits_share_context_and_close_once( | |
| tmp_path, native_rest_catalog): | |
| table = _table(tmp_path, catalog=native_rest_catalog) | |
| builder = table.new_stream_write_builder() | |
| commit = builder.new_commit() | |
| first, second = Mock(), Mock() | |
| events = [] | |
| def first_call(context): | |
| events.append(('first', context.identifier, context)) | |
| # Preserve Python's shared Context contract between registered callbacks. | |
| context.observed = True | |
| def second_call(context): | |
| assert context.observed | |
| events.append(('second', context.identifier, context)) | |
| first.call.side_effect = first_call | |
| second.call.side_effect = second_call | |
| first.close.side_effect = OSError('close failed') | |
| commit.add_commit_callback(first) | |
| try: | |
| with _must_not_fallback(commit): | |
| commit.commit(_prepare(builder, [{'id': 1, 'pt': None}], 1), 1) | |
| commit.add_commit_callback(second) | |
| commit.commit(_prepare(builder, [{'id': 2, 'pt': None}], 2), 2) | |
| commit.commit(_prepare(builder, [{'id': 3, 'pt': None}], 3), 3) | |
| assert [(name, identifier) for name, identifier, _ in events] == [ | |
| ('first', 1), ('first', 2), ('second', 2), ('first', 3), ('second', 3)] | |
| assert events[1][2] is events[2][2] | |
| assert events[3][2] is events[4][2] | |
| finally: | |
| commit.close() | |
| first.close.assert_called_once() | |
| second.close.assert_called_once() | |
| @requires_native | |
| @pytest.mark.parametrize('close', [False, True]) | |
| def test_native_commit_callback_cycle_is_collected(tmp_path, native_rest_catalog, close): | |
| import gc | |
| import weakref | |
| table = _table(tmp_path, catalog=native_rest_catalog) | |
| builder = table.new_stream_write_builder() | |
| commit = builder.new_commit() | |
| callback = Mock() | |
| callback.owner = commit | |
| reference = weakref.ref(callback) | |
| commit.add_commit_callback(callback) | |
| with _must_not_fallback(commit): | |
| commit.commit(_prepare(builder, [{'id': 1, 'pt': None}], 1), 1) | |
| if close: | |
| commit.close() | |
| del callback, commit | |
| gc.collect() | |
| assert reference() is None |