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 18613c808a [Python] Execute captured Ray mutation and index plans 
through native core (#10757)
18613c808a is described below

commit 18613c808a7d2e957989a2538e3d8db13ca1d1cc
Author: Jingsong Lee <[email protected]>
AuthorDate: Fri Oct 23 08:47:44 2026 +0800

    [Python] Execute captured Ray mutation and index plans through native core 
(#10757)
---
 .../pypaimon/execution/native/write/commit.py      |  24 +-
 .../execution/native/write/native_commit.py        |  34 +++
 .../execution/native/write/native_index_build.py   |  16 +-
 .../execution/native/write/native_update.py        |  28 +-
 .../pypaimon/execution/native/write/row_id_plan.py |  66 +++++
 .../python/write/commit/conflict_detection.py      |  28 +-
 .../python/write/commit/row_id_conflict.py         |  55 ++++
 .../pypaimon/execution/python/write/row_id_plan.py |  42 +++
 .../pypaimon/globalindex/index_dispatch.py         |   6 +-
 .../pypaimon/ray/data_evolution_merge_join.py      |  88 +++---
 .../pypaimon/ray/row_id_conflict_rewriter.py       |  19 +-
 paimon-python/pypaimon/ray/vector_index_build.py   |  44 ++-
 .../pypaimon/tests/native_ray_index_build_test.py  | 220 ++++++++++++++
 .../pypaimon/tests/native_ray_row_id_plan_test.py  | 112 +++++++
 .../tests/native_sorted_index_build_test.py        |  41 ++-
 .../tests/ray_data_evolution_merge_into_test.py    |   2 +-
 .../tests/ray_row_id_conflict_rewriter_test.py     |   2 +-
 .../tests/write/row_id_conflict_protocol_test.py   | 326 +++++++++++++++++++++
 .../tests/write/row_id_execution_boundary_test.py  |   6 +-
 .../pypaimon/tests/write/row_id_plan_test.py       | 302 +++++++++++++++++++
 paimon-python/pypaimon/write/backend_dispatch.py   |  31 +-
 paimon-python/pypaimon/write/row_id_conflict.py    | 132 +++++++++
 paimon-python/pypaimon/write/row_id_plan.py        | 112 +++++++
 paimon-python/pypaimon/write/update_dispatch.py    |  12 +-
 24 files changed, 1632 insertions(+), 116 deletions(-)

diff --git a/paimon-python/pypaimon/execution/native/write/commit.py 
b/paimon-python/pypaimon/execution/native/write/commit.py
index 6f6f4ed7ac..71629f75fd 100644
--- a/paimon-python/pypaimon/execution/native/write/commit.py
+++ b/paimon-python/pypaimon/execution/native/write/commit.py
@@ -26,9 +26,27 @@ class NativeTableCommit:
     def commit(self, messages, commit_identifier, snapshot_properties=None):
         kwargs = ({} if snapshot_properties is None else
                   {'snapshot_properties': snapshot_properties})
-        if self._is_batch:
-            return self._native_commit.commit(messages, **kwargs)
-        return self._native_commit.commit(commit_identifier, messages, 
**kwargs)
+        try:
+            if self._is_batch:
+                return self._native_commit.commit(messages, **kwargs)
+            return self._native_commit.commit(commit_identifier, messages, 
**kwargs)
+        except Exception as error:
+            self._raise_commit_error(error)
+
+    def check_row_id_conflicts(self, messages, latest_snapshot_id, 
check_from_snapshot):
+        try:
+            return self._native_commit.check_row_id_conflicts(messages, 
latest_snapshot_id, check_from_snapshot)
+        except Exception as error:
+            self._raise_commit_error(error)
+
+    @staticmethod
+    def _raise_commit_error(error):
+        from pypaimon.execution.native.write.native_commit import 
native_commit_error
+
+        converted = native_commit_error(error)
+        if converted is error:
+            raise error
+        raise converted from error
 
     def abort(self, messages):
         return self._native_commit.abort(messages)
diff --git a/paimon-python/pypaimon/execution/native/write/native_commit.py 
b/paimon-python/pypaimon/execution/native/write/native_commit.py
index 5e4e77d529..adc819e1aa 100644
--- a/paimon-python/pypaimon/execution/native/write/native_commit.py
+++ b/paimon-python/pypaimon/execution/native/write/native_commit.py
@@ -202,6 +202,40 @@ def from_native_commit_messages(table, messages):
     return decoded
 
 
+def native_commit_error(error):
+    """Keep retry classification structured; never infer it from error text."""
+    from pypaimon_rust import RowIdExistenceConflict as NativeRowIdConflict
+    from pypaimon.write.row_id_conflict import RowIdExistenceConflict
+
+    if not isinstance(error, NativeRowIdConflict):
+        return error
+    return RowIdExistenceConflict._from_fields(
+        file_name=error.file_name, first_row_id=error.first_row_id,
+        row_count=error.row_count, bucket=error.bucket,
+        current_first_row_id=error.current_first_row_id,
+        current_row_count=error.current_row_count)
+
+
+def prepare_native_row_id_conflict_check(table, messages):
+    """Prepare a check without creating Python publication or conflict 
state."""
+    from pypaimon.execution.native.write.commit import NativeTableCommit
+
+    if not native_messages_supported(table, messages):
+        return None
+    native = create_native_commit(table, 'row-id-conflict-check', batch=True)
+    if native is None:
+        return None
+    try:
+        converted = to_native_commit_messages(table, messages)
+    except BaseException:
+        try:
+            native.close()
+        except BaseException:
+            pass
+        raise
+    return NativeTableCommit(native, is_batch=True), converted
+
+
 def native_commit_callback(table, callbacks):
     """Translate the native notification into the existing Python context."""
     from pypaimon.manifest.schema.manifest_entry import ManifestEntry
diff --git 
a/paimon-python/pypaimon/execution/native/write/native_index_build.py 
b/paimon-python/pypaimon/execution/native/write/native_index_build.py
index 4eeeb63c2e..7c0735b0bc 100644
--- a/paimon-python/pypaimon/execution/native/write/native_index_build.py
+++ b/paimon-python/pypaimon/execution/native/write/native_index_build.py
@@ -15,7 +15,7 @@
 # specific language governing permissions and limitations
 # under the License.
 
-"""Local global-index builds delegated to Rust, retaining Python's commit 
API."""
+"""Global-index builds delegated to Rust, retaining Python's commit API."""
 
 from pypaimon.execution.native.table import (_native_table, 
_option_value_to_string)
 from pypaimon.execution.native.read.builder import (_predicate_to_native)
@@ -23,7 +23,7 @@ from pypaimon.execution.native.runtime import 
(native_runtime_available)
 from pypaimon.execution.native.write.native_commit import 
_rest_catalog_supported, from_native_commit_messages
 
 
-def build_native_global_index(builder, partition_filter):
+def build_native_global_index(builder, partition_filter, *, execution="local", 
concurrency=None, ray_remote_args=None):
     """Return unpublished messages, or None before building for an ineligible 
table.
 
     Rust owns snapshot selection, range planning and index file generation.
@@ -77,4 +77,16 @@ def build_native_global_index(builder, partition_filter):
     native.with_options(options)
     if partition_filter is not None:
         native.with_partition_filter(_predicate_to_native(partition_filter))
+    if execution == "ray":
+        from pypaimon.ray.vector_index_build import build_native_vector_index
+        # Planning may authorize and capture inputs; its errors must not select
+        # another engine or restart a build that may already have side effects.
+        plan = native.plan()
+        return build_native_vector_index(table, plan, concurrency, 
ray_remote_args)
     return from_native_commit_messages(table, native.build())
+
+
+def build_native_index_shard(table, shard):
+    """Execute captured metadata in Core; workers never replan or publish."""
+    native = _native_table(table).new_global_index_build_builder()
+    return from_native_commit_messages(table, native.build_shard(shard))
diff --git a/paimon-python/pypaimon/execution/native/write/native_update.py 
b/paimon-python/pypaimon/execution/native/write/native_update.py
index f7e58eab7e..7844079a1f 100644
--- a/paimon-python/pypaimon/execution/native/write/native_update.py
+++ b/paimon-python/pypaimon/execution/native/write/native_update.py
@@ -84,21 +84,25 @@ def create_native_update(table, commit_user, columns):
     return NativeBatchTableUpdate(table, writer)
 
 
-def create_native_update_by_row_id(table, commit_user, commit_identifier):
+def create_native_update_by_row_id(table, commit_user, commit_identifier, 
plan=None):
     """Create a core updater sharing one snapshot across incremental calls."""
     # Columns are selected per call; one core updater pins the snapshot and
     # tracks overlaps across Parquet and dedicated Blob updates.
     if not _native_update_columns_supported(table, None):
         return None
-    native_table = _native_row_id_table(table)
+    native_table = _native_row_id_table(
+        table._copy_with_snapshot(plan.table._read_snapshot) if plan is not 
None else table)
     if native_table is None:
         return None
     if commit_identifier == BATCH_COMMIT_IDENTIFIER:
-        writer = 
(native_table.new_batch_write_builder().with_commit_user(commit_user)
-                  .new_update().new_update_by_row_id())
+        update = 
native_table.new_batch_write_builder().with_commit_user(commit_user).new_update()
     else:
-        writer = 
(native_table.new_stream_write_builder().with_commit_user(commit_user)
-                  .new_update().new_update_by_row_id(commit_identifier))
+        update = 
native_table.new_stream_write_builder().with_commit_user(commit_user).new_update()
+    if plan is not None:
+        from pypaimon.execution.native.write.row_id_plan import native_plan
+        update.with_plan(native_plan(plan.table, plan.plan))
+    writer = (update.new_update_by_row_id() if commit_identifier == 
BATCH_COMMIT_IDENTIFIER
+              else update.new_update_by_row_id(commit_identifier))
     return NativeTableUpdateByRowId(
         table, commit_user, commit_identifier, writer)
 
@@ -438,22 +442,26 @@ def create_native_predicate_delete(table, commit_user, 
predicate):
     return NativePredicateTableUpdate(table, writer, native_predicate)
 
 
-def create_native_delete(table, commit_user):
+def create_native_delete(table, commit_user, plan=None):
     """Select Rust's deletion-vector writer for supported batch deletes."""
     if not table.options.deletion_vectors_enabled(False):
         return None
-    native_table = _native_row_id_table(table)
+    native_table = _native_row_id_table(
+        table._copy_with_snapshot(plan.table._read_snapshot) if plan is not 
None else table)
     if native_table is None:
         return None
     writer = (native_table.new_batch_write_builder()
               .with_commit_user(commit_user)
               .new_update())
+    if plan is not None:
+        from pypaimon.execution.native.write.row_id_plan import native_plan
+        writer.with_plan(native_plan(plan.table, plan.plan))
     return NativeBatchTableUpdate(table, writer)
 
 
-def create_native_row_id_delete(table):
+def create_native_row_id_delete(table, plan=None):
     """Adapt the direct deletion API to the existing core table updater."""
-    operation = create_native_delete(table, 
table.new_batch_write_builder().commit_user)
+    operation = create_native_delete(table, 
table.new_batch_write_builder().commit_user, plan)
     return None if operation is None else NativeTableDeleteByRowId(operation)
 
 
diff --git a/paimon-python/pypaimon/execution/native/write/row_id_plan.py 
b/paimon-python/pypaimon/execution/native/write/row_id_plan.py
new file mode 100644
index 0000000000..d040b5b205
--- /dev/null
+++ b/paimon-python/pypaimon/execution/native/write/row_id_plan.py
@@ -0,0 +1,66 @@
+# 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.
+
+
+"""Typed split transport for Core's captured row-ID plans."""
+
+from pypaimon.execution.native.runtime import native_runtime_available
+
+
+def _with_bucket_path(table, split):
+    from copy import copy
+    from pypaimon.globalindex.indexed_split import IndexedSplit
+    from pypaimon.read.query_auth_split import QueryAuthSplit
+    from pypaimon.read.split import DataSplit
+
+    if isinstance(split, QueryAuthSplit):
+        inner = _with_bucket_path(table, split.split)
+        return split if inner is split.split else QueryAuthSplit(inner, 
split.auth_result)
+    if isinstance(split, IndexedSplit):
+        inner = _with_bucket_path(table, split.data_split())
+        return split if inner is split.data_split() else IndexedSplit(inner, 
split.row_ranges(), split.scores())
+    if not isinstance(split, DataSplit) or split.bucket_path:
+        return split
+    # Java keeps the table's canonical bucket path even when individual files
+    # live elsewhere. Serialization retains each exact external/file path.
+    captured = copy(split)
+    captured.bucket_path = table.path_factory().bucket_path(
+        tuple(split.partition.values), split.bucket, canonical_partition=True)
+    return captured
+
+
+def native_plan(table, plan):
+    from pypaimon_rust.datafusion import Plan, Split
+    from pypaimon.read.split_serializer import serialize_split_v1
+    return Plan(plan.snapshot_id, 
[Split.deserialize(serialize_split_v1(_with_bucket_path(table, split)))
+                                   for split in plan.splits()])
+
+
+def create_native_row_id_plan(table, plan):
+    if not (table.options.native_write_enabled() or 
table.options.native_read_enabled()
+            or table.options.native_plan_enabled()) or not 
native_runtime_available():
+        return None
+    from pypaimon_rust.datafusion import RowIdPlan
+    return RowIdPlan(native_plan(table, plan))
+
+
+def python_groups(table, plan):
+    from pypaimon.execution.native.table import _partition_fields
+    from pypaimon.read.split_serializer import deserialize_split_v1
+    return [(start, end, deserialize_split_v1(split.serialize(), 
_partition_fields(table),
+                                              
table.trimmed_primary_keys_fields))
+            for start, end, split in plan.groups()]
diff --git 
a/paimon-python/pypaimon/execution/python/write/commit/conflict_detection.py 
b/paimon-python/pypaimon/execution/python/write/commit/conflict_detection.py
index 4c18e16868..3f6c3e5553 100644
--- a/paimon-python/pypaimon/execution/python/write/commit/conflict_detection.py
+++ b/paimon-python/pypaimon/execution/python/write/commit/conflict_detection.py
@@ -28,6 +28,7 @@ from pypaimon.manifest.schema.file_entry import FileEntry
 from pypaimon.table.special_fields import SpecialFields
 from pypaimon.utils.range import Range
 from pypaimon.utils.range_helper import RangeHelper
+from pypaimon.write.row_id_conflict import RowIdExistenceConflict
 from pypaimon.execution.python.write.commit.commit_scanner import CommitScanner
 
 
@@ -165,23 +166,6 @@ class _WriteRange:
         self.field_ids = field_ids
 
 
-class RowIdExistenceConflict(RuntimeError):
-    """A staged row-id file no longer matches the current base-file layout."""
-
-    def __init__(self, entry):
-        self.entry = entry
-        super().__init__(
-            "Row ID existence conflict: file '{}' references "
-            "firstRowId={}, rowCount={} in bucket {}, "
-            "but no matching file exists in the current snapshot. "
-            "The referenced file may have been rewritten by a "
-            "concurrent compaction or removed by an overwrite.".format(
-                entry.file.file_name,
-                entry.file.first_row_id,
-                entry.file.row_count,
-                entry.bucket))
-
-
 class ConflictDetection:
     """Detects conflicts between base and delta files during commit."""
 
@@ -489,6 +473,16 @@ class ConflictDetection:
         if not self.data_evolution_enabled:
             return None
 
+        # File identity excludes row IDs. A DELETE must still describe the
+        # current ADD before identifier-based manifest merging cancels it.
+        deleted = {entry.identifier(): entry for entry in delta_entries if 
entry.kind == 1}
+        for current in base_entries:
+            stale = deleted.get(current.identifier()) if current.kind == 0 
else None
+            if stale is not None and (
+                    current.file.first_row_id != stale.file.first_row_id
+                    or current.file.row_count != stale.file.row_count):
+                return RowIdExistenceConflict._from_delete(stale, current)
+
         if next_row_id is None:
             return None
 
diff --git 
a/paimon-python/pypaimon/execution/python/write/commit/row_id_conflict.py 
b/paimon-python/pypaimon/execution/python/write/commit/row_id_conflict.py
new file mode 100644
index 0000000000..773fd22354
--- /dev/null
+++ b/paimon-python/pypaimon/execution/python/write/commit/row_id_conflict.py
@@ -0,0 +1,55 @@
+# 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.
+
+"""Read-only logical conflict checks for the selected Python backend."""
+
+from pypaimon.manifest.manifest_list_manager import ManifestListManager
+from pypaimon.manifest.schema.manifest_entry import ManifestEntry
+from pypaimon.execution.python.write.commit.commit_scanner import CommitScanner
+from pypaimon.execution.python.write.commit.conflict_detection import 
ConflictDetection
+from pypaimon.table.row.generic_row import GenericRow
+
+
+def check_row_id_conflicts(table, messages, latest_snapshot_id, 
check_from_snapshot):
+    if not table.options.data_evolution_enabled():
+        raise NotImplementedError('Row-ID conflict checks require a 
data-evolution table')
+    if any(message.compact_before or message.compact_after or 
message.compact_changelog_files
+           or message.compact_index_adds or message.compact_index_deletes for 
message in messages):
+        raise NotImplementedError('Row-ID conflict checks do not accept 
compact increments')
+    manager = table.snapshot_manager()
+    latest = manager.get_snapshot_by_id(latest_snapshot_id)
+    baseline = manager.get_snapshot_by_id(check_from_snapshot)
+    if latest is None or baseline is None:
+        raise ValueError('Row-ID conflict check snapshots must exist in the 
current table')
+    current_schema = table.schema_manager.latest()
+    if (baseline.schema_id != latest.schema_id
+            or current_schema is not None and current_schema.id != 
table.table_schema.id):
+        raise ValueError('Row-ID conflict checks require an unchanged, current 
table schema')
+    entries = []
+    for message in messages:
+        partition = GenericRow(list(message.partition), 
table.partition_keys_fields)
+        for kind, files in ((0, message.new_files), (1, 
message.deleted_files)):
+            entries.extend(ManifestEntry(
+                kind, partition, message.bucket,
+                table.options.bucket() if message.total_buckets is None else 
message.total_buckets, file)
+                for file in files)
+    manifests = ManifestListManager(table)
+    detection = ConflictDetection(True, manager, manifests, table, 
CommitScanner(table, manifests))
+    detection._row_id_check_from_snapshot = check_from_snapshot
+    conflict = detection.check_row_id_from_snapshot(latest, entries, 
check_compaction=False)
+    if conflict is not None:
+        raise conflict
diff --git a/paimon-python/pypaimon/execution/python/write/row_id_plan.py 
b/paimon-python/pypaimon/execution/python/write/row_id_plan.py
new file mode 100644
index 0000000000..72f486afaf
--- /dev/null
+++ b/paimon-python/pypaimon/execution/python/write/row_id_plan.py
@@ -0,0 +1,42 @@
+# 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.
+
+
+"""Python grouping for distributed row-ID plans, selected before execution."""
+
+from pypaimon.execution.python.write.table_update import PythonTableUpdate
+from pypaimon.manifest.schema.data_file_meta import DataFileMeta
+from pypaimon.read.query_auth_split import QueryAuthSplit
+
+
+def _file_groups(split):
+    if isinstance(split, QueryAuthSplit):
+        for group in _file_groups(split.split):
+            yield QueryAuthSplit(group, split.auth_result)
+    else:
+        yield from PythonTableUpdate._predicate_update_file_groups([split])
+
+
+def row_id_groups(plan):
+    groups = []
+    for planned in plan.splits():
+        for split in _file_groups(planned):
+            ranges = [file.row_id_range() for file in split.files
+                      if file.first_row_id is not None and not 
DataFileMeta.is_blob_file(file.file_name)]
+            if ranges:
+                groups.append((min(r.from_ for r in ranges), max(r.to for r in 
ranges), split))
+    return sorted(groups, key=lambda group: group[0])
diff --git a/paimon-python/pypaimon/globalindex/index_dispatch.py 
b/paimon-python/pypaimon/globalindex/index_dispatch.py
index 9704299096..39c18df03b 100644
--- a/paimon-python/pypaimon/globalindex/index_dispatch.py
+++ b/paimon-python/pypaimon/globalindex/index_dispatch.py
@@ -83,9 +83,11 @@ class _VindexReaderExecution:
 
 
 def build_global_index(request, partition_filter, *, execution="local", 
concurrency=None, ray_remote_args=None):
-    if execution == "local" and request._table.options.native_write_enabled():
+    if request._table.options.native_write_enabled():
         from pypaimon.execution.native.write.native_index_build import 
build_native_global_index
-        messages = build_native_global_index(request, partition_filter)
+        messages = build_native_global_index(
+            request, partition_filter, execution=execution, 
concurrency=concurrency,
+            ray_remote_args=ray_remote_args)
         if messages is not None:
             return messages
     from pypaimon.execution.python.globalindex.create_global_index import 
PythonGlobalIndexBuilder
diff --git a/paimon-python/pypaimon/ray/data_evolution_merge_join.py 
b/paimon-python/pypaimon/ray/data_evolution_merge_join.py
index 433f35a19f..6a9ecd1df4 100644
--- a/paimon-python/pypaimon/ray/data_evolution_merge_join.py
+++ b/paimon-python/pypaimon/ray/data_evolution_merge_join.py
@@ -81,6 +81,7 @@ class _SelfMergeUpdatePlan:
     row_id_name: str
     snapshot_id: int
     callable_input_columns: Optional[List[str]]
+    row_id_plan: Any
 
 
 @dataclass(frozen=True)
@@ -162,7 +163,7 @@ def build_self_merge_update_plan(
         CoreOptions, GlobalIndexSearchMode,
     )
     from pypaimon.table.special_fields import SpecialFields
-    from pypaimon.execution.python.write.table_update import PythonTableUpdate
+    from pypaimon.write.row_id_plan import capture_row_id_plan
 
     row_id_name = SpecialFields.ROW_ID.name
     needed_cols = set(resolve_target_projection(
@@ -197,6 +198,8 @@ def build_self_merge_update_plan(
         table.copy_without_time_travel(dynamic_options)
         if dynamic_options else table
     )
+    from pypaimon.snapshot.time_travel_util import TimeTravelUtil
+    scan_table = 
scan_table._copy_with_snapshot(TimeTravelUtil.resolve_snapshot(scan_table))
     read_builder = scan_table.new_read_builder().with_projection(projection)
     if scan_predicate is not None:
         read_builder.with_filter(scan_predicate)
@@ -207,9 +210,8 @@ def build_self_merge_update_plan(
     )
     # 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(PythonTableUpdate._predicate_update_file_groups(
-        scan_plan.splits()
-    ))
+    row_id_plan = capture_row_id_plan(scan_table, scan_plan)
+    file_groups = [split for _, _, split in row_id_plan.groups]
     update_schema = build_update_schema(target_pa_schema, update_cols, 
row_id_name)
     return _SelfMergeUpdatePlan(
         table=table,
@@ -226,6 +228,7 @@ def build_self_merge_update_plan(
             list(read_columns) + [row_id_name]
             if read_columns else None
         ),
+        row_id_plan=row_id_plan,
     )
 
 
@@ -241,11 +244,11 @@ def _self_merge_aliases(batch: pa.Table, row_id_name: 
str) -> pa.Table:
     return pa.table(columns, names=names)
 
 
-def _apply_self_merge_update_group(context, file_group, collect_row_ids):
+def _apply_self_merge_update_group(context, group_plan, collect_row_ids):
     """Read, transform, and stage one complete first-row-id file group."""
     from pypaimon.read.table_read import TableRead
-    from pypaimon.execution.python.write.row_id_file_index import 
RowIdFileIndex
 
+    file_group = group_plan.plan.splits()[0]
     table_read = TableRead(
         context.scan_table,
         context.predicate,
@@ -284,11 +287,8 @@ def _apply_self_merge_update_group(context, file_group, 
collect_row_ids):
         if collect_row_ids else []
     )
 
-    files_info = RowIdFileIndex.from_splits(
-        context.snapshot_id, [file_group],
-    )
     updater = 
context.table.new_batch_write_builder().new_update().new_update_by_row_id(
-        _precomputed_files_info=files_info)
+        _precomputed_files_info=group_plan)
     try:
         messages = updater.update_columns(updates, context.update_cols)
     except Exception:
@@ -307,8 +307,11 @@ def distributed_self_merge_update_apply(
 ) -> Tuple[list, int, list]:
     """Stage self-merge updates in scan tasks without a routing shuffle."""
     import ray
+    from pypaimon.read.plan import Plan
+    from pypaimon.write.row_id_plan import CapturedRowIdPlan
 
-    if not plan.file_groups:
+    captured = plan.row_id_plan
+    if not captured.groups:
         return [], 0, []
 
     remote_args = dict(ray_remote_args or {})
@@ -328,8 +331,8 @@ def distributed_self_merge_update_apply(
         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)))
+    group_iter = iter(captured.groups)
+    max_in_flight = max(1, min(num_partitions, len(captured.groups)))
     pending = set()
     messages = []
     num_updated = 0
@@ -338,11 +341,15 @@ def distributed_self_merge_update_apply(
 
     def submit_next():
         try:
-            file_group = next(group_iter)
+            group = next(group_iter)
         except StopIteration:
             return False
+        # Transport only this canonical group. Ray deserializes shared Python
+        # objects per task, so broadcasting the full plan repeats all metadata.
+        group_plan = CapturedRowIdPlan(
+            captured.table, Plan([group[2]], captured.plan.snapshot_id), 
[group])
         pending.add(apply_remote.remote(
-            context, file_group, collect_row_ids
+            context, group_plan, collect_row_ids
         ))
         return True
 
@@ -612,11 +619,11 @@ def distributed_update_apply(
     # the delete path).
     from pypaimon.common.options.core_options import CoreOptions
     scan_table = (
-        table.copy({CoreOptions.SCAN_SNAPSHOT_ID.key(): str(base_snapshot_id)})
+        table.copy_without_time_travel({CoreOptions.SCAN_SNAPSHOT_ID.key(): 
str(base_snapshot_id)})
         if base_snapshot_id is not None else table
     )
-    from pypaimon.execution.python.write.row_id_file_index import 
RowIdFileIndex
-    planner = RowIdFileIndex.from_table(scan_table)
+    from pypaimon.write.row_id_plan import capture_row_id_plan
+    planner = capture_row_id_plan(scan_table)
     sorted_first_row_ids = list(planner.first_row_ids)
     if not sorted_first_row_ids:
         return [], 0, []
@@ -632,23 +639,9 @@ def distributed_update_apply(
         # Keep the matched join and file-routing shuffle in separate Ray jobs.
         update_ds = update_ds.materialize()
 
-    # Pin commit-time conflict check to the snapshot the join was built on,
-    # so concurrent commits between read and planner are detected.
-    check_from_snapshot = (
-        base_snapshot_id if base_snapshot_id is not None
-        else planner.snapshot_id
-    )
-
-    # Put file metadata into Ray's object store and pass a single ref to
-    # workers. Avoids per-task manifest re-scans (Jingsong review #6) and
-    # avoids serializing the metadata into every task's closure. Override
-    # snapshot_id with the join's base snapshot so commit-time conflict
-    # detection covers the read→planner window.
-    from dataclasses import replace
-    files_info = replace(
-        planner,
-        snapshot_id=check_from_snapshot,
-    )
+    # Capture files and the resolved snapshot together. Workers must not plan
+    # again after later append/compaction commits.
+    files_info = planner
     precomputed_info_ref = ray.put(files_info)
 
     frid_col = "_FIRST_ROW_ID"
@@ -708,7 +701,7 @@ def distributed_update_apply(
     )
     with_frid = with_frid.union(ray.data.from_arrow(sentinel))
 
-    captured_table = table
+    captured_table = planner.table
     captured_cols = cols
 
     def _apply_group(group: pa.Table) -> pa.Table:
@@ -731,15 +724,16 @@ def distributed_update_apply(
 
         for_update = group.drop_columns([frid_col])
         info = ray.get(precomputed_info_ref)
-        _validate_live_row_ids(
-            captured_table, info, int(group[frid_col][0].as_py()), 
for_update[row_id_name].to_pylist())
         row_ids = (
             for_update.column(row_id_name).to_pylist()
             if collect_row_ids else []
         )
         update = captured_table.new_batch_write_builder().new_update()
-        worker = update.new_update_by_row_id(
-            _precomputed_files_info=ray.get(precomputed_info_ref))
+        worker = update.new_update_by_row_id(_precomputed_files_info=info)
+        from pypaimon.write.backend_dispatch import 
native_row_id_update_selected
+        if not native_row_id_update_selected(worker):
+            _validate_live_row_ids(
+                captured_table, info, int(group[frid_col][0].as_py()), 
for_update[row_id_name].to_pylist())
         msgs = worker.update_columns(for_update, list(captured_cols))
         return pa.Table.from_pydict({
             "msgs_blob": [pickle.dumps(msgs)],
@@ -817,8 +811,8 @@ def distributed_read_by_row_id(
     empty_out = _read_output_schema(table, read_cols).empty_table()
 
     # The caller pinned the resolved snapshot, including any retained tag 
metadata.
-    from pypaimon.execution.python.write.row_id_file_index import 
RowIdFileIndex
-    planner = RowIdFileIndex.from_table(table)
+    from pypaimon.write.row_id_plan import capture_row_id_plan
+    planner = capture_row_id_plan(table)
     sorted_first_row_ids = list(planner.first_row_ids)
     if not sorted_first_row_ids:
         return None
@@ -869,7 +863,7 @@ def distributed_read_by_row_id(
             frid_col, pa.array(sorted_arr[idx], type=pa.int64())
         )
 
-    captured_table = table  # read at the same pinned snapshot the planner 
routed on
+    captured_table = planner.table  # read the captured view used for routing
     captured_read_cols = read_cols
     captured_empty = empty_out
 
@@ -920,12 +914,12 @@ def distributed_delete_apply(
 
     row_id_name = SpecialFields.ROW_ID.name
     scan_table = (
-        table.copy({CoreOptions.SCAN_SNAPSHOT_ID.key(): str(base_snapshot_id)})
+        table.copy_without_time_travel({CoreOptions.SCAN_SNAPSHOT_ID.key(): 
str(base_snapshot_id)})
         if base_snapshot_id is not None else table
     )
 
-    planner = TableDeleteByRowId._from_python(scan_table)
-    anchor_info = planner._snapshot_anchor_ranges()
+    from pypaimon.write.row_id_plan import capture_row_id_plan
+    anchor_info = capture_row_id_plan(scan_table)
     if not anchor_info.anchors:
         return [], 0, []
 
@@ -990,7 +984,7 @@ def distributed_delete_apply(
 
     map_kwargs = _map_kwargs(ray_remote_args)
     with_group = delete_ds.map_batches(_assign_group, **map_kwargs)
-    captured_table = scan_table
+    captured_table = anchor_info.table
 
     def _apply_group(group: pa.Table) -> pa.Table:
         if group.num_rows == 0:
diff --git a/paimon-python/pypaimon/ray/row_id_conflict_rewriter.py 
b/paimon-python/pypaimon/ray/row_id_conflict_rewriter.py
index d289ad439e..4995bcaca7 100644
--- a/paimon-python/pypaimon/ray/row_id_conflict_rewriter.py
+++ b/paimon-python/pypaimon/ray/row_id_conflict_rewriter.py
@@ -31,7 +31,7 @@ from pypaimon.read.split import DataSplit
 from pypaimon.table.row.generic_row import GenericRow
 from pypaimon.table.special_fields import SpecialFields
 from pypaimon.utils.range import Range
-from pypaimon.execution.python.write.commit.conflict_detection import 
RowIdExistenceConflict
+from pypaimon.write.row_id_conflict import RowIdExistenceConflict, 
check_row_id_conflicts
 from pypaimon.write.commit_message import CommitMessage
 
 logger = logging.getLogger(__name__)
@@ -367,22 +367,7 @@ def _validate_no_logical_conflict(
         base_snapshot_id: int,
 ) -> None:
     """Reject non-compaction changes before advancing the rewrite baseline."""
-    commit = table.new_batch_write_builder().new_commit()
-    try:
-        file_store_commit = commit.file_store_commit
-        file_store_commit.conflict_detection._row_id_check_from_snapshot = (
-            base_snapshot_id
-        )
-        entries = file_store_commit._collect_manifest_entries(update_messages)
-        conflict = 
file_store_commit.conflict_detection.check_row_id_from_snapshot(
-            latest_snapshot,
-            entries,
-            check_compaction=False,
-        )
-        if conflict is not None:
-            raise conflict
-    finally:
-        commit.close()
+    check_row_id_conflicts(table, update_messages, latest_snapshot.id, 
base_snapshot_id)
 
 
 def _find_row_id_conflict(error) -> Optional[RowIdExistenceConflict]:
diff --git a/paimon-python/pypaimon/ray/vector_index_build.py 
b/paimon-python/pypaimon/ray/vector_index_build.py
index 31bf1a73a5..fa5f722578 100644
--- a/paimon-python/pypaimon/ray/vector_index_build.py
+++ b/paimon-python/pypaimon/ray/vector_index_build.py
@@ -17,8 +17,6 @@
 
 """Ray shard construction with one driver-side global-index commit."""
 
-from pypaimon.execution.python.globalindex.build_plan import 
split_by_global_index_shard
-from pypaimon.execution.python.globalindex.index_file_utils import 
new_global_index_file_name
 from pypaimon.globalindex.vindex_options import VINDEX_IDENTIFIERS
 from pypaimon.ray.search_execution import execution_options
 
@@ -40,6 +38,8 @@ def validate_build_options(builder, concurrency, 
ray_remote_args):
 def build_vector_index(builder, splits, unindexed_ranges, index_field, 
table_read,
                        index_path, concurrency, remote_args):
     import ray
+    from pypaimon.execution.python.globalindex.build_plan import 
split_by_global_index_shard
+    from pypaimon.execution.python.globalindex.index_file_utils import 
new_global_index_file_name
 
     shards = split_by_global_index_shard(
         splits, builder._core_options.global_index_row_count_per_shard(), 
unindexed_ranges)
@@ -79,3 +79,43 @@ def _build_shard(context, shard, file_name):
     index_split, index_range = shard
     return builder._build_generic_shard(
         index_split, index_range, index_field, table_read, index_path, 
file_name=file_name)
+
+
+def build_native_vector_index(table, plan, concurrency, remote_args):
+    """Schedule immutable Core tasks; all publication remains with the 
caller."""
+    import ray
+
+    shards = plan.shards()
+    if not shards:
+        return []
+    context = ray.put(table)
+    remote = ray.remote(_build_native_shard).options(**remote_args)
+    pending, results = {}, {}
+    next_shard = 0
+    try:
+        while pending or next_shard < len(shards):
+            while len(pending) < concurrency and next_shard < len(shards):
+                ref = remote.remote(context, shards[next_shard])
+                pending[ref] = next_shard
+                next_shard += 1
+            ready, _ = ray.wait(list(pending), num_returns=1)
+            ref = ready[0]
+            ordinal = pending[ref]
+            results[ordinal] = ray.get(ref)
+            del pending[ref]
+        return [message for ordinal in range(len(shards)) for message in 
results[ordinal]]
+    except BaseException:
+        # A worker may have completed even when result delivery failed. Wait
+        # for dispatched tasks, but retain every output: its commit ownership
+        # is unknown and another attempt may already hold the messages.
+        for ref in pending:
+            try:
+                ray.get(ref)
+            except BaseException:
+                pass
+        raise
+
+
+def _build_native_shard(table, shard):
+    from pypaimon.execution.native.write.native_index_build import 
build_native_index_shard
+    return build_native_index_shard(table, shard)
diff --git a/paimon-python/pypaimon/tests/native_ray_index_build_test.py 
b/paimon-python/pypaimon/tests/native_ray_index_build_test.py
new file mode 100644
index 0000000000..5f6fd1b626
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_ray_index_build_test.py
@@ -0,0 +1,220 @@
+# 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.
+
+"""Native Ray tasks build captured shards without Python materialization or 
replay."""
+
+import sys
+import subprocess
+import textwrap
+from types import ModuleType, SimpleNamespace
+from unittest.mock import Mock, patch
+
+import pyarrow as pa
+import pytest
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.globalindex.create_global_index import GlobalIndexBuilder
+
+pytestmark = [pytest.mark.python_plan, pytest.mark.python_read,
+              pytest.mark.python_write, pytest.mark.python_commit]
+
+
+class _Reference:
+    def __init__(self, function, args):
+        self.function, self.args, self.done = function, args, False
+
+    def get(self):
+        if not self.done:
+            self.done = True
+            try:
+                self.value, self.error = self.function(*self.args), None
+            except BaseException as error:
+                self.error = error
+        if self.error is not None:
+            raise self.error
+        return self.value
+
+
+class _Ray(ModuleType):
+    def __init__(self):
+        super().__init__('ray')
+        self.submitted, self.remote_options, self.delivery_failure = [], None, 
None
+
+    def put(self, value):
+        return value
+
+    def remote(self, function):
+        owner = self
+
+        class Remote:
+            def options(self, **options):
+                owner.remote_options = options
+                return self
+
+            def remote(self, *args):
+                ref = _Reference(function, args)
+                owner.submitted.append(ref)
+                return ref
+
+        return Remote()
+
+    def wait(self, refs, num_returns):
+        return [refs[-1]], refs[:-1]
+
+    def get(self, ref):
+        value = ref.get()
+        if self.delivery_failure is not None and ref is self.submitted[0]:
+            raise self.delivery_failure
+        return value
+
+
+def _request(tmp_path):
+    catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+    catalog.create_database('default', True)
+    schema = pa.schema([('id', pa.int32()), ('embedding', 
pa.list_(pa.float32()))])
+    catalog.create_table('default.vectors', Schema.from_pyarrow_schema(schema, 
options={
+        'bucket': '-1', 'row-tracking.enabled': 'true', 
'data-evolution.enabled': 'true',
+        'write.native.enabled': 'true', 'read.native.enabled': 'false',
+        'scan.native-plan.enabled': 'false', 'commit.native.enabled': 'false',
+    }), False)
+    return GlobalIndexBuilder(catalog.get_table('default.vectors'), 
'embedding', 'ivf-flat', options={
+        'ivf-flat.dimension': '2', 'ivf-flat.nlist': '1', 
'global-index.row-count-per-shard': '2'})
+
+
[email protected]
+def native_protocol():
+    module = 'pypaimon.execution.native.write.native_index_build'
+    core = Mock()
+    shards = [SimpleNamespace(ordinal=ordinal) for ordinal in range(3)]
+    core.plan.return_value = SimpleNamespace(shards=lambda: shards)
+    core.build_shard.side_effect = lambda shard: ['message-' + 
str(shard.ordinal)]
+    native_table = SimpleNamespace(new_global_index_build_builder=lambda: core)
+    sdk = ModuleType('pypaimon_rust.datafusion')
+    sdk.GlobalIndexBuildBuilder = SimpleNamespace(supports_index_type=lambda 
kind: True)
+    scheduler = _Ray()
+    with patch.dict(sys.modules, {'pypaimon_rust.datafusion': sdk, 'ray': 
scheduler}), \
+            patch(module + '.native_runtime_available', return_value=True), \
+            patch(module + '._rest_catalog_supported', return_value=True), \
+            patch(module + '._native_table', return_value=native_table), \
+            patch(module + '.from_native_commit_messages', side_effect=lambda 
table, messages: messages), \
+            
patch('pypaimon.execution.python.globalindex.create_global_index.PythonGlobalIndexBuilder',
+                  side_effect=AssertionError('Python index execution')):
+        yield core, shards, scheduler
+
+
+def test_native_ray_build_plans_once_and_builds_only_selected_shards(tmp_path, 
native_protocol):
+    core, _, scheduler = native_protocol
+    assert _request(tmp_path).build(execution='ray', concurrency=2) == 
['message-0', 'message-1', 'message-2']
+    core.plan.assert_called_once_with()
+    core.build.assert_not_called()
+    assert len(scheduler.submitted) == 3
+    assert sorted(call[0][0].ordinal for call in 
core.build_shard.call_args_list) == [0, 1, 2]
+    assert scheduler.remote_options['max_retries'] == 0
+    assert scheduler.remote_options['retry_exceptions'] is False
+
+
+def 
test_native_ray_delivery_failure_preserves_outputs_and_never_replays(tmp_path, 
native_protocol):
+    core, _, scheduler = native_protocol
+    request = _request(tmp_path)
+    failure = RuntimeError('result delivery failed after the worker wrote its 
file')
+    scheduler.delivery_failure = failure
+    with patch.object(request._table.file_io, 'delete_quietly') as delete:
+        with pytest.raises(RuntimeError) as raised:
+            request.build(execution='ray', concurrency=2)
+        assert raised.value is failure
+        delete.assert_not_called()
+    core.build.assert_not_called()
+    assert len(core.build_shard.call_args_list) == 3
+    assert all(ref.done for ref in scheduler.submitted)
+
+
+def test_native_ray_plan_permission_failure_does_not_select_python(tmp_path, 
native_protocol):
+    core, _, scheduler = native_protocol
+    failure = PermissionError('index build denied')
+    core.plan.side_effect = failure
+    with pytest.raises(PermissionError) as raised:
+        _request(tmp_path).build(execution='ray', concurrency=2)
+    assert raised.value is failure
+    core.build_shard.assert_not_called()
+    assert not scheduler.submitted
+
+
+def test_native_ray_empty_plan_does_not_submit_or_select_python(tmp_path, 
native_protocol):
+    core, _, scheduler = native_protocol
+    core.plan.return_value = SimpleNamespace(shards=lambda: [])
+    assert _request(tmp_path).build(execution='ray', concurrency=2) == []
+    core.plan.assert_called_once_with()
+    core.build.assert_not_called()
+    core.build_shard.assert_not_called()
+    assert not scheduler.submitted
+
+
[email protected]('execution', ['local', 'ray'])
+def test_query_auth_selects_python_before_native_index_execution(tmp_path, 
native_protocol, execution):
+    core, _, scheduler = native_protocol
+    request = _request(tmp_path)
+    request._table = request._table.copy({'query-auth.enabled': 'true'})
+    with 
patch('pypaimon.execution.python.globalindex.create_global_index.PythonGlobalIndexBuilder')
 as python:
+        python.return_value.build.return_value = ['authorized-python-message']
+        kwargs = {'execution': execution}
+        if execution == 'ray':
+            kwargs['concurrency'] = 2
+        assert request.build(**kwargs) == ['authorized-python-message']
+        assert python.return_value.build.call_args[1]['execution'] == execution
+    core.plan.assert_not_called()
+    core.build.assert_not_called()
+    core.build_shard.assert_not_called()
+    assert not scheduler.submitted
+
+
+def 
test_native_ray_worker_failure_stops_admission_and_retains_outputs(tmp_path, 
native_protocol):
+    core, _, scheduler = native_protocol
+    failure = PermissionError('worker authorization changed')
+
+    def build(shard):
+        if shard.ordinal == 1:
+            raise failure
+        return ['message-' + str(shard.ordinal)]
+
+    core.build_shard.side_effect = build
+    request = _request(tmp_path)
+    with patch.object(request._table.file_io, 'delete_quietly') as delete:
+        with pytest.raises(PermissionError) as raised:
+            request.build(execution='ray', concurrency=2)
+        assert raised.value is failure
+        delete.assert_not_called()
+    core.build.assert_not_called()
+    assert sorted(call[0][0].ordinal for call in 
core.build_shard.call_args_list) == [0, 1]
+    assert len(scheduler.submitted) == 2
+    assert all(ref.done for ref in scheduler.submitted)
+
+
+def test_native_ray_scheduler_import_does_not_load_python_execution():
+    script = """
+        import importlib.abc
+        import sys
+
+        class RejectPythonExecution(importlib.abc.MetaPathFinder):
+            def find_spec(self, fullname, path=None, target=None):
+                if fullname.startswith('pypaimon.execution.python'):
+                    raise AssertionError('Python execution imported: ' + 
fullname)
+
+        sys.meta_path.insert(0, RejectPythonExecution())
+        from pypaimon.ray.vector_index_build import build_native_vector_index
+        assert callable(build_native_vector_index)
+    """
+    subprocess.run([sys.executable, '-c', textwrap.dedent(script)], check=True)
diff --git a/paimon-python/pypaimon/tests/native_ray_row_id_plan_test.py 
b/paimon-python/pypaimon/tests/native_ray_row_id_plan_test.py
new file mode 100644
index 0000000000..1ff0a6c400
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_ray_row_id_plan_test.py
@@ -0,0 +1,112 @@
+# 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.
+
+"""Exercise the actual SDK plan bridge without rebuilding the extension."""
+
+from pathlib import Path
+from unittest.mock import patch
+
+import pyarrow as pa
+import pytest
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.execution.native.runtime import native_runtime_available
+from pypaimon.snapshot.snapshot import BATCH_COMMIT_IDENTIFIER
+from pypaimon.write.backend_dispatch import native_row_id_update_selected
+from pypaimon.write.row_id_plan import capture_row_id_plan
+from pypaimon.write.table_delete import TableDeleteByRowId
+from pypaimon.write.table_update_by_row_id import TableUpdateByRowId
+
+pytestmark = [pytest.mark.python_plan, pytest.mark.python_read,
+              pytest.mark.python_write, pytest.mark.python_commit]
+_ENGINES = [False, pytest.param(True, marks=pytest.mark.skipif(
+    not native_runtime_available(), reason='pypaimon-rust not installed'))]
+_SCHEMA = pa.schema([('id', pa.int32()), ('value', pa.int32())])
+
+
+def _table(tmp_path):
+    catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+    catalog.create_database('default', True)
+    catalog.create_table('default.t', Schema.from_pyarrow_schema(_SCHEMA, 
options={
+        'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true',
+        'deletion-vectors.enabled': 'true', 'write.native.enabled': 'false',
+        'commit.native.enabled': 'false', 'scan.native-plan.enabled': 'false',
+        'read.native.enabled': 'false'}), False)
+    return catalog.get_table('default.t')
+
+
+def _append(table, ids):
+    builder = table.new_batch_write_builder()
+    writer = builder.new_write()
+    writer.write_arrow(pa.table({'id': ids, 'value': [0] * len(ids)}, 
schema=_SCHEMA))
+    builder.new_commit().commit(writer.prepare_commit())
+    writer.close()
+
+
[email protected]('native', _ENGINES)
+def test_planned_worker_preserves_snapshot_and_cross_backend_commit(tmp_path, 
native):
+    table = _table(tmp_path)
+    _append(table, [1, 2])
+    worker_table = table.copy({'write.native.enabled': str(native).lower()})
+    captured = capture_row_id_plan(worker_table)
+    _append(table, [3])
+    with patch('pypaimon.read.table_scan.TableScan.plan_all', 
side_effect=AssertionError('worker replanned')):
+        worker = TableUpdateByRowId(worker_table, 'planned', 
BATCH_COMMIT_IDENTIFIER, captured)
+        assert native_row_id_update_selected(worker) == native
+        updates = worker.update_columns(pa.table({'_ROW_ID': [1], 'value': 
[9]}), ['value'])
+        deletes = TableDeleteByRowId(worker_table, captured).delete([0])
+    assert all(message.check_from_snapshot == captured.snapshot_id for message 
in updates + deletes)
+    table.new_batch_write_builder().new_commit().commit(updates + deletes)
+    builder = table.new_read_builder()
+    data = 
builder.new_read().to_arrow(builder.new_scan().plan().splits()).sort_by('id')
+    assert data.to_pydict() == {'id': [2, 3], 'value': [9, 0]}
+
+
[email protected](not native_runtime_available(), reason='pypaimon-rust not 
installed')
+def test_native_planned_deleted_row_rejected_before_output(tmp_path):
+    table = _table(tmp_path)
+    _append(table, [1, 2])
+    
table.new_batch_write_builder().new_commit().commit(TableDeleteByRowId(table).delete([0]))
+    table = table.copy({'write.native.enabled': 'true'})
+    captured = capture_row_id_plan(table)
+    worker = TableUpdateByRowId(table, 'deleted', BATCH_COMMIT_IDENTIFIER, 
captured)
+    assert native_row_id_update_selected(worker)
+    before = set(Path(tmp_path).rglob('*.parquet'))
+    with pytest.raises(ValueError, match='deleted.*ROW_ID|deleted.*row ID'):
+        worker.update_columns(pa.table({'_ROW_ID': [1, 0], 'value': [9, 9]}), 
['value'])
+    assert set(Path(tmp_path).rglob('*.parquet')) == before
+
+
[email protected]('native', _ENGINES)
+def test_planned_update_keeps_resolved_write_schema_with_older_files(tmp_path, 
native):
+    from pypaimon.schema.data_types import AtomicType
+    from pypaimon.schema.schema_change import SchemaChange
+    table = _table(tmp_path)
+    _append(table, [1, 2])
+    catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+    catalog.alter_table(table.identifier, [SchemaChange.add_column('extra', 
AtomicType('INT'))])
+    table = catalog.get_table(table.identifier).copy({'write.native.enabled': 
str(native).lower()})
+    captured = 
capture_row_id_plan(table.copy_without_time_travel({'scan.snapshot-id': '1'}))
+    assert captured.table.table_schema.id == table.table_schema.id == 1
+    assert captured.table._read_snapshot.schema_id == 0
+    worker = TableUpdateByRowId(table, 'new-field', BATCH_COMMIT_IDENTIFIER, 
captured)
+    assert native_row_id_update_selected(worker) == native
+    messages = worker.update_columns(pa.table({'_ROW_ID': [0], 'extra': [7]}), 
['extra'])
+    table.new_batch_write_builder().new_commit().commit(messages)
+    read = table.new_read_builder()
+    data = 
read.new_read().to_arrow(read.new_scan().plan().splits()).sort_by('id')
+    assert data.to_pydict() == {'id': [1, 2], 'value': [0, 0], 'extra': [7, 
None]}
diff --git a/paimon-python/pypaimon/tests/native_sorted_index_build_test.py 
b/paimon-python/pypaimon/tests/native_sorted_index_build_test.py
index 204a95c563..13fce8767c 100644
--- a/paimon-python/pypaimon/tests/native_sorted_index_build_test.py
+++ b/paimon-python/pypaimon/tests/native_sorted_index_build_test.py
@@ -26,7 +26,8 @@ import pyarrow as pa
 import pytest
 
 from pypaimon import Schema
-from pypaimon.api.api_response import ErrorResponse, GetTableSnapshotResponse
+from pypaimon.api.api_response import AuthTableQueryResponse, ErrorResponse, 
GetTableSnapshotResponse
+from pypaimon.catalog.catalog_exception import TableNoPermissionException
 from pypaimon.globalindex.create_global_index import GlobalIndexBuilder
 from pypaimon.execution.python.globalindex.create_global_index import 
PythonGlobalIndexBuilder
 from pypaimon.execution.python.globalindex.data_evolution_global_index_scanner 
import DataEvolutionGlobalIndexScanner
@@ -35,6 +36,7 @@ from pypaimon.execution.native.table import (_native_table)
 from pypaimon.execution.native.read.builder import (_predicate_to_native)
 from pypaimon.execution.native.runtime import (native_runtime_available)
 from pypaimon.snapshot.table_snapshot import TableSnapshot
+from pypaimon.schema.schema_change import SchemaChange
 from pypaimon.tests import native_plan_rest_test
 from pypaimon.execution.native.write.native_commit import 
from_native_commit_messages
 from pypaimon.write.table_commit import BatchTableCommit
@@ -118,6 +120,43 @@ def _delete(table, ids):
     _commit(table, builder.new_update().delete_by_row_id(ids))
 
 
[email protected]_plan
[email protected]_read
[email protected]_write
[email protected]_commit
[email protected]('policy', ['unrestricted', 'filtered', 'masked', 
'denied'])
+def test_query_auth_keeps_python_index_build_authorization(rest_catalog, 
policy):
+    catalog, server = rest_catalog
+    table = _create(catalog, options={'scan.native-plan.enabled': 'false'})
+    _append(table, ROWS)
+    catalog.alter_table(table.identifier, 
[SchemaChange.set_option('query-auth.enabled', 'true')])
+    table = catalog.get_table(table.identifier)
+    if policy == 'denied':
+        reply = server._mock_response(ErrorResponse('TABLE', 'indexes', 'index 
build denied', 403), 403)
+    else:
+        response = AuthTableQueryResponse(
+            filter=['{"kind":"LEAF","transform":{"name":"FIELD_REF",'
+                    '"fieldRef":{"name":"id","index":0,"type":"INT"}},'
+                    '"function":"GREATER_THAN","literals":[1]}'] if policy == 
'filtered' else None,
+            column_masking={'name': '{"name":"NULL"}'} if policy == 'masked' 
else None)
+        reply = server._mock_response(response, 200)
+    native_module = 'pypaimon.execution.native.write.native_index_build'
+    with patch.object(server, '_table_query_auth_handle', return_value=reply) 
as authorize, \
+            patch(native_module + '._native_table', 
side_effect=AssertionError('Native index build selected')):
+        for native in [False, True]:
+            request = GlobalIndexBuilder(table.copy({'write.native.enabled': 
str(native).lower()}), 'name')
+            if policy == 'unrestricted':
+                files = _message_files(request.build())
+                assert sum(file.row_count for file in files) == len(ROWS)
+                assert all(table.file_io.exists(_path(table, file)) for file 
in files)
+            else:
+                with pytest.raises(TableNoPermissionException):
+                    request.build()
+        assert authorize.call_count >= 2
+    assert _files(table) == []
+    assert table.snapshot_manager().get_latest_snapshot().id == 1
+
+
 @pytest.mark.parametrize('kind', ['btree', 'bitmap'])
 @pytest.mark.parametrize('deleted', [[0, 2], [0, 1, 2, 3]])
 @pytest.mark.parametrize('native_commit', [False, True])
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 fd1a63d2f3..9f2440d492 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
@@ -3311,7 +3311,7 @@ class RayDataEvolutionMergeIntoTest(unittest.TestCase):
             ):
                 messages, count, row_ids = (
                     join_module._apply_self_merge_update_group(
-                        context, plan.file_groups[0], True,
+                        context, 
plan.row_id_plan.for_split(plan.file_groups[0]), True,
                     )
                 )
 
diff --git a/paimon-python/pypaimon/tests/ray_row_id_conflict_rewriter_test.py 
b/paimon-python/pypaimon/tests/ray_row_id_conflict_rewriter_test.py
index 6bbd778faa..eda5c87928 100644
--- a/paimon-python/pypaimon/tests/ray_row_id_conflict_rewriter_test.py
+++ b/paimon-python/pypaimon/tests/ray_row_id_conflict_rewriter_test.py
@@ -26,7 +26,7 @@ from pypaimon.ray.row_id_conflict_rewriter import (
 )
 from pypaimon.schema.data_types import AtomicType, DataField
 from pypaimon.schema.table_schema import TableSchema
-from pypaimon.execution.python.write.commit.conflict_detection import 
RowIdExistenceConflict
+from pypaimon.write.row_id_conflict import RowIdExistenceConflict
 
 
 class RayRowIdConflictRewriterTest(unittest.TestCase):
diff --git 
a/paimon-python/pypaimon/tests/write/row_id_conflict_protocol_test.py 
b/paimon-python/pypaimon/tests/write/row_id_conflict_protocol_test.py
new file mode 100644
index 0000000000..091cc57d20
--- /dev/null
+++ b/paimon-python/pypaimon/tests/write/row_id_conflict_protocol_test.py
@@ -0,0 +1,326 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from dataclasses import replace
+import pickle
+import sys
+from types import ModuleType
+from unittest.mock import Mock, patch
+
+import pytest
+
+from pypaimon.tests.write.conflict_detection_test import _make_entry
+from pypaimon.execution.python.write.commit.conflict_detection import 
ConflictDetection
+from pypaimon.write.commit_message import CommitMessage
+from pypaimon.write.row_id_conflict import RowIdExistenceConflict, 
check_row_id_conflicts
+
+
+def _native_error_module():
+    module = ModuleType('pypaimon_rust')
+
+    class NativeRowIdConflict(RuntimeError):
+        pass
+
+    module.RowIdExistenceConflict = NativeRowIdConflict
+    return module
+
+
+def _native_conflict(module):
+    error = module.RowIdExistenceConflict('an opaque transport message')
+    error.file_name = "opaque '(4): file.parquet"
+    error.first_row_id = 4
+    error.row_count = 8
+    error.bucket = 3
+    error.current_first_row_id = None
+    error.current_row_count = 9
+    return error
+
+
[email protected]('from_entry', [False, True])
+def test_structured_conflict_survives_worker_serialization(from_entry):
+    if from_entry:
+        error = RowIdExistenceConflict(_make_entry('staged.parquet', 
first_row_id=7))
+    else:
+        error = RowIdExistenceConflict._from_fields(
+            file_name='staged.parquet', first_row_id=7,
+            row_count=100, bucket=0, current_row_count=101)
+    restored = pickle.loads(pickle.dumps(error))
+    assert str(restored) == str(error)
+    assert restored.file_name == error.file_name
+    assert restored.first_row_id == error.first_row_id
+    assert restored.current_row_count == error.current_row_count
+    assert (restored.entry is None) == (error.entry is None)
+
+
[email protected]('batch', [False, True])
+def test_native_commit_translates_only_typed_row_id_conflicts(batch):
+    from pypaimon.execution.native.write.commit import NativeTableCommit
+    from pypaimon.ray.row_id_conflict_rewriter import _find_row_id_conflict
+
+    module = _native_error_module()
+    native = _native_conflict(module)
+    core = Mock()
+    core.commit.side_effect = native
+    commit = NativeTableCommit(core, is_batch=batch)
+    with patch.dict(sys.modules, {'pypaimon_rust': module}):
+        with pytest.raises(RowIdExistenceConflict) as raised:
+            commit.commit([], 7)
+    error = raised.value
+    assert error.__cause__ is native
+    assert error.entry is None
+    assert (error.file_name, error.first_row_id, error.row_count, 
error.bucket) == (
+        "opaque '(4): file.parquet", 4, 8, 3)
+    assert error.current_first_row_id is None
+    assert error.current_row_count == 9
+    assert _find_row_id_conflict(error) is error
+    core.abort.assert_not_called()
+
+
[email protected]('opaque_message', ['unknown commit result', 'Row ID 
existence conflict: not a typed error'])
+def test_native_unknown_outcome_is_not_a_rewrite_signal(opaque_message):
+    from pypaimon.execution.native.write.commit import NativeTableCommit
+    from pypaimon.ray.row_id_conflict_rewriter import _find_row_id_conflict
+
+    module = _native_error_module()
+    failure = ValueError(opaque_message)
+    failure.__cause__ = _native_conflict(module)
+    core = Mock()
+    core.commit.side_effect = failure
+    with patch.dict(sys.modules, {'pypaimon_rust': module}):
+        with pytest.raises(ValueError) as raised:
+            NativeTableCommit(core, is_batch=True).commit([], 7)
+    assert raised.value is failure
+    assert _find_row_id_conflict(failure) is None
+    core.abort.assert_not_called()
+
+
[email protected]('refuse', [False, True])
+def 
test_native_logical_check_never_constructs_python_commit_or_replays(refuse):
+    message = CommitMessage((), 0, [_make_entry('staged.parquet', 
first_row_id=0).file], check_from_snapshot=1)
+    table = Mock()
+    table.options.native_commit_enabled.return_value = True
+    core = Mock()
+    failure = RuntimeError('logical overlap')
+    if refuse:
+        core.check_row_id_conflicts.side_effect = failure
+        core.close.side_effect = RuntimeError('closing failed')
+    with patch('pypaimon.write.update_validation._authorize_update') as 
authorize, patch(
+            
'pypaimon.execution.native.write.native_commit.prepare_native_row_id_conflict_check',
+            return_value=(core, ['wire-message'])) as prepare, patch(
+            
'pypaimon.execution.python.write.commit.row_id_conflict.check_row_id_conflicts')
 as pure, patch(
+            
'pypaimon.execution.python.write.file_store_commit.FileStoreCommit') as 
publisher:
+        if refuse:
+            with pytest.raises(RuntimeError) as raised:
+                check_row_id_conflicts(table, [message], 2, 1)
+            assert raised.value is failure
+        else:
+            check_row_id_conflicts(table, [message], 2, 1)
+    authorize.assert_called_once_with(table)
+    prepare.assert_called_once_with(table, [message])
+    core.check_row_id_conflicts.assert_called_once_with(['wire-message'], 2, 1)
+    core.close.assert_called_once_with()
+    core.abort.assert_not_called()
+    pure.assert_not_called()
+    publisher.assert_not_called()
+
+
[email protected]('failure', [PermissionError('grant refused'), 
ValueError('unknown transport failure')])
+def test_native_check_preparation_errors_are_never_replayed(failure):
+    table = Mock()
+    table.options.native_commit_enabled.return_value = True
+    message = CommitMessage((), 0, [], check_from_snapshot=1)
+    with patch('pypaimon.write.update_validation._authorize_update'), patch(
+            
'pypaimon.execution.native.write.native_commit.prepare_native_row_id_conflict_check',
+            side_effect=failure), patch(
+            
'pypaimon.execution.python.write.commit.row_id_conflict.check_row_id_conflicts')
 as pure:
+        with pytest.raises(type(failure)) as raised:
+            check_row_id_conflicts(table, [message], 2, 1)
+    assert raised.value is failure
+    pure.assert_not_called()
+
+
[email protected]('latest,baseline', [
+    (2, 0), (1, 2), (0, 1), (2, -1), (2, 2), (2.5, 1), (True, 1), (2, False), 
(1 << 63, 1)])
+def test_check_rejects_invalid_baseline_before_authorization_or_io(latest, 
baseline):
+    table = Mock()
+    message = CommitMessage((), 0, [], check_from_snapshot=1)
+    with patch('pypaimon.write.update_validation._authorize_update') as 
authorize:
+        with pytest.raises(ValueError):
+            check_row_id_conflicts(table, [message], latest, baseline)
+    authorize.assert_not_called()
+    table.snapshot_manager.assert_not_called()
+
+
[email protected]('baseline', [None, True, 1.0, '1', -1, 1 << 63])
+def test_check_rejects_invalid_message_baseline_before_io(baseline):
+    table = Mock()
+    message = CommitMessage((), 0, [], check_from_snapshot=baseline)
+    with patch('pypaimon.write.update_validation._authorize_update') as 
authorize:
+        with pytest.raises(ValueError):
+            check_row_id_conflicts(table, [message], 2, 1)
+    authorize.assert_not_called()
+    table.snapshot_manager.assert_not_called()
+
+
+def test_pure_logical_check_uses_history_without_a_publisher(tmp_path):
+    import pyarrow as pa
+    from pypaimon import CatalogFactory, Schema
+
+    catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+    catalog.create_database('default', False)
+    catalog.create_table('default.t', Schema.from_pyarrow_schema(
+        pa.schema([('id', pa.int32()), ('name', pa.string())]), options={
+            'data-evolution.enabled': 'true', 'row-tracking.enabled': 'true',
+            'read.native.enabled': 'false', 'scan.native-plan.enabled': 
'false',
+            'write.native.enabled': 'false', 'commit.native.enabled': 
'false'}), False)
+    table = catalog.get_table('default.t')
+    builder = table.new_batch_write_builder()
+    writer = builder.new_write()
+    writer.write_arrow(pa.table({'id': pa.array([1, 2], pa.int32()), 'name': 
['a', 'b']}))
+    initial = writer.prepare_commit()
+    writer.close()
+    commit = builder.new_commit()
+    commit.commit(initial)
+    commit.close()
+    update = 
table.new_batch_write_builder().new_update().with_update_type(['name'])
+    changes = update.update_by_arrow_with_row_id(pa.table({'_ROW_ID': [0, 1], 
'name': ['x', 'y']}))
+    commit = table.new_batch_write_builder().new_commit()
+    commit.commit(changes)
+    commit.close()
+    staged = replace(changes[0], new_files=[replace(changes[0].new_files[0], 
file_name='still-prepared.parquet')])
+    before = sorted(path.name for path in tmp_path.rglob('*') if 
path.is_file())
+    with 
patch('pypaimon.execution.python.write.file_store_commit.FileStoreCommit',
+               side_effect=AssertionError('a publisher was constructed')), 
patch.object(
+            table, 'new_snapshot_commit', 
side_effect=AssertionError('publication route requested')):
+        with pytest.raises(RuntimeError, match='MERGE INTO') as raised:
+            check_row_id_conflicts(table, [staged], 2, 1)
+        assert not isinstance(raised.value, RowIdExistenceConflict)
+        disjoint = replace(staged, new_files=[replace(staged.new_files[0], 
write_cols=['id'])])
+        check_row_id_conflicts(table, [disjoint], 2, 1)
+    assert before == sorted(path.name for path in tmp_path.rglob('*') if 
path.is_file())
+
+
[email protected]('first_row_id,row_count', [(10, 100), (None, 100), 
(0, 200)])
+def test_delete_rejects_reassigned_current_file(first_row_id, row_count):
+    detection = ConflictDetection(True, None, None, None, None)
+    current = _make_entry('same-file.parquet', first_row_id=first_row_id, 
row_count=row_count)
+    stale = _make_entry('same-file.parquet', kind=1, first_row_id=0)
+    conflict = detection.check_row_id_existence([current], [stale], None)
+    assert conflict is not None
+    assert conflict.entry is stale
+    assert conflict.file_name == 'same-file.parquet'
+    assert conflict.first_row_id == 0
+    assert conflict.row_count == 100
+    assert conflict.current_first_row_id == first_row_id
+    assert conflict.current_row_count == row_count
+
+
+def test_conflict_type_remains_available_from_previous_module():
+    from pypaimon.write.row_id_conflict import RowIdExistenceConflict
+    from pypaimon.execution.python.write.commit.conflict_detection import 
RowIdExistenceConflict as Previous
+
+    assert Previous is RowIdExistenceConflict
+    entry = _make_entry('stale.parquet', first_row_id=7)
+    error = RowIdExistenceConflict(entry)
+    assert error.entry is entry
+    assert (error.file_name, error.first_row_id, error.row_count, 
error.bucket) == ('stale.parquet', 7, 100, 0)
+    assert error.current_row_count is None
+
+
+def test_ray_conflict_module_does_not_import_python_execution():
+    # A fresh process catches imports hidden by the module cache of this test 
run.
+    import subprocess
+    import sys
+
+    program = '''
+import importlib.abc
+import sys
+class Guard(importlib.abc.MetaPathFinder):
+    def find_spec(self, fullname, path=None, target=None):
+        if fullname == 'pypaimon.execution.python' or 
fullname.startswith('pypaimon.execution.python.'):
+            raise AssertionError('Python execution imported: ' + fullname)
+sys.meta_path.insert(0, Guard())
+import pypaimon.ray.row_id_conflict_rewriter
+'''
+    subprocess.run([sys.executable, '-c', program], check=True)
+
+
+def test_native_logical_check_does_not_import_python_execution():
+    import subprocess
+
+    program = '''
+import importlib.abc
+import sys
+from tempfile import TemporaryDirectory
+from types import ModuleType
+from unittest.mock import patch
+class Guard(importlib.abc.MetaPathFinder):
+    def find_spec(self, fullname, path=None, target=None):
+        if fullname == 'pypaimon.execution.python' or 
fullname.startswith('pypaimon.execution.python.'):
+            raise AssertionError('Python execution imported: ' + fullname)
+sys.meta_path.insert(0, Guard())
+class NativeMessage:
+    @staticmethod
+    def deserialize(payload, version):
+        assert isinstance(payload, bytes) and payload and version == 14
+        return NativeMessage()
+runtime = ModuleType('pypaimon_rust')
+runtime.datafusion = ModuleType('pypaimon_rust.datafusion')
+runtime.datafusion.CommitMessage = NativeMessage
+sys.modules['pypaimon_rust'] = runtime
+sys.modules['pypaimon_rust.datafusion'] = runtime.datafusion
+import pyarrow as pa
+from pypaimon import CatalogFactory, Schema
+from pypaimon.execution.native.write import native_commit
+from pypaimon.write.commit_message import CommitMessage
+from pypaimon.write.row_id_conflict import check_row_id_conflicts
+class Core:
+    checked = False
+    closed = False
+    def check_row_id_conflicts(self, messages, latest, baseline):
+        from pypaimon_rust.datafusion import CommitMessage as NativeMessage
+        assert len(messages) == 1 and isinstance(messages[0], NativeMessage)
+        assert (latest, baseline) == (2, 1)
+        self.checked = True
+    def close(self):
+        self.closed = True
+with TemporaryDirectory() as path:
+    catalog = CatalogFactory.create({'warehouse': path})
+    catalog.create_database('default', False)
+    catalog.create_table('default.t', Schema.from_pyarrow_schema(
+        pa.schema([('id', pa.int32())]), options={
+            'data-evolution.enabled': 'true', 'row-tracking.enabled': 'true',
+            'commit.native.enabled': 'true'}), False)
+    table = catalog.get_table('default.t')
+    core = Core()
+    with patch.object(native_commit, 'create_native_commit', 
return_value=core), patch.object(
+            table, 'new_snapshot_commit', 
side_effect=AssertionError('publisher requested')):
+        check_row_id_conflicts(table, [CommitMessage((), 0, [], 
check_from_snapshot=1)], 2, 1)
+    assert core.checked and core.closed
+'''
+    subprocess.run([sys.executable, '-c', program], check=True)
+
+
+def test_ordinary_delete_and_empty_messages_are_not_existence_conflicts():
+    current = _make_entry('ordinary.parquet')
+    deleted = _make_entry('ordinary.parquet', kind=1)
+    detection = ConflictDetection(True, None, None, None, None)
+    assert detection.check_row_id_existence([current], [deleted], None) is None
+    assert detection.check_row_id_existence([], [], None) is None
+    disabled = ConflictDetection(False, None, None, None, None)
+    stale = _make_entry('ordinary.parquet', kind=1, first_row_id=7)
+    assert disabled.check_row_id_existence([current], [stale], 100) is None
diff --git 
a/paimon-python/pypaimon/tests/write/row_id_execution_boundary_test.py 
b/paimon-python/pypaimon/tests/write/row_id_execution_boundary_test.py
index b0a4d9d3fa..bb17d33888 100644
--- a/paimon-python/pypaimon/tests/write/row_id_execution_boundary_test.py
+++ b/paimon-python/pypaimon/tests/write/row_id_execution_boundary_test.py
@@ -106,10 +106,8 @@ def 
test_builder_row_id_fallback_prepares_native_only_once():
 
     table = Mock()
     index = RowIdFileIndex(snapshot_id=7, first_row_ids=[])
-    with patch('pypaimon.write.update_dispatch._authorize_update'), \
-            
patch('pypaimon.write.update_dispatch.prepare_native_write_operation', 
return_value=None) as prepare, \
-            
patch('pypaimon.write.backend_dispatch.prepare_native_write_operation',
-                  side_effect=AssertionError('Native prepared twice')), \
+    with patch('pypaimon.write.update_validation._authorize_update'), \
+            
patch('pypaimon.write.backend_dispatch.prepare_native_write_operation', 
return_value=None) as prepare, \
             patch.object(RowIdFileIndex, 'from_table', return_value=index):
         update = BatchTableUpdate(table, 'user').new_update_by_row_id()
     prepare.assert_called_once_with('update_by_row_id', table, 'user', 
BATCH_COMMIT_IDENTIFIER)
diff --git a/paimon-python/pypaimon/tests/write/row_id_plan_test.py 
b/paimon-python/pypaimon/tests/write/row_id_plan_test.py
new file mode 100644
index 0000000000..f2c970974e
--- /dev/null
+++ b/paimon-python/pypaimon/tests/write/row_id_plan_test.py
@@ -0,0 +1,302 @@
+# 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.
+
+
+import pickle
+import sys
+from types import ModuleType, SimpleNamespace
+from unittest.mock import Mock, patch
+
+import pyarrow as pa
+import pytest
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.write.row_id_plan import capture_row_id_plan
+
+pytestmark = [pytest.mark.python_plan, pytest.mark.python_read,
+              pytest.mark.python_write, pytest.mark.python_commit]
+
+
[email protected]
+def table(tmp_path):
+    catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+    catalog.create_database('default', True)
+    catalog.create_table('default.t', Schema.from_pyarrow_schema(
+        pa.schema([('id', pa.int32()), ('v', pa.int32())]), options={
+            'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true',
+            'deletion-vectors.enabled': 'true'}), False)
+    return catalog.get_table('default.t')
+
+
+def _append(table, ids):
+    builder = table.new_batch_write_builder()
+    writer = builder.new_write()
+    writer.write_arrow(pa.table({'id': ids, 'v': [0] * len(ids)},
+                                schema=pa.schema([('id', pa.int32()), ('v', 
pa.int32())])))
+    builder.new_commit().commit(writer.prepare_commit())
+    writer.close()
+
+
+def 
test_captured_update_and_delete_keep_snapshot_without_manifest_rescan(table):
+    _append(table, [1, 2])
+    captured = pickle.loads(pickle.dumps(capture_row_id_plan(table)))
+    _append(table, [3])
+    assert captured.snapshot_id == 1
+    assert captured.table._read_snapshot.id == 1
+    assert [(r.from_, r.to) for r in captured.valid_row_id_ranges] == [(0, 1)]
+    assert captured.anchors[0].row_range.to == 1
+
+    from pypaimon.write.table_update_by_row_id import TableUpdateByRowId
+    from pypaimon.write.table_delete import TableDeleteByRowId
+    with patch('pypaimon.read.table_scan.TableScan.plan_all', 
side_effect=AssertionError('worker replanned')):
+        updater = TableUpdateByRowId(table, 'planned', 1, captured)
+        updates = updater.update_columns(pa.table({'_ROW_ID': [1], 'v': [9]}), 
['v'])
+        deletes = TableDeleteByRowId(table, captured).delete([0])
+    assert updates and deletes
+    assert all(message.check_from_snapshot == 1 for message in updates + 
deletes)
+    with pytest.raises(ValueError, match='valid range'):
+        TableUpdateByRowId(table, 'foreign', 2, captured).update_columns(
+            pa.table({'_ROW_ID': [2], 'v': [9]}), ['v'])
+
+
+def test_self_merge_tasks_transport_only_their_complete_captured_group(table):
+    from pypaimon.ray.data_evolution_merge_join import (
+        build_self_merge_update_plan, distributed_self_merge_update_apply)
+    from pypaimon.write.row_id_plan import CapturedRowIdPlan
+    from pypaimon.write.table_update_by_row_id import TableUpdateByRowId
+
+    _append(table, [1, 2])
+    _append(table, [3, 4])
+    messages = TableUpdateByRowId(table, 'partial', 1).update_columns(
+        pa.table({'_ROW_ID': [0], 'v': [9]}), ['v'])
+    table.new_batch_write_builder().new_commit().commit(messages)
+    plan = build_self_merge_update_plan(
+        table=table, clauses=[], target_field_names=table.field_names,
+        target_pa_schema=pa.schema([('id', pa.int32()), ('v', pa.int32())]),
+        update_cols=['v'], resolve_target_projection=lambda *args, **kwargs: 
['v'])
+    assert len(plan.file_groups) == 2
+    expected = {start: {file.file_name for file in split.files}
+                for start, _, split in plan.row_id_plan.groups}
+    assert max(len(names) for names in expected.values()) == 2
+    _append(table, [5])
+    submitted = []
+
+    def submit(context, captured, collect_row_ids):
+        assert not hasattr(context, 'row_id_plan'), 'shared context includes 
whole-table metadata'
+        assert isinstance(captured, CapturedRowIdPlan)
+        captured = pickle.loads(pickle.dumps(captured))
+        assert len(captured.groups) == len(captured.plan.splits()) == 1
+        assert captured.table._read_snapshot.id == captured.snapshot_id == 
plan.snapshot_id
+        start, _, split = captured.groups[0]
+        assert {file.file_name for file in split.files} == expected[start]
+        assert captured.plan.splits() == [split]
+        submitted.append(start)
+        return start
+
+    scheduler = SimpleNamespace(
+        put=lambda value: value, remote=lambda function: 
SimpleNamespace(remote=submit),
+        wait=lambda pending, **kwargs: (pending[:1], pending[1:]), get=lambda 
ref: ([], 0, []))
+    with patch.dict(sys.modules, {'ray': scheduler}):
+        assert distributed_self_merge_update_apply(plan, num_partitions=2) == 
([], 0, [])
+    assert submitted == plan.row_id_plan.first_row_ids
+
+
+def test_native_captured_plan_does_not_construct_python_index(table):
+    _append(table, [1, 2])
+    plan = table.new_read_builder().new_scan().plan_all()
+    split = plan.splits()[0]
+    native_plan = Mock()
+    native_plan.groups.return_value = [(0, 1, split)]
+    with 
patch('pypaimon.execution.native.write.row_id_plan.create_native_row_id_plan', 
return_value=native_plan), \
+            patch('pypaimon.execution.native.write.row_id_plan.python_groups',
+                  return_value=[(0, 1, split)]) as core_groups, \
+            
patch('pypaimon.execution.python.write.row_id_file_index.RowIdFileIndex.from_splits',
+                  side_effect=AssertionError('Python index loaded')):
+        captured = capture_row_id_plan(table, plan)
+    core_groups.assert_called_once_with(captured.table, native_plan)
+    assert captured.first_row_id_index[0][1] == split.files
+    assert captured.for_split(split).plan.splits() == [split]
+    assert pickle.loads(pickle.dumps(captured)).snapshot_id == plan.snapshot_id
+
+
+def test_planned_native_factory_applies_plan_before_creating_writer(table):
+    from pypaimon.execution.native.write.native_update import 
create_native_update_by_row_id
+    _append(table, [1])
+    captured = capture_row_id_plan(table)
+    core = Mock()
+    update = 
core.new_batch_write_builder.return_value.with_commit_user.return_value.new_update.return_value
+    wire = object()
+    with 
patch('pypaimon.execution.native.write.native_update._native_row_id_table', 
return_value=core), \
+            patch('pypaimon.execution.native.write.row_id_plan.native_plan', 
return_value=wire):
+        operation = create_native_update_by_row_id(table, 'user', (1 << 63) - 
1, captured)
+    update.with_plan.assert_called_once_with(wire)
+    update.new_update_by_row_id.assert_called_once_with()
+    assert operation.writer is update.new_update_by_row_id.return_value
+
+
+def test_planned_native_authorization_error_is_not_replayed(table):
+    from pypaimon.write.backend_dispatch import create_row_id_update_execution
+    _append(table, [1])
+    captured = capture_row_id_plan(table)
+    failure = PermissionError('worker grant denied')
+    with patch.object(table.options, 'native_write_enabled', 
return_value=True), \
+            
patch('pypaimon.execution.native.write.native_update.create_native_update_by_row_id',
+                  side_effect=failure), \
+            
patch('pypaimon.write.backend_dispatch.create_python_row_id_update',
+                  side_effect=AssertionError('Python replay')):
+        with pytest.raises(PermissionError) as raised:
+            create_row_id_update_execution(table, 'user', 1, captured)
+    assert raised.value is failure
+
+
[email protected]('operation', ['update', 'delete'])
+def test_foreign_captured_table_is_rejected_before_backend_preparation(table, 
operation):
+    from dataclasses import replace
+    from pypaimon.write.backend_dispatch import 
create_row_id_update_execution, create_row_id_delete_execution
+    _append(table, [1])
+    captured = capture_row_id_plan(table)
+    foreign = table.copy_without_time_travel({})
+    foreign.table_path = table.table_path + '-foreign'
+    captured = replace(captured, table=foreign)
+    with 
patch('pypaimon.execution.native.write.native_update.create_native_update_by_row_id',
+               side_effect=AssertionError('worker created')):
+        with pytest.raises(ValueError, match='different table'):
+            if operation == 'update':
+                create_row_id_update_execution(table, 'user', 1, captured)
+            else:
+                create_row_id_delete_execution(table, captured)
+
+
+def test_captured_split_requires_the_complete_group(table):
+    _append(table, [1, 2])
+    captured = capture_row_id_plan(table)
+    from dataclasses import replace
+    unrelated = replace(captured.plan.splits()[0].files[0], 
file_name='not-captured.parquet')
+    from pypaimon.read.split import DataSplit
+    split = DataSplit(files=[unrelated], 
partition=captured.plan.splits()[0].partition, bucket=0)
+    with pytest.raises(ValueError, match='complete captured'):
+        captured.for_split(split)
+
+
+def 
test_capture_normalizes_index_selection_before_core_mutation_routing(table):
+    from pypaimon.globalindex.indexed_split import IndexedSplit
+    from pypaimon.read.plan import Plan
+    from pypaimon.utils.range import Range
+
+    _append(table, [1, 2])
+    plan = table.new_read_builder().new_scan().plan_all()
+    original = plan.splits()[0]
+    indexed = Plan([IndexedSplit(original, [Range(1, 1)])], plan.snapshot_id)
+    native = object()
+
+    def prepare(table, routing):
+        assert routing.snapshot_id == plan.snapshot_id
+        assert routing.splits() == [original], 'Core mutation routing requires 
the complete physical group'
+        return native
+
+    with 
patch('pypaimon.execution.native.write.row_id_plan.create_native_row_id_plan', 
side_effect=prepare), \
+            patch('pypaimon.execution.native.write.row_id_plan.python_groups',
+                  return_value=[(0, 1, original)]) as core_groups:
+        captured = capture_row_id_plan(table, indexed)
+    core_groups.assert_called_once_with(captured.table, native)
+    assert captured.plan.splits() == [original]
+    # Normalization does not mutate the read plan used to refine the predicate.
+    assert indexed.splits()[0].row_ranges() == [Range(1, 1)]
+
+
[email protected]('native', [False, True])
+def test_capture_keeps_query_auth_rules_when_normalizing_and_grouping(table, 
native):
+    from pypaimon.catalog.table_query_auth import TableQueryAuthResult
+    from pypaimon.globalindex.indexed_split import IndexedSplit
+    from pypaimon.read.plan import Plan
+    from pypaimon.read.query_auth_split import QueryAuthSplit
+    from pypaimon.utils.range import Range
+
+    _append(table, [1, 2])
+    plan = table.new_read_builder().new_scan().plan_all()
+    original = plan.splits()[0]
+    grant = TableQueryAuthResult(['restricted-row-filter'], {})
+    indexed = Plan([QueryAuthSplit(IndexedSplit(original, [Range(1, 1)]), 
grant)], plan.snapshot_id)
+    with 
patch('pypaimon.execution.native.write.row_id_plan.create_native_row_id_plan',
+               return_value=object() if native else None) as prepare, \
+            patch('pypaimon.execution.native.write.row_id_plan.python_groups',
+                  return_value=[(0, 1, QueryAuthSplit(original, grant))]) as 
native_groups:
+        captured = capture_row_id_plan(table, indexed)
+    routed = prepare.call_args[0][1].splits()[0]
+    assert isinstance(routed, QueryAuthSplit)
+    assert routed.auth_result is grant
+    assert routed.split is original
+    assert native_groups.call_count == int(native)
+    complete = captured.plan.splits()[0]
+    assert isinstance(complete, QueryAuthSplit)
+    assert complete.auth_result is grant
+    assert complete.split is original
+    group = captured.groups[0][2]
+    assert isinstance(group, QueryAuthSplit)
+    assert group.auth_result is grant
+    # A same-name caller split cannot replace the captured grant or snapshot.
+    retained = captured.for_split(original).plan.splits()[0]
+    assert retained is group
+    assert retained.auth_result is grant
+
+
[email protected]('layout', ['external', 'dedicated'])
[email protected]('authorized', [False, True])
[email protected]('bucket_path', [None, '', 'memory:/foreign/bucket-0'])
+def 
test_native_mutation_transport_retains_canonical_bucket_and_external_layout(
+        table, layout, authorized, bucket_path):
+    from dataclasses import replace
+    from pypaimon.catalog.table_query_auth import TableQueryAuthResult
+    from pypaimon.execution.native.write.row_id_plan import native_plan
+    from pypaimon.read.plan import Plan
+    from pypaimon.read.query_auth_split import QueryAuthSplit
+    from pypaimon.read.split import DataSplit
+    from pypaimon.read.split_serializer import deserialize_split_v1
+    from pypaimon.table.source.deletion_file import DeletionFile
+
+    _append(table, [1, 2])
+    read_plan = table.new_read_builder().new_scan().plan_all()
+    source = read_plan.splits()[0]
+    files = list(source.files)
+    if layout == 'external':
+        path = str(table.table_path) + '-external/' + files[0].file_name
+        files[0] = replace(files[0], external_path=path, file_path=path)
+    else:
+        files.append(replace(files[0], file_name='payload.blob', 
write_cols=['payload'],
+                             file_path=str(table.table_path) + 
'-blobs/payload.blob'))
+    deletion = DeletionFile(str(table.table_path) + '/index/dv', 4, 12, 1)
+    split = DataSplit(files, source.partition, source.bucket, 
data_deletion_files=[deletion] * len(files),
+                      snapshot_id=read_plan.snapshot_id, 
bucket_path=bucket_path)
+    grant = TableQueryAuthResult(['restricted-row-filter'], {})
+    selected = QueryAuthSplit(split, grant) if authorized else split
+    module = ModuleType('pypaimon_rust.datafusion')
+    module.Plan = lambda snapshot_id, splits: 
SimpleNamespace(snapshot_id=snapshot_id, splits=lambda: splits)
+    module.Split = SimpleNamespace(deserialize=lambda value: 
deserialize_split_v1(
+        value, table.partition_keys_fields, table.trimmed_primary_keys_fields))
+    with patch.dict(sys.modules, {'pypaimon_rust.datafusion': module}):
+        transported = native_plan(table, Plan([selected], 
read_plan.snapshot_id)).splits()[0]
+    if authorized:
+        assert isinstance(transported, QueryAuthSplit)
+        assert transported.auth_result.filter == grant.filter
+        transported = transported.split
+    expected = bucket_path or table.path_factory().bucket_path(
+        tuple(source.partition.values), source.bucket, 
canonical_partition=True)
+    assert transported.bucket_path == expected
+    assert transported.snapshot_id == read_plan.snapshot_id
+    assert transported.data_deletion_files == [deletion] * len(files)
+    assert [file.file_path for file in transported.files] == [file.file_path 
for file in files]
+    assert split.bucket_path == bucket_path, 'transport must not mutate the 
caller\'s read metadata'
diff --git a/paimon-python/pypaimon/write/backend_dispatch.py 
b/paimon-python/pypaimon/write/backend_dispatch.py
index f30ccd7c1a..4460950cf2 100644
--- a/paimon-python/pypaimon/write/backend_dispatch.py
+++ b/paimon-python/pypaimon/write/backend_dispatch.py
@@ -335,6 +335,14 @@ def create_row_id_update_execution(table, commit_user, 
commit_identifier, precom
     """Select one incremental updater before capturing target files."""
     from pypaimon.write.update_validation import _authorize_update
     _authorize_update(table)
+    from pypaimon.write.row_id_plan import CapturedRowIdPlan
+    if isinstance(precomputed, CapturedRowIdPlan):
+        _validate_captured_row_id_plan(table, precomputed)
+        if table.options.native_write_enabled():
+            from pypaimon.execution.native.write.native_update import 
create_native_update_by_row_id
+            native = create_native_update_by_row_id(table, commit_user, 
commit_identifier, precomputed)
+            if native is not None:
+                return native
     if precomputed is None:
         native = prepare_native_write_operation('update_by_row_id', table, 
commit_user, commit_identifier)
         if native is not None:
@@ -343,9 +351,17 @@ def create_row_id_update_execution(table, commit_user, 
commit_identifier, precom
 
 
 def create_row_id_delete_execution(table, precomputed=None):
-    """Keep precomputed broadcast metadata within its selected Python 
executor."""
+    """Select one deleter, reusing captured metadata when supplied."""
     from pypaimon.write.update_validation import _authorize_update
     _authorize_update(table)
+    from pypaimon.write.row_id_plan import CapturedRowIdPlan
+    if isinstance(precomputed, CapturedRowIdPlan):
+        _validate_captured_row_id_plan(table, precomputed)
+        if table.options.native_write_enabled():
+            from pypaimon.execution.native.write.native_update import 
create_native_row_id_delete
+            native = create_native_row_id_delete(table, precomputed)
+            if native is not None:
+                return native
     if precomputed is None and table.options.native_write_enabled():
         def prepare():
             from pypaimon.execution.native.write.native_update import 
create_native_row_id_delete
@@ -356,6 +372,19 @@ def create_row_id_delete_execution(table, 
precomputed=None):
     return create_python_row_id_delete(table, precomputed)
 
 
+def _validate_captured_row_id_plan(table, captured):
+    if (table.table_path != captured.table.table_path or 
table.current_branch() != captured.table.current_branch()
+            or table.table_schema.id != captured.table.table_schema.id
+            or table.fields != captured.table.fields):
+        raise ValueError('Captured row-ID plan belongs to a different table, 
branch or schema')
+
+
+def native_row_id_update_selected(worker):
+    from pypaimon.execution.native.write.native_update import 
NativeTableUpdateByRowId
+    execution = vars(worker).get('_execution', worker)
+    return isinstance(execution, NativeTableUpdateByRowId)
+
+
 def python_commit(owner):
     """Create Python commit resources only after that backend is selected."""
     if owner._python_commit is None:
diff --git a/paimon-python/pypaimon/write/row_id_conflict.py 
b/paimon-python/pypaimon/write/row_id_conflict.py
new file mode 100644
index 0000000000..61cda36f30
--- /dev/null
+++ b/paimon-python/pypaimon/write/row_id_conflict.py
@@ -0,0 +1,132 @@
+# 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.
+
+"""Structured row-ID conflicts shared by both execution backends."""
+
+
+class RowIdExistenceConflict(RuntimeError):
+    """A staged row-id file no longer matches the current base-file layout."""
+
+    def __init__(self, entry):
+        self.entry = entry
+        self._initialize(entry.file.file_name, entry.file.first_row_id,
+                         entry.file.row_count, entry.bucket)
+
+    @classmethod
+    def _from_fields(cls, file_name, first_row_id, row_count, bucket,
+                     current_first_row_id=None, current_row_count=None):
+        error = cls.__new__(cls)
+        error.entry = None
+        error._initialize(file_name, first_row_id, row_count, bucket,
+                          current_first_row_id, current_row_count)
+        return error
+
+    @classmethod
+    def _from_delete(cls, deleted, current):
+        error = cls._from_fields(
+            deleted.file.file_name, deleted.file.first_row_id,
+            deleted.file.row_count, deleted.bucket,
+            current.file.first_row_id, current.file.row_count)
+        error.entry = deleted
+        return error
+
+    def _initialize(self, file_name, first_row_id, row_count, bucket,
+                    current_first_row_id=None, current_row_count=None):
+        self.file_name = file_name
+        self.first_row_id = first_row_id
+        self.row_count = row_count
+        self.bucket = bucket
+        self.current_first_row_id = current_first_row_id
+        self.current_row_count = current_row_count
+        first_id_text = 'null' if first_row_id is None else first_row_id
+        if current_row_count is None:
+            message = (
+                "Row ID existence conflict: file '{}' references "
+                "firstRowId={}, rowCount={} in bucket {}, "
+                "but no matching file exists in the current snapshot. "
+                "The referenced file may have been rewritten by a "
+                "concurrent compaction or removed by an overwrite.".format(
+                    file_name, first_id_text, row_count, bucket))
+        else:
+            message = (
+                "Row ID existence conflict: DELETE for file '{}' references "
+                "firstRowId={}, rowCount={} in bucket {}, but the current file 
"
+                "has firstRowId={}, rowCount={}. The file may have been "
+                "reassigned by a concurrent commit.".format(
+                    file_name, first_id_text, row_count, bucket,
+                    'null' if current_first_row_id is None else 
current_first_row_id, current_row_count))
+        super().__init__(message)
+
+    def __reduce__(self):
+        # Ray transports errors between worker processes. RuntimeError's
+        # default reduction would pass the formatted message as a file entry.
+        return (_restore_row_id_conflict, (
+            self.entry, self.file_name, self.first_row_id, self.row_count,
+            self.bucket, self.current_first_row_id, self.current_row_count))
+
+
+def _restore_row_id_conflict(entry, file_name, first_row_id, row_count, bucket,
+                             current_first_row_id, current_row_count):
+    error = RowIdExistenceConflict._from_fields(
+        file_name, first_row_id, row_count, bucket,
+        current_first_row_id, current_row_count)
+    error.entry = entry
+    return error
+
+
+def check_row_id_conflicts(table, messages, latest_snapshot_id, 
check_from_snapshot):
+    """Check logical writes before a rebase, without opening a publisher."""
+    from pypaimon.write.update_validation import _authorize_update
+
+    if (any(not isinstance(value, int) or isinstance(value, bool)
+            or value <= 0 or value > (1 << 63) - 1
+            for value in (latest_snapshot_id, check_from_snapshot))
+            or check_from_snapshot > latest_snapshot_id):
+        raise ValueError('Row-ID conflict checks require 0 < check-from 
snapshot <= latest snapshot')
+    baselines = [message.check_from_snapshot for message in messages if 
message.check_from_snapshot is not None]
+    invalid_baseline = any(
+        not isinstance(value, int) or isinstance(value, bool) or value < 0 or 
value > (1 << 63) - 1
+        for value in baselines)
+    missing_baseline = any(
+        message.check_from_snapshot is None and any(
+            file.first_row_id is not None for file in message.new_files + 
message.deleted_files)
+        for message in messages)
+    if (not baselines or invalid_baseline or min(baselines) != 
check_from_snapshot or missing_baseline):
+        raise ValueError('Row-ID conflict check baseline must match the commit 
messages')
+    _authorize_update(table)
+    if table.options.native_commit_enabled():
+        from pypaimon.execution.native.write.native_commit import 
prepare_native_row_id_conflict_check
+        # Unsupported configurations return None explicitly. Permission or
+        # transport failures must never restart the check in another backend.
+        native = prepare_native_row_id_conflict_check(table, messages)
+        if native is not None:
+            commit, native_messages = native
+            failure = None
+            try:
+                commit.check_row_id_conflicts(native_messages, 
latest_snapshot_id, check_from_snapshot)
+            except BaseException as error:
+                failure = error
+                raise
+            finally:
+                try:
+                    commit.close()
+                except BaseException:
+                    if failure is None:
+                        raise
+            return
+    from pypaimon.execution.python.write.commit.row_id_conflict import 
check_row_id_conflicts as check
+    check(table, messages, latest_snapshot_id, check_from_snapshot)
diff --git a/paimon-python/pypaimon/write/row_id_plan.py 
b/paimon-python/pypaimon/write/row_id_plan.py
new file mode 100644
index 0000000000..3b859b54fe
--- /dev/null
+++ b/paimon-python/pypaimon/write/row_id_plan.py
@@ -0,0 +1,112 @@
+# 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.
+
+
+"""Serializable routing metadata for a captured row-ID mutation plan.
+
+Ray transports Python metadata; file grouping belongs to the selected backend.
+Workers reuse complete logical groups and the original snapshot.
+"""
+
+from dataclasses import dataclass, replace
+
+from pypaimon.read.plan import Plan
+from pypaimon.utils.data_evolution_utils import retrieve_anchor_file
+from pypaimon.utils.range import Range
+
+
+@dataclass(frozen=True)
+class _RowIdAnchor:
+    row_range: Range
+    partition: object
+    bucket: int
+    anchor_file: object
+
+
+@dataclass(frozen=True)
+class CapturedRowIdPlan:
+    table: object
+    plan: Plan
+    groups: list
+
+    @property
+    def snapshot_id(self):
+        return self.plan.snapshot_id if self.plan.snapshot_id is not None else 
-1
+
+    @property
+    def first_row_ids(self):
+        return [start for start, _, _ in self.groups]
+
+    @property
+    def first_row_id_index(self):
+        return {start: (split, split.files) for start, _, split in self.groups}
+
+    @property
+    def valid_row_id_ranges(self):
+        return Range.sort_and_merge_overlap(
+            [Range(start, end) for start, end, _ in self.groups], True, True)
+
+    @property
+    def anchors(self):
+        return [_RowIdAnchor(anchor.row_id_range(), split.partition, 
split.bucket, anchor)
+                for _, _, split in self.groups
+                for anchor in [retrieve_anchor_file(split.files)]]
+
+    def for_split(self, split):
+        """Keep one complete logical group and its captured snapshot 
metadata."""
+        names = {file.file_name for file in split.files}
+        groups = [group for group in self.groups
+                  if group[2].bucket == split.bucket and group[2].partition == 
split.partition
+                  and {file.file_name for file in group[2].files} == names]
+        if len(groups) != 1:
+            raise ValueError('Split is not a complete captured row-ID group')
+        return CapturedRowIdPlan(self.table, Plan([groups[0][2]], 
self.plan.snapshot_id), groups)
+
+
+def _mutation_routing_split(split):
+    from pypaimon.globalindex.indexed_split import IndexedSplit
+    from pypaimon.read.query_auth_split import QueryAuthSplit
+
+    if isinstance(split, QueryAuthSplit):
+        complete = _mutation_routing_split(split.split)
+        return split if complete is split.split else QueryAuthSplit(complete, 
split.auth_result)
+    if isinstance(split, IndexedSplit):
+        # A trusted scan's index selection limits reads, not the physical file
+        # group rewritten by an update. Keep every file and deletion vector;
+        # the caller's read predicate still selects the rows to mutate.
+        return _mutation_routing_split(split.data_split())
+    return split
+
+
+def capture_row_id_plan(table, plan=None):
+    """Resolve one read view before planning, then retain it across Ray 
tasks."""
+    from pypaimon.snapshot.time_travel_util import TimeTravelUtil
+    snapshot = TimeTravelUtil.resolve_snapshot(table)
+    if plan is not None and (snapshot.id if snapshot is not None else None) != 
plan.snapshot_id:
+        raise ValueError('Row-ID plan does not belong to the captured 
snapshot')
+    table = table._copy_with_snapshot(snapshot)
+    if plan is None:
+        plan = table.new_read_builder().new_scan().plan_all()
+    plan = replace(plan, _splits=[_mutation_routing_split(split) for split in 
plan.splits()])
+    from pypaimon.execution.native.write.row_id_plan import 
create_native_row_id_plan, python_groups
+    native = create_native_row_id_plan(table, plan)
+    if native is not None:
+        groups = python_groups(table, native)
+    else:
+        from pypaimon.execution.python.write.row_id_plan import row_id_groups
+        groups = row_id_groups(plan)
+    return CapturedRowIdPlan(table, plan, groups)
diff --git a/paimon-python/pypaimon/write/update_dispatch.py 
b/paimon-python/pypaimon/write/update_dispatch.py
index 0b170d9855..383192225c 100644
--- a/paimon-python/pypaimon/write/update_dispatch.py
+++ b/paimon-python/pypaimon/write/update_dispatch.py
@@ -42,14 +42,10 @@ class TableUpdateOperations:
 
     def _new_row_id_updater(self, commit_identifier: int, 
_precomputed_files_info=None) -> TableUpdateByRowId:
         context = self._context
-        _authorize_update(context.table)
-        if _precomputed_files_info is None:
-            native = prepare_native_write_operation(
-                'update_by_row_id', context.table, context.commit_user, 
commit_identifier)
-            if native is not None:
-                return native
-        return TableUpdateByRowId._from_python(context.table, 
context.commit_user, commit_identifier,
-                                               
_precomputed_files_info=_precomputed_files_info)
+        # The public constructor and builders share one selection boundary,
+        # including complete plans transported to distributed workers.
+        return TableUpdateByRowId(context.table, context.commit_user, 
commit_identifier,
+                                  
_precomputed_files_info=_precomputed_files_info)
 
     def new_shard_updator(self, shard_num: int, total_shard_count: int):
         context = self._context

Reply via email to