blob: 4c22822587ebb674686aad16857a576803ec72a1 [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.
from dataclasses import replace
import pickle
import sys
from types import ModuleType
from unittest.mock import Mock, patch
import pytest
from pypaimon.tests.write.conflict_detection_test import _make_entry
from pypaimon.execution.python.write.commit.conflict_detection import ConflictDetection
from pypaimon.write.commit_message import CommitMessage
from pypaimon.write.row_id_conflict import RowIdExistenceConflict, check_row_id_conflicts
def _native_error_module():
module = ModuleType('pypaimon_rust')
class NativeRowIdConflict(RuntimeError):
pass
module.RowIdExistenceConflict = NativeRowIdConflict
return module
def _native_conflict(module):
error = module.RowIdExistenceConflict('an opaque transport message')
error.file_name = "opaque '(4): file.parquet"
error.first_row_id = 4
error.row_count = 8
error.bucket = 3
error.current_first_row_id = None
error.current_row_count = 9
return error
@pytest.mark.parametrize('from_entry', [False, True])
def test_structured_conflict_survives_worker_serialization(from_entry):
if from_entry:
error = RowIdExistenceConflict(_make_entry('staged.parquet', first_row_id=7))
else:
error = RowIdExistenceConflict._from_fields(
file_name='staged.parquet', first_row_id=7,
row_count=100, bucket=0, current_row_count=101)
restored = pickle.loads(pickle.dumps(error))
assert str(restored) == str(error)
assert restored.file_name == error.file_name
assert restored.first_row_id == error.first_row_id
assert restored.current_row_count == error.current_row_count
assert (restored.entry is None) == (error.entry is None)
@pytest.mark.parametrize('batch', [False, True])
def test_native_commit_translates_only_typed_row_id_conflicts(batch):
from pypaimon.execution.native.write.commit import NativeTableCommit
from pypaimon.ray.row_id_conflict_rewriter import _find_row_id_conflict
module = _native_error_module()
native = _native_conflict(module)
core = Mock()
core.commit.side_effect = native
commit = NativeTableCommit(core, is_batch=batch)
with patch.dict(sys.modules, {'pypaimon_rust': module}):
with pytest.raises(RowIdExistenceConflict) as raised:
commit.commit([], 7)
error = raised.value
assert error.__cause__ is native
assert error.entry is None
assert (error.file_name, error.first_row_id, error.row_count, error.bucket) == (
"opaque '(4): file.parquet", 4, 8, 3)
assert error.current_first_row_id is None
assert error.current_row_count == 9
assert _find_row_id_conflict(error) is error
core.abort.assert_not_called()
@pytest.mark.parametrize('opaque_message', ['unknown commit result', 'Row ID existence conflict: not a typed error'])
@pytest.mark.parametrize('native_available', [False, True])
def test_native_unknown_outcome_is_not_a_rewrite_signal(opaque_message, native_available):
from pypaimon.execution.native.write.commit import NativeTableCommit
from pypaimon.ray.row_id_conflict_rewriter import _find_row_id_conflict
module = _native_error_module()
failure = ValueError(opaque_message)
failure.__cause__ = _native_conflict(module)
core = Mock()
core.commit.side_effect = failure
with patch.dict(sys.modules, {'pypaimon_rust': module if native_available else None}):
with pytest.raises(ValueError) as raised:
NativeTableCommit(core, is_batch=True).commit([], 7)
assert raised.value is failure
assert _find_row_id_conflict(failure) is None
core.abort.assert_not_called()
@pytest.mark.parametrize('refuse', [False, True])
def test_native_logical_check_never_constructs_python_commit_or_replays(refuse):
message = CommitMessage((), 0, [_make_entry('staged.parquet', first_row_id=0).file], check_from_snapshot=1)
table = Mock()
table.options.native_commit_enabled.return_value = True
core = Mock()
failure = RuntimeError('logical overlap')
if refuse:
core.check_row_id_conflicts.side_effect = failure
core.close.side_effect = RuntimeError('closing failed')
with patch('pypaimon.write.update_validation._authorize_update') as authorize, patch(
'pypaimon.execution.native.write.native_commit.prepare_native_row_id_conflict_check',
return_value=(core, ['wire-message'])) as prepare, patch(
'pypaimon.execution.python.write.commit.row_id_conflict.check_row_id_conflicts') as pure, patch(
'pypaimon.execution.python.write.file_store_commit.FileStoreCommit') as publisher:
if refuse:
with pytest.raises(RuntimeError) as raised:
check_row_id_conflicts(table, [message], 2, 1)
assert raised.value is failure
else:
check_row_id_conflicts(table, [message], 2, 1)
authorize.assert_called_once_with(table)
prepare.assert_called_once_with(table, [message])
core.check_row_id_conflicts.assert_called_once_with(['wire-message'], 2, 1)
core.close.assert_called_once_with()
core.abort.assert_not_called()
pure.assert_not_called()
publisher.assert_not_called()
@pytest.mark.parametrize('failure', [PermissionError('grant refused'), ValueError('unknown transport failure')])
def test_native_check_preparation_errors_are_never_replayed(failure):
table = Mock()
table.options.native_commit_enabled.return_value = True
message = CommitMessage((), 0, [], check_from_snapshot=1)
with patch('pypaimon.write.update_validation._authorize_update'), patch(
'pypaimon.execution.native.write.native_commit.prepare_native_row_id_conflict_check',
side_effect=failure), patch(
'pypaimon.execution.python.write.commit.row_id_conflict.check_row_id_conflicts') as pure:
with pytest.raises(type(failure)) as raised:
check_row_id_conflicts(table, [message], 2, 1)
assert raised.value is failure
pure.assert_not_called()
@pytest.mark.parametrize('latest,baseline', [
(2, 0), (1, 2), (0, 1), (2, -1), (2, 2), (2.5, 1), (True, 1), (2, False), (1 << 63, 1)])
def test_check_rejects_invalid_baseline_before_authorization_or_io(latest, baseline):
table = Mock()
message = CommitMessage((), 0, [], check_from_snapshot=1)
with patch('pypaimon.write.update_validation._authorize_update') as authorize:
with pytest.raises(ValueError):
check_row_id_conflicts(table, [message], latest, baseline)
authorize.assert_not_called()
table.snapshot_manager.assert_not_called()
@pytest.mark.parametrize('baseline', [None, True, 1.0, '1', -1, 1 << 63])
def test_check_rejects_invalid_message_baseline_before_io(baseline):
table = Mock()
message = CommitMessage((), 0, [], check_from_snapshot=baseline)
with patch('pypaimon.write.update_validation._authorize_update') as authorize:
with pytest.raises(ValueError):
check_row_id_conflicts(table, [message], 2, 1)
authorize.assert_not_called()
table.snapshot_manager.assert_not_called()
def test_pure_logical_check_uses_history_without_a_publisher(tmp_path):
import pyarrow as pa
from pypaimon import CatalogFactory, Schema
catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
catalog.create_database('default', False)
catalog.create_table('default.t', Schema.from_pyarrow_schema(
pa.schema([('id', pa.int32()), ('name', pa.string())]), options={
'data-evolution.enabled': 'true', 'row-tracking.enabled': 'true',
'read.native.enabled': 'false', 'scan.native-plan.enabled': 'false',
'write.native.enabled': 'false', 'commit.native.enabled': 'false'}), False)
table = catalog.get_table('default.t')
builder = table.new_batch_write_builder()
writer = builder.new_write()
writer.write_arrow(pa.table({'id': pa.array([1, 2], pa.int32()), 'name': ['a', 'b']}))
initial = writer.prepare_commit()
writer.close()
commit = builder.new_commit()
commit.commit(initial)
commit.close()
update = table.new_batch_write_builder().new_update().with_update_type(['name'])
changes = update.update_by_arrow_with_row_id(pa.table({'_ROW_ID': [0, 1], 'name': ['x', 'y']}))
commit = table.new_batch_write_builder().new_commit()
commit.commit(changes)
commit.close()
staged = replace(changes[0], new_files=[replace(changes[0].new_files[0], file_name='still-prepared.parquet')])
before = sorted(path.name for path in tmp_path.rglob('*') if path.is_file())
with patch('pypaimon.execution.python.write.file_store_commit.FileStoreCommit',
side_effect=AssertionError('a publisher was constructed')), patch.object(
table, 'new_snapshot_commit', side_effect=AssertionError('publication route requested')):
with pytest.raises(RuntimeError, match='MERGE INTO') as raised:
check_row_id_conflicts(table, [staged], 2, 1)
assert not isinstance(raised.value, RowIdExistenceConflict)
disjoint = replace(staged, new_files=[replace(staged.new_files[0], write_cols=['id'])])
check_row_id_conflicts(table, [disjoint], 2, 1)
assert before == sorted(path.name for path in tmp_path.rglob('*') if path.is_file())
@pytest.mark.parametrize('first_row_id,row_count', [(10, 100), (None, 100), (0, 200)])
def test_delete_rejects_reassigned_current_file(first_row_id, row_count):
detection = ConflictDetection(True, None, None, None, None)
current = _make_entry('same-file.parquet', first_row_id=first_row_id, row_count=row_count)
stale = _make_entry('same-file.parquet', kind=1, first_row_id=0)
conflict = detection.check_row_id_existence([current], [stale], None)
assert conflict is not None
assert conflict.entry is stale
assert conflict.file_name == 'same-file.parquet'
assert conflict.first_row_id == 0
assert conflict.row_count == 100
assert conflict.current_first_row_id == first_row_id
assert conflict.current_row_count == row_count
def test_conflict_type_remains_available_from_previous_module():
from pypaimon.write.row_id_conflict import RowIdExistenceConflict
from pypaimon.execution.python.write.commit.conflict_detection import RowIdExistenceConflict as Previous
assert Previous is RowIdExistenceConflict
entry = _make_entry('stale.parquet', first_row_id=7)
error = RowIdExistenceConflict(entry)
assert error.entry is entry
assert (error.file_name, error.first_row_id, error.row_count, error.bucket) == ('stale.parquet', 7, 100, 0)
assert error.current_row_count is None
def test_ray_conflict_module_does_not_import_python_execution():
# A fresh process catches imports hidden by the module cache of this test run.
import subprocess
import sys
program = '''
import importlib.abc
import sys
class Guard(importlib.abc.MetaPathFinder):
def find_spec(self, fullname, path=None, target=None):
if fullname == 'pypaimon.execution.python' or fullname.startswith('pypaimon.execution.python.'):
raise AssertionError('Python execution imported: ' + fullname)
sys.meta_path.insert(0, Guard())
import pypaimon.ray.row_id_conflict_rewriter
'''
subprocess.run([sys.executable, '-c', program], check=True)
def test_native_logical_check_does_not_import_python_execution():
import subprocess
program = '''
import importlib.abc
import sys
from tempfile import TemporaryDirectory
from types import ModuleType
from unittest.mock import patch
class Guard(importlib.abc.MetaPathFinder):
def find_spec(self, fullname, path=None, target=None):
if fullname == 'pypaimon.execution.python' or fullname.startswith('pypaimon.execution.python.'):
raise AssertionError('Python execution imported: ' + fullname)
sys.meta_path.insert(0, Guard())
class NativeMessage:
@staticmethod
def deserialize(payload, version):
assert isinstance(payload, bytes) and payload and version == 14
return NativeMessage()
runtime = ModuleType('pypaimon_rust')
runtime.datafusion = ModuleType('pypaimon_rust.datafusion')
runtime.datafusion.CommitMessage = NativeMessage
sys.modules['pypaimon_rust'] = runtime
sys.modules['pypaimon_rust.datafusion'] = runtime.datafusion
import pyarrow as pa
from pypaimon import CatalogFactory, Schema
from pypaimon.execution.native.write import native_commit
from pypaimon.write.commit_message import CommitMessage
from pypaimon.write.row_id_conflict import check_row_id_conflicts
class Core:
checked = False
closed = False
def check_row_id_conflicts(self, messages, latest, baseline):
from pypaimon_rust.datafusion import CommitMessage as NativeMessage
assert len(messages) == 1 and isinstance(messages[0], NativeMessage)
assert (latest, baseline) == (2, 1)
self.checked = True
def close(self):
self.closed = True
with TemporaryDirectory() as path:
catalog = CatalogFactory.create({'warehouse': path})
catalog.create_database('default', False)
catalog.create_table('default.t', Schema.from_pyarrow_schema(
pa.schema([('id', pa.int32())]), options={
'data-evolution.enabled': 'true', 'row-tracking.enabled': 'true',
'commit.native.enabled': 'true'}), False)
table = catalog.get_table('default.t')
core = Core()
with patch.object(native_commit, 'create_native_commit', return_value=core), patch.object(
table, 'new_snapshot_commit', side_effect=AssertionError('publisher requested')):
check_row_id_conflicts(table, [CommitMessage((), 0, [], check_from_snapshot=1)], 2, 1)
assert core.checked and core.closed
'''
subprocess.run([sys.executable, '-c', program], check=True)
def test_ordinary_delete_and_empty_messages_are_not_existence_conflicts():
current = _make_entry('ordinary.parquet')
deleted = _make_entry('ordinary.parquet', kind=1)
detection = ConflictDetection(True, None, None, None, None)
assert detection.check_row_id_existence([current], [deleted], None) is None
assert detection.check_row_id_existence([], [], None) is None
disabled = ConflictDetection(False, None, None, None, None)
stale = _make_entry('ordinary.parquet', kind=1, first_row_id=7)
assert disabled.check_row_id_existence([current], [stale], 100) is None