This is an automated email from the ASF dual-hosted git repository.

JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git


The following commit(s) were added to refs/heads/master by this push:
     new a65238d017 [Python] Execute Ray MERGE actions through native core 
(#10761)
a65238d017 is described below

commit a65238d017ee11cadf81340c0fef63b094ca2deb
Author: Jingsong Lee <[email protected]>
AuthorDate: Fri Oct 23 09:26:35 2026 +0800

    [Python] Execute Ray MERGE actions through native core (#10761)
---
 .../execution/native/write/merge_transform.py      | 148 +++++++
 .../execution/native/write/native_merge_into.py    |  28 +-
 .../execution/python/write/merge_transform.py      |   4 +
 .../pypaimon/ray/data_evolution_merge_into.py      |  11 +-
 .../pypaimon/ray/data_evolution_merge_join.py      |  36 +-
 .../tests/merge_execution_protocol_test.py         | 446 +++++++++++++++++++++
 .../tests/native_merge_batch_execution_test.py     | 120 ++++++
 paimon-python/pypaimon/write/backend_dispatch.py   |  17 +
 8 files changed, 774 insertions(+), 36 deletions(-)

diff --git a/paimon-python/pypaimon/execution/native/write/merge_transform.py 
b/paimon-python/pypaimon/execution/native/write/merge_transform.py
new file mode 100644
index 0000000000..f3a5d64178
--- /dev/null
+++ b/paimon-python/pypaimon/execution/native/write/merge_transform.py
@@ -0,0 +1,148 @@
+# 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 joined Arrow batches to the Core MERGE action executor."""
+
+
+def _callable_assignment(function, on_pairs, callable_input_columns=None, 
hidden_columns=()):
+    from pypaimon.write.merge_transform import _merge_callable_input
+
+    def evaluate(batch):
+        if hidden_columns:
+            batch = batch.drop(hidden_columns)
+        if callable_input_columns is not None:
+            import pyarrow as pa
+            view = pa.Table.from_arrays(
+                [batch.column('t.' + name) for name in callable_input_columns],
+                names=list(callable_input_columns),
+            )
+        else:
+            view = _merge_callable_input(batch, on_pairs)
+        return function(view)
+
+    return evaluate
+
+
+def encode_merge_clause(clause, on_pairs=(), on_map=None, input_schema=None,
+                        callable_input_columns=None, hidden_columns=()):
+    """Encode the existing clause contract without evaluating user 
callbacks."""
+    from pypaimon.write.merge_types import SourceColumnRef, TargetColumnRef, 
LiteralValue
+    from pypaimon.write.merge_condition import remap_source_on_keys, 
rewrite_condition
+
+    available = None if input_schema is None else set(input_schema.names)
+    values = []
+    for name, value in clause.spec.items():
+        if isinstance(value, SourceColumnRef):
+            kind, column = 'source', value.column
+            if available is not None and 's.' + column not in available:
+                target = next((target for source, target in on_pairs
+                               if source == column and 't.' + target in 
available), None)
+                if target is None:
+                    values.append((name, 'literal', None))
+                    continue
+                kind, column = 'target', target
+            values.append((name, kind, column))
+        elif isinstance(value, TargetColumnRef):
+            if available is not None and 't.' + value.column not in available:
+                values.append((name, 'literal', None))
+            else:
+                values.append((name, 'target', value.column))
+        elif isinstance(value, LiteralValue):
+            values.append((name, 'literal', value.value))
+        elif callable(value) and not isinstance(value, type):
+            values.append((name, 'callable', _callable_assignment(
+                value, on_pairs, callable_input_columns, hidden_columns)))
+        else:
+            return None
+    condition = None
+    if clause.condition is not None:
+        rewritten = rewrite_condition(clause.condition)
+        if on_map:
+            rewritten = remap_source_on_keys(rewritten, on_map)
+        condition = dict(sql=rewritten)
+    return dict(assignments=values, condition=condition, delete=clause.delete)
+
+
+class _NativeMergeTransform:
+    def __init__(self, clauses, output_schema, mode, on_pairs=(), on_map=None,
+                 callable_input_columns=None, row_id_name='_ROW_ID'):
+        # Require this release's protocol before consuming input. An installed
+        # incompatible SDK must not silently select another executor.
+        from pypaimon_rust.datafusion import MergeBatchExecution  # noqa: F401
+
+        self.clauses = clauses
+        self.output_schema = output_schema
+        self.mode = mode
+        self.on_pairs = on_pairs
+        self.on_map = on_map
+        self.callable_input_columns = callable_input_columns
+        self.row_id_name = row_id_name
+        self._execution = None
+
+    def __getstate__(self):
+        # Ray transports the request; each worker compiles its own operator.
+        state = self.__dict__.copy()
+        state['_execution'] = None
+        return state
+
+    def __call__(self, batch):
+        if batch.num_rows == 0:
+            return self.output_schema.empty_table()
+        output_schema = self.output_schema
+        hidden_columns = ()
+        if self.mode != 'insert' and self.row_id_name != '_ROW_ID':
+            batch = batch.append_column('t._ROW_ID', batch.column('t.' + 
self.row_id_name))
+            hidden_columns = ('t._ROW_ID',)
+            index = output_schema.get_field_index(self.row_id_name)
+            output_schema = output_schema.set(index, 
output_schema.field(index).with_name('_ROW_ID'))
+        if self._execution is None:
+            from pypaimon_rust.datafusion import MergeBatchExecution
+
+            clauses = [encode_merge_clause(
+                clause, self.on_pairs, self.on_map, batch.schema,
+                self.callable_input_columns, hidden_columns,
+            ) for clause in self.clauses]
+            if any(clause is None for clause in clauses):
+                raise TypeError('Unsupported normalized MERGE assignment')
+            self._execution = MergeBatchExecution(
+                batch.schema, output_schema, clauses, self.mode,
+            )
+        result = self._execution.execute(batch)
+        if hidden_columns:
+            import pyarrow as pa
+            result = pa.Table.from_arrays(result.columns, 
schema=self.output_schema)
+        return result
+
+
+def _build_matched_transform(
+    clauses, on_map, on_pairs, update_cols, row_id_name,
+    update_schema, callable_input_columns=None,
+):
+    return _NativeMergeTransform(
+        clauses, update_schema, 'update', on_pairs, on_map,
+        callable_input_columns, row_id_name,
+    )
+
+
+def _build_matched_delete_transform(clauses, on_map, row_id_name, 
delete_schema):
+    return _NativeMergeTransform(
+        clauses, delete_schema, 'delete', on_map=on_map, 
row_id_name=row_id_name,
+    )
+
+
+def _build_insert_transform(clauses, target_field_names, target_pa_schema):
+    return _NativeMergeTransform(clauses, target_pa_schema, 'insert')
diff --git a/paimon-python/pypaimon/execution/native/write/native_merge_into.py 
b/paimon-python/pypaimon/execution/native/write/native_merge_into.py
index cd92aca1e3..c60540f8b4 100644
--- a/paimon-python/pypaimon/execution/native/write/native_merge_into.py
+++ b/paimon-python/pypaimon/execution/native/write/native_merge_into.py
@@ -25,22 +25,12 @@ from pypaimon.execution.native.write.native_update import (
     _dictionary_value_type, _native_row_id_table, 
_native_update_columns_supported,
     _native_update_formats_supported, _supported_upsert_key_type,
 )
-
-
-def _callable_assignment(function, on_pairs):
-    from pypaimon.write.merge_transform import _merge_callable_input
-
-    def evaluate(batch):
-        return function(_merge_callable_input(batch, on_pairs))
-
-    return evaluate
+from pypaimon.execution.native.write.merge_transform import encode_merge_clause
 
 
 def create_native_merge_into(table, source, on, matched, not_matched, 
commit_user, commit_identifier):
     from pypaimon.write.merge_inputs import _is_self_merge, _is_table_like, 
_prepare
     from pypaimon.write.merge_inputs import _normalize_on, _union_update_cols
-    from pypaimon.write.merge_types import SourceColumnRef, TargetColumnRef, 
LiteralValue
-    from pypaimon.write.merge_condition import rewrite_condition
 
     target_keys, source_keys = _normalize_on(on)
     self_merge = _is_self_merge(table, source, target_keys, source_keys)
@@ -100,21 +90,7 @@ def create_native_merge_into(table, source, on, matched, 
not_matched, commit_use
             return None
 
     def encode(clause):
-        values = []
-        for name, value in clause.spec.items():
-            if isinstance(value, SourceColumnRef):
-                values.append((name, 'source', value.column))
-            elif isinstance(value, TargetColumnRef):
-                values.append((name, 'target', value.column))
-            elif isinstance(value, LiteralValue):
-                values.append((name, 'literal', value.value))
-            elif callable(value) and not isinstance(value, type):
-                values.append((name, 'callable', _callable_assignment(
-                    value, list(zip(source_keys, target_keys)))))
-            else:
-                return None
-        condition = None if clause.condition is None else 
dict(sql=rewrite_condition(clause.condition))
-        return dict(assignments=values, condition=condition, 
delete=clause.delete)
+        return encode_merge_clause(clause, list(zip(source_keys, target_keys)))
 
     encoded_matched = [encode(clause) for clause in matched]
     encoded_not_matched = [encode(clause) for clause in not_matched]
diff --git a/paimon-python/pypaimon/execution/python/write/merge_transform.py 
b/paimon-python/pypaimon/execution/python/write/merge_transform.py
index 4418a8b914..d009eccc1b 100644
--- a/paimon-python/pypaimon/execution/python/write/merge_transform.py
+++ b/paimon-python/pypaimon/execution/python/write/merge_transform.py
@@ -22,6 +22,7 @@ from typing import Dict, List, Optional, Sequence, Tuple
 
 import pyarrow as pa
 
+from pypaimon.write.merge_condition import _load_datafusion
 from pypaimon.write.merge_types import _NormalizedClause
 from pypaimon.write.merge_transform import (
     cast_to_schema,
@@ -54,6 +55,7 @@ def _build_matched_transform(
 
     _filter_batch = None
     if any(r is not None for _, r, _ in prepared_clauses):
+        _load_datafusion()
         from pypaimon.execution.python.write.merge_condition import 
filter_batch as _filter_batch
 
     def _transform(batch: pa.Table) -> pa.Table:
@@ -120,6 +122,7 @@ def _build_matched_delete_transform(
 
     _filter_batch = None
     if any(r is not None for r, _ in prepared_clauses):
+        _load_datafusion()
         from pypaimon.execution.python.write.merge_condition import 
filter_batch as _filter_batch
 
     def _transform(batch: pa.Table) -> pa.Table:
@@ -170,6 +173,7 @@ def _build_insert_transform(
 
     _filter_batch = None
     if any(r is not None for _, r in prepared_clauses):
+        _load_datafusion()
         from pypaimon.execution.python.write.merge_condition import 
filter_batch as _filter_batch
 
     def _transform(batch: pa.Table) -> pa.Table:
diff --git a/paimon-python/pypaimon/ray/data_evolution_merge_into.py 
b/paimon-python/pypaimon/ray/data_evolution_merge_into.py
index 9209a3f5a8..77be7bd9d9 100644
--- a/paimon-python/pypaimon/ray/data_evolution_merge_into.py
+++ b/paimon-python/pypaimon/ray/data_evolution_merge_into.py
@@ -237,10 +237,7 @@ def _prepare(
         for c in list(when_matched) + list(when_not_matched)
     )
     if has_condition:
-        from pypaimon.write.merge_condition import (
-            _load_datafusion, extract_target_columns,
-        )
-        _load_datafusion()
+        from pypaimon.write.merge_condition import extract_target_columns
         for c in when_not_matched:
             if c.condition is not None:
                 t_refs = extract_target_columns(c.condition)
@@ -394,6 +391,7 @@ def _build_datasets(
     insert_ds = None
     update_cols_union: List[str] = []
     target_empty = _is_target_empty(base_snapshot)
+    native_enabled = table.options.native_write_enabled()
 
     if ctx.is_self_merge:
         if matched_specs and not target_empty:
@@ -409,6 +407,7 @@ def _build_datasets(
                     snapshot_id=base_snapshot_id,
                     scan_predicate=ctx.self_merge_scan_predicate,
                     read_columns=ctx.read_columns,
+                    native_enabled=native_enabled,
                 )
             if any(c.delete for c in matched_specs):
                 delete_ds = build_self_merge_delete_ds(
@@ -420,6 +419,7 @@ def _build_datasets(
                     snapshot_id=base_snapshot_id,
                     scan_predicate=ctx.self_merge_scan_predicate,
                     ray_remote_args=ray_remote_args,
+                    native_enabled=native_enabled,
                 )
         return update_ds, delete_ds, insert_ds, update_cols_union
 
@@ -444,6 +444,7 @@ def _build_datasets(
                 resolve_target_projection=_resolve_target_projection,
                 snapshot_id=base_snapshot_id,
                 ray_remote_args=ray_remote_args,
+                native_enabled=native_enabled,
             )
         if any(c.delete for c in matched_specs):
             delete_ds = build_matched_delete_ds(
@@ -459,6 +460,7 @@ def _build_datasets(
                 resolve_target_projection=_resolve_target_projection,
                 snapshot_id=base_snapshot_id,
                 ray_remote_args=ray_remote_args,
+                native_enabled=native_enabled,
             )
 
     if not_matched_specs:
@@ -475,6 +477,7 @@ def _build_datasets(
             snapshot_id=base_snapshot_id,
             target_empty=target_empty,
             ray_remote_args=ray_remote_args,
+            native_enabled=native_enabled,
         )
 
     return update_ds, delete_ds, insert_ds, update_cols_union
diff --git a/paimon-python/pypaimon/ray/data_evolution_merge_join.py 
b/paimon-python/pypaimon/ray/data_evolution_merge_join.py
index 6a9ecd1df4..edbb845d39 100644
--- a/paimon-python/pypaimon/ray/data_evolution_merge_join.py
+++ b/paimon-python/pypaimon/ray/data_evolution_merge_join.py
@@ -82,6 +82,7 @@ class _SelfMergeUpdatePlan:
     snapshot_id: int
     callable_input_columns: Optional[List[str]]
     row_id_plan: Any
+    native_enabled: bool = False
 
 
 @dataclass(frozen=True)
@@ -98,6 +99,7 @@ class _SelfMergeUpdateContext:
     row_id_name: str
     snapshot_id: int
     callable_input_columns: Optional[List[str]]
+    native_enabled: bool = False
 
 
 def _resolve_source_projection(
@@ -132,9 +134,13 @@ def _build_matched_transform(
     row_id_name: str,
     update_schema: pa.Schema,
     callable_input_columns: Optional[Sequence[str]] = None,
+    native_enabled: bool = False,
 ):
-    from pypaimon.execution.python.write.merge_transform import 
_build_matched_transform as execute
-    return execute(clauses, on_map, on_pairs, update_cols, row_id_name, 
update_schema, callable_input_columns)
+    from pypaimon.write.backend_dispatch import create_merge_batch_transform
+    return create_merge_batch_transform(
+        'update', clauses, on_map, on_pairs, update_cols, row_id_name,
+        update_schema, callable_input_columns, native_enabled=native_enabled,
+    )
 
 
 def _build_matched_delete_transform(
@@ -142,9 +148,13 @@ def _build_matched_delete_transform(
     on_map: Dict[str, str],
     row_id_name: str,
     delete_schema: pa.Schema,
+    native_enabled: bool = False,
 ):
-    from pypaimon.execution.python.write.merge_transform import 
_build_matched_delete_transform as execute
-    return execute(clauses, on_map, row_id_name, delete_schema)
+    from pypaimon.write.backend_dispatch import create_merge_batch_transform
+    return create_merge_batch_transform(
+        'delete', clauses, on_map, row_id_name, delete_schema,
+        native_enabled=native_enabled,
+    )
 
 
 def build_self_merge_update_plan(
@@ -158,6 +168,7 @@ def build_self_merge_update_plan(
     snapshot_id: Optional[int] = None,
     scan_predicate=None,
     read_columns: Sequence[str] = (),
+    native_enabled: bool = False,
 ) -> _SelfMergeUpdatePlan:
     from pypaimon.common.options.core_options import (
         CoreOptions, GlobalIndexSearchMode,
@@ -229,6 +240,7 @@ def build_self_merge_update_plan(
             if read_columns else None
         ),
         row_id_plan=row_id_plan,
+        native_enabled=native_enabled,
     )
 
 
@@ -262,6 +274,7 @@ def _apply_self_merge_update_group(context, group_plan, 
collect_row_ids):
         row_id_name=context.row_id_name,
         update_schema=context.update_schema,
         callable_input_columns=context.callable_input_columns,
+        native_enabled=context.native_enabled,
     )
     update_parts = []
     batch_reader = table_read.to_arrow_batch_reader([file_group])
@@ -330,6 +343,7 @@ def distributed_self_merge_update_apply(
         row_id_name=plan.row_id_name,
         snapshot_id=plan.snapshot_id,
         callable_input_columns=plan.callable_input_columns,
+        native_enabled=plan.native_enabled,
     ))
     group_iter = iter(captured.groups)
     max_in_flight = max(1, min(num_partitions, len(captured.groups)))
@@ -388,6 +402,7 @@ def build_self_merge_delete_ds(
     snapshot_id: Optional[int] = None,
     scan_predicate=None,
     ray_remote_args: Optional[Dict[str, Any]] = None,
+    native_enabled: bool = False,
 ) -> Tuple:
     from pypaimon.ray.ray_paimon import read_paimon
     from pypaimon.table.special_fields import SpecialFields
@@ -453,6 +468,7 @@ def build_self_merge_delete_ds(
         on_map={row_id_name: row_id_name},
         row_id_name=row_id_name,
         delete_schema=delete_schema,
+        native_enabled=native_enabled,
     )
     return aliased.map_batches(_transform, **_map_kwargs(ray_remote_args))
 
@@ -473,6 +489,7 @@ def build_matched_update_ds(
     estimated_size_bytes: Optional[int] = None,
     snapshot_id: Optional[int] = None,
     ray_remote_args: Optional[Dict[str, Any]] = None,
+    native_enabled: bool = False,
 ) -> Tuple:
     from pypaimon.ray.ray_paimon import read_paimon
     from pypaimon.table.special_fields import SpecialFields
@@ -518,6 +535,7 @@ def build_matched_update_ds(
         update_cols=list(update_cols),
         row_id_name=row_id_name,
         update_schema=update_schema,
+        native_enabled=native_enabled,
     )
     return joined.map_batches(_transform, **_map_kwargs(ray_remote_args))
 
@@ -536,6 +554,7 @@ def build_matched_delete_ds(
     estimated_size_bytes: Optional[int] = None,
     snapshot_id: Optional[int] = None,
     ray_remote_args: Optional[Dict[str, Any]] = None,
+    native_enabled: bool = False,
 ) -> Tuple:
     from pypaimon.ray.ray_paimon import read_paimon
     from pypaimon.table.special_fields import SpecialFields
@@ -579,6 +598,7 @@ def build_matched_delete_ds(
         on_map=dict(zip(source_on, target_on)),
         row_id_name=row_id_name,
         delete_schema=delete_schema,
+        native_enabled=native_enabled,
     )
     return joined.map_batches(_transform, **_map_kwargs(ray_remote_args))
 
@@ -1052,6 +1072,7 @@ def build_not_matched_insert_ds(
     target_empty: bool = False,
     snapshot_id: Optional[int] = None,
     ray_remote_args: Optional[Dict[str, Any]] = None,
+    native_enabled: bool = False,
 ):
     from pypaimon.ray.ray_paimon import read_paimon
 
@@ -1084,9 +1105,12 @@ def build_not_matched_insert_ds(
             right_on=tuple(f"t.{c}" for c in target_on),
         )
 
-    from pypaimon.execution.python.write.merge_transform import 
_build_insert_transform
+    from pypaimon.write.backend_dispatch import create_merge_batch_transform
 
-    _transform = _build_insert_transform(clauses, captured_field_names, 
out_schema)
+    _transform = create_merge_batch_transform(
+        'insert', clauses, captured_field_names, out_schema,
+        native_enabled=native_enabled,
+    )
 
     return unmatched.map_batches(
         _transform, **_map_kwargs(ray_remote_args)
diff --git a/paimon-python/pypaimon/tests/merge_execution_protocol_test.py 
b/paimon-python/pypaimon/tests/merge_execution_protocol_test.py
new file mode 100644
index 0000000000..083476ec45
--- /dev/null
+++ b/paimon-python/pypaimon/tests/merge_execution_protocol_test.py
@@ -0,0 +1,446 @@
+# 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.write.backend_dispatch import create_merge_batch_transform
+from pypaimon.write.merge_types import (
+    LiteralValue, SourceColumnRef, TargetColumnRef, _NormalizedClause,
+)
+
+
[email protected]
+def native_protocol(monkeypatch):
+    calls = []
+    execute = Mock()
+
+    class MergeBatchExecution:
+        def __init__(self, input_schema, output_schema, clauses, mode):
+            calls.append((input_schema, output_schema, clauses, mode))
+
+        def execute(self, batch):
+            return execute(batch, calls[-1])
+
+    runtime = ModuleType('pypaimon_rust')
+    runtime.__path__ = []
+    datafusion = ModuleType('pypaimon_rust.datafusion')
+    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'] == {'sql': '"t.id" > 0'}
+        name, kind, callback = clauses[0]['assignments'][0]
+        assert (name, kind) == ('value', 'callable')
+        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'][0][2]
+        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']
+
+
[email protected]('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'] == {'sql': '"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'][0][2](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', 'target', 'id'), ('source', 'source', 'value'),
+        ('target', 'target', 'value'), ('missing_source', 'literal', None),
+        ('missing_target', 'literal', None), ('constant', 'literal', 9),
+    ]
+
+
+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'][0][2](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
+
+
[email protected]('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
+
+
[email protected]('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 = 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'][0][2](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()
+
+
[email protected]('native_enabled', [False, True])
[email protected]('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()
+
+
[email protected]('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'] == {'sql': '"s.value" > 
0'}
+
+
[email protected]('operation', ['update', 'delete', 'insert'])
[email protected]('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()
+
+
[email protected]('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:
+        def __init__(self, input_schema, output_schema, clauses, mode):
+            self.output_schema = output_schema
+        def execute(self, batch):
+            return self.output_schema.empty_table()
+    module = ModuleType('pypaimon_rust.datafusion')
+    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
diff --git a/paimon-python/pypaimon/tests/native_merge_batch_execution_test.py 
b/paimon-python/pypaimon/tests/native_merge_batch_execution_test.py
new file mode 100644
index 0000000000..7711be8667
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_merge_batch_execution_test.py
@@ -0,0 +1,120 @@
+# 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.
+
+"""Real SDK coverage of the joined-batch MERGE boundary used by Ray workers."""
+
+import pyarrow as pa
+import pytest
+
+from pypaimon.write.backend_dispatch import create_merge_batch_transform
+from pypaimon.write.merge_types import LiteralValue, SourceColumnRef, 
_NormalizedClause
+
+pytestmark = pytest.mark.native_plan
+
+
+def test_native_matched_actions_preserve_clause_order_and_callable_view():
+    batch = pa.table({
+        't._ROW_ID': pa.chunked_array([[0, 1], [2, 3]], type=pa.int64()),
+        't.id': [10, 11, 12, 13],
+        't.value': pa.array([90, 91, 92, 93], type=pa.int32()),
+        't.other': ['a', 'b', 'c', 'd'],
+        's.value': pa.array([None, 5, 15, 25], type=pa.int32()),
+    })
+    calls = []
+
+    def fallback(rows):
+        calls.append(rows.to_pydict())
+        assert rows.column_names == ['s.key', 's.value', 't._ROW_ID', 't.id', 
't.other', 't.value']
+        return pa.chunked_array([[str(value)] for value in 
rows.column('s.key').to_pylist()])
+
+    clauses = [
+        _NormalizedClause({}, condition='s.value > 20', delete=True),
+        _NormalizedClause({'value': SourceColumnRef('value')}, 
condition='s.value > 10'),
+        _NormalizedClause({'other': fallback}),
+    ]
+    # Output order can differ from the first appearance of fields in clauses.
+    schema = pa.schema([pa.field('_ROW_ID', pa.int64(), nullable=False),
+                        ('other', pa.string()), ('value', pa.int32())])
+    transform = create_merge_batch_transform(
+        'update', clauses, {'key': 'id'}, [('key', 'id')], ['other', 'value'],
+        '_ROW_ID', schema, native_enabled=True)
+    actual = transform(batch)
+    assert actual.schema == schema
+    assert actual.to_pylist() == [
+        {'_ROW_ID': 2, 'other': 'c', 'value': 15},
+        {'_ROW_ID': 0, 'other': '10', 'value': 90},
+        {'_ROW_ID': 1, 'other': '11', 'value': 91},
+    ]
+    assert len(calls) == 1
+    assert calls[0]['t._ROW_ID'] == [0, 1]
+    delete_schema = pa.schema([pa.field('_ROW_ID', pa.int64(), 
nullable=False)])
+    deletes = create_merge_batch_transform(
+        'delete', clauses, {'key': 'id'}, '_ROW_ID', delete_schema, 
native_enabled=True)
+    assert deletes(batch).to_pylist() == [{'_ROW_ID': 3}]
+    assert len(calls) == 1  # UPDATE assignments are irrelevant to the delete 
route.
+
+
+def test_native_insert_actions_fill_typed_nulls_and_keep_chunk_order():
+    schema = pa.schema([('value', pa.int32()), ('optional', pa.string())])
+    clauses = [
+        _NormalizedClause({'value': LiteralValue(20)}, condition='s.value >= 
(SELECT 20)'),
+        _NormalizedClause({'value': LiteralValue(10)}, condition='s.value >= 
10'),
+        _NormalizedClause({'value': SourceColumnRef('value')}),
+    ]
+    batch = pa.table({'s.value': pa.chunked_array([
+        pa.array([None, 5], type=pa.int32()), pa.array([15, 25], 
type=pa.int32()),
+    ])})
+    transform = create_merge_batch_transform(
+        'insert', clauses, schema.names, schema, native_enabled=True)
+    actual = transform(batch)
+    assert actual.schema == schema
+    assert actual.to_pylist() == [
+        {'value': 20, 'optional': None}, {'value': 10, 'optional': None},
+        {'value': None, 'optional': None}, {'value': 5, 'optional': None},
+    ]
+
+
+def test_native_worker_transform_is_serializable_after_compilation():
+    import cloudpickle
+
+    schema = pa.schema([pa.field('_ROW_ID', pa.int64(), nullable=False), 
('value', pa.int32())])
+    transform = create_merge_batch_transform(
+        'update', [_NormalizedClause({'value': SourceColumnRef('key')})],
+        {'key': 'id'}, [('key', 'id')], ['value'], '_ROW_ID', schema, 
native_enabled=True)
+    batch = pa.table({'t._ROW_ID': [7], 't.id': pa.array([42], 
type=pa.int32())})
+    assert transform(batch).to_pylist() == [{'_ROW_ID': 7, 'value': 42}]
+    restored = cloudpickle.loads(cloudpickle.dumps(transform))
+    assert restored(batch).to_pylist() == [{'_ROW_ID': 7, 'value': 42}]
+
+
+def test_native_self_update_callable_keeps_explicit_unqualified_projection():
+    calls = []
+
+    def assignment(rows):
+        calls.append(rows.column_names)
+        assert rows.schema == pa.schema([('value', pa.int32()), ('id', 
pa.int64())])
+        return rows.column('value')
+
+    schema = pa.schema([pa.field('_ROW_ID', pa.int64(), nullable=False), 
('value', pa.int32())])
+    transform = create_merge_batch_transform(
+        'update', [_NormalizedClause({'value': assignment}, condition='s.id > 
1')],
+        {'id': 'id'}, [('id', 'id')], ['value'], '_ROW_ID', schema,
+        callable_input_columns=['value', 'id'], native_enabled=True)
+    batch = pa.table({'t._ROW_ID': [0, 1], 't.id': [1, 2],
+                      't.value': pa.array([3, 4], type=pa.int32())})
+    assert transform(batch).to_pylist() == [{'_ROW_ID': 1, 'value': 4}]
+    assert calls == [['value', 'id']]
diff --git a/paimon-python/pypaimon/write/backend_dispatch.py 
b/paimon-python/pypaimon/write/backend_dispatch.py
index 4460950cf2..5961d946a2 100644
--- a/paimon-python/pypaimon/write/backend_dispatch.py
+++ b/paimon-python/pypaimon/write/backend_dispatch.py
@@ -274,6 +274,23 @@ def prepare_native_merge_into(*args, **kwargs):
     return create_native_merge_into(*args, **kwargs)
 
 
+def create_merge_batch_transform(operation, *args, native_enabled=False, 
**kwargs):
+    """Select the joined-batch executor before a Ray task consumes input."""
+    if native_enabled:
+        from pypaimon.execution.native.runtime import native_runtime_available
+        native_enabled = native_runtime_available()
+    if native_enabled:
+        from pypaimon.execution.native.write import merge_transform
+    else:
+        from pypaimon.execution.python.write import merge_transform
+    builders = {
+        'update': merge_transform._build_matched_transform,
+        'delete': merge_transform._build_matched_delete_transform,
+        'insert': merge_transform._build_insert_transform,
+    }
+    return builders[operation](*args, **kwargs)
+
+
 def create_python_row_id_update(table, commit_user, commit_identifier, 
precomputed=None):
     """Create an updater after Python execution has already been selected."""
     from pypaimon.execution.python.write.table_update_by_row_id import 
PythonTableUpdateByRowId

Reply via email to