blob: d5f05961743ce13d3a45a12767b059d1249ec199 [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.
"""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()