blob: 61a694080298140e34fc7f1806a6dbe3e8c92bbb [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.
"""Transport and backend selection for distributed MERGE action batches."""
import pickle
import subprocess
import sys
from types import ModuleType, SimpleNamespace
from unittest.mock import Mock, patch
import pyarrow as pa
import pytest
from pypaimon.tests.merge_sdk_test_support import MergeAssignment, WhenMatched, WhenNotMatched, merge_sdk_module
from pypaimon.write.backend_dispatch import create_merge_batch_transform
from pypaimon.write.merge_types import (
LiteralValue, SourceColumnRef, TargetColumnRef, _NormalizedClause,
)
@pytest.fixture
def native_protocol(monkeypatch):
calls = []
execute = Mock()
class MergeBatchExecution:
@classmethod
def for_updates(cls, input_schema, update_schema, when_matched):
assert all(isinstance(clause, WhenMatched) for clause in when_matched)
return cls((input_schema, update_schema, when_matched, 'update'))
@classmethod
def for_deletes(cls, input_schema, when_matched):
assert all(isinstance(clause, WhenMatched) for clause in when_matched)
schema = pa.schema([pa.field('_ROW_ID', pa.int64(), nullable=False)])
return cls((input_schema, schema, when_matched, 'delete'))
@classmethod
def for_inserts(cls, input_schema, target_schema, when_not_matched):
assert all(isinstance(clause, WhenNotMatched) for clause in when_not_matched)
return cls((input_schema, target_schema, when_not_matched, 'insert'))
def __init__(self, compiled):
self.compiled = compiled
calls.append(compiled)
def execute(self, batch):
return execute(batch, self.compiled)
runtime = ModuleType('pypaimon_rust')
runtime.__path__ = []
datafusion = merge_sdk_module()
datafusion.MergeBatchExecution = MergeBatchExecution
monkeypatch.setitem(sys.modules, 'pypaimon_rust', runtime)
monkeypatch.setitem(sys.modules, 'pypaimon_rust.datafusion', datafusion)
return SimpleNamespace(calls=calls, execute=execute, module=datafusion)
def _joined_batch():
return pa.table({
't._ROW_ID': pa.array([7, 8], pa.int64()),
't.id': pa.array([1, 2], pa.int32()),
't.value': pa.array([10, 20], pa.int32()),
's.value': pa.array([100, 200], pa.int32()),
'_scratch': [0, 0],
})
def _update_schema():
return pa.schema([
pa.field('_ROW_ID', pa.int64(), nullable=False),
pa.field('value', pa.int32()),
])
def _native_update(clauses, callable_input_columns=None):
return create_merge_batch_transform(
'update', clauses, {'source_id': 'id'}, [('source_id', 'id')],
['value'], '_ROW_ID', _update_schema(), callable_input_columns,
native_enabled=True,
)
def test_native_update_restores_callable_aliases_and_preserves_result_chunks(native_protocol):
observed = []
chunks = pa.chunked_array([
pa.array([101], pa.int32()), pa.array([202], pa.int32()),
])
def values(view):
observed.append(view)
return chunks
transform = _native_update([_NormalizedClause(
{'value': values}, 's.source_id > 0',
)])
batch = _joined_batch()
result = pa.Table.from_arrays([
batch['t._ROW_ID'], chunks,
], schema=_update_schema())
def execute(rows, compiled):
input_schema, output_schema, clauses, mode = compiled
assert input_schema == batch.schema
assert output_schema == _update_schema()
assert mode == 'update'
assert clauses[0].condition == '"t.id" > 0'
assignment = clauses[0].assignments['value']
assert assignment.kind == 'function'
callback = assignment.value
assert callback(rows).equals(result['value'])
return result
native_protocol.execute.side_effect = execute
assert not observed
actual = transform(batch)
assert actual is result
assert actual['value'].num_chunks == 2
assert len(observed) == 1
assert observed[0].column_names == ['s.source_id', 's.value', 't._ROW_ID', 't.id', 't.value']
assert observed[0]['s.source_id'].equals(batch['t.id'])
def test_native_self_update_keeps_explicit_callable_read_order(native_protocol):
observed = []
def values(view):
observed.append(view)
return view['value']
transform = _native_update(
[_NormalizedClause({'value': values})], ['value', '_ROW_ID', 'id'],
)
batch = _joined_batch()
def execute(rows, compiled):
callback = compiled[2][0].assignments['value'].value
return pa.Table.from_arrays([rows['t._ROW_ID'], callback(rows)], schema=_update_schema())
native_protocol.execute.side_effect = execute
assert transform(batch).to_pylist() == [{'_ROW_ID': 7, 'value': 10}, {'_ROW_ID': 8, 'value': 20}]
assert observed[0].column_names == ['value', '_ROW_ID', 'id']
@pytest.mark.parametrize('operation', ['update', 'delete'])
def test_native_custom_row_id_preserves_aliases_and_output_schema(native_protocol, operation):
observed = []
def values(view):
observed.append(view)
return view['t.value']
schema = pa.schema([pa.field('custom_id', pa.int64(), nullable=False)])
if operation == 'update':
schema = schema.append(pa.field('value', pa.int32()))
transform = create_merge_batch_transform(
'update', [_NormalizedClause({'value': values}, 't.custom_id > 0')], {}, [],
['value'], 'custom_id', schema, native_enabled=True,
)
else:
transform = create_merge_batch_transform(
'delete', [_NormalizedClause({}, 't.custom_id > 0', delete=True)], {},
'custom_id', schema, native_enabled=True,
)
batch = pa.table({'t.custom_id': pa.array([7, 8], pa.int64()), 't.value': pa.array([10, 20], pa.int32())})
def execute(rows, compiled):
assert rows['t._ROW_ID'].equals(batch['t.custom_id'])
assert compiled[2][0].condition == '"t.custom_id" > 0'
assert compiled[1].names[0] == '_ROW_ID'
columns = [rows['t._ROW_ID']]
if operation == 'update':
columns.append(compiled[2][0].assignments['value'].value(rows))
return pa.Table.from_arrays(columns, schema=compiled[1])
native_protocol.execute.side_effect = execute
result = transform(batch)
assert result.schema == schema
assert result['custom_id'].to_pylist() == [7, 8]
if operation == 'update':
assert observed[0].column_names == ['t.custom_id', 't.value']
def test_native_transports_coalesced_and_missing_column_assignments(native_protocol):
clauses = [_NormalizedClause({
'key': SourceColumnRef('source_id'),
'source': SourceColumnRef('value'),
'target': TargetColumnRef('value'),
'missing_source': SourceColumnRef('absent'),
'missing_target': TargetColumnRef('absent'),
'constant': LiteralValue(9),
})]
transform = _native_update(clauses)
native_protocol.execute.return_value = _update_schema().empty_table()
transform(_joined_batch())
assert native_protocol.calls[0][2][0].assignments == {
'key': MergeAssignment.target_column('id'),
'source': MergeAssignment.source_column('value'),
'target': MergeAssignment.target_column('value'),
'missing_source': MergeAssignment.literal(None),
'missing_target': MergeAssignment.literal(None),
'constant': MergeAssignment.literal(9),
}
@pytest.mark.parametrize('stream', [False, True], ids=['batch', 'stream'])
def test_native_full_merge_uses_typed_branches_without_evaluating_callbacks(tmp_path, native_protocol, stream):
from pypaimon import CatalogFactory, Schema
from pypaimon.write.merge_types import WhenMatched as PublicWhenMatched, WhenNotMatched as PublicWhenNotMatched
from pypaimon.write.merge_types import lit, source_col, target_col
catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
catalog.create_database('default', True)
schema = pa.schema([('id', pa.int32()), ('value', pa.int32()), ('other', pa.int32())])
catalog.create_table('default.t', Schema.from_pyarrow_schema(schema, options={
'data-evolution.enabled': 'true', 'row-tracking.enabled': 'true', 'write.native.enabled': 'true',
'deletion-vectors.enabled': 'true',
}), False)
table = catalog.get_table('default.t')
native_table = Mock()
builder = (native_table.new_stream_write_builder.return_value if stream else
native_table.new_batch_write_builder.return_value)
builder.with_commit_user.return_value = builder
writer = builder.new_update.return_value
writer.merge_into.return_value = []
callback = Mock(return_value=pa.array([11], pa.int32()))
source = pa.table({'source_id': pa.array([1], pa.int32()), 'value': pa.array([11], pa.int32())})
public_builder = table.new_stream_write_builder() if stream else table.new_batch_write_builder()
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.from_native_commit_messages',
side_effect=lambda table, messages: messages):
assert public_builder.new_update().merge_into(
source, on={'id': 'source_id'}, when_matched=[
PublicWhenMatched.delete('s.value < 0'),
PublicWhenMatched.update({'value': callback}, 's.source_id > 0'),
PublicWhenMatched.update({'value': source_col('value'), 'other': target_col('value')}),
], when_not_matched=[PublicWhenNotMatched(
{'id': source_col('source_id'), 'value': lit(5)}, 's.value > 0')],
**({'commit_identifier': 31} if stream else {})) == []
callback.assert_not_called()
args, kwargs = writer.merge_into.call_args
assert args[0] is source
assert kwargs['on'] == [('id', 'source_id')]
if stream:
assert kwargs['commit_identifier'] == 31
else:
assert 'commit_identifier' not in kwargs
matched = kwargs['when_matched']
assert all(isinstance(clause, WhenMatched) for clause in matched)
assert [clause.action for clause in matched] == ['delete', 'update', 'update']
assert [clause.condition for clause in matched] == ['"s.value" < 0', '"s.source_id" > 0', None]
assert matched[2].assignments == {
'value': MergeAssignment.source_column('value'), 'other': MergeAssignment.target_column('value'),
}
inserted = kwargs['when_not_matched']
assert all(isinstance(clause, WhenNotMatched) for clause in inserted)
assert inserted[0].condition == '"s.value" > 0'
assert inserted[0].assignments == {
'id': MergeAssignment.source_column('source_id'), 'value': MergeAssignment.literal(5),
}
joined = pa.table({'t.id': [1], 't.value': [3], 't.other': [4], 's.value': [11]})
assert matched[1].assignments['value'].value(joined).to_pylist() == [11]
assert callback.call_args[0][0].column_names == ['s.source_id', 's.value', 't.id', 't.other', 't.value']
def test_native_delete_restores_requested_field_and_schema_metadata(native_protocol):
schema = pa.schema([pa.field('_ROW_ID', pa.int64(), nullable=False, metadata={b'field': b'value'})],
metadata={b'schema': b'value'})
transform = create_merge_batch_transform(
'delete', [_NormalizedClause({}, delete=True)], {}, '_ROW_ID', schema, native_enabled=True,
)
native_protocol.execute.side_effect = lambda batch, compiled: pa.Table.from_arrays(
[batch['t._ROW_ID']], schema=compiled[1])
result = transform(_joined_batch())
assert result.schema.equals(schema, check_metadata=True)
assert result['_ROW_ID'].to_pylist() == [7, 8]
def test_native_insert_callback_retains_source_aliases_and_dictionary_types(native_protocol):
batch = pa.table({
's.source_id': pa.array([1, 2], pa.int32()).dictionary_encode(),
's.value': pa.array([10, 20], pa.int32()),
'_scratch': [0, 0],
})
observed = []
def values(view):
observed.append(view)
return view['s.value']
schema = pa.schema([('value', pa.int32())])
transform = create_merge_batch_transform(
'insert', [_NormalizedClause({'value': values})], ['value'], schema,
native_enabled=True,
)
def execute(rows, compiled):
assert compiled[3] == 'insert'
return pa.Table.from_arrays([compiled[2][0].assignments['value'].value(rows)], schema=schema)
native_protocol.execute.side_effect = execute
assert transform(batch)['value'].to_pylist() == [10, 20]
assert observed[0].column_names == ['s.source_id', 's.value']
assert observed[0]['s.source_id'].type == batch['s.source_id'].type
@pytest.mark.parametrize('operation', ['update', 'delete', 'insert'])
def test_native_transform_keeps_empty_schema_and_compiled_state_local(native_protocol, operation):
clauses = [_NormalizedClause({'value': SourceColumnRef('value')})]
schema = _update_schema()
if operation == 'update':
transform = _native_update(clauses)
elif operation == 'delete':
schema = pa.schema([pa.field('_ROW_ID', pa.int64(), nullable=False)])
transform = create_merge_batch_transform(
'delete', clauses, {'source_id': 'id'}, '_ROW_ID', schema, native_enabled=True,
)
else:
schema = pa.schema([pa.field('value', pa.int32())])
transform = create_merge_batch_transform(
'insert', clauses, ['value'], schema, native_enabled=True,
)
batch = _joined_batch()
empty = transform(batch.slice(0, 0))
assert empty.schema == schema
assert empty.num_rows == 0
assert native_protocol.calls == []
native_protocol.execute.return_value = schema.empty_table()
transform(batch)
transform(batch)
assert len(native_protocol.calls) == 1
assert native_protocol.calls[0][3] == operation
restored = pickle.loads(pickle.dumps(transform))
restored(batch)
assert len(native_protocol.calls) == 2
@pytest.mark.parametrize('phase', ['compile', 'execute', 'callable'])
def test_native_failure_preserves_exception_identity_without_python_replay(native_protocol, phase):
failure = ValueError('MERGE failure after selecting native execution')
def callback(rows):
raise failure
if phase == 'compile':
native_protocol.module.MergeBatchExecution.for_updates = Mock(side_effect=failure)
elif phase == 'execute':
native_protocol.execute.side_effect = failure
else:
native_protocol.execute.side_effect = lambda rows, compiled: compiled[2][0].assignments['value'].value(rows)
with patch('pypaimon.execution.python.write.merge_transform._build_matched_transform') as python:
transform = _native_update([_NormalizedClause({'value': callback})])
with pytest.raises(ValueError) as caught:
transform(_joined_batch())
assert caught.value is failure
python.assert_not_called()
@pytest.mark.parametrize('native_enabled', [False, True])
@pytest.mark.parametrize('self_merge', [False, True])
def test_ray_merge_seals_table_backend_for_every_action(native_enabled, self_merge):
from pypaimon.ray import data_evolution_merge_into as merge
requested = Mock(return_value=native_enabled)
table = SimpleNamespace(options=SimpleNamespace(native_write_enabled=requested))
ctx = SimpleNamespace(
is_self_merge=self_merge, full_target_field_names=['id', 'value'],
settable_field_names=['id', 'value'], target_on_cols=['id'], source_on_cols=['source_id'],
update_pa_schema=_update_schema(), full_pa_schema=_update_schema(),
catalog_options={'warehouse': '/tmp/merge'}, self_merge_scan_predicate=None,
read_columns=('value',),
)
names = (['build_self_merge_update_plan', 'build_self_merge_delete_ds'] if self_merge else
['build_matched_update_ds', 'build_matched_delete_ds', 'build_not_matched_insert_ds'])
builders = {name: Mock(return_value=object()) for name in names}
matched = [_NormalizedClause({'value': LiteralValue(1)}), _NormalizedClause({}, delete=True)]
with patch.multiple(merge, **builders):
merge._build_datasets(
table, 'default.t', object(), matched, [_NormalizedClause({'value': LiteralValue(2)})],
ctx, SimpleNamespace(id=3, total_record_count=1), 2, None,
)
requested.assert_called_once_with()
for builder in builders.values():
builder.assert_called_once()
assert builder.call_args.kwargs['native_enabled'] is native_enabled
def test_missing_optional_runtime_selects_python_before_consuming_input():
with patch('pypaimon.execution.native.runtime.native_runtime_available', return_value=False) as available, \
patch('pypaimon.execution.native.write.merge_transform._build_matched_transform') as native:
transform = _native_update([_NormalizedClause({'value': LiteralValue(5)})])
result = transform(_joined_batch())
assert result.to_pylist() == [{'_ROW_ID': 7, 'value': 5}, {'_ROW_ID': 8, 'value': 5}]
available.assert_called_once_with()
native.assert_not_called()
@pytest.mark.parametrize('operation', ['update', 'delete', 'insert', 'self-update', 'self-delete'])
def test_native_ray_preparation_does_not_require_python_datafusion(
tmp_path, monkeypatch, native_protocol, operation):
from pypaimon import CatalogFactory, Schema
from pypaimon.ray import data_evolution_merge_into as merge
from pypaimon.write.merge_types import WhenMatched, WhenNotMatched
catalog_options = {'warehouse': str(tmp_path)}
catalog = CatalogFactory.create(catalog_options)
catalog.create_database('default', True)
schema = pa.schema([('id', pa.int32()), ('value', pa.int32())])
catalog.create_table('default.t', Schema.from_pyarrow_schema(schema, options={
'data-evolution.enabled': 'true', 'row-tracking.enabled': 'true',
'deletion-vectors.enabled': 'true', 'write.native.enabled': 'true',
}), False)
source = Mock()
source.schema.return_value = schema
monkeypatch.setitem(sys.modules, 'datafusion', None)
self_merge = operation.startswith('self-')
operation = operation[5:] if self_merge else operation
condition = 's.value > 0'
matched = ([WhenMatched.delete(condition)] if operation == 'delete' else
[WhenMatched.update({'value': 5}, condition)] if operation == 'update' else [])
not_matched = ([WhenNotMatched({'value': 5}, condition)] if operation == 'insert' else [])
with patch.object(merge, '_normalize_source', return_value=source):
table, _, matched, not_matched, ctx = merge._prepare(
'default.t', 'default.t' if self_merge else object(), catalog_options,
matched, not_matched, ['_ROW_ID'] if self_merge else ['id'],
)
assert table.options.native_write_enabled()
assert ctx.is_self_merge is self_merge
assert ctx.self_merge_scan_predicate is None
if operation == 'update':
output_schema = _update_schema()
transform = create_merge_batch_transform(
'update', matched, dict(zip(ctx.source_on_cols, ctx.target_on_cols)),
list(zip(ctx.source_on_cols, ctx.target_on_cols)), ['value'],
'_ROW_ID', output_schema, native_enabled=True,
)
elif operation == 'delete':
output_schema = pa.schema([pa.field('_ROW_ID', pa.int64(), nullable=False)])
transform = create_merge_batch_transform(
'delete', matched, {}, '_ROW_ID', output_schema, native_enabled=True,
)
else:
output_schema = schema
transform = create_merge_batch_transform(
'insert', not_matched, schema.names, schema, native_enabled=True,
)
native_protocol.execute.return_value = output_schema.empty_table()
batch = (pa.table({'s.id': pa.array([1], pa.int32()), 's.value': pa.array([10], pa.int32())})
if operation == 'insert' else _joined_batch())
assert transform(batch).schema == output_schema
assert native_protocol.calls[0][2][0].condition == '"s.value" > 0'
@pytest.mark.parametrize('operation', ['update', 'delete', 'insert'])
@pytest.mark.parametrize('native_requested', [False, True])
def test_python_conditional_merge_requires_datafusion_before_batch_execution(
monkeypatch, operation, native_requested):
monkeypatch.setitem(sys.modules, 'datafusion', None)
clauses = [_NormalizedClause(
{} if operation == 'delete' else {'value': LiteralValue(5)},
's.value > 0', delete=operation == 'delete',
)]
with patch('pypaimon.execution.native.runtime.native_runtime_available', return_value=False):
with pytest.raises(ImportError, match='require DataFusion'):
if operation == 'update':
create_merge_batch_transform(
'update', clauses, {}, [], ['value'], '_ROW_ID', _update_schema(),
native_enabled=native_requested,
)
elif operation == 'delete':
create_merge_batch_transform(
'delete', clauses, {}, '_ROW_ID',
pa.schema([pa.field('_ROW_ID', pa.int64(), nullable=False)]),
native_enabled=native_requested,
)
else:
create_merge_batch_transform(
'insert', clauses, ['value'], pa.schema([('value', pa.int32())]),
native_enabled=native_requested,
)
def test_incompatible_installed_runtime_is_not_replayed_in_python(native_protocol):
del native_protocol.module.MergeBatchExecution
with patch('pypaimon.execution.python.write.merge_transform._build_matched_transform') as python:
with pytest.raises(ImportError):
_native_update([_NormalizedClause({'value': LiteralValue(5)})])
python.assert_not_called()
@pytest.mark.parametrize('native_enabled', [False, True])
def test_merge_transform_imports_only_its_selected_backend(native_enabled):
source = r'''
import importlib.abc
import sys
from types import ModuleType
import pyarrow as pa
native_enabled = NATIVE_ENABLED
blocked = 'pypaimon.execution.python' if native_enabled else 'pypaimon.execution.native'
class RejectOtherBackend(importlib.abc.MetaPathFinder):
def find_spec(self, fullname, path=None, target=None):
if fullname.startswith(blocked) or (not native_enabled and fullname.startswith('pypaimon_rust')):
raise AssertionError('Loaded disabled backend: ' + fullname)
sys.meta_path.insert(0, RejectOtherBackend())
if native_enabled:
class MergeBatchExecution:
@classmethod
def for_updates(cls, input_schema, update_schema, when_matched):
return cls(update_schema)
def __init__(self, output_schema):
self.output_schema = output_schema
def execute(self, batch):
return self.output_schema.empty_table()
from pypaimon.tests.merge_sdk_test_support import merge_sdk_module
module = merge_sdk_module()
module.MergeBatchExecution = MergeBatchExecution
sys.modules['pypaimon_rust.datafusion'] = module
from pypaimon.ray.data_evolution_merge_join import _build_matched_transform
from pypaimon.write.merge_types import LiteralValue, _NormalizedClause
schema = pa.schema([pa.field('_ROW_ID', pa.int64(), nullable=False), pa.field('value', pa.int32())])
transform = _build_matched_transform([_NormalizedClause({'value': LiteralValue(5)})], {}, [],
['value'], '_ROW_ID', schema, native_enabled=native_enabled)
result = transform(pa.table({'t._ROW_ID': pa.array([7], pa.int64()), 't.value': pa.array([10], pa.int32())}))
assert result.schema == schema
assert result.num_rows == (0 if native_enabled else 1)
'''.replace('NATIVE_ENABLED', repr(native_enabled))
result = subprocess.run([sys.executable, '-c', source], stdout=subprocess.PIPE, stderr=subprocess.PIPE,
universal_newlines=True, timeout=30)
assert result.returncode == 0, result.stderr