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 a6ffec2b1f [python][ray] Rebase self-merge updates after compaction 
(#9339)
a6ffec2b1f is described below

commit a6ffec2b1f169cd0594a97a4bb80dc154731275f
Author: XiaoHongbo <[email protected]>
AuthorDate: Sun Aug 23 09:19:50 2026 +0800

    [python][ray] Rebase self-merge updates after compaction (#9339)
---
 .../pypaimon/ray/data_evolution_merge_into.py      |  58 +-
 .../pypaimon/ray/row_id_conflict_rewriter.py       | 462 ++++++++++++++
 .../pypaimon/tests/file_store_commit_test.py       |  86 ++-
 .../tests/overwrite_commit_conflict_test.py        |  23 +-
 .../tests/ray_data_evolution_merge_into_test.py    | 707 +++++++++++++++++++++
 .../tests/ray_row_id_conflict_rewriter_test.py     | 289 +++++++++
 .../pypaimon/tests/reader_append_only_test.py      |   4 +-
 .../tests/write/conflict_detection_test.py         |  14 +
 .../pypaimon/tests/write/table_write_test.py       |  20 +-
 .../pypaimon/write/commit/conflict_detection.py    |   4 +-
 paimon-python/pypaimon/write/file_store_commit.py  |  63 +-
 11 files changed, 1670 insertions(+), 60 deletions(-)

diff --git a/paimon-python/pypaimon/ray/data_evolution_merge_into.py 
b/paimon-python/pypaimon/ray/data_evolution_merge_into.py
index 6124ebefd5..e0544f1890 100644
--- a/paimon-python/pypaimon/ray/data_evolution_merge_into.py
+++ b/paimon-python/pypaimon/ray/data_evolution_merge_into.py
@@ -436,6 +436,8 @@ def _execute_and_commit(
     num_deleted = 0
     delete_row_ids = []
     num_inserted = 0
+    insert_msgs: list = []
+    self_merge_update = isinstance(update_ds, _SelfMergeUpdatePlan)
 
     try:
         if update_ds is not None:
@@ -494,20 +496,32 @@ def _execute_and_commit(
 
         all_msgs: list = list(commit_messages)
         if all_msgs:
-            table_commit = None
-            try:
-                table_commit = table.new_batch_write_builder().new_commit()
-                table_commit.commit(all_msgs)
-            finally:
-                if table_commit is not None:
-                    try:
-                        table_commit.close()
-                    except Exception as close_error:
-                        logger.warning(
-                            "Failed to close merge_into commit: %s",
-                            close_error,
-                            exc_info=close_error,
-                        )
+            if self_merge_update and update_msgs:
+                from pypaimon.ray.row_id_conflict_rewriter import (
+                    commit_self_merge_with_compaction_retry,
+                )
+                commit_self_merge_with_compaction_retry(
+                    table,
+                    update_msgs,
+                    delete_msgs + insert_msgs,
+                    num_partitions=num_partitions,
+                    ray_remote_args=ray_remote_args,
+                )
+            else:
+                table_commit = None
+                try:
+                    table_commit = table.new_batch_write_builder().new_commit()
+                    table_commit.commit(all_msgs)
+                finally:
+                    if table_commit is not None:
+                        try:
+                            table_commit.close()
+                        except Exception as close_error:
+                            logger.warning(
+                                "Failed to close merge_into commit: %s",
+                                close_error,
+                                exc_info=close_error,
+                            )
     except Exception as e:
         _reraise_inner(e)
 
@@ -555,14 +569,22 @@ def _require_ray_join() -> None:
 
 
 def _reraise_inner(err: BaseException) -> None:
-    """Unwrap Ray's RayTaskError so callers see the worker-side exception."""
+    """Unwrap only RayTaskError layers and preserve ordinary exception 
chains."""
+    try:
+        from ray.exceptions import RayTaskError
+    except ImportError:
+        raise err
+
     inner = err
-    cause = getattr(err, "cause", None) or getattr(err, "__cause__", None)
-    while cause is not None:
+    while isinstance(inner, RayTaskError):
+        cause = getattr(inner, "cause", None)
+        if cause is None or cause is inner:
+            break
         inner = cause
-        cause = getattr(inner, "cause", None) or getattr(inner, "__cause__", 
None)
     if inner is err:
         raise err
+    if getattr(inner, "__cause__", None) is not None:
+        raise inner
     raise inner from err
 
 
diff --git a/paimon-python/pypaimon/ray/row_id_conflict_rewriter.py 
b/paimon-python/pypaimon/ray/row_id_conflict_rewriter.py
new file mode 100644
index 0000000000..14cfb0ac22
--- /dev/null
+++ b/paimon-python/pypaimon/ray/row_id_conflict_rewriter.py
@@ -0,0 +1,462 @@
+################################################################################
+#  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.
+################################################################################
+
+"""Rebase Ray self-merge updates after concurrent compaction."""
+
+import logging
+import random
+import time
+from dataclasses import dataclass, replace
+from typing import Dict, List, Optional, Sequence, Tuple
+
+from pypaimon.common.options.core_options import CoreOptions
+from pypaimon.manifest.schema.data_file_meta import DataFileMeta
+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.write.commit.conflict_detection import RowIdExistenceConflict
+from pypaimon.write.commit_message import CommitMessage
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass(frozen=True)
+class _StagedFile:
+    message_index: int
+    message: CommitMessage
+    file: DataFileMeta
+
+
+@dataclass(frozen=True)
+class _RewriteResult:
+    update_messages: List[CommitMessage]
+    rewritten_file_count: int
+
+
+def commit_self_merge_with_compaction_retry(
+        table,
+        update_messages: Sequence[CommitMessage],
+        other_messages: Sequence[CommitMessage],
+        *,
+        num_partitions: int,
+        ray_remote_args=None,
+) -> None:
+    """Commit self-merge messages, rebasing stale updates when compaction 
wins."""
+    current_updates = list(update_messages)
+    other_messages = list(other_messages)
+    retry_count = 0
+    start_millis = int(time.time() * 1000)
+
+    # Ray performs the rebase below without the local driver's size limit.
+    commit_table = table.copy_without_time_travel({
+        CoreOptions.DATA_EVOLUTION_ROW_ID_CONFLICT_REWRITE_MAX_SIZE.key():
+            "0 B",
+    })
+    base_snapshot_ids = _base_snapshot_ids(current_updates)
+    latest_snapshot = table.snapshot_manager().get_latest_snapshot()
+    if (
+            len(base_snapshot_ids) == 1
+            and latest_snapshot is not None
+            and latest_snapshot.id != next(iter(base_snapshot_ids))
+    ):
+        result = _rewrite_updates(
+            table,
+            current_updates,
+            latest_snapshot,
+            num_partitions=num_partitions,
+            ray_remote_args=ray_remote_args,
+        )
+        if result is not None:
+            current_updates = result.update_messages
+            logger.info(
+                "Rewrote %d stale self-merge file(s) against snapshot %d "
+                "before committing to table %s.",
+                result.rewritten_file_count,
+                latest_snapshot.id,
+                table.identifier,
+            )
+
+    # Match Spark's PaimonSparkWriter: every outer rebase attempt creates a
+    # fresh commit from one write builder, preserving the commit user.
+    write_builder = commit_table.new_batch_write_builder()
+    while True:
+        commit = None
+        conflict = None
+        try:
+            commit = write_builder.new_commit()
+            commit.commit(current_updates + other_messages)
+        except Exception as error:
+            conflict = _find_row_id_conflict(error)
+            if conflict is None:
+                raise
+        finally:
+            if commit is not None:
+                try:
+                    commit.close()
+                except Exception as close_error:
+                    logger.warning(
+                        "Failed to close self-merge commit: %s",
+                        close_error,
+                        exc_info=close_error,
+                    )
+
+        if conflict is None:
+            return
+
+        elapsed = int(time.time() * 1000) - start_millis
+        if (
+                elapsed > table.options.commit_timeout()
+                or retry_count >= table.options.commit_max_retries()
+        ):
+            raise conflict
+
+        latest_snapshot = table.snapshot_manager().get_latest_snapshot()
+        if latest_snapshot is None:
+            raise conflict
+
+        try:
+            result = _rewrite_updates(
+                table,
+                current_updates,
+                latest_snapshot,
+                num_partitions=num_partitions,
+                ray_remote_args=ray_remote_args,
+            )
+        except Exception as rewrite_error:
+            raise RuntimeError(
+                "{} {}".format(conflict, rewrite_error)
+            ) from conflict
+        if result is None:
+            raise conflict
+
+        current_updates = result.update_messages
+        elapsed = int(time.time() * 1000) - start_millis
+        if elapsed > table.options.commit_timeout():
+            raise conflict
+
+        logger.info(
+            "Rewrote %d stale self-merge file(s) against snapshot %d "
+            "before retrying commit to table %s.",
+            result.rewritten_file_count,
+            latest_snapshot.id,
+            table.identifier,
+        )
+        _retry_wait(table, retry_count)
+        retry_count += 1
+
+
+def _rewrite_updates(
+        table,
+        update_messages: List[CommitMessage],
+        latest_snapshot,
+        *,
+        num_partitions: int,
+        ray_remote_args=None,
+) -> Optional[_RewriteResult]:
+    if table.options.deletion_vectors_enabled(False):
+        return None
+    if latest_snapshot.next_row_id is None:
+        return None
+    if any(
+            message.deleted_files or message.changelog_files
+            for message in update_messages
+    ):
+        return None
+
+    base_snapshot_ids = _base_snapshot_ids(update_messages)
+    if len(base_snapshot_ids) != 1:
+        return None
+    base_snapshot = table.snapshot_manager().get_snapshot_by_id(
+        next(iter(base_snapshot_ids))
+    )
+    if (
+            base_snapshot is None
+            or base_snapshot.schema_id != latest_snapshot.schema_id
+    ):
+        return None
+
+    # The failed commit already checked for logical conflicts up to the
+    # snapshot it observed. Repeat that check against the snapshot selected
+    # for this rewrite to close the race between those two reads of latest.
+    _validate_no_logical_conflict(
+        table,
+        update_messages,
+        latest_snapshot,
+        min(base_snapshot_ids),
+    )
+
+    scan_table = table.copy_without_time_travel({
+        CoreOptions.SCAN_SNAPSHOT_ID.key(): str(latest_snapshot.id),
+    })
+    scan_plan = scan_table.new_read_builder().new_scan().plan_for_write()
+    if scan_plan.snapshot_id != latest_snapshot.id:
+        return None
+    current_splits = list(scan_plan.splits())
+    current_files = [
+        (split, file)
+        for split in current_splits
+        for file in split.files
+        if _is_normal_row_id_file(file)
+    ]
+    current_exact_ranges = {
+        _range_key(tuple(split.partition.values), split.bucket, file)
+        for split, file in current_files
+    }
+
+    staged = [
+        _StagedFile(index, message, file)
+        for index, message in enumerate(update_messages)
+        for file in message.new_files
+    ]
+    if any(
+            _is_dedicated_file(item.file)
+            and item.file.first_row_id is not None
+            and item.file.first_row_id < latest_snapshot.next_row_id
+            for item in staged
+    ):
+        return None
+
+    candidates = [
+        item for item in staged
+        if _is_rewrite_candidate(
+            item,
+            current_exact_ranges,
+            latest_snapshot.next_row_id,
+        )
+    ]
+    if not candidates:
+        return None
+    if not _ranges_are_still_covered(current_files, candidates):
+        return None
+
+    candidate_ids = {id(candidate.file) for candidate in candidates}
+
+    remaining_messages = []
+    for message in update_messages:
+        kept_files = [
+            file for file in message.new_files
+            if id(file) not in candidate_ids
+        ]
+        remaining = replace(
+            message,
+            new_files=kept_files,
+            check_from_snapshot=latest_snapshot.id,
+        )
+        if not remaining.is_empty():
+            remaining_messages.append(remaining)
+
+    rewritten_messages = []
+    groups: Dict[Tuple[str, ...], List[_StagedFile]] = {}
+    for candidate in candidates:
+        groups.setdefault(tuple(candidate.file.write_cols), []).append(
+            candidate
+        )
+    for columns, files in groups.items():
+        messages, rewritten_rows, _ = _rewrite_group(
+            table,
+            list(columns),
+            files,
+            latest_snapshot.id,
+            num_partitions=num_partitions,
+            ray_remote_args=ray_remote_args,
+        )
+        expected_rows = sum(item.file.row_count for item in files)
+        if rewritten_rows != expected_rows:
+            raise RuntimeError(
+                "Distributed row-id conflict rewrite read {} rows from "
+                "staged files, expected {}.".format(
+                    rewritten_rows,
+                    expected_rows,
+                )
+            )
+        rewritten_messages.extend(messages)
+
+    return _RewriteResult(
+        update_messages=remaining_messages + rewritten_messages,
+        rewritten_file_count=len(candidates),
+    )
+
+
+def _base_snapshot_ids(update_messages: Sequence[CommitMessage]):
+    return {
+        message.check_from_snapshot
+        for message in update_messages
+        if message.check_from_snapshot is not None
+        and message.check_from_snapshot >= 0
+    }
+
+
+def _rewrite_group(
+        table,
+        columns: List[str],
+        candidates: List[_StagedFile],
+        snapshot_id: int,
+        *,
+        num_partitions: int,
+        ray_remote_args=None,
+):
+    import ray
+
+    from pypaimon.ray.data_evolution_merge_join import (
+        distributed_update_apply,
+    )
+    from pypaimon.read.datasource.ray_datasource import RayDatasource
+    from pypaimon.read.datasource.split_provider import (
+        PreResolvedSplitProvider,
+    )
+
+    read_type = [table.field_dict[name] for name in columns]
+    read_type.append(SpecialFields.ROW_ID)
+    splits = [
+        DataSplit(
+            files=[candidate.file],
+            partition=GenericRow(
+                list(candidate.message.partition),
+                table.partition_keys_fields,
+            ),
+            bucket=candidate.message.bucket,
+            raw_convertible=True,
+        )
+        for candidate in candidates
+    ]
+    provider = PreResolvedSplitProvider(
+        table,
+        splits,
+        read_type,
+    )
+    parallelism = max(1, min(num_partitions, len(splits)))
+    staged_updates = ray.data.read_datasource(
+        RayDatasource(provider),
+        ray_remote_args=ray_remote_args,
+        concurrency=parallelism,
+        override_num_blocks=parallelism,
+    )
+    return distributed_update_apply(
+        staged_updates,
+        table,
+        columns,
+        num_partitions=num_partitions,
+        ray_remote_args=ray_remote_args,
+        base_snapshot_id=snapshot_id,
+    )
+
+
+def _validate_no_logical_conflict(
+        table,
+        update_messages: List[CommitMessage],
+        latest_snapshot,
+        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()
+
+
+def _find_row_id_conflict(error) -> Optional[RowIdExistenceConflict]:
+    seen = set()
+    current = error
+    while current is not None and id(current) not in seen:
+        seen.add(id(current))
+        if isinstance(current, RowIdExistenceConflict):
+            return current
+        current = (
+            getattr(current, "cause", None)
+            or getattr(current, "__cause__", None)
+        )
+    return None
+
+
+def _is_rewrite_candidate(
+        item: _StagedFile,
+        current_exact_ranges,
+        next_row_id: int,
+) -> bool:
+    file = item.file
+    return (
+        _is_normal_row_id_file(file)
+        and file.first_row_id < next_row_id
+        and bool(file.write_cols)
+        and not any(
+            SpecialFields.is_system_field(name)
+            for name in file.write_cols
+        )
+        and _range_key(item.message.partition, item.message.bucket, file)
+        not in current_exact_ranges
+    )
+
+
+def _ranges_are_still_covered(current_files, candidates) -> bool:
+    current_ranges = {}
+    for split, file in current_files:
+        key = (tuple(split.partition.values), split.bucket)
+        current_ranges.setdefault(key, []).append(file.row_id_range())
+    current_ranges = {
+        key: Range.sort_and_merge_overlap(ranges, True, True)
+        for key, ranges in current_ranges.items()
+    }
+    for candidate in candidates:
+        key = (tuple(candidate.message.partition), candidate.message.bucket)
+        if candidate.file.row_id_range().exclude(
+                current_ranges.get(key, [])):
+            return False
+    return True
+
+
+def _range_key(partition, bucket, file):
+    return (
+        tuple(partition),
+        bucket,
+        file.first_row_id,
+        file.row_count,
+    )
+
+
+def _is_normal_row_id_file(file) -> bool:
+    return file.first_row_id is not None and not _is_dedicated_file(file)
+
+
+def _is_dedicated_file(file) -> bool:
+    return (
+        DataFileMeta.is_blob_file(file.file_name)
+        or DataFileMeta.is_vector_file(file.file_name)
+    )
+
+
+def _retry_wait(table, retry_count: int) -> None:
+    wait_millis = min(
+        table.options.commit_min_retry_wait() * (2 ** retry_count),
+        table.options.commit_max_retry_wait(),
+    )
+    jitter = random.randint(0, max(1, int(wait_millis * 0.2)))
+    time.sleep((wait_millis + jitter) / 1000.0)
diff --git a/paimon-python/pypaimon/tests/file_store_commit_test.py 
b/paimon-python/pypaimon/tests/file_store_commit_test.py
index 3576d659cb..df1992cc71 100644
--- a/paimon-python/pypaimon/tests/file_store_commit_test.py
+++ b/paimon-python/pypaimon/tests/file_store_commit_test.py
@@ -32,9 +32,10 @@ from pypaimon.table.row.generic_row import GenericRow, 
GenericRowSerializer
 from pypaimon.write.commit.row_id_conflict_rewriter import RowIdRewriteResult
 from pypaimon.write.commit_message import CommitMessage
 from pypaimon.write.file_store_commit import (
+    CommitFailRetryResult,
     FileStoreCommit,
     ManifestMergeResult,
-    RetryResult,
+    RollbackRetryResult,
     RewriteResult,
     _abort_commit_messages,
     _try_replace_manifest_files,
@@ -355,6 +356,77 @@ class TestFileStoreCommit(unittest.TestCase):
              for manifest in result.merge_after_manifests],
         )
 
+    def test_conflict_rollback_retry_skips_history_and_rescans_base(
+            self, mock_manifest_list_manager, mock_manifest_file_manager):
+        file_store_commit = self._create_file_store_commit()
+        conflict = RuntimeError("conflicting compaction")
+        read_all = Mock(return_value=[])
+        
file_store_commit.commit_scanner.read_all_entries_from_changed_partitions = 
read_all
+        file_store_commit.conflict_detection.check_conflicts = Mock(
+            return_value=conflict
+        )
+        file_store_commit.rollback = Mock()
+        file_store_commit.rollback.try_to_rollback.return_value = True
+
+        result = file_store_commit._try_commit_once(
+            retry_result=None,
+            commit_kind="APPEND",
+            commit_entries=[Mock()],
+            changelog_entries=[],
+            commit_identifier=11,
+            latest_snapshot=Mock(id=3),
+            detect_conflicts=True,
+            allow_rollback=True,
+        )
+
+        self.assertIsInstance(result, RollbackRetryResult)
+        self.assertIs(result.exception, conflict)
+
+        file_store_commit.snapshot_manager.get_snapshot_by_id.side_effect = (
+            AssertionError("rollback retry must not scan snapshot history")
+        )
+        file_store_commit.commit_scanner.read_incremental_changes = Mock(
+            side_effect=AssertionError(
+                "rollback retry must not reuse the previous conflict base"
+            )
+        )
+        read_all.reset_mock()
+        file_store_commit.conflict_detection.check_conflicts.return_value = (
+            RuntimeError("conflict after rollback retry")
+        )
+
+        with self.assertRaisesRegex(
+                RuntimeError, "conflict after rollback retry"):
+            file_store_commit._try_commit_once(
+                retry_result=result,
+                commit_kind="APPEND",
+                commit_entries=[Mock()],
+                changelog_entries=[],
+                commit_identifier=11,
+                latest_snapshot=Mock(id=2),
+                detect_conflicts=True,
+            )
+
+        
file_store_commit.snapshot_manager.get_snapshot_by_id.assert_not_called()
+        
file_store_commit.commit_scanner.read_incremental_changes.assert_not_called()
+        read_all.assert_called_once()
+
+    def test_commit_fail_retry_missing_snapshot_still_fails_closed(
+            self, mock_manifest_list_manager, mock_manifest_file_manager):
+        file_store_commit = self._create_file_store_commit()
+        file_store_commit.snapshot_manager.get_snapshot_by_id.return_value = 
None
+
+        with self.assertRaisesRegex(
+                RuntimeError, "snapshot 1 cannot be found"):
+            file_store_commit._is_duplicate_commit(
+                CommitFailRetryResult(None),
+                Mock(id=3),
+                11,
+                "APPEND",
+            )
+
+        
file_store_commit.snapshot_manager.get_snapshot_by_id.assert_called_once_with(1)
+
     def _run_manifest_commit_attempt(self, commit_side_effect=None,
                                      commit_result=None, retry_result=None,
                                      existing_manifests=None,
@@ -429,7 +501,7 @@ class TestFileStoreCommit(unittest.TestCase):
         file_store_commit, result = self._run_manifest_commit_attempt(
             commit_result=False)
 
-        self.assertIsInstance(result, RetryResult)
+        self.assertIsInstance(result, CommitFailRetryResult)
         self.assertIsNone(result.exception)
         self.assertEqual(
             ['before'],
@@ -449,7 +521,7 @@ class TestFileStoreCommit(unittest.TestCase):
         file_store_commit, result = self._run_manifest_commit_attempt(
             commit_side_effect=failure)
 
-        self.assertIsInstance(result, RetryResult)
+        self.assertIsInstance(result, CommitFailRetryResult)
         self.assertIs(failure, result.exception)
         self.assertTrue(result.commit_result_may_be_uncertain)
         self.assertIsNone(result.manifest_merge_result)
@@ -463,7 +535,7 @@ class TestFileStoreCommit(unittest.TestCase):
             self._manifest_meta('before-b'),
         ]
         previous_after = [self._manifest_meta('merged')]
-        retry_result = RetryResult(
+        retry_result = CommitFailRetryResult(
             Mock(id=3),
             manifest_merge_result=ManifestMergeResult(
                 previous_before, previous_after),
@@ -481,7 +553,7 @@ class TestFileStoreCommit(unittest.TestCase):
             existing_manifests=current,
         )
 
-        self.assertIsInstance(result, RetryResult)
+        self.assertIsInstance(result, CommitFailRetryResult)
         self.assertEqual(
             ['prefix', 'before-a', 'before-b', 'suffix'],
             [manifest.file_name for manifest
@@ -507,7 +579,7 @@ class TestFileStoreCommit(unittest.TestCase):
             self._manifest_meta('before-a'),
             self._manifest_meta('before-b'),
         ]
-        retry_result = RetryResult(
+        retry_result = CommitFailRetryResult(
             Mock(id=3),
             manifest_merge_result=ManifestMergeResult(
                 previous_before, [self._manifest_meta('merged')]),
@@ -524,7 +596,7 @@ class TestFileStoreCommit(unittest.TestCase):
             existing_manifests=current,
         )
 
-        self.assertIsInstance(result, RetryResult)
+        self.assertIsInstance(result, CommitFailRetryResult)
         self.assertIsNone(result.manifest_merge_result)
         file_store_commit.manifest_file_merger.merge.assert_not_called()
         base_manifests = (
diff --git a/paimon-python/pypaimon/tests/overwrite_commit_conflict_test.py 
b/paimon-python/pypaimon/tests/overwrite_commit_conflict_test.py
index e18439ea84..da20c300bc 100644
--- a/paimon-python/pypaimon/tests/overwrite_commit_conflict_test.py
+++ b/paimon-python/pypaimon/tests/overwrite_commit_conflict_test.py
@@ -209,9 +209,9 @@ class OverwriteCommitConflictTest(unittest.TestCase):
         self.assertEqual(sorted(actual[actual['f0'] == 1]['f1'].tolist()), 
['new'])
         self.assertEqual(sorted(actual[actual['f0'] == 2]['f1'].tolist()), 
['c'])
 
-    def test_falls_back_to_full_scan_when_intermediate_snapshot_missing(self):
-        # A missing intermediate snapshot -> read_incremental_changes returns 
None
-        # and the retry falls back to a full scan.
+    def test_fails_when_intermediate_snapshot_missing(self):
+        # Match Java SnapshotManager.snapshot: duplicate detection cannot skip
+        # an unavailable snapshot in the retry history.
         K = 1
         missing_id = self.table.snapshot_manager().get_latest_snapshot().id + 1
 
@@ -239,8 +239,8 @@ class OverwriteCommitConflictTest(unittest.TestCase):
         fsc.commit_scanner.read_all_entries_from_changed_partitions = spy_full
         fsc.commit_scanner.read_incremental_changes = spy_incr
 
-        # Only the scanner's lookups see missing_id as absent; the commit's own
-        # manager (bound earlier) is untouched.
+        # Both duplicate detection and incremental conflict scanning observe
+        # the expired snapshot.
         real_mgr = fsc.commit_scanner.table.snapshot_manager()
 
         class _Wrap:
@@ -250,7 +250,9 @@ class OverwriteCommitConflictTest(unittest.TestCase):
             def get_snapshot_by_id(self, i):
                 return None if i == missing_id else 
real_mgr.get_snapshot_by_id(i)
 
-        fsc.commit_scanner.table.snapshot_manager = lambda: _Wrap()
+        wrapped_mgr = _Wrap()
+        fsc.snapshot_manager = wrapped_mgr
+        fsc.commit_scanner.table.snapshot_manager = lambda: wrapped_mgr
 
         orig_cas = fsc.snapshot_commit.commit
         cas = {'fails': 0}
@@ -264,12 +266,15 @@ class OverwriteCommitConflictTest(unittest.TestCase):
 
         fsc.snapshot_commit.commit = patched_cas
 
-        c.commit(messages)
+        with self.assertRaisesRegex(
+                RuntimeError,
+                "snapshot {} cannot be found".format(missing_id)):
+            c.commit(messages)
         c.close()
 
         self.assertEqual(cas['fails'], K, "expected exactly K forced 
conflicts")
-        self.assertIn(None, incr_results)          # incremental bailed on 
missing
-        self.assertEqual(full_scans['n'], 2)       # first attempt + fallback
+        self.assertEqual([], incr_results)
+        self.assertEqual(full_scans['n'], 1)
 
     def test_incremental_merge_across_non_append_snapshot(self):
         self._assert_merge_equals_full_scan(self._overwrite_target)
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 8e48773d4b..d75dbf4a39 100644
--- a/paimon-python/pypaimon/tests/ray_data_evolution_merge_into_test.py
+++ b/paimon-python/pypaimon/tests/ray_data_evolution_merge_into_test.py
@@ -114,6 +114,45 @@ class RayDataEvolutionMergeIntoTest(unittest.TestCase):
         snap = table.snapshot_manager().get_latest_snapshot()
         return snap.id if snap is not None else None
 
+    def _compact_all_data_files(self, table):
+        """Replace all current data files with one COMPACT output file."""
+        from pypaimon.table.special_fields import SpecialFields
+
+        read_builder = table.new_read_builder().with_projection(
+            list(table.field_names) + [SpecialFields.ROW_ID.name]
+        )
+        plan = read_builder.new_scan().plan_for_write()
+        old_files = [
+            file for split in plan.splits() for file in split.files
+        ]
+        current = read_builder.new_read().to_arrow(plan.splits()).sort_by(
+            [(SpecialFields.ROW_ID.name, 'ascending')]
+        ).select(list(table.field_names))
+
+        write_builder = table.new_batch_write_builder()
+        writer = write_builder.new_write()
+        writer.write_arrow(current)
+        messages = writer.prepare_commit()
+        self.assertEqual(len(messages), 1)
+        self.assertEqual(len(messages[0].new_files), 1)
+        messages[0].new_files = [
+            messages[0].new_files[0].assign_first_row_id(0)
+        ]
+        messages[0].deleted_files.extend(old_files)
+
+        commit = write_builder.new_commit()
+        file_store_commit = commit.file_store_commit
+        original_try_commit = file_store_commit._try_commit
+        file_store_commit._try_commit = (
+            lambda commit_kind, *args, **kwargs:
+            original_try_commit('COMPACT', *args, **kwargs)
+        )
+        try:
+            commit.commit(messages)
+        finally:
+            writer.close()
+            commit.close()
+
     def _merge_and_capture_self_merge_plan(self, **kwargs):
         from pypaimon.ray.data_evolution_merge_join import (
             build_self_merge_update_plan as real_build_plan,
@@ -2089,6 +2128,674 @@ class RayDataEvolutionMergeIntoTest(unittest.TestCase):
         self.assertEqual(result['num_matched'], 4)
         self.assertEqual(self._read_sorted(target)['age'], [99, 99, 99, 99])
 
+    def test_self_merge_rebases_staged_updates_after_compaction(self):
+        from pypaimon.data.generic_variant import GenericVariant
+        from pypaimon.data.variant_path import variant_get, variant_replace
+        from pypaimon.ray import data_evolution_merge_into as merge_module
+
+        options = dict(self.de_options)
+        options.update({
+            'commit.max-retries': '0',
+            'data-evolution.row-id-conflict-rewrite.max-size': '1 B',
+        })
+        variant_type = pa.struct([
+            pa.field('value', pa.binary(), nullable=False),
+            pa.field('metadata', pa.binary(), nullable=False),
+        ])
+        schema = pa.schema([
+            ('id', pa.int32()),
+            ('payload', variant_type),
+            ('topic_schema', pa.string()),
+        ])
+        target = 'default.tbl_{}'.format(uuid.uuid4().hex[:8])
+        self.catalog.create_table(
+            target,
+            Schema.from_pyarrow_schema(schema, options=options),
+            False,
+        )
+
+        def payload(values):
+            return GenericVariant.to_arrow_array([
+                GenericVariant.from_python({
+                    'angular_velocity': {'y': value, 'z': value + 1.0},
+                    'linear_acceleration': {
+                        'y': value + 2.0,
+                        'z': value + 3.0,
+                    },
+                })
+                for value in values
+            ])
+
+        self._write(
+            target,
+            pa.table({
+                'id': pa.array([1, 2], type=pa.int32()),
+                'payload': payload([1.0, 10.0]),
+                'topic_schema': ['old', 'old'],
+            }, schema=schema),
+        )
+        self._write(
+            target,
+            pa.table({
+                'id': pa.array([3, 4], type=pa.int32()),
+                'payload': payload([20.0, 30.0]),
+                'topic_schema': ['old', 'old'],
+            }, schema=schema),
+        )
+        table = self.catalog.get_table(target)
+        real_apply = merge_module.distributed_self_merge_update_apply
+        stale_paths = []
+
+        def stage_then_compact(*args, **kwargs):
+            result = real_apply(*args, **kwargs)
+            stale_paths.extend(
+                file.file_path
+                for message in result[0]
+                for file in message.new_files
+            )
+            self._compact_all_data_files(table)
+            return result
+
+        path_types = {
+            '$.angular_velocity.y': pa.float64(),
+            '$.angular_velocity.z': pa.float64(),
+            '$.linear_acceleration.y': pa.float64(),
+            '$.linear_acceleration.z': pa.float64(),
+        }
+
+        def negate_imu_yz(rows):
+            values = variant_get(rows['payload'], path_types)
+            return variant_replace(
+                rows['payload'],
+                {
+                    path: pc.negate(value)
+                    for path, value in values.items()
+                },
+                strict=True,
+            )
+
+        with patch.object(
+                merge_module,
+                'distributed_self_merge_update_apply',
+                side_effect=stage_then_compact,
+        ), patch(
+                'pypaimon.ray.row_id_conflict_rewriter._retry_wait',
+        ):
+            result = merge_into(
+                target=target,
+                source=target,
+                catalog_options=self.catalog_options,
+                on=['_ROW_ID'],
+                read_columns=['payload'],
+                when_matched=[WhenMatched.update({
+                    'payload': negate_imu_yz,
+                    'topic_schema': lit('imu-yz-negated-v1'),
+                })],
+                num_partitions=_TEST_NUM_PARTITIONS,
+            )
+
+        self.assertEqual(result['num_matched'], 4)
+        output = self._read_sorted(target)
+        decoded = [
+            GenericVariant.from_arrow_struct(value).to_python()
+            for value in output['payload']
+        ]
+        self.assertEqual(
+            [row['angular_velocity']['y'] for row in decoded],
+            [-1.0, -10.0, -20.0, -30.0],
+        )
+        self.assertEqual(
+            output['topic_schema'],
+            ['imu-yz-negated-v1'] * 4,
+        )
+        self.assertTrue(stale_paths)
+        # Match Spark: replaced staging files are left for orphan cleanup.
+        self.assertTrue(all(os.path.exists(path) for path in stale_paths))
+
+    def test_self_merge_compaction_retry_checks_core_rollback(self):
+        from pypaimon.ray import data_evolution_merge_into as merge_module
+        from pypaimon.ray import row_id_conflict_rewriter as rewriter_module
+        from pypaimon.write.file_store_commit import FileStoreCommit
+
+        target = self._create_table()
+        self._write(target, self._source(ids=(1, 2)))
+        self._write(target, self._source(ids=(3, 4)))
+        table = self.catalog.get_table(target)
+        real_apply = merge_module.distributed_self_merge_update_apply
+        real_rewrite = rewriter_module._rewrite_updates
+        real_commit_init = FileStoreCommit.__init__
+        rollbacks = []
+
+        def stage_then_compact(*args, **kwargs):
+            result = real_apply(*args, **kwargs)
+            self._compact_all_data_files(table)
+            return result
+
+        rewrite_calls = [0]
+
+        def miss_precommit_race(*args, **kwargs):
+            rewrite_calls[0] += 1
+            if rewrite_calls[0] == 1:
+                return None
+            return real_rewrite(*args, **kwargs)
+
+        def init_with_rollback(commit, *args, **kwargs):
+            real_commit_init(commit, *args, **kwargs)
+            commit.rollback = Mock()
+            commit.rollback.try_to_rollback.return_value = False
+            rollbacks.append(commit.rollback)
+
+        with patch.object(
+                merge_module,
+                'distributed_self_merge_update_apply',
+                side_effect=stage_then_compact,
+        ), patch.object(
+                rewriter_module,
+                '_rewrite_updates',
+                side_effect=miss_precommit_race,
+        ), patch.object(
+                FileStoreCommit,
+                '__init__',
+                new=init_with_rollback,
+        ), patch.object(
+                FileStoreCommit,
+                '_commit_retry_wait',
+        ) as commit_retry_wait, patch.object(
+                rewriter_module,
+                '_retry_wait',
+        ) as ray_retry_wait:
+            result = merge_into(
+                target=target,
+                source=target,
+                catalog_options=self.catalog_options,
+                on=['_ROW_ID'],
+                when_matched=[WhenMatched.update({'age': lit(99)})],
+                num_partitions=_TEST_NUM_PARTITIONS,
+            )
+
+        self.assertEqual(result['num_matched'], 4)
+        self.assertEqual(self._read_sorted(target)['age'], [99, 99, 99, 99])
+        commit_retry_wait.assert_not_called()
+        self.assertTrue(any(
+            rollback.try_to_rollback.called for rollback in rollbacks
+        ))
+        ray_retry_wait.assert_called_once()
+        self.assertEqual(ray_retry_wait.call_args[0][1], 0)
+        self.assertEqual(rewrite_calls[0], 2)
+
+    def test_self_merge_rebases_again_after_second_compaction(self):
+        from pypaimon.ray import data_evolution_merge_into as merge_module
+        from pypaimon.ray import row_id_conflict_rewriter as rewriter_module
+        from pypaimon.write.write_builder import BatchWriteBuilder
+
+        target = self._create_table()
+        self._write(target, self._source(ids=(1, 2)))
+        self._write(target, self._source(ids=(3, 4)))
+        table = self.catalog.get_table(target)
+        real_apply = merge_module.distributed_self_merge_update_apply
+        real_rewrite = rewriter_module._rewrite_updates
+        real_new_commit = BatchWriteBuilder.new_commit
+        coordinator_commit_users = []
+
+        def capture_new_commit(write_builder):
+            commit = real_new_commit(write_builder)
+            if (
+                    write_builder.table.options
+                    .data_evolution_row_id_conflict_rewrite_max_size() == 0
+            ):
+                coordinator_commit_users.append(write_builder.commit_user)
+            return commit
+
+        def stage_then_compact(*args, **kwargs):
+            result = real_apply(*args, **kwargs)
+            self._compact_all_data_files(table)
+            return result
+
+        rewrite_snapshot_ids = []
+
+        def miss_precommit_race(
+                rewrite_table, messages, latest_snapshot, **kwargs):
+            rewrite_snapshot_ids.append(latest_snapshot.id)
+            if len(rewrite_snapshot_ids) == 1:
+                return None
+            return real_rewrite(
+                rewrite_table,
+                messages,
+                latest_snapshot,
+                **kwargs,
+            )
+
+        retry_counts = []
+
+        def compact_before_first_retry(_table, retry_count):
+            retry_counts.append(retry_count)
+            if retry_count == 0:
+                # Change the current row-id boundary before the second
+                # compaction so the next commit observes the same
+                # RowIdExistenceConflict used by Spark's retry loop.
+                self._write(target, self._source(ids=(5, 6)))
+                self._compact_all_data_files(table)
+
+        with patch.object(
+                merge_module,
+                'distributed_self_merge_update_apply',
+                side_effect=stage_then_compact,
+        ), patch.object(
+                rewriter_module,
+                '_rewrite_updates',
+                side_effect=miss_precommit_race,
+        ), patch.object(
+            rewriter_module,
+            '_retry_wait',
+            side_effect=compact_before_first_retry,
+        ), patch.object(
+            BatchWriteBuilder,
+            'new_commit',
+            new=capture_new_commit,
+        ):
+            result = merge_into(
+                target=target,
+                source=target,
+                catalog_options=self.catalog_options,
+                on=['_ROW_ID'],
+                when_matched=[WhenMatched.update({'age': lit(99)})],
+                num_partitions=_TEST_NUM_PARTITIONS,
+            )
+
+        self.assertEqual(result['num_matched'], 4)
+        output = self._read_sorted(target)
+        self.assertEqual(output['id'], [1, 2, 3, 4, 5, 6])
+        self.assertEqual(output['age'], [99, 99, 99, 99, 10, 10])
+        self.assertEqual(retry_counts, [0, 1])
+        self.assertEqual(len(rewrite_snapshot_ids), 3)
+        self.assertEqual(rewrite_snapshot_ids[0], rewrite_snapshot_ids[1])
+        self.assertGreater(rewrite_snapshot_ids[2], rewrite_snapshot_ids[1])
+        self.assertEqual(len(coordinator_commit_users), 3)
+        self.assertEqual(len(set(coordinator_commit_users)), 1)
+
+    def test_self_merge_uncertain_commit_then_compaction_is_duplicate(self):
+        from pypaimon.ray import row_id_conflict_rewriter as rewriter_module
+        from pypaimon.write.write_builder import BatchWriteBuilder
+
+        target = self._create_table()
+        self._write(target, self._source(ids=(1, 2)))
+        self._write(target, self._source(ids=(3, 4)))
+        table = self.catalog.get_table(target)
+        base_snapshot_id = self._snapshot_id(target)
+        real_new_commit = BatchWriteBuilder.new_commit
+        coordinator_commit_users = []
+        duplicate_results = []
+        atomic_attempts = []
+        injected = [False]
+
+        def inject_uncertain_commit(write_builder):
+            commit = real_new_commit(write_builder)
+            if (
+                    injected[0]
+                    or write_builder.table.options
+                    .data_evolution_row_id_conflict_rewrite_max_size() != 0
+            ):
+                return commit
+
+            injected[0] = True
+            coordinator_commit_users.append(write_builder.commit_user)
+            file_store_commit = commit.file_store_commit
+            real_atomic_commit = file_store_commit.snapshot_commit.commit
+            real_duplicate_check = file_store_commit._is_duplicate_commit
+
+            def track_duplicate(*args, **kwargs):
+                result = real_duplicate_check(*args, **kwargs)
+                duplicate_results.append(result)
+                return result
+
+            def commit_then_compact_and_timeout(
+                    base_uuid, snapshot, statistics):
+                atomic_attempts.append(snapshot.id)
+                self.assertTrue(real_atomic_commit(
+                    base_uuid, snapshot, statistics,
+                ))
+                self._compact_all_data_files(table)
+                raise TimeoutError('lost snapshot commit response')
+
+            file_store_commit._is_duplicate_commit = track_duplicate
+            file_store_commit.snapshot_commit.commit = (
+                commit_then_compact_and_timeout
+            )
+            file_store_commit._commit_retry_wait = Mock()
+            return commit
+
+        with patch.object(
+            BatchWriteBuilder,
+            'new_commit',
+            new=inject_uncertain_commit,
+        ), patch.object(
+            rewriter_module,
+            '_rewrite_updates',
+            wraps=rewriter_module._rewrite_updates,
+        ) as rewrite_updates:
+            result = merge_into(
+                target=target,
+                source=target,
+                catalog_options=self.catalog_options,
+                on=['_ROW_ID'],
+                when_matched=[WhenMatched.update({'age': lit(99)})],
+                num_partitions=_TEST_NUM_PARTITIONS,
+            )
+
+        self.assertTrue(injected[0])
+        self.assertEqual(result['num_matched'], 4)
+        self.assertEqual(self._read_sorted(target)['age'], [99, 99, 99, 99])
+        self.assertEqual(atomic_attempts, [base_snapshot_id + 1])
+        self.assertEqual(duplicate_results, [False, True])
+        self.assertEqual(len(coordinator_commit_users), 1)
+        self.assertEqual(
+            table.snapshot_manager().get_snapshot_by_id(
+                base_snapshot_id + 1
+            ).commit_user,
+            coordinator_commit_users[0],
+        )
+        self.assertEqual(
+            table.snapshot_manager().get_snapshot_by_id(
+                base_snapshot_id + 2
+            ).commit_kind,
+            'COMPACT',
+        )
+        self.assertEqual(self._snapshot_id(target), base_snapshot_id + 2)
+        rewrite_updates.assert_not_called()
+
+    def test_self_merge_rejects_concurrent_overwrite(self):
+        from pypaimon.ray import data_evolution_merge_into as merge_module
+
+        target = self._create_table()
+        self._write(target, self._source(ids=(1, 2)))
+        table = self.catalog.get_table(target)
+        real_apply = merge_module.distributed_self_merge_update_apply
+        staging_paths = []
+
+        def stage_then_overwrite(*args, **kwargs):
+            result = real_apply(*args, **kwargs)
+            staging_paths.extend(
+                file.external_path or file.file_path
+                for message in result[0]
+                for file in message.new_files
+            )
+            replacement = pa.Table.from_pydict(
+                {
+                    'id': pa.array([30, 40], type=pa.int32()),
+                    'name': ['replacement', 'replacement'],
+                    'age': pa.array([30, 40], type=pa.int32()),
+                },
+                schema=self.pa_schema,
+            )
+            write_builder = table.new_batch_write_builder().overwrite({})
+            writer = write_builder.new_write()
+            commit = write_builder.new_commit()
+            try:
+                writer.write_arrow(replacement)
+                commit.commit(writer.prepare_commit())
+            finally:
+                writer.close()
+                commit.close()
+            return result
+
+        with patch.object(
+                merge_module,
+                'distributed_self_merge_update_apply',
+                side_effect=stage_then_overwrite,
+        ), self.assertRaises(Exception):
+            merge_into(
+                target=target,
+                source=target,
+                catalog_options=self.catalog_options,
+                on=['_ROW_ID'],
+                when_matched=[WhenMatched.update({'age': lit(99)})],
+                num_partitions=_TEST_NUM_PARTITIONS,
+            )
+
+        output = self._read_sorted(target)
+        self.assertEqual(output['id'], [30, 40])
+        self.assertEqual(output['age'], [30, 40])
+        self.assertTrue(staging_paths)
+        # Match Spark: failed staging files are left for orphan cleanup.
+        self.assertTrue(
+            all(os.path.exists(path) for path in staging_paths)
+        )
+
+    @unittest.skipIf(_SKIP_CONDITION, _SKIP_REASON)
+    def test_self_merge_update_and_delete(self):
+        options = dict(self.de_options)
+        options['deletion-vectors.enabled'] = 'true'
+        target = self._create_table(options=options)
+        self._write(target, self._source(ids=(1, 2, 3, 4)))
+
+        result = merge_into(
+            target=target,
+            source=target,
+            catalog_options=self.catalog_options,
+            on=['_ROW_ID'],
+            when_matched=[
+                WhenMatched.update(
+                    {'age': lit(99)}, condition='t.id <= 2',
+                ),
+                WhenMatched.delete(condition='t.id = 3'),
+            ],
+            num_partitions=_TEST_NUM_PARTITIONS,
+        )
+
+        self.assertEqual(result['num_matched'], 3)
+        output = self._read_sorted(target)
+        self.assertEqual(output['id'], [1, 2, 4])
+        self.assertEqual(output['age'], [99, 99, 10])
+
+    @unittest.skipIf(_SKIP_CONDITION, _SKIP_REASON)
+    def test_self_merge_update_delete_does_not_rebase_after_compaction(self):
+        from pypaimon.ray import row_id_conflict_rewriter as rewriter_module
+
+        options = dict(self.de_options)
+        options['deletion-vectors.enabled'] = 'true'
+        target = self._create_table(options=options)
+        self._write(target, self._source(ids=(1, 2, 3, 4)))
+        table = self.catalog.get_table(target)
+        real_commit = (
+            rewriter_module.commit_self_merge_with_compaction_retry
+        )
+        real_rewrite = rewriter_module._rewrite_updates
+        rewrite_results = []
+
+        def compact_then_commit(*args, **kwargs):
+            self._compact_all_data_files(table)
+            return real_commit(*args, **kwargs)
+
+        def assert_rewrite_disabled(*args, **kwargs):
+            result = real_rewrite(*args, **kwargs)
+            rewrite_results.append(result)
+            return result
+
+        with patch.object(
+                rewriter_module,
+                'commit_self_merge_with_compaction_retry',
+                side_effect=compact_then_commit,
+        ), patch.object(
+                rewriter_module,
+                '_rewrite_updates',
+                side_effect=assert_rewrite_disabled,
+        ), self.assertRaises(Exception):
+            merge_into(
+                target=target,
+                source=target,
+                catalog_options=self.catalog_options,
+                on=['_ROW_ID'],
+                when_matched=[
+                    WhenMatched.update(
+                        {'age': lit(99)}, condition='t.id <= 2',
+                    ),
+                    WhenMatched.delete(condition='t.id = 3'),
+                ],
+                num_partitions=_TEST_NUM_PARTITIONS,
+            )
+
+        self.assertTrue(rewrite_results)
+        self.assertTrue(all(result is None for result in rewrite_results))
+        output = self._read_sorted(target)
+        self.assertEqual(output['id'], [1, 2, 3, 4])
+        self.assertEqual(output['age'], [10, 10, 10, 10])
+
+    def test_self_merge_compaction_rebase_keeps_logical_conflicts(self):
+        from pypaimon.ray import data_evolution_merge_into as merge_module
+
+        target = self._create_table()
+        self._write(target, self._source(ids=(1, 2)))
+        self._write(target, self._source(ids=(3, 4)))
+        table = self.catalog.get_table(target)
+        real_apply = merge_module.distributed_self_merge_update_apply
+
+        def stage_then_update_and_compact(*args, **kwargs):
+            result = real_apply(*args, **kwargs)
+            write_builder = table.new_batch_write_builder()
+            update = write_builder.new_update()
+            predicate = update.new_predicate_builder().equal('id', 2)
+            messages = update.update_by_predicate(
+                predicate,
+                {'age': 100},
+            )
+            write_builder.new_commit().commit(messages)
+            self._compact_all_data_files(table)
+            return result
+
+        def increment_age(rows):
+            return pc.add(rows['age'], 1)
+
+        with patch.object(
+                merge_module,
+                'distributed_self_merge_update_apply',
+                side_effect=stage_then_update_and_compact,
+        ), self.assertRaises(Exception) as ctx:
+            merge_into(
+                target=target,
+                source=target,
+                catalog_options=self.catalog_options,
+                on=['_ROW_ID'],
+                read_columns=['age'],
+                when_matched=[WhenMatched.update({
+                    'age': increment_age,
+                })],
+                num_partitions=_TEST_NUM_PARTITIONS,
+            )
+
+        self.assertIn("multiple 'MERGE INTO'", str(ctx.exception))
+        self.assertEqual(self._read_sorted(target)['age'][1], 100)
+
+    def test_self_merge_missing_logical_snapshot_fails_closed(self):
+        from pypaimon.ray import data_evolution_merge_into as merge_module
+        from pypaimon.snapshot.snapshot_manager import SnapshotManager
+
+        target = self._create_table()
+        self._write(target, self._source(ids=(1, 2)))
+        self._write(target, self._source(ids=(3, 4)))
+        table = self.catalog.get_table(target)
+        real_apply = merge_module.distributed_self_merge_update_apply
+        real_get_snapshot = SnapshotManager.get_snapshot_by_id
+        hidden_snapshot = {'id': None}
+
+        def get_snapshot_except_hidden(manager, snapshot_id):
+            if snapshot_id == hidden_snapshot['id']:
+                return None
+            return real_get_snapshot(manager, snapshot_id)
+
+        def stage_then_update_and_compact(*args, **kwargs):
+            result = real_apply(*args, **kwargs)
+            write_builder = table.new_batch_write_builder()
+            update = write_builder.new_update()
+            predicate = update.new_predicate_builder().equal('id', 2)
+            messages = update.update_by_predicate(predicate, {'age': 100})
+            write_builder.new_commit().commit(messages)
+            logical_snapshot_id = self._snapshot_id(target)
+            self._compact_all_data_files(table)
+            hidden_snapshot['id'] = logical_snapshot_id
+            return result
+
+        def increment_age(rows):
+            return pc.add(rows['age'], 1)
+
+        with patch.object(
+                merge_module,
+                'distributed_self_merge_update_apply',
+                side_effect=stage_then_update_and_compact,
+        ), patch.object(
+                SnapshotManager,
+                'get_snapshot_by_id',
+                new=get_snapshot_except_hidden,
+        ), self.assertRaisesRegex(
+                RuntimeError,
+                "snapshot .* cannot be found",
+        ):
+            merge_into(
+                target=target,
+                source=target,
+                catalog_options=self.catalog_options,
+                on=['_ROW_ID'],
+                read_columns=['age'],
+                when_matched=[WhenMatched.update({
+                    'age': increment_age,
+                })],
+                num_partitions=_TEST_NUM_PARTITIONS,
+            )
+
+        self.assertEqual(self._read_sorted(target)['age'][1], 100)
+
+    def test_self_merge_compaction_rebase_preserves_other_column_update(self):
+        from pypaimon.ray import data_evolution_merge_into as merge_module
+
+        target = self._create_table()
+        self._write(target, self._source(ids=(1, 2)))
+        self._write(target, self._source(ids=(3, 4)))
+        table = self.catalog.get_table(target)
+        real_apply = merge_module.distributed_self_merge_update_apply
+
+        def stage_then_update_and_compact(*args, **kwargs):
+            result = real_apply(*args, **kwargs)
+            self._write(target, self._source(ids=(5,)))
+            write_builder = table.new_batch_write_builder()
+            update = write_builder.new_update()
+            predicate = update.new_predicate_builder().equal('id', 2)
+            messages = update.update_by_predicate(
+                predicate,
+                {'name': 'concurrent'},
+            )
+            write_builder.new_commit().commit(messages)
+            self._compact_all_data_files(table)
+            return result
+
+        def increment_age(rows):
+            return pc.add(rows['age'], 1)
+
+        with patch.object(
+                merge_module,
+                'distributed_self_merge_update_apply',
+                side_effect=stage_then_update_and_compact,
+        ), patch(
+                'pypaimon.ray.row_id_conflict_rewriter._retry_wait',
+        ):
+            result = merge_into(
+                target=target,
+                source=target,
+                catalog_options=self.catalog_options,
+                on=['_ROW_ID'],
+                read_columns=['age'],
+                when_matched=[WhenMatched.update({
+                    'age': increment_age,
+                })],
+                num_partitions=_TEST_NUM_PARTITIONS,
+            )
+
+        self.assertEqual(result['num_matched'], 4)
+        output = self._read_sorted(target)
+        self.assertEqual(output['age'], [11, 11, 11, 11, 10])
+        self.assertEqual(
+            output['name'],
+            ['x', 'concurrent', 'x', 'x', 'x'],
+        )
+
     @unittest.skipIf(_SKIP_CONDITION, _SKIP_REASON)
     def test_self_merge_filters_file_group_in_batches(self):
         from pypaimon.ray import data_evolution_merge_into as merge_module
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
new file mode 100644
index 0000000000..6b2ec0d82b
--- /dev/null
+++ b/paimon-python/pypaimon/tests/ray_row_id_conflict_rewriter_test.py
@@ -0,0 +1,289 @@
+# 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 importlib.util
+import unittest
+from unittest.mock import Mock, patch
+
+from pypaimon.ray.data_evolution_merge_into import _reraise_inner
+from pypaimon.ray.row_id_conflict_rewriter import (
+    commit_self_merge_with_compaction_retry,
+)
+from pypaimon.write.commit.conflict_detection import RowIdExistenceConflict
+
+
+class RayRowIdConflictRewriterTest(unittest.TestCase):
+
+    @staticmethod
+    def _message(snapshot_id, name):
+        return Mock(check_from_snapshot=snapshot_id, name=name)
+
+    @staticmethod
+    def _conflict(name):
+        entry = Mock(bucket=0)
+        entry.file = Mock(
+            file_name=name,
+            first_row_id=0,
+            row_count=1,
+        )
+        return RowIdExistenceConflict(entry)
+
+    @staticmethod
+    def _table(commits, snapshots, max_retries=3):
+        builder = Mock()
+        builder.new_commit.side_effect = commits
+        commit_table = Mock()
+        commit_table.new_batch_write_builder.return_value = builder
+        table = Mock()
+        table.copy_without_time_travel.return_value = commit_table
+        table.snapshot_manager.return_value.get_latest_snapshot.side_effect = (
+            snapshots
+        )
+        table.options.commit_timeout.return_value = 60_000
+        table.options.commit_max_retries.return_value = max_retries
+        return table
+
+    def test_nested_row_id_conflict_is_rebased(self):
+        entry = Mock(bucket=0)
+        entry.file = Mock(
+            file_name='data-file.parquet',
+            first_row_id=0,
+            row_count=1,
+        )
+        conflict = RowIdExistenceConflict(entry)
+        commit_error = RuntimeError('commit failed')
+        commit_error.__cause__ = conflict
+
+        commit = Mock()
+        commit.commit.side_effect = [commit_error, None]
+        table = self._table(
+            [commit, commit],
+            [Mock(id=1), Mock(id=2)],
+        )
+        rewrite_result = Mock(
+            update_messages=[],
+            rewritten_file_count=1,
+        )
+
+        with patch(
+            'pypaimon.ray.row_id_conflict_rewriter._rewrite_updates',
+            return_value=rewrite_result,
+        ) as rewrite_updates, patch(
+            'pypaimon.ray.row_id_conflict_rewriter._retry_wait',
+        ):
+            commit_self_merge_with_compaction_retry(
+                table,
+                [],
+                [],
+                num_partitions=1,
+            )
+
+        rewrite_updates.assert_called_once()
+        self.assertEqual(2, commit.commit.call_count)
+        self.assertEqual(2, commit.close.call_count)
+
+    def test_commit_preserves_file_store_rollback(self):
+        rollback = Mock()
+        commit = Mock()
+        commit.file_store_commit.rollback = rollback
+        table = self._table([commit], [Mock(id=1)])
+
+        commit_self_merge_with_compaction_retry(
+            table,
+            [],
+            [],
+            num_partitions=1,
+        )
+
+        self.assertIs(commit.file_store_commit.rollback, rollback)
+
+    def test_rewrite_error_keeps_row_id_conflict_as_cause(self):
+        conflict = self._conflict('compact')
+        rewrite_error = ValueError('rewrite failed')
+        update = self._message(1, 'update')
+        commit = Mock()
+        commit.commit.side_effect = conflict
+        table = self._table(
+            [commit],
+            [Mock(id=1), Mock(id=2)],
+        )
+
+        with patch(
+            'pypaimon.ray.row_id_conflict_rewriter._rewrite_updates',
+            side_effect=rewrite_error,
+        ), self.assertRaises(RuntimeError) as context:
+            commit_self_merge_with_compaction_retry(
+                table,
+                [update],
+                [],
+                num_partitions=1,
+            )
+
+        self.assertIs(conflict, context.exception.__cause__)
+
+    def test_public_error_does_not_unwrap_regular_cause(self):
+        timeout = TimeoutError('commit timeout')
+        outer = RuntimeError('commit result is uncertain')
+        outer.__cause__ = timeout
+
+        with self.assertRaises(RuntimeError) as context:
+            _reraise_inner(outer)
+
+        self.assertIs(outer, context.exception)
+
+    @unittest.skipUnless(
+        importlib.util.find_spec('ray') is not None,
+        'Ray is not installed.',
+    )
+    def test_public_error_unwraps_ray_task_error(self):
+        from ray.exceptions import RayTaskError
+
+        cause = ValueError('worker failure')
+        ray_error = RayTaskError(
+            'worker',
+            'Traceback (most recent call last):\nValueError: worker failure',
+            cause,
+            proctitle='ray::worker',
+            pid=1,
+            ip='127.0.0.1',
+        ).as_instanceof_cause()
+
+        with self.assertRaises(ValueError) as context:
+            _reraise_inner(ray_error)
+
+        self.assertIs(cause, context.exception)
+
+    def test_final_error_after_multiple_rebases_does_not_abort_messages(self):
+        generation_0 = self._message(1, 'generation-0')
+        generation_1 = self._message(2, 'generation-1')
+        generation_2 = self._message(3, 'generation-2')
+        other = self._message(-1, 'insert')
+        snapshot_1 = Mock(id=1, uuid='uuid-1')
+        snapshot_2 = Mock(id=2, uuid='uuid-2')
+        snapshot_3 = Mock(id=3, uuid='uuid-3')
+        commits = [Mock(), Mock(), Mock()]
+        commits[0].commit.side_effect = self._conflict('compact-1')
+        commits[1].commit.side_effect = self._conflict('compact-2')
+        terminal = RuntimeError('final attempt failed')
+        commits[2].commit.side_effect = terminal
+        table = self._table(
+            commits,
+            [snapshot_1, snapshot_2, snapshot_3],
+        )
+
+        with patch(
+            'pypaimon.ray.row_id_conflict_rewriter._rewrite_updates',
+            side_effect=[
+                Mock(
+                    update_messages=[generation_1],
+                    rewritten_file_count=1,
+                ),
+                Mock(
+                    update_messages=[generation_2],
+                    rewritten_file_count=1,
+                ),
+            ],
+        ), patch(
+            'pypaimon.ray.row_id_conflict_rewriter._retry_wait',
+        ), patch(
+            'pypaimon.write.file_store_commit._abort_commit_messages',
+        ) as abort_messages, self.assertRaises(RuntimeError) as context:
+            commit_self_merge_with_compaction_retry(
+                table,
+                [generation_0],
+                [other],
+                num_partitions=1,
+            )
+
+        self.assertIs(terminal, context.exception)
+        abort_messages.assert_not_called()
+
+    def test_exhausted_deterministic_conflict_does_not_abort_messages(self):
+        generation_0 = self._message(1, 'generation-0')
+        generation_1 = self._message(2, 'generation-1')
+        other = self._message(-1, 'insert')
+        snapshot_1 = Mock(id=1, uuid='uuid-1')
+        snapshot_2 = Mock(id=2, uuid='uuid-2')
+        commits = [Mock(), Mock()]
+        commits[0].commit.side_effect = self._conflict('compact-1')
+        terminal = self._conflict('compact-2')
+        commits[1].commit.side_effect = terminal
+        table = self._table(
+            commits,
+            [snapshot_1, snapshot_2],
+            max_retries=1,
+        )
+
+        with patch(
+            'pypaimon.ray.row_id_conflict_rewriter._rewrite_updates',
+            return_value=Mock(
+                update_messages=[generation_1],
+                rewritten_file_count=1,
+            ),
+        ), patch(
+            'pypaimon.ray.row_id_conflict_rewriter._retry_wait',
+        ), patch(
+            'pypaimon.write.file_store_commit._abort_commit_messages',
+        ) as abort_messages, self.assertRaises(
+            RowIdExistenceConflict,
+        ) as context:
+            commit_self_merge_with_compaction_retry(
+                table,
+                [generation_0],
+                [other],
+                num_partitions=1,
+            )
+
+        self.assertIs(terminal, context.exception)
+        abort_messages.assert_not_called()
+
+    def test_unknown_final_error_does_not_abort_messages(self):
+        generation_0 = self._message(1, 'generation-0')
+        generation_1 = self._message(2, 'generation-1')
+        other = self._message(-1, 'insert')
+        snapshot_1 = Mock(id=1, uuid='uuid-1')
+        snapshot_2 = Mock(id=2, uuid='uuid-2')
+        commits = [Mock(), Mock()]
+        commits[0].commit.side_effect = self._conflict('compact')
+        unknown = RuntimeError('callback failed after commit')
+        commits[1].commit.side_effect = unknown
+        table = self._table(commits, [snapshot_1, snapshot_2])
+
+        with patch(
+            'pypaimon.ray.row_id_conflict_rewriter._rewrite_updates',
+            return_value=Mock(
+                update_messages=[generation_1],
+                rewritten_file_count=1,
+            ),
+        ), patch(
+            'pypaimon.ray.row_id_conflict_rewriter._retry_wait',
+        ), patch(
+            'pypaimon.write.file_store_commit._abort_commit_messages',
+        ) as abort_messages, self.assertRaises(RuntimeError) as context:
+            commit_self_merge_with_compaction_retry(
+                table,
+                [generation_0],
+                [other],
+                num_partitions=1,
+            )
+
+        self.assertIs(unknown, context.exception)
+        abort_messages.assert_not_called()
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/paimon-python/pypaimon/tests/reader_append_only_test.py 
b/paimon-python/pypaimon/tests/reader_append_only_test.py
index 4a7a266f70..3988359ad9 100644
--- a/paimon-python/pypaimon/tests/reader_append_only_test.py
+++ b/paimon-python/pypaimon/tests/reader_append_only_test.py
@@ -32,7 +32,7 @@ from pypaimon.common.options.core_options import CoreOptions
 from pypaimon.manifest.schema.manifest_entry import ManifestEntry
 from pypaimon.snapshot.snapshot import BATCH_COMMIT_IDENTIFIER
 from pypaimon.table.row.generic_row import GenericRow
-from pypaimon.write.file_store_commit import RetryResult
+from pypaimon.write.file_store_commit import CommitFailRetryResult
 
 
 class AoReaderTest(unittest.TestCase):
@@ -669,7 +669,7 @@ class AoReaderTest(unittest.TestCase):
                 ))
         # mock retry
         success = table_commit.file_store_commit._try_commit_once(
-            RetryResult(None),
+            CommitFailRetryResult(None),
             "APPEND",
             commit_entries,
             [],
diff --git a/paimon-python/pypaimon/tests/write/conflict_detection_test.py 
b/paimon-python/pypaimon/tests/write/conflict_detection_test.py
index 4d02a21252..36b2dfc053 100644
--- a/paimon-python/pypaimon/tests/write/conflict_detection_test.py
+++ b/paimon-python/pypaimon/tests/write/conflict_detection_test.py
@@ -408,6 +408,20 @@ class TestCheckRowIdFromSnapshot(unittest.TestCase):
         self.assertIsNone(
             detection.check_row_id_from_snapshot(compact_snap, 
self._blob_delta()))
 
+    def test_missing_intermediate_snapshot_fails_closed(self):
+        check_snap = _FakeSnapshot(1, "APPEND", next_row_id=200)
+        latest_snap = _FakeSnapshot(3, "COMPACT", next_row_id=200)
+        detection = self._make_detection(
+            [check_snap, latest_snap], {3: []})
+
+        with self.assertRaisesRegex(
+                RuntimeError, "snapshot 2 cannot be found"):
+            detection.check_row_id_from_snapshot(
+                latest_snap,
+                self._blob_delta(),
+                check_compaction=False,
+            )
+
     def test_compact_no_conflict_when_no_matching_delete(self):
         check_snap = _FakeSnapshot(1, "APPEND", next_row_id=400)
         compact_snap = _FakeSnapshot(2, "COMPACT", next_row_id=400)
diff --git a/paimon-python/pypaimon/tests/write/table_write_test.py 
b/paimon-python/pypaimon/tests/write/table_write_test.py
index 4009b25790..d7c12735e4 100644
--- a/paimon-python/pypaimon/tests/write/table_write_test.py
+++ b/paimon-python/pypaimon/tests/write/table_write_test.py
@@ -1541,9 +1541,9 @@ class TableWriteTest(unittest.TestCase):
             commit_two.close()
             commit_three.close()
 
-    def test_uncertain_commit_then_cas_failure_keeps_files(self):
+    def test_uncertain_commit_with_unavailable_snapshot_fails_closed(self):
         table = self._create_postpone_table(
-            'default.test_uncertain_commit_then_cas_failure',
+            'default.test_uncertain_commit_with_unavailable_snapshot',
             pa_schema=self.postpone_pa_schema,
             partition_keys=['dt'],
             primary_keys=['id', 'dt'],
@@ -1588,7 +1588,6 @@ class TableWriteTest(unittest.TestCase):
             def hide_first_snapshot(snapshot_id):
                 return None if snapshot_id == 1 else 
real_get_snapshot(snapshot_id)
 
-            # Keep the retry on the CAS path after snapshot 1 becomes 
unavailable.
             with patch.object(
                 snapshot_commit,
                 'commit',
@@ -1601,15 +1600,20 @@ class TableWriteTest(unittest.TestCase):
                 file_store_commit.conflict_detection,
                 'check_conflicts',
                 return_value=None,
-            ), patch.object(file_store_commit, '_commit_retry_wait'):
-                with self.assertRaises(RuntimeError) as context:
+            ) as check_conflicts, patch.object(
+                file_store_commit,
+                '_commit_retry_wait',
+            ):
+                with self.assertRaisesRegex(
+                        RuntimeError, 'snapshot 1 cannot be found'):
                     commit.commit(messages)
 
-            self.assertIs(uncertain_error, context.exception.__cause__)
-            self.assertEqual(2, attempts)
+            self.assertEqual(1, attempts)
+            check_conflicts.assert_called_once()
             self.assertTrue(all(table.file_io.exists(path) for path in 
data_paths))
             self.assertEqual(
-                [1, 2], self._read_sorted(table, 'id').column('id').to_pylist()
+                [1, 2], self._read_sorted(
+                    table, 'id').column('id').to_pylist()
             )
         finally:
             write.close()
diff --git a/paimon-python/pypaimon/write/commit/conflict_detection.py 
b/paimon-python/pypaimon/write/commit/conflict_detection.py
index 4e6026f7b6..871013cf75 100644
--- a/paimon-python/pypaimon/write/commit/conflict_detection.py
+++ b/paimon-python/pypaimon/write/commit/conflict_detection.py
@@ -696,7 +696,9 @@ class ConflictDetection:
                 latest_snapshot.id + 1):
             snapshot = self.snapshot_manager.get_snapshot_by_id(snapshot_id)
             if snapshot is None:
-                continue
+                raise RuntimeError(
+                    "Row-id conflict check cannot continue because snapshot "
+                    "{} cannot be found.".format(snapshot_id))
 
             if snapshot.commit_kind == "COMPACT":
                 if check_compaction:
diff --git a/paimon-python/pypaimon/write/file_store_commit.py 
b/paimon-python/pypaimon/write/file_store_commit.py
index 7bb6db4dad..5f8f054aa8 100644
--- a/paimon-python/pypaimon/write/file_store_commit.py
+++ b/paimon-python/pypaimon/write/file_store_commit.py
@@ -150,7 +150,7 @@ class ManifestMergeResult:
 
 
 def _try_reuse_manifest_merge_result(retry_result, current_manifests):
-    if (retry_result is None
+    if (not isinstance(retry_result, CommitFailRetryResult)
             or retry_result.commit_result_may_be_uncertain
             or retry_result.manifest_merge_result is None):
         return None
@@ -180,6 +180,17 @@ class RetryResult(CommitResult):
         return False
 
 
+class CommitFailRetryResult(RetryResult):
+    """Retry after an atomic snapshot commit failed, matching Java."""
+
+
+class RollbackRetryResult(RetryResult):
+    """Retry after a conflicting compaction was rolled back, matching Java."""
+
+    def __init__(self, exception: Optional[Exception] = None):
+        super().__init__(None, exception)
+
+
 class RewriteResult(CommitResult):
 
     def __init__(self, rewrite: RowIdRewriteResult):
@@ -489,8 +500,8 @@ class FileStoreCommit:
                 else commit_entries_plan(latest_snapshot)
             )
 
-            # No entries to commit (e.g. drop_partitions with no matching 
data): skip commit
-            # to avoid creating manifest/snapshot with empty partition_stats 
(causes read errors).
+            # No entries to commit (e.g. drop_partitions with no matching
+            # data): skip an empty snapshot.
             if not commit_entries and not index_deletes and not index_adds:
                 break
 
@@ -587,7 +598,8 @@ class FileStoreCommit:
                          hash_index_base_snapshot=None,
                          commit_result_may_be_uncertain: bool = False) -> 
CommitResult:
         start_millis = int(time.time() * 1000)
-        if self._is_duplicate_commit(retry_result, latest_snapshot, 
commit_identifier, commit_kind):
+        if self._is_duplicate_commit(
+                retry_result, latest_snapshot, commit_identifier, commit_kind):
             return SuccessResult()
 
         latest_snapshot_id = latest_snapshot.id if latest_snapshot else 0
@@ -618,17 +630,22 @@ class FileStoreCommit:
         base_data_files = None
         if detect_conflicts:
             incremental = None
+            commit_fail_retry = (
+                retry_result
+                if isinstance(retry_result, CommitFailRetryResult)
+                else None
+            )
             if (latest_snapshot is not None
-                    and retry_result is not None
-                    and retry_result.latest_snapshot is not None
-                    and retry_result.base_data_files is not None):
+                    and commit_fail_retry is not None
+                    and commit_fail_retry.latest_snapshot is not None
+                    and commit_fail_retry.base_data_files is not None):
                 incremental = self.commit_scanner.read_incremental_changes(
-                    retry_result.latest_snapshot,
+                    commit_fail_retry.latest_snapshot,
                     latest_snapshot,
                     commit_entries,
                     index_entries)
             if incremental is not None:
-                base_data_files = list(retry_result.base_data_files)
+                base_data_files = list(commit_fail_retry.base_data_files)
                 if incremental:
                     base_data_files.extend(incremental)
                     base_data_files = FileEntry.merge_entries(base_data_files)
@@ -665,7 +682,7 @@ class FileStoreCommit:
                     if self.rollback.try_to_rollback(latest_snapshot):
                         # Rolled back: base/snapshot no longer valid; next 
attempt
                         # re-scans from scratch (matches Java 
RollbackRetryResult).
-                        return RetryResult(None, conflict_exception)
+                        return RollbackRetryResult(conflict_exception)
                 raise conflict_exception
 
         # Apply row tracking logic after conflict detection (matches Java 
ordering)
@@ -818,7 +835,7 @@ class FileStoreCommit:
                             merge_after_manifests,
                         )
                     )
-                    return RetryResult(
+                    return CommitFailRetryResult(
                         latest_snapshot,
                         None,
                         base_data_files=base_data_files,
@@ -827,7 +844,7 @@ class FileStoreCommit:
         except Exception as e:
             # Commit exception, not sure about the situation and should not 
clean up the files
             logger.warning("Retry commit for exception.", exc_info=True)
-            return RetryResult(
+            return CommitFailRetryResult(
                 latest_snapshot,
                 e,
                 base_data_files=base_data_files,
@@ -905,15 +922,31 @@ class FileStoreCommit:
         return self.manifest_file_manager.rolling_write(
             commit_entries, self.manifest_target_size, base_name)
 
-    def _is_duplicate_commit(self, retry_result, latest_snapshot, 
commit_identifier, commit_kind) -> bool:
-        if retry_result is not None and latest_snapshot is not None:
+    def _is_duplicate_commit(
+            self,
+            retry_result,
+            latest_snapshot,
+            commit_identifier,
+            commit_kind) -> bool:
+        if (isinstance(retry_result, CommitFailRetryResult)
+                and latest_snapshot is not None):
             start_check_snapshot_id = 1  # Snapshot.FIRST_SNAPSHOT_ID
             if retry_result.latest_snapshot is not None:
                 start_check_snapshot_id = retry_result.latest_snapshot.id + 1
 
             for snapshot_id in range(start_check_snapshot_id, 
latest_snapshot.id + 1):
                 snapshot = 
self.snapshot_manager.get_snapshot_by_id(snapshot_id)
-                if (snapshot and snapshot.commit_user == self.commit_user and
+                if snapshot is None:
+                    raise RuntimeError(
+                        "Cannot determine whether commit {} by user {} "
+                        "succeeded because snapshot {} cannot be found."
+                        .format(
+                            commit_identifier,
+                            self.commit_user,
+                            snapshot_id,
+                        )
+                    )
+                if (snapshot.commit_user == self.commit_user and
                         snapshot.commit_identifier == commit_identifier and
                         snapshot.commit_kind == commit_kind):
                     logger.info(

Reply via email to