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 ddddf73dbb [python][ray] Avoid routing shuffle in self-merge updates 
(#9335)
ddddf73dbb is described below

commit ddddf73dbbea8463e0d739de97a451de1f16ea14
Author: XiaoHongbo <[email protected]>
AuthorDate: Fri Aug 21 15:43:56 2026 +0800

    [python][ray] Avoid routing shuffle in self-merge updates (#9335)
---
 .../pypaimon/ray/data_evolution_merge_into.py      |  46 +-
 .../pypaimon/ray/data_evolution_merge_join.py      | 267 +++++++++--
 .../tests/ray_data_evolution_merge_into_test.py    | 501 +++++++++++++--------
 3 files changed, 573 insertions(+), 241 deletions(-)

diff --git a/paimon-python/pypaimon/ray/data_evolution_merge_into.py 
b/paimon-python/pypaimon/ray/data_evolution_merge_into.py
index 3f8e3f3d16..6124ebefd5 100644
--- a/paimon-python/pypaimon/ray/data_evolution_merge_into.py
+++ b/paimon-python/pypaimon/ray/data_evolution_merge_into.py
@@ -32,8 +32,10 @@ from pypaimon.ray.data_evolution_merge_join import (
     build_matched_update_ds,
     build_not_matched_insert_ds,
     build_self_merge_delete_ds,
-    build_self_merge_update_ds,
+    _SelfMergeUpdatePlan,
+    build_self_merge_update_plan,
     distributed_delete_apply,
+    distributed_self_merge_update_apply,
     distributed_update_apply,
     distributed_write_collect_msgs,
 )
@@ -92,7 +94,7 @@ def merge_into(
     base_snapshot = table.snapshot_manager().get_latest_snapshot()
 
     update_ds, delete_ds, insert_ds, update_cols_union = _build_datasets(
-        target, source_ds, matched_specs, not_matched_specs,
+        table, target, source_ds, matched_specs, not_matched_specs,
         ctx, base_snapshot, num_partitions, ray_remote_args,
     )
 
@@ -323,7 +325,7 @@ def _is_self_merge(target, source, target_on_cols, 
source_on_cols) -> bool:
 
 
 def _build_datasets(
-    target, source_ds, matched_specs, not_matched_specs,
+    table, target, source_ds, matched_specs, not_matched_specs,
     ctx: "_PrepareCtx", base_snapshot, num_partitions, ray_remote_args,
 ):
     # Pin every target read to base_snapshot so all branches see the same
@@ -340,18 +342,16 @@ def _build_datasets(
         if matched_specs and base_snapshot is not None:
             update_cols_union = _union_update_cols(matched_specs)
             if update_cols_union:
-                update_ds = build_self_merge_update_ds(
-                    target_identifier=target,
+                update_ds = build_self_merge_update_plan(
+                    table=table,
                     clauses=matched_specs,
                     target_field_names=ctx.full_target_field_names,
                     target_pa_schema=ctx.update_pa_schema,
                     update_cols=update_cols_union,
-                    catalog_options=ctx.catalog_options,
                     resolve_target_projection=_resolve_target_projection,
                     snapshot_id=base_snapshot_id,
                     scan_predicate=ctx.self_merge_scan_predicate,
                     read_columns=ctx.read_columns,
-                    ray_remote_args=ray_remote_args,
                 )
             if any(c.delete for c in matched_specs):
                 delete_ds = build_self_merge_delete_ds(
@@ -439,16 +439,28 @@ def _execute_and_commit(
 
     try:
         if update_ds is not None:
-            update_msgs, num_updated, update_row_ids = 
distributed_update_apply(
-                update_ds, table, update_cols_union,
-                num_partitions=num_partitions,
-                ray_remote_args=ray_remote_args,
-                base_snapshot_id=(
-                    base_snapshot.id
-                    if base_snapshot is not None else None
-                ),
-                collect_row_ids=collect_action_row_ids,
-            )
+            if isinstance(update_ds, _SelfMergeUpdatePlan):
+                update_msgs, num_updated, update_row_ids = (
+                    distributed_self_merge_update_apply(
+                        update_ds,
+                        num_partitions=num_partitions,
+                        ray_remote_args=ray_remote_args,
+                        collect_row_ids=collect_action_row_ids,
+                    )
+                )
+            else:
+                update_msgs, num_updated, update_row_ids = (
+                    distributed_update_apply(
+                        update_ds, table, update_cols_union,
+                        num_partitions=num_partitions,
+                        ray_remote_args=ray_remote_args,
+                        base_snapshot_id=(
+                            base_snapshot.id
+                            if base_snapshot is not None else None
+                        ),
+                        collect_row_ids=collect_action_row_ids,
+                    )
+                )
             commit_messages.extend(update_msgs)
 
         if delete_ds is not None:
diff --git a/paimon-python/pypaimon/ray/data_evolution_merge_join.py 
b/paimon-python/pypaimon/ray/data_evolution_merge_join.py
index d7e381adc4..af56d7ca30 100644
--- a/paimon-python/pypaimon/ray/data_evolution_merge_join.py
+++ b/paimon-python/pypaimon/ray/data_evolution_merge_join.py
@@ -16,6 +16,7 @@
 # limitations under the License.
 
################################################################################
 
+from dataclasses import dataclass
 from typing import Any, Dict, List, Optional, Sequence, Tuple
 
 import pyarrow as pa
@@ -44,6 +45,39 @@ def _map_kwargs(
     return kwargs
 
 
+@dataclass(frozen=True)
+class _SelfMergeUpdatePlan:
+    """Pinned target file groups for self-merge update execution."""
+
+    table: Any
+    scan_table: Any
+    file_groups: list
+    predicate: Any
+    read_type: list
+    clauses: List[_NormalizedClause]
+    update_cols: List[str]
+    update_schema: pa.Schema
+    row_id_name: str
+    snapshot_id: int
+    callable_input_columns: Optional[List[str]]
+
+
+@dataclass(frozen=True)
+class _SelfMergeUpdateContext:
+    """Worker state shared by all file groups."""
+
+    table: Any
+    scan_table: Any
+    predicate: Any
+    read_type: list
+    clauses: List[_NormalizedClause]
+    update_cols: List[str]
+    update_schema: pa.Schema
+    row_id_name: str
+    snapshot_id: int
+    callable_input_columns: Optional[List[str]]
+
+
 def _resolve_source_projection(
     clauses: List[_NormalizedClause],
     source_on: Sequence[str],
@@ -189,22 +223,23 @@ def _build_matched_delete_transform(
     return _transform
 
 
-def build_self_merge_update_ds(
+def build_self_merge_update_plan(
     *,
-    target_identifier: str,
+    table,
     clauses: List[_NormalizedClause],
     target_field_names: Sequence[str],
     target_pa_schema: pa.Schema,
     update_cols: Sequence[str],
-    catalog_options: Dict[str, str],
     resolve_target_projection,
     snapshot_id: Optional[int] = None,
     scan_predicate=None,
     read_columns: Sequence[str] = (),
-    ray_remote_args: Optional[Dict[str, Any]] = None,
-) -> Tuple:
-    from pypaimon.ray.ray_paimon import read_paimon
+) -> _SelfMergeUpdatePlan:
+    from pypaimon.common.options.core_options import (
+        CoreOptions, GlobalIndexSearchMode,
+    )
     from pypaimon.table.special_fields import SpecialFields
+    from pypaimon.write.table_update import TableUpdate
 
     row_id_name = SpecialFields.ROW_ID.name
     needed_cols = set(resolve_target_projection(
@@ -227,59 +262,195 @@ def build_self_merge_update_ds(
         c for c in target_field_names if c in needed_cols
     ]
 
-    read_kwargs = {}
+    dynamic_options = {}
+    if snapshot_id is not None:
+        dynamic_options[CoreOptions.SCAN_SNAPSHOT_ID.key()] = str(snapshot_id)
     if scan_predicate is not None:
-        from pypaimon.common.options.core_options import (
-            CoreOptions, GlobalIndexSearchMode,
+        dynamic_options[CoreOptions.SCALAR_INDEX_SEARCH_MODE.key()] = (
+            GlobalIndexSearchMode.FULL.value
         )
-        read_kwargs["filter"] = scan_predicate
-        read_kwargs["dynamic_options"] = {
-            CoreOptions.SCALAR_INDEX_SEARCH_MODE.key():
-                GlobalIndexSearchMode.FULL.value,
-        }
-    target_ds = read_paimon(
-        target_identifier, catalog_options,
-        projection=projection, snapshot_id=snapshot_id,
-        _preserve_current_schema=True,
-        **read_kwargs,
-    )
-    update_schema = build_update_schema(target_pa_schema, update_cols, 
row_id_name)
-
-    orig_names = target_ds.schema().names
-    target_renamed = target_ds.rename_columns(
-        {c: f"t.{c}" for c in orig_names}
+    scan_table = (
+        table.copy_without_time_travel(dynamic_options)
+        if dynamic_options else table
     )
-
-    def _add_source_aliases(batch: pa.Table) -> pa.Table:
-        columns = list(batch.columns)
-        names = list(batch.schema.names)
-        for orig in orig_names:
-            if orig == row_id_name:
-                continue
-            t_col_name = f"t.{orig}"
-            if t_col_name in names:
-                idx = names.index(t_col_name)
-                columns.append(columns[idx])
-                names.append(f"s.{orig}")
-        return pa.table(columns, names=names)
-
-    aliased = target_renamed.map_batches(
-        _add_source_aliases, **_map_kwargs(ray_remote_args),
+    read_builder = scan_table.new_read_builder().with_projection(projection)
+    if scan_predicate is not None:
+        read_builder.with_filter(scan_predicate)
+    scan_plan = read_builder.new_scan().plan_for_write()
+    planned_snapshot_id = (
+        scan_plan.snapshot_id
+        if scan_plan.snapshot_id is not None else -1
     )
-
-    _transform = _build_matched_transform(
-        clauses,
-        on_map={row_id_name: row_id_name},
-        on_pairs=[(row_id_name, row_id_name)],
+    # A packed scan split may contain multiple logical row-id groups. Keep
+    # each group intact, but do not materialize unrelated groups together.
+    file_groups = list(TableUpdate._predicate_update_file_groups(
+        scan_plan.splits()
+    ))
+    update_schema = build_update_schema(target_pa_schema, update_cols, 
row_id_name)
+    return _SelfMergeUpdatePlan(
+        table=table,
+        scan_table=scan_table,
+        file_groups=file_groups,
+        predicate=scan_predicate,
+        read_type=read_builder.read_type(),
+        clauses=clauses,
         update_cols=list(update_cols),
-        row_id_name=row_id_name,
         update_schema=update_schema,
+        row_id_name=row_id_name,
+        snapshot_id=planned_snapshot_id,
         callable_input_columns=(
             list(read_columns) + [row_id_name]
             if read_columns else None
         ),
     )
-    return aliased.map_batches(_transform, **_map_kwargs(ray_remote_args))
+
+
+def _self_merge_aliases(batch: pa.Table, row_id_name: str) -> pa.Table:
+    columns = []
+    names = []
+    for name, column in zip(batch.schema.names, batch.columns):
+        columns.append(column)
+        names.append(f"t.{name}")
+        if name != row_id_name:
+            columns.append(column)
+            names.append(f"s.{name}")
+    return pa.table(columns, names=names)
+
+
+def _apply_self_merge_update_group(context, file_group, collect_row_ids):
+    """Read, transform, and stage one complete first-row-id file group."""
+    from pypaimon.read.table_read import TableRead
+    from pypaimon.snapshot.snapshot import BATCH_COMMIT_IDENTIFIER
+    from pypaimon.write.file_store_commit import _abort_commit_messages
+    from pypaimon.write.table_update_by_row_id import TableUpdateByRowId
+
+    table_read = TableRead(
+        context.scan_table,
+        context.predicate,
+        context.read_type,
+    )
+    transform = _build_matched_transform(
+        context.clauses,
+        on_map={context.row_id_name: context.row_id_name},
+        on_pairs=[(context.row_id_name, context.row_id_name)],
+        update_cols=context.update_cols,
+        row_id_name=context.row_id_name,
+        update_schema=context.update_schema,
+        callable_input_columns=context.callable_input_columns,
+    )
+    update_parts = []
+    batch_reader = table_read.to_arrow_batch_reader([file_group])
+    for batch in iter(batch_reader.read_next_batch, None):
+        if batch.num_rows == 0:
+            continue
+        matched = pa.Table.from_batches([batch])
+        updates = transform(_self_merge_aliases(
+            matched, context.row_id_name,
+        ))
+        if updates.num_rows > 0:
+            update_parts.append(updates)
+
+    if not update_parts:
+        return [], 0, []
+    updates = (
+        update_parts[0]
+        if len(update_parts) == 1 else pa.concat_tables(update_parts)
+    )
+
+    row_ids = (
+        updates.column(context.row_id_name).to_pylist()
+        if collect_row_ids else []
+    )
+
+    import uuid
+    files_info = TableUpdateByRowId._files_info_from_splits(
+        context.snapshot_id, [file_group],
+    )
+    updater = TableUpdateByRowId(
+        context.table,
+        "_self_merge_group_" + uuid.uuid4().hex[:8],
+        BATCH_COMMIT_IDENTIFIER,
+        _precomputed_files_info=files_info,
+    )
+    try:
+        messages = updater.update_columns(updates, context.update_cols)
+    except Exception:
+        _abort_commit_messages(context.table, updater.commit_messages)
+        raise
+    return messages, updates.num_rows, row_ids
+
+
+def distributed_self_merge_update_apply(
+    plan: _SelfMergeUpdatePlan,
+    *,
+    num_partitions: int,
+    ray_remote_args: Optional[Dict[str, Any]] = None,
+    collect_row_ids: bool = False,
+) -> Tuple[list, int, list]:
+    """Stage self-merge updates in scan tasks without a routing shuffle."""
+    import ray
+
+    if not plan.file_groups:
+        return [], 0, []
+
+    remote_args = dict(ray_remote_args or {})
+    apply_remote = (
+        ray.remote(**remote_args)(_apply_self_merge_update_group)
+        if remote_args else ray.remote(_apply_self_merge_update_group)
+    )
+    context = ray.put(_SelfMergeUpdateContext(
+        table=plan.table,
+        scan_table=plan.scan_table,
+        predicate=plan.predicate,
+        read_type=plan.read_type,
+        clauses=plan.clauses,
+        update_cols=plan.update_cols,
+        update_schema=plan.update_schema,
+        row_id_name=plan.row_id_name,
+        snapshot_id=plan.snapshot_id,
+        callable_input_columns=plan.callable_input_columns,
+    ))
+    group_iter = iter(plan.file_groups)
+    max_in_flight = max(1, min(num_partitions, len(plan.file_groups)))
+    pending = set()
+    messages = []
+    num_updated = 0
+    row_ids = []
+    first_error = None
+
+    def submit_next():
+        try:
+            file_group = next(group_iter)
+        except StopIteration:
+            return False
+        pending.add(apply_remote.remote(
+            context, file_group, collect_row_ids
+        ))
+        return True
+
+    for _ in range(max_in_flight):
+        submit_next()
+
+    while pending:
+        ready, remaining = ray.wait(list(pending), num_returns=1)
+        pending = set(remaining)
+        ref = ready[0]
+        try:
+            split_messages, split_count, split_row_ids = ray.get(ref)
+            messages.extend(split_messages)
+            num_updated += split_count
+            row_ids.extend(split_row_ids)
+        except Exception as error:
+            if first_error is None:
+                first_error = error
+        if first_error is None:
+            submit_next()
+
+    if first_error is not None:
+        from pypaimon.write.file_store_commit import _abort_commit_messages
+        _abort_commit_messages(plan.table, messages)
+        raise first_error
+    return messages, num_updated, row_ids
 
 
 def build_self_merge_delete_ds(
diff --git a/paimon-python/pypaimon/tests/ray_data_evolution_merge_into_test.py 
b/paimon-python/pypaimon/tests/ray_data_evolution_merge_into_test.py
index e8a66a6061..8e48773d4b 100644
--- a/paimon-python/pypaimon/tests/ray_data_evolution_merge_into_test.py
+++ b/paimon-python/pypaimon/tests/ray_data_evolution_merge_into_test.py
@@ -114,6 +114,26 @@ class RayDataEvolutionMergeIntoTest(unittest.TestCase):
         snap = table.snapshot_manager().get_latest_snapshot()
         return snap.id if snap is not None else None
 
+    def _merge_and_capture_self_merge_plan(self, **kwargs):
+        from pypaimon.ray.data_evolution_merge_join import (
+            build_self_merge_update_plan as real_build_plan,
+        )
+
+        captured = {}
+
+        def capture(**plan_kwargs):
+            plan = real_build_plan(**plan_kwargs)
+            captured['plan'] = plan
+            return plan
+
+        with patch(
+                'pypaimon.ray.data_evolution_merge_into.'
+                'build_self_merge_update_plan',
+                side_effect=capture,
+        ):
+            result = merge_into(**kwargs)
+        return result, captured['plan']
+
     def test_paimon_source_table_pins_snapshot(self):
         from pypaimon.ray import data_evolution_merge_into as m
 
@@ -2038,10 +2058,184 @@ class RayDataEvolutionMergeIntoTest(unittest.TestCase):
         self.assertEqual(out['age'], [99, 99, 99])
         self.assertEqual(out['name'], ['a', 'b', 'c'])
 
+    def test_self_merge_update_bypasses_routing_shuffle(self):
+        options = dict(self.de_options)
+        options.update({
+            'source.split.target-size': '1gb',
+            'source.split.open-file-cost': '1b',
+        })
+        target = self._create_table(options=options)
+        self._write(target, self._source(ids=(1, 2)))
+        self._write(target, self._source(ids=(3, 4)))
+        table = self.catalog.get_table(target)
+        packed_splits = 
table.new_read_builder().new_scan().plan_for_write().splits()
+        self.assertEqual(len(packed_splits), 1)
+
+        with patch.object(
+                ray.data.Dataset,
+                'groupby',
+                side_effect=AssertionError('routing shuffle is not allowed'),
+        ):
+            result, plan = self._merge_and_capture_self_merge_plan(
+                target=target,
+                source=target,
+                catalog_options=self.catalog_options,
+                on=['_ROW_ID'],
+                when_matched=[WhenMatched.update({'age': lit(99)})],
+                num_partitions=_TEST_NUM_PARTITIONS,
+            )
+
+        self.assertEqual(len(plan.file_groups), 2)
+        self.assertEqual(result['num_matched'], 4)
+        self.assertEqual(self._read_sorted(target)['age'], [99, 99, 99, 99])
+
     @unittest.skipIf(_SKIP_CONDITION, _SKIP_REASON)
-    def test_self_merge_callable_assignment(self):
-        from pypaimon.ray.ray_paimon import read_paimon as real_read_paimon
+    def test_self_merge_filters_file_group_in_batches(self):
+        from pypaimon.ray import data_evolution_merge_into as merge_module
+        from pypaimon.ray import data_evolution_merge_join as join_module
+        from pypaimon.write.file_store_commit import _abort_commit_messages
+
+        options = dict(self.de_options)
+        options['read.batch-size'] = '2'
+        target = self._create_table(options=options)
+        self._write(
+            target,
+            pa.Table.from_pydict(
+                {
+                    'id': pa.array([1, 2, 3, 4, 5, 6], type=pa.int32()),
+                    'name': ['a', 'b', 'c', 'd', 'e', 'f'],
+                    'age': pa.array([0, 2, 0, 4, 0, 6], type=pa.int32()),
+                },
+                schema=self.pa_schema,
+            ),
+        )
 
+        clauses = [WhenMatched.update(
+            {'age': lit(99)}, condition='t.age = t.id',
+        )]
+        table, source_ds, matched, not_matched, ctx = merge_module._prepare(
+            target,
+            target,
+            self.catalog_options,
+            clauses,
+            [],
+            ['_ROW_ID'],
+        )
+        snapshot = table.snapshot_manager().get_latest_snapshot()
+        plan, _, _, _ = merge_module._build_datasets(
+            table,
+            target,
+            source_ds,
+            matched,
+            not_matched,
+            ctx,
+            snapshot,
+            _TEST_NUM_PARTITIONS,
+            None,
+        )
+        self.assertIsNone(plan.predicate)
+        self.assertEqual(len(plan.file_groups), 1)
+
+        context = join_module._SelfMergeUpdateContext(
+            table=plan.table,
+            scan_table=plan.scan_table,
+            predicate=plan.predicate,
+            read_type=plan.read_type,
+            clauses=plan.clauses,
+            update_cols=plan.update_cols,
+            update_schema=plan.update_schema,
+            row_id_name=plan.row_id_name,
+            snapshot_id=plan.snapshot_id,
+            callable_input_columns=plan.callable_input_columns,
+        )
+        batch_sizes = []
+        real_build_transform = join_module._build_matched_transform
+
+        def tracked_build_transform(*args, **kwargs):
+            real_transform = real_build_transform(*args, **kwargs)
+
+            def tracked_transform(batch):
+                batch_sizes.append(batch.num_rows)
+                return real_transform(batch)
+
+            return tracked_transform
+
+        messages = []
+        try:
+            with patch.object(
+                    join_module,
+                    '_build_matched_transform',
+                    side_effect=tracked_build_transform,
+            ):
+                messages, count, row_ids = (
+                    join_module._apply_self_merge_update_group(
+                        context, plan.file_groups[0], True,
+                    )
+                )
+
+            self.assertEqual(batch_sizes, [2, 2, 2])
+            self.assertEqual(count, 3)
+            self.assertEqual(row_ids, [1, 3, 5])
+        finally:
+            _abort_commit_messages(table, messages)
+
+    def test_self_merge_update_aborts_other_groups_after_failure(self):
+        from pypaimon.ray import data_evolution_merge_into as merge_module
+        from pypaimon.ray.data_evolution_merge_join import (
+            distributed_self_merge_update_apply,
+        )
+
+        options = dict(self.de_options)
+        options.update({
+            'source.split.target-size': '1b',
+            'source.split.open-file-cost': '1b',
+        })
+        target = self._create_table(options=options)
+        self._write(target, self._source(ids=(1, 2)))
+        self._write(target, self._source(ids=(3, 4)))
+
+        table, source_ds, matched, not_matched, ctx = merge_module._prepare(
+            target,
+            target,
+            self.catalog_options,
+            [WhenMatched.update({'age': lit(99)})],
+            [],
+            ['_ROW_ID'],
+        )
+        snapshot = table.snapshot_manager().get_latest_snapshot()
+        plan, _, _, _ = merge_module._build_datasets(
+            table,
+            target,
+            source_ds,
+            matched,
+            not_matched,
+            ctx,
+            snapshot,
+            _TEST_NUM_PARTITIONS,
+            None,
+        )
+        self.assertGreaterEqual(len(plan.file_groups), 2)
+
+        before = set()
+        for root, _, files in os.walk(self.warehouse):
+            before.update(os.path.join(root, name) for name in files)
+
+        for data_file in plan.file_groups[-1].files:
+            data_file.file_path += '.missing'
+
+        with self.assertRaises(Exception):
+            distributed_self_merge_update_apply(
+                plan,
+                num_partitions=_TEST_NUM_PARTITIONS,
+            )
+
+        after = set()
+        for root, _, files in os.walk(self.warehouse):
+            after.update(os.path.join(root, name) for name in files)
+        self.assertEqual(before, after)
+
+    @unittest.skipIf(_SKIP_CONDITION, _SKIP_REASON)
+    def test_self_merge_callable_assignment(self):
         target = self._create_table()
         self._write(
             target,
@@ -2060,11 +2254,12 @@ class RayDataEvolutionMergeIntoTest(unittest.TestCase):
                 raise AssertionError(rows.column_names)
             return pc.add(rows['age'], 1)
 
-        with patch(
-                'pypaimon.ray.ray_paimon.read_paimon',
-                wraps=real_read_paimon,
-        ) as mock_read:
-            result = merge_into(
+        with patch.object(
+                ray.data.Dataset,
+                'groupby',
+                side_effect=AssertionError('routing shuffle is not allowed'),
+        ):
+            result, plan = self._merge_and_capture_self_merge_plan(
                 target=target,
                 source=target,
                 catalog_options=self.catalog_options,
@@ -2090,8 +2285,10 @@ class RayDataEvolutionMergeIntoTest(unittest.TestCase):
             },
         )
         self.assertEqual(
-            mock_read.call_args[1]['projection'], ['_ROW_ID', 'id', 'age']
+            [field.name for field in plan.read_type],
+            ['_ROW_ID', 'id', 'age'],
         )
+        self.assertEqual(plan.callable_input_columns, ['age', '_ROW_ID'])
 
     @unittest.skipIf(_SKIP_CONDITION, _SKIP_REASON)
     def test_self_merge_callable_updates_variant_path(self):
@@ -2290,7 +2487,6 @@ class RayDataEvolutionMergeIntoTest(unittest.TestCase):
         from pypaimon.common.options.core_options import (
             CoreOptions, GlobalIndexSearchMode,
         )
-        from pypaimon.ray.ray_paimon import read_paimon as real_read_paimon
 
         target = self._create_table()
         self._write(
@@ -2304,20 +2500,16 @@ class RayDataEvolutionMergeIntoTest(unittest.TestCase):
                 schema=self.pa_schema,
             ),
         )
-        with patch(
-                'pypaimon.ray.ray_paimon.read_paimon',
-                wraps=real_read_paimon,
-        ) as mock_read:
-            result = merge_into(
-                target=target,
-                source=target,
-                catalog_options=self.catalog_options,
-                on=['_ROW_ID'],
-                when_matched=[WhenMatched.update(
-                    {'age': lit(99)}, condition='t.id IN (1, 3)',
-                )],
-                num_partitions=_TEST_NUM_PARTITIONS,
-            )
+        result, plan = self._merge_and_capture_self_merge_plan(
+            target=target,
+            source=target,
+            catalog_options=self.catalog_options,
+            on=['_ROW_ID'],
+            when_matched=[WhenMatched.update(
+                {'age': lit(99)}, condition='t.id IN (1, 3)',
+            )],
+            num_partitions=_TEST_NUM_PARTITIONS,
+        )
 
         self.assertEqual(result['num_matched'], 2)
         self.assertEqual(
@@ -2328,18 +2520,23 @@ class RayDataEvolutionMergeIntoTest(unittest.TestCase):
                 'age': [99, 20, 99],
             },
         )
-        read_kwargs = mock_read.call_args[1]
-        predicate = read_kwargs['filter']
+        predicate = plan.predicate
         self.assertEqual(predicate.method, 'in')
         self.assertEqual(predicate.field, 'id')
         self.assertEqual(predicate.literals, [1, 3])
         self.assertEqual(
-            read_kwargs['dynamic_options'][
-                CoreOptions.SCALAR_INDEX_SEARCH_MODE.key()
-            ],
+            plan.scan_table.table_schema.options.get(
+                CoreOptions.SCALAR_INDEX_SEARCH_MODE.key()),
             GlobalIndexSearchMode.FULL.value,
         )
-        self.assertTrue(read_kwargs['_preserve_current_schema'])
+        self.assertEqual(
+            plan.scan_table.table_schema.fields,
+            plan.table.table_schema.fields,
+        )
+        self.assertEqual(
+            plan.scan_table.table_schema.id,
+            plan.table.table_schema.id,
+        )
 
     @unittest.skipIf(_SKIP_CONDITION, _SKIP_REASON)
     def test_self_merge_pushdown_handles_evolved_file_groups(self):
@@ -2462,33 +2659,27 @@ class RayDataEvolutionMergeIntoTest(unittest.TestCase):
 
     @unittest.skipIf(_SKIP_CONDITION, _SKIP_REASON)
     def test_self_merge_multiple_conditions_push_down_or(self):
-        from pypaimon.ray.ray_paimon import read_paimon as real_read_paimon
-
         target = self._create_table()
         self._write(target, self._source(ids=(1, 2, 3)))
 
-        with patch(
-                'pypaimon.ray.ray_paimon.read_paimon',
-                wraps=real_read_paimon,
-        ) as mock_read:
-            result = merge_into(
-                target=target,
-                source=target,
-                catalog_options=self.catalog_options,
-                on=['_ROW_ID'],
-                when_matched=[
-                    WhenMatched.update(
-                        {'age': lit(11)}, condition='t.id = 1',
-                    ),
-                    WhenMatched.update(
-                        {'age': lit(33)}, condition='s.id = 3',
-                    ),
-                ],
-                num_partitions=_TEST_NUM_PARTITIONS,
-            )
+        result, plan = self._merge_and_capture_self_merge_plan(
+            target=target,
+            source=target,
+            catalog_options=self.catalog_options,
+            on=['_ROW_ID'],
+            when_matched=[
+                WhenMatched.update(
+                    {'age': lit(11)}, condition='t.id = 1',
+                ),
+                WhenMatched.update(
+                    {'age': lit(33)}, condition='s.id = 3',
+                ),
+            ],
+            num_partitions=_TEST_NUM_PARTITIONS,
+        )
 
         self.assertEqual(result['num_matched'], 2)
-        predicate = mock_read.call_args[1]['filter']
+        predicate = plan.predicate
         self.assertEqual(predicate.method, 'or')
         self.assertEqual(
             [(p.field, p.literals) for p in predicate.literals],
@@ -2497,63 +2688,49 @@ class RayDataEvolutionMergeIntoTest(unittest.TestCase):
 
     @unittest.skipIf(_SKIP_CONDITION, _SKIP_REASON)
     def test_self_merge_unconditional_clause_disables_pushdown(self):
-        from pypaimon.ray.ray_paimon import read_paimon as real_read_paimon
-
         target = self._create_table()
         self._write(target, self._source(ids=(1, 2, 3)))
 
-        with patch(
-                'pypaimon.ray.ray_paimon.read_paimon',
-                wraps=real_read_paimon,
-        ) as mock_read:
-            result = merge_into(
-                target=target,
-                source=target,
-                catalog_options=self.catalog_options,
-                on=['_ROW_ID'],
-                when_matched=[
-                    WhenMatched.update(
-                        {'age': lit(11)}, condition='t.id = 1',
-                    ),
-                    WhenMatched.update({'age': lit(99)}),
-                ],
-                num_partitions=_TEST_NUM_PARTITIONS,
-            )
+        result, plan = self._merge_and_capture_self_merge_plan(
+            target=target,
+            source=target,
+            catalog_options=self.catalog_options,
+            on=['_ROW_ID'],
+            when_matched=[
+                WhenMatched.update(
+                    {'age': lit(11)}, condition='t.id = 1',
+                ),
+                WhenMatched.update({'age': lit(99)}),
+            ],
+            num_partitions=_TEST_NUM_PARTITIONS,
+        )
 
         self.assertEqual(result['num_matched'], 3)
-        self.assertNotIn('filter', mock_read.call_args[1])
+        self.assertIsNone(plan.predicate)
 
     @unittest.skipIf(_SKIP_CONDITION, _SKIP_REASON)
     def test_self_merge_column_comparison_fails_open(self):
-        from pypaimon.ray.ray_paimon import read_paimon as real_read_paimon
-
         target = self._create_table()
         self._write(target, self._source(ids=(1, 2, 3)))
 
-        with patch(
-                'pypaimon.ray.ray_paimon.read_paimon',
-                wraps=real_read_paimon,
-        ) as mock_read:
-            result = merge_into(
-                target=target,
-                source=target,
-                catalog_options=self.catalog_options,
-                on=['_ROW_ID'],
-                when_matched=[WhenMatched.update(
-                    {'name': lit('same')}, condition='t.age = s.age',
-                )],
-                num_partitions=_TEST_NUM_PARTITIONS,
-            )
+        result, plan = self._merge_and_capture_self_merge_plan(
+            target=target,
+            source=target,
+            catalog_options=self.catalog_options,
+            on=['_ROW_ID'],
+            when_matched=[WhenMatched.update(
+                {'name': lit('same')}, condition='t.age = s.age',
+            )],
+            num_partitions=_TEST_NUM_PARTITIONS,
+        )
 
         self.assertEqual(result['num_matched'], 3)
-        self.assertNotIn('filter', mock_read.call_args[1])
+        self.assertIsNone(plan.predicate)
         self.assertEqual(self._read_sorted(target)['name'],
                          ['same', 'same', 'same'])
 
     @unittest.skipIf(_SKIP_CONDITION, _SKIP_REASON)
     def test_self_merge_pushdown_preserves_field_case(self):
-        from pypaimon.ray.ray_paimon import read_paimon as real_read_paimon
-
         case_schema = pa.schema([
             ('UserID', pa.int32()),
             ('Value', pa.int32()),
@@ -2568,24 +2745,20 @@ class RayDataEvolutionMergeIntoTest(unittest.TestCase):
             schema=case_schema,
         ))
 
-        with patch(
-                'pypaimon.ray.ray_paimon.read_paimon',
-                wraps=real_read_paimon,
-        ) as mock_read:
-            result = merge_into(
-                target=target,
-                source=target,
-                catalog_options=self.catalog_options,
-                on=['_ROW_ID'],
-                when_matched=[WhenMatched.update(
-                    {'Value': lit(99)},
-                    condition='t.UserID IN (1, 3)',
-                )],
-                num_partitions=_TEST_NUM_PARTITIONS,
-            )
+        result, plan = self._merge_and_capture_self_merge_plan(
+            target=target,
+            source=target,
+            catalog_options=self.catalog_options,
+            on=['_ROW_ID'],
+            when_matched=[WhenMatched.update(
+                {'Value': lit(99)},
+                condition='t.UserID IN (1, 3)',
+            )],
+            num_partitions=_TEST_NUM_PARTITIONS,
+        )
 
         self.assertEqual(result['num_matched'], 2)
-        predicate = mock_read.call_args[1]['filter']
+        predicate = plan.predicate
         self.assertEqual(predicate.field, 'UserID')
         table = self.catalog.get_table(target)
         read_builder = table.new_read_builder()
@@ -2595,8 +2768,6 @@ class RayDataEvolutionMergeIntoTest(unittest.TestCase):
 
     @unittest.skipIf(_SKIP_CONDITION, _SKIP_REASON)
     def test_self_merge_date_condition_fails_open(self):
-        from pypaimon.ray.ray_paimon import read_paimon as real_read_paimon
-
         date_schema = pa.schema([
             ('id', pa.int32()),
             ('event_date', pa.date32()),
@@ -2616,24 +2787,20 @@ class RayDataEvolutionMergeIntoTest(unittest.TestCase):
             'value': [10, 20],
         }, schema=date_schema))
 
-        with patch(
-                'pypaimon.ray.ray_paimon.read_paimon',
-                wraps=real_read_paimon,
-        ) as mock_read:
-            result = merge_into(
-                target=target,
-                source=target,
-                catalog_options=self.catalog_options,
-                on=['_ROW_ID'],
-                when_matched=[WhenMatched.update(
-                    {'value': lit(99)},
-                    condition="t.event_date = '2026-01-01'",
-                )],
-                num_partitions=_TEST_NUM_PARTITIONS,
-            )
+        result, plan = self._merge_and_capture_self_merge_plan(
+            target=target,
+            source=target,
+            catalog_options=self.catalog_options,
+            on=['_ROW_ID'],
+            when_matched=[WhenMatched.update(
+                {'value': lit(99)},
+                condition="t.event_date = '2026-01-01'",
+            )],
+            num_partitions=_TEST_NUM_PARTITIONS,
+        )
 
         self.assertEqual(result['num_matched'], 1)
-        self.assertNotIn('filter', mock_read.call_args[1])
+        self.assertIsNone(plan.predicate)
         table = self.catalog.get_table(target)
         read_builder = table.new_read_builder()
         splits = read_builder.new_scan().plan().splits()
@@ -2642,8 +2809,6 @@ class RayDataEvolutionMergeIntoTest(unittest.TestCase):
 
     @unittest.skipIf(_SKIP_CONDITION, _SKIP_REASON)
     def test_self_merge_double_condition_fails_open(self):
-        from pypaimon.ray.ray_paimon import read_paimon as real_read_paimon
-
         double_schema = pa.schema([
             ('id', pa.int32()),
             ('metric', pa.float64()),
@@ -2660,23 +2825,19 @@ class RayDataEvolutionMergeIntoTest(unittest.TestCase):
             'value': [10, 20, 30],
         }, schema=double_schema))
 
-        with patch(
-                'pypaimon.ray.ray_paimon.read_paimon',
-                wraps=real_read_paimon,
-        ) as mock_read:
-            result = merge_into(
-                target=target,
-                source=target,
-                catalog_options=self.catalog_options,
-                on=['_ROW_ID'],
-                when_matched=[WhenMatched.update(
-                    {'value': lit(99)}, condition='t.metric > 0',
-                )],
-                num_partitions=_TEST_NUM_PARTITIONS,
-            )
+        result, plan = self._merge_and_capture_self_merge_plan(
+            target=target,
+            source=target,
+            catalog_options=self.catalog_options,
+            on=['_ROW_ID'],
+            when_matched=[WhenMatched.update(
+                {'value': lit(99)}, condition='t.metric > 0',
+            )],
+            num_partitions=_TEST_NUM_PARTITIONS,
+        )
 
         self.assertEqual(result['num_matched'], 2)
-        self.assertNotIn('filter', mock_read.call_args[1])
+        self.assertIsNone(plan.predicate)
         table = self.catalog.get_table(target)
         read_builder = table.new_read_builder()
         splits = read_builder.new_scan().plan().splits()
@@ -2685,8 +2846,6 @@ class RayDataEvolutionMergeIntoTest(unittest.TestCase):
 
     @unittest.skipIf(_SKIP_CONDITION, _SKIP_REASON)
     def test_self_merge_like_condition_fails_open(self):
-        from pypaimon.ray.ray_paimon import read_paimon as real_read_paimon
-
         string_schema = pa.schema([
             ('id', pa.int32()),
             ('text', pa.string()),
@@ -2703,23 +2862,19 @@ class RayDataEvolutionMergeIntoTest(unittest.TestCase):
             'value': [10, 20, 30, 40, 50],
         }, schema=string_schema))
 
-        with patch(
-                'pypaimon.ray.ray_paimon.read_paimon',
-                wraps=real_read_paimon,
-        ) as mock_read:
-            result = merge_into(
-                target=target,
-                source=target,
-                catalog_options=self.catalog_options,
-                on=['_ROW_ID'],
-                when_matched=[WhenMatched.update(
-                    {'value': lit(99)}, condition=r"t.text LIKE '%\n%'",
-                )],
-                num_partitions=_TEST_NUM_PARTITIONS,
-            )
+        result, plan = self._merge_and_capture_self_merge_plan(
+            target=target,
+            source=target,
+            catalog_options=self.catalog_options,
+            on=['_ROW_ID'],
+            when_matched=[WhenMatched.update(
+                {'value': lit(99)}, condition=r"t.text LIKE '%\n%'",
+            )],
+            num_partitions=_TEST_NUM_PARTITIONS,
+        )
 
         self.assertEqual(result['num_matched'], 4)
-        self.assertNotIn('filter', mock_read.call_args[1])
+        self.assertIsNone(plan.predicate)
         table = self.catalog.get_table(target)
         read_builder = table.new_read_builder()
         splits = read_builder.new_scan().plan().splits()
@@ -2728,8 +2883,6 @@ class RayDataEvolutionMergeIntoTest(unittest.TestCase):
 
     @unittest.skipIf(_SKIP_CONDITION, _SKIP_REASON)
     def test_self_merge_out_of_range_integer_fails_open(self):
-        from pypaimon.ray.ray_paimon import read_paimon as real_read_paimon
-
         int_schema = pa.schema([
             ('id', pa.int64()),
             ('value', pa.int32()),
@@ -2744,24 +2897,20 @@ class RayDataEvolutionMergeIntoTest(unittest.TestCase):
             'value': [10, 20, 30],
         }, schema=int_schema))
 
-        with patch(
-                'pypaimon.ray.ray_paimon.read_paimon',
-                wraps=real_read_paimon,
-        ) as mock_read:
-            result = merge_into(
-                target=target,
-                source=target,
-                catalog_options=self.catalog_options,
-                on=['_ROW_ID'],
-                when_matched=[WhenMatched.update(
-                    {'value': lit(99)},
-                    condition='t.id < 9223372036854775808',
-                )],
-                num_partitions=_TEST_NUM_PARTITIONS,
-            )
+        result, plan = self._merge_and_capture_self_merge_plan(
+            target=target,
+            source=target,
+            catalog_options=self.catalog_options,
+            on=['_ROW_ID'],
+            when_matched=[WhenMatched.update(
+                {'value': lit(99)},
+                condition='t.id < 9223372036854775808',
+            )],
+            num_partitions=_TEST_NUM_PARTITIONS,
+        )
 
         self.assertEqual(result['num_matched'], 3)
-        self.assertNotIn('filter', mock_read.call_args[1])
+        self.assertIsNone(plan.predicate)
         table = self.catalog.get_table(target)
         read_builder = table.new_read_builder()
         splits = read_builder.new_scan().plan().splits()

Reply via email to