| # 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. | |
| """REST commits preserve Java's empty-overwrite and changelog-only semantics.""" | |
| from contextlib import contextmanager | |
| from unittest.mock import Mock, patch | |
| import pyarrow as pa | |
| import pytest | |
| from pypaimon import Schema | |
| from pypaimon.manifest.manifest_file_manager import ManifestFileManager | |
| from pypaimon.manifest.manifest_list_manager import ManifestListManager | |
| from pypaimon.execution.native.runtime import (native_runtime_available) | |
| from pypaimon.snapshot.snapshot import BATCH_COMMIT_IDENTIFIER | |
| from pypaimon.tests import native_plan_rest_test | |
| from pypaimon.write.commit_message import CommitMessage | |
| rest_catalog = native_plan_rest_test.rest_catalog | |
| pytestmark = [pytest.mark.python_write, pytest.mark.python_commit] | |
| _ENGINES = [False, pytest.param(True, marks=pytest.mark.skipif( | |
| not native_runtime_available(), reason='pypaimon-rust not installed'))] | |
| _SCHEMA = pa.schema([('id', pa.int64()), ('pt', pa.string()), ('region', pa.string())]) | |
| _ROWS = [dict(id=1, pt='a', region='us'), dict(id=2, pt='b', region='eu')] | |
| def _table(catalog, use_native, ignore_empty, primary_key=False): | |
| catalog.create_table('default.t', Schema.from_pyarrow_schema( | |
| _SCHEMA, primary_keys=['id'] if primary_key else [], | |
| partition_keys=[] if primary_key else ['pt', 'region'], options={ | |
| 'query-auth.enabled': 'true', 'bucket': '1' if primary_key else '-1', | |
| 'changelog-producer': 'input' if primary_key else 'none', | |
| 'write.native.enabled': 'false', 'read.native.enabled': 'false', | |
| 'scan.native-plan.enabled': 'false', | |
| 'commit.native.enabled': str(use_native).lower(), | |
| 'snapshot.ignore-empty-commit': str(ignore_empty).lower(), | |
| 'commit.min-retry-wait': '0 ms', 'commit.max-retry-wait': '0 ms', | |
| }), False) | |
| return catalog.get_table('default.t') | |
| def _prepare(builder, rows, identifier=None): | |
| writer = builder.new_write() | |
| try: | |
| writer.write_arrow(pa.Table.from_pylist(rows, schema=_SCHEMA)) | |
| return writer.prepare_commit() if identifier is None else writer.prepare_commit(identifier) | |
| finally: | |
| writer.close() | |
| @contextmanager | |
| def _publication(server, commit, use_native, lose_response=False): | |
| original = server._table_commit_handle | |
| attempts = [] | |
| def publish(*args, **kwargs): | |
| response = original(*args, **kwargs) | |
| attempts.append(response[1]) | |
| assert response[1] == 200 | |
| # The snapshot is published but the client cannot decode its response. | |
| return ('not-json', 200) if lose_response else response | |
| with patch.object(server, '_table_commit_handle', side_effect=publish), \ | |
| patch.object(server, '_table_query_auth_handle', | |
| side_effect=AssertionError('publication must not request SELECT')), \ | |
| patch('pypaimon.execution.python.write.file_store_commit.' | |
| 'FileStoreCommit.abort', | |
| side_effect=AssertionError('implicit abort')): | |
| if use_native: | |
| with patch('pypaimon.execution.python.write.file_store_commit.' | |
| 'FileStoreCommit._try_commit', | |
| side_effect=AssertionError('native commit fell back')): | |
| yield attempts | |
| else: | |
| yield attempts | |
| @pytest.mark.parametrize('use_native', _ENGINES) | |
| @pytest.mark.parametrize('ignore_empty', [False, True]) | |
| @pytest.mark.parametrize('target', ['empty-table', 'empty-partition', 'missing-partition', 'partial-spec']) | |
| def test_truncate_records_empty_overwrite_and_notifies(rest_catalog, use_native, ignore_empty, target): | |
| catalog, server = rest_catalog | |
| table = _table(catalog, use_native, ignore_empty) | |
| seeded = target in ('missing-partition', 'partial-spec') | |
| if seeded: | |
| builder = table.copy({'commit.native.enabled': 'false'}).new_batch_write_builder() | |
| seed_commit = builder.new_commit() | |
| try: | |
| seed_commit.commit(_prepare(builder, _ROWS)) | |
| finally: | |
| seed_commit.close() | |
| commit = table.new_batch_write_builder().new_commit() | |
| callback = Mock() | |
| commit.add_commit_callback(callback) | |
| try: | |
| with _publication(server, commit, use_native) as attempts: | |
| if target == 'empty-table': | |
| commit.truncate_table() | |
| else: | |
| spec = {'pt': 'absent'} | |
| if target != 'partial-spec': | |
| spec['region'] = 'us' | |
| commit.truncate_partitions([spec]) | |
| assert attempts == [200] | |
| snapshot = table.snapshot_manager().get_latest_snapshot() | |
| assert snapshot.id == (2 if seeded else 1) | |
| assert snapshot.commit_kind == 'OVERWRITE' | |
| assert snapshot.commit_identifier == BATCH_COMMIT_IDENTIFIER | |
| assert snapshot.total_record_count == (2 if seeded else 0) | |
| assert snapshot.delta_record_count == 0 | |
| callback.call.assert_called_once() | |
| context = callback.call.call_args[0][0] | |
| assert context.snapshot == snapshot | |
| assert context.identifier == BATCH_COMMIT_IDENTIFIER | |
| assert context.commit_entries == [] | |
| finally: | |
| commit.close() | |
| callback.close.assert_called_once() | |
| @pytest.mark.parametrize('use_native', _ENGINES) | |
| @pytest.mark.parametrize('ignore_empty', [False, True]) | |
| @pytest.mark.parametrize('stream', [False, True]) | |
| @pytest.mark.parametrize('lose_response', [False, True]) | |
| def test_changelog_only_commit_keeps_snapshot_and_recovers_once( | |
| rest_catalog, use_native, ignore_empty, stream, lose_response): | |
| catalog, server = rest_catalog | |
| table = _table(catalog, use_native, ignore_empty, primary_key=True) | |
| builder = table.new_stream_write_builder() if stream else table.new_batch_write_builder() | |
| prepared = _prepare(builder, _ROWS[:1], 7 if stream else None) | |
| assert sum(len(message.changelog_files) for message in prepared) == 1 | |
| changes = [CommitMessage(message.partition, message.bucket, [], | |
| changelog_files=message.changelog_files) for message in prepared] | |
| paths = [file.file_path for message in prepared for file in message.new_files + message.changelog_files] | |
| commit = builder.new_commit() | |
| callback = Mock() | |
| commit.add_commit_callback(callback) | |
| try: | |
| with _publication(server, commit, use_native, lose_response) as attempts: | |
| args = (changes, 7) if stream else (changes,) | |
| commit.commit(*args, snapshot_properties={'source': 'changelog-only'}) | |
| assert attempts == [200], 'lost response recovery must not publish a second snapshot' | |
| snapshot = table.snapshot_manager().get_latest_snapshot() | |
| assert snapshot.id == 1 | |
| assert snapshot.commit_kind == 'APPEND' | |
| assert snapshot.commit_identifier == (7 if stream else BATCH_COMMIT_IDENTIFIER) | |
| assert snapshot.total_record_count == snapshot.delta_record_count == 0 | |
| assert snapshot.changelog_record_count == 1 | |
| assert snapshot.changelog_manifest_list | |
| assert snapshot.properties == {'source': 'changelog-only'} | |
| manifests = ManifestListManager(table).read_changelog(snapshot) | |
| entries = [entry for manifest in manifests for entry in ManifestFileManager(table).read( | |
| manifest.file_name, drop_stats=False, file_size=manifest.file_size)] | |
| assert [entry.file.file_name for entry in entries] == [ | |
| file.file_name for message in changes for file in message.changelog_files] | |
| callback.call.assert_called_once() | |
| context = callback.call.call_args[0][0] | |
| assert context.snapshot == snapshot | |
| assert context.identifier == snapshot.commit_identifier | |
| assert context.commit_entries == [], 'changelogs are not data-file deltas' | |
| assert all(table.file_io.exists(path) for path in paths) | |
| if stream: | |
| with _publication(server, commit, use_native) as empty_attempts: | |
| commit.commit([], 8) | |
| latest = table.snapshot_manager().get_latest_snapshot() | |
| if ignore_empty: | |
| assert empty_attempts == [] | |
| assert latest.id == 1 | |
| callback.call.assert_called_once() | |
| else: | |
| assert empty_attempts == [200] | |
| assert latest.id == 2 | |
| assert latest.commit_identifier == 8 | |
| assert latest.properties is None | |
| assert latest.changelog_manifest_list is None | |
| assert latest.changelog_record_count is None | |
| assert callback.call.call_count == 2 | |
| else: | |
| with pytest.raises(RuntimeError, match='one-time'): | |
| commit.commit([]) | |
| assert table.snapshot_manager().get_latest_snapshot().id == 1 | |
| finally: | |
| commit.close() | |
| callback.close.assert_called_once() | |
| @pytest.mark.parametrize('use_native', _ENGINES) | |
| @pytest.mark.parametrize('operation', ['truncate-table', 'truncate-partitions', 'changelog-only']) | |
| def test_zero_data_snapshot_callback_error_preserves_confirmed_publication(rest_catalog, use_native, operation): | |
| catalog, server = rest_catalog | |
| table = _table(catalog, use_native, True, primary_key=operation == 'changelog-only') | |
| builder = table.new_batch_write_builder() | |
| paths = [] | |
| changes = [] | |
| if operation == 'changelog-only': | |
| prepared = _prepare(builder, _ROWS[:1]) | |
| changes = [CommitMessage(message.partition, message.bucket, [], | |
| changelog_files=message.changelog_files) for message in prepared] | |
| assert sum(len(message.changelog_files) for message in changes) == 1 | |
| paths = [file.file_path for message in prepared for file in message.new_files + message.changelog_files] | |
| commit = builder.new_commit() | |
| failure = LookupError('notification failed after publication') | |
| callback = Mock() | |
| callback.call.side_effect = failure | |
| follower = Mock() | |
| commit.add_commit_callback(callback) | |
| commit.add_commit_callback(follower) | |
| try: | |
| with _publication(server, commit, use_native, lose_response=True) as attempts: | |
| with pytest.raises(LookupError) as error: | |
| if operation == 'truncate-table': | |
| commit.truncate_table() | |
| elif operation == 'truncate-partitions': | |
| commit.truncate_partitions([{'pt': 'absent'}]) | |
| else: | |
| commit.commit(changes) | |
| assert error.value is failure | |
| assert attempts == [200] | |
| snapshot = table.snapshot_manager().get_latest_snapshot() | |
| assert snapshot.id == 1 | |
| assert snapshot.commit_kind == ('APPEND' if operation == 'changelog-only' else 'OVERWRITE') | |
| assert snapshot.total_record_count == snapshot.delta_record_count == 0 | |
| callback.call.assert_called_once() | |
| assert callback.call.call_args[0][0].snapshot == snapshot | |
| follower.call.assert_not_called() | |
| assert all(table.file_io.exists(path) for path in paths) | |
| finally: | |
| commit.close() | |
| callback.close.assert_called_once() | |
| follower.close.assert_called_once() |