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 6464ef3011 [python] Recover row-id updates after concurrent compaction 
(#8915)
6464ef3011 is described below

commit 6464ef3011170a9a72452f6292d7a234e300e14a
Author: Jingsong Lee <[email protected]>
AuthorDate: Thu Jul 30 16:01:31 2026 +0800

    [python] Recover row-id updates after concurrent compaction (#8915)
---
 docs/docs/pypaimon/data-evolution.md               |  27 ++
 docs/generated/core_configuration.html             |   6 +
 .../main/java/org/apache/paimon/CoreOptions.java   |  14 +
 .../pypaimon/common/options/core_options.py        |  19 ++
 .../pypaimon/tests/e2e/java_py_read_write_test.py  |  17 +-
 .../pypaimon/tests/file_store_commit_test.py       |  38 ++-
 .../pypaimon/tests/table_upsert_by_key_test.py     | 261 ++++++++++++++++++
 .../pypaimon/write/commit/conflict_detection.py    |  40 +--
 .../write/commit/row_id_conflict_rewriter.py       | 293 +++++++++++++++++++++
 paimon-python/pypaimon/write/file_store_commit.py  | 102 ++++++-
 .../pypaimon/write/table_update_by_row_id.py       |  43 ++-
 11 files changed, 826 insertions(+), 34 deletions(-)

diff --git a/docs/docs/pypaimon/data-evolution.md 
b/docs/docs/pypaimon/data-evolution.md
index 43f31c63b8..279e942357 100644
--- a/docs/docs/pypaimon/data-evolution.md
+++ b/docs/docs/pypaimon/data-evolution.md
@@ -577,6 +577,33 @@ commit.close()
   same order for that shard.
 - **Parallelism**: run multiple shards by calling 
`new_shard_updator(shard_idx, num_shards)` for each shard.
 
+## Concurrent Compaction Recovery
+
+A partial-column update records the row-ID boundary of each data file it read.
+If compaction merges those files before `commit`, PyPaimon automatically
+rebases regular (non-BLOB and non-VECTOR) staged update files onto the latest
+file boundaries and retries the commit.
+
+The recovery is bounded by the total size of the current data files whose
+row-ID ranges are affected:
+
+```python
+options = {
+    'row-tracking.enabled': 'true',
+    'data-evolution.enabled': 'true',
+    'data-evolution.row-id-conflict-rewrite.max-size': '256 MB',
+}
+```
+
+The default is `256 MB`. Set the option to `0 B` to disable automatic
+rewriting. If the affected files exceed the configured size, or if the row IDs
+were removed by an overwrite, the commit keeps the normal
+`Row ID existence conflict` behavior. Logical concurrent updates are still
+checked and are never hidden by compaction recovery.
+
+Recovery is not attempted when deletion vectors are enabled, or when the same
+commit contains existing-row BLOB or VECTOR staged files.
+
 ## Stream Mode
 
 Data evolution also supports stream mode. The operation semantics are the same
diff --git a/docs/generated/core_configuration.html 
b/docs/generated/core_configuration.html
index 5670cc65c8..78d3e5a168 100644
--- a/docs/generated/core_configuration.html
+++ b/docs/generated/core_configuration.html
@@ -518,6 +518,12 @@ under the License.
             <td>Long</td>
             <td>Strictly contiguous same-partition logical row-id runs 
containing more than this number of rows are excluded from row-id reassignment. 
Set to 0 to disable this filtering.</td>
         </tr>
+        <tr>
+            <td><h5>data-evolution.row-id-conflict-rewrite.max-size</h5></td>
+            <td style="word-wrap: break-word;">256 mb</td>
+            <td>MemorySize</td>
+            <td>Maximum total size of current data files whose row-id ranges 
PyPaimon may automatically rebase staged updates against when a concurrent 
compaction changes file boundaries. Set to 0 B to disable.</td>
+        </tr>
         <tr>
             <td><h5>data-evolution.row-sidecar.enabled</h5></td>
             <td style="word-wrap: break-word;">false</td>
diff --git a/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java 
b/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java
index 7449142e8f..a405d1318e 100644
--- a/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java
+++ b/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java
@@ -2506,6 +2506,16 @@ public class CoreOptions implements Serializable {
                                     + "more than this number of rows are 
excluded from row-id "
                                     + "reassignment. Set to 0 to disable this 
filtering.");
 
+    public static final ConfigOption<MemorySize> 
DATA_EVOLUTION_ROW_ID_CONFLICT_REWRITE_MAX_SIZE =
+            key("data-evolution.row-id-conflict-rewrite.max-size")
+                    .memoryType()
+                    .defaultValue(MemorySize.ofMebiBytes(256))
+                    .withDescription(
+                            "Maximum total size of current data files whose 
row-id ranges PyPaimon "
+                                    + "may automatically rebase staged updates 
against when a "
+                                    + "concurrent compaction changes file 
boundaries. Set to 0 B "
+                                    + "to disable.");
+
     public static final ConfigOption<Boolean> 
DATA_EVOLUTION_ROW_SIDECAR_ENABLED =
             key("data-evolution.row-sidecar.enabled")
                     .booleanType()
@@ -4235,6 +4245,10 @@ public class CoreOptions implements Serializable {
         return threshold;
     }
 
+    public long dataEvolutionRowIdConflictRewriteMaxSize() {
+        return 
options.get(DATA_EVOLUTION_ROW_ID_CONFLICT_REWRITE_MAX_SIZE).getBytes();
+    }
+
     public boolean dataEvolutionRowSidecarEnabled() {
         return options.get(DATA_EVOLUTION_ROW_SIDECAR_ENABLED);
     }
diff --git a/paimon-python/pypaimon/common/options/core_options.py 
b/paimon-python/pypaimon/common/options/core_options.py
index f53c3722b4..8ee6016e64 100644
--- a/paimon-python/pypaimon/common/options/core_options.py
+++ b/paimon-python/pypaimon/common/options/core_options.py
@@ -659,6 +659,18 @@ class CoreOptions:
         .with_description("Whether to enable data evolution.")
     )
 
+    DATA_EVOLUTION_ROW_ID_CONFLICT_REWRITE_MAX_SIZE: ConfigOption[MemorySize] 
= (
+        ConfigOptions.key("data-evolution.row-id-conflict-rewrite.max-size")
+        .memory_type()
+        .default_value(MemorySize.of_mebi_bytes(256))
+        .with_description(
+            "Maximum total size of current data files whose row-id ranges "
+            "PyPaimon may automatically rebase staged updates against when "
+            "a concurrent compaction changes file boundaries. Set to 0 B "
+            "to disable."
+        )
+    )
+
     DATA_EVOLUTION_ROW_SIDECAR_ENABLED: ConfigOption[bool] = (
         ConfigOptions.key("data-evolution.row-sidecar.enabled")
         .boolean_type()
@@ -1261,6 +1273,13 @@ class CoreOptions:
     def data_evolution_enabled(self, default=None):
         return self.options.get(CoreOptions.DATA_EVOLUTION_ENABLED, default)
 
+    def data_evolution_row_id_conflict_rewrite_max_size(self, default=None):
+        value = self.options.get(
+            CoreOptions.DATA_EVOLUTION_ROW_ID_CONFLICT_REWRITE_MAX_SIZE,
+            default,
+        )
+        return value.get_bytes()
+
     def data_evolution_row_sidecar_enabled(self, default=None):
         return 
self.options.get(CoreOptions.DATA_EVOLUTION_ROW_SIDECAR_ENABLED, default)
 
diff --git a/paimon-python/pypaimon/tests/e2e/java_py_read_write_test.py 
b/paimon-python/pypaimon/tests/e2e/java_py_read_write_test.py
index 89b9833f1e..33bfe83e19 100644
--- a/paimon-python/pypaimon/tests/e2e/java_py_read_write_test.py
+++ b/paimon-python/pypaimon/tests/e2e/java_py_read_write_test.py
@@ -1613,7 +1613,7 @@ class JavaPyReadWriteTest(unittest.TestCase):
         1. Java writes 5 base files (testCompactConflictWriteBase)
         2. pypaimon ShardTableUpdator scans table, prepares evolution
         3. Java runs compact (testCompactConflictRunCompact)
-        4. pypaimon commits stale evolution -> conflict detected, raises 
RuntimeError
+        4. pypaimon rebases the stale evolution files and commits successfully
         """
         import subprocess
 
@@ -1659,13 +1659,18 @@ class JavaPyReadWriteTest(unittest.TestCase):
                          f"Java compact 
failed:\n{result.stdout}\n{result.stderr}")
         print("Java compact completed")
 
-        # Step 4: pypaimon commits stale evolution -> conflict detected
+        # Step 4: pypaimon rewrites stale evolution files against the 
compacted range
         tc = wb.new_commit()
-        with self.assertRaises(RuntimeError) as ctx:
-            tc.commit(stale_commit_msgs)
-        self.assertIn("conflict", str(ctx.exception))
+        tc.commit(stale_commit_msgs)
         tc.close()
-        print(f"Conflict detected as expected: {ctx.exception}")
+
+        read_builder = table.new_read_builder()
+        result = read_builder.new_read().to_arrow(
+            read_builder.new_scan().plan().splits())
+        self.assertEqual(
+            rows_read,
+            sum(value is not None for value in 
result.column('f2').to_pylist()),
+        )
 
     def test_blob_compact_conflict_update(self):
         import subprocess
diff --git a/paimon-python/pypaimon/tests/file_store_commit_test.py 
b/paimon-python/pypaimon/tests/file_store_commit_test.py
index c6451f0a9b..88350bfd5f 100644
--- a/paimon-python/pypaimon/tests/file_store_commit_test.py
+++ b/paimon-python/pypaimon/tests/file_store_commit_test.py
@@ -24,8 +24,12 @@ from pypaimon.manifest.schema.data_file_meta import 
DataFileMeta
 from pypaimon.manifest.schema.manifest_entry import ManifestEntry
 from pypaimon.snapshot.snapshot_commit import PartitionStatistics
 from pypaimon.table.row.generic_row import GenericRow
+from pypaimon.write.commit.row_id_conflict_rewriter import RowIdRewriteResult
 from pypaimon.write.commit_message import CommitMessage
-from pypaimon.write.file_store_commit import FileStoreCommit
+from pypaimon.write.file_store_commit import (
+    FileStoreCommit,
+    RewriteResult,
+)
 
 
 @patch('pypaimon.write.file_store_commit.ManifestFileManager')
@@ -503,6 +507,38 @@ class TestFileStoreCommit(unittest.TestCase):
         result = file_store_commit._write_manifest_files(entries, 
"manifest-test")
         self.assertIsNotNone(result)
 
+    def test_row_id_rewrite_respects_commit_retry_limit(
+            self, mock_manifest_list_manager, mock_manifest_file_manager):
+        file_store_commit = self._create_file_store_commit()
+        file_store_commit.commit_max_retries = 1
+        file_store_commit.commit_timeout = 10 ** 9
+        file_store_commit._commit_retry_wait = Mock()
+
+        latest_snapshot = Mock()
+        latest_snapshot.id = 7
+        file_store_commit.snapshot_manager.get_latest_snapshot.return_value = (
+            latest_snapshot
+        )
+
+        commit_entry = Mock()
+        rewrite = RewriteResult(RowIdRewriteResult([commit_entry], 1))
+        file_store_commit._try_commit_once = Mock(side_effect=[
+            rewrite,
+            rewrite,
+            AssertionError("rewrite retry budget was not enforced"),
+        ])
+
+        with self.assertRaises(RuntimeError) as ctx:
+            file_store_commit._try_commit(
+                commit_kind="APPEND",
+                commit_identifier=11,
+                commit_entries_plan=lambda snapshot: [commit_entry],
+            )
+
+        self.assertIn("with 1 retries", str(ctx.exception))
+        self.assertEqual(2, file_store_commit._try_commit_once.call_count)
+        file_store_commit._commit_retry_wait.assert_called_once_with(0)
+
     @staticmethod
     def _to_entries(commit_messages):
         commit_entries = []
diff --git a/paimon-python/pypaimon/tests/table_upsert_by_key_test.py 
b/paimon-python/pypaimon/tests/table_upsert_by_key_test.py
index bd05a276ff..feb98ae3c6 100644
--- a/paimon-python/pypaimon/tests/table_upsert_by_key_test.py
+++ b/paimon-python/pypaimon/tests/table_upsert_by_key_test.py
@@ -15,10 +15,13 @@
 # specific language governing permissions and limitations
 # under the License.
 
+import os
 import unittest
+from unittest import mock
 
 import pyarrow as pa
 
+from pypaimon.table.special_fields import SpecialFields
 from pypaimon.tests.data_evolution_test_helpers import (
     BatchModeMixin,
     DataEvolutionTestBase,
@@ -87,6 +90,43 @@ class _TableUpsertByKeyTestBase(DataEvolutionTestBase):
         tc.close()
         return msgs
 
+    def _compact_all_data_files(self, table):
+        """Replace all current data files with one COMPACT output file."""
+        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))
+
+        wb = table.new_batch_write_builder()
+        writer = wb.new_write()
+        writer.write_arrow(current)
+        messages = writer.prepare_commit()
+        self.assertEqual(1, len(messages))
+        self.assertEqual(1, len(messages[0].new_files))
+        messages[0].new_files = [
+            messages[0].new_files[0].assign_first_row_id(0)
+        ]
+        messages[0].deleted_files.extend(old_files)
+
+        commit = wb.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)
+        )
+        commit.commit(messages)
+        writer.close()
+        commit.close()
+
     # ==================================================================
     # Basic upsert tests (non-partitioned)
     # ==================================================================
@@ -359,6 +399,227 @@ class _TableUpsertByKeyTestBase(DataEvolutionTestBase):
         self.assertEqual('Dave',     rows[4])
         self.assertEqual('Eve',      rows[5])
 
+    def test_commit_rewrites_stale_update_after_compaction(self):
+        table = self._create_table()
+        self._write_arrow(table, pa.Table.from_pydict({
+            'id': [1, 2],
+            'name': ['Alice', 'Bob'],
+            'age': [25, 30],
+            'city': ['NYC', 'LA'],
+        }, schema=self.pa_schema))
+        self._write_arrow(table, pa.Table.from_pydict({
+            'id': [3, 4],
+            'name': ['Carol', 'Dave'],
+            'age': [35, 40],
+            'city': ['Chicago', 'Houston'],
+        }, schema=self.pa_schema))
+
+        wb = self._make_write_builder(table)
+        update = wb.new_update().with_update_type(['age', 'city'])
+        commit_identifier = self._next_commit_id()
+        messages = self._apply_upsert(
+            update,
+            pa.Table.from_pydict({
+                'id': [2, 3],
+                'name': ['ignored', 'ignored'],
+                'age': [31, 36],
+                'city': ['LA2', 'Chicago2'],
+            }, schema=self.pa_schema),
+            ['id'],
+            commit_identifier,
+        )
+        stale_paths = [
+            file.file_path
+            for message in messages
+            for file in message.new_files
+        ]
+
+        self._compact_all_data_files(table)
+
+        commit = wb.new_commit()
+        self._apply_commit(commit, messages, commit_identifier)
+        commit.close()
+
+        rows = {
+            row['id']: (row['name'], row['age'], row['city'])
+            for row in self._read_all(table).to_pylist()
+        }
+        self.assertEqual(('Bob', 31, 'LA2'), rows[2])
+        self.assertEqual(('Carol', 36, 'Chicago2'), rows[3])
+        self.assertEqual(('Dave', 40, 'Houston'), rows[4])
+        self.assertTrue(all(os.path.exists(path) for path in stale_paths))
+
+    def test_commit_rewrite_uses_checked_base_entries(self):
+        table = self._create_table()
+        self._write_arrow(table, pa.Table.from_pydict({
+            'id': [1, 2],
+            'name': ['Alice', 'Bob'],
+            'age': [25, 30],
+            'city': ['NYC', 'LA'],
+        }, schema=self.pa_schema))
+        self._write_arrow(table, pa.Table.from_pydict({
+            'id': [3, 4],
+            'name': ['Carol', 'Dave'],
+            'age': [35, 40],
+            'city': ['Chicago', 'Houston'],
+        }, schema=self.pa_schema))
+
+        wb = self._make_write_builder(table)
+        update = wb.new_update().with_update_type(['age'])
+        commit_identifier = self._next_commit_id()
+        messages = self._apply_upsert(
+            update,
+            pa.Table.from_pydict({
+                'id': [2],
+                'name': ['ignored'],
+                'age': [31],
+                'city': ['ignored'],
+            }, schema=self.pa_schema),
+            ['id'],
+            commit_identifier,
+        )
+        self._compact_all_data_files(table)
+
+        from pypaimon.write.table_update_by_row_id import TableUpdateByRowId
+
+        original_build = TableUpdateByRowId._files_info_from_entries
+        advanced = [False]
+
+        def build_after_concurrent_compaction(
+                updater_cls, current_table, snapshot_id, entries):
+            if not advanced[0]:
+                advanced[0] = True
+                self._write_arrow(table, pa.Table.from_pydict({
+                    'id': [5],
+                    'name': ['Eve'],
+                    'age': [45],
+                    'city': ['Boston'],
+                }, schema=self.pa_schema))
+                self._compact_all_data_files(table)
+            return original_build(current_table, snapshot_id, entries)
+
+        with mock.patch.object(
+                TableUpdateByRowId,
+                '_load_existing_files_info',
+                side_effect=AssertionError("unexpected snapshot scan"),
+        ), mock.patch.object(
+                TableUpdateByRowId,
+                '_files_info_from_entries',
+                classmethod(build_after_concurrent_compaction)):
+            commit = wb.new_commit()
+            self._apply_commit(commit, messages, commit_identifier)
+            commit.close()
+
+        rows = {
+            row['id']: row['age']
+            for row in self._read_all(table).to_pylist()
+        }
+        self.assertEqual(31, rows[2])
+        self.assertEqual(45, rows[5])
+
+    def test_commit_rewrite_respects_max_size(self):
+        options = dict(self.table_options)
+        options[
+            'data-evolution.row-id-conflict-rewrite.max-size'
+        ] = '1 B'
+        table = self._create_table(options=options)
+        self._write_arrow(table, pa.Table.from_pydict({
+            'id': [1, 2],
+            'name': ['Alice', 'Bob'],
+            'age': [25, 30],
+            'city': ['NYC', 'LA'],
+        }, schema=self.pa_schema))
+        self._write_arrow(table, pa.Table.from_pydict({
+            'id': [3, 4],
+            'name': ['Carol', 'Dave'],
+            'age': [35, 40],
+            'city': ['Chicago', 'Houston'],
+        }, schema=self.pa_schema))
+
+        wb = self._make_write_builder(table)
+        update = wb.new_update().with_update_type(['age'])
+        commit_identifier = self._next_commit_id()
+        messages = self._apply_upsert(
+            update,
+            pa.Table.from_pydict({
+                'id': [2],
+                'name': ['ignored'],
+                'age': [31],
+                'city': ['ignored'],
+            }, schema=self.pa_schema),
+            ['id'],
+            commit_identifier,
+        )
+        self._compact_all_data_files(table)
+
+        commit = wb.new_commit()
+        with self.assertRaises(RuntimeError) as ctx:
+            self._apply_commit(commit, messages, commit_identifier)
+        commit.close()
+        self.assertIn('Row ID existence conflict', str(ctx.exception))
+        self.assertIn(
+            'data-evolution.row-id-conflict-rewrite.max-size',
+            str(ctx.exception),
+        )
+
+    def test_compaction_rewrite_does_not_hide_logical_update_conflict(self):
+        table = self._create_table()
+        self._write_arrow(table, pa.Table.from_pydict({
+            'id': [1, 2],
+            'name': ['Alice', 'Bob'],
+            'age': [25, 30],
+            'city': ['NYC', 'LA'],
+        }, schema=self.pa_schema))
+        self._write_arrow(table, pa.Table.from_pydict({
+            'id': [3, 4],
+            'name': ['Carol', 'Dave'],
+            'age': [35, 40],
+            'city': ['Chicago', 'Houston'],
+        }, schema=self.pa_schema))
+
+        wb = self._make_write_builder(table)
+        update = wb.new_update().with_update_type(['age'])
+        commit_identifier = self._next_commit_id()
+        stale_messages = self._apply_upsert(
+            update,
+            pa.Table.from_pydict({
+                'id': [2],
+                'name': ['ignored'],
+                'age': [31],
+                'city': ['ignored'],
+            }, schema=self.pa_schema),
+            ['id'],
+            commit_identifier,
+        )
+
+        self._upsert(
+            table,
+            pa.Table.from_pydict({
+                'id': [2],
+                'name': ['ignored'],
+                'age': [99],
+                'city': ['ignored'],
+            }, schema=self.pa_schema),
+            ['id'],
+            update_cols=['age'],
+        )
+        self._compact_all_data_files(table)
+
+        commit = wb.new_commit()
+        with self.assertRaises(RuntimeError):
+            self._apply_commit(
+                commit,
+                stale_messages,
+                commit_identifier,
+            )
+        commit.close()
+
+        rows = {
+            row['id']: row['age']
+            for row in self._read_all(table).to_pylist()
+        }
+        self.assertEqual(99, rows[2])
+
     def test_large_table_upsert(self):
         """Upsert that touches a wide selection of rows in a 200-row table."""
         table = self._create_table()
diff --git a/paimon-python/pypaimon/write/commit/conflict_detection.py 
b/paimon-python/pypaimon/write/commit/conflict_detection.py
index 3534960c1c..3799223959 100644
--- a/paimon-python/pypaimon/write/commit/conflict_detection.py
+++ b/paimon-python/pypaimon/write/commit/conflict_detection.py
@@ -153,6 +153,23 @@ class CommitConflictError(RuntimeError):
     """A deterministic pre-snapshot conflict which is safe to abort."""
 
 
+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."""
 
@@ -512,16 +529,7 @@ class ConflictDetection:
             key = (entry.partition, entry.bucket,
                    entry.file.first_row_id, entry.file.row_count)
             if key not in existing_index:
-                return RuntimeError(
-                    "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))
+                return RowIdExistenceConflict(entry)
 
         return None
 
@@ -635,7 +643,8 @@ class ConflictDetection:
             count=entry.file.row_count,
         )
 
-    def check_row_id_from_snapshot(self, latest_snapshot, commit_entries):
+    def check_row_id_from_snapshot(
+            self, latest_snapshot, commit_entries, check_compaction=True):
         if not self.data_evolution_enabled:
             return None
         if self._row_id_check_from_snapshot is None:
@@ -672,10 +681,11 @@ class ConflictDetection:
                 continue
 
             if snapshot.commit_kind == "COMPACT":
-                err = self._compact_conflicts_with_delta(
-                    snapshot, delta_signatures, column_checker, commit_entries)
-                if err is not None:
-                    return err
+                if check_compaction:
+                    err = self._compact_conflicts_with_delta(
+                        snapshot, delta_signatures, column_checker, 
commit_entries)
+                    if err is not None:
+                        return err
                 continue
 
             incremental_entries = 
self.commit_scanner.read_incremental_entries_from_changed_partitions(
diff --git a/paimon-python/pypaimon/write/commit/row_id_conflict_rewriter.py 
b/paimon-python/pypaimon/write/commit/row_id_conflict_rewriter.py
new file mode 100644
index 0000000000..7eb623271e
--- /dev/null
+++ b/paimon-python/pypaimon/write/commit/row_id_conflict_rewriter.py
@@ -0,0 +1,293 @@
+# 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 dataclass
+from typing import List, Optional
+
+import pyarrow as pa
+
+from pypaimon.manifest.schema.data_file_meta import DataFileMeta
+from pypaimon.manifest.schema.manifest_entry import ManifestEntry
+from pypaimon.read.split import DataSplit
+from pypaimon.read.table_read import TableRead
+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_message import CommitMessage
+from pypaimon.write.table_update_by_row_id import TableUpdateByRowId
+
+
+@dataclass
+class RowIdRewriteResult:
+    commit_entries: List[ManifestEntry]
+    rewritten_file_count: int
+
+
+class RowIdConflictRewriter:
+    """Rebase stale partial-column files onto current row-id file ranges."""
+
+    def __init__(
+            self,
+            table,
+            commit_user: str,
+            commit_identifier: int,
+            max_rewrite_size: int):
+        self.table = table
+        self.commit_user = commit_user
+        self.commit_identifier = commit_identifier
+        self.max_rewrite_size = max_rewrite_size
+
+    def rewrite(
+            self,
+            latest_snapshot,
+            base_entries: List[ManifestEntry],
+            delta_entries: List[ManifestEntry],
+    ) -> Optional[RowIdRewriteResult]:
+        if self.max_rewrite_size <= 0:
+            return None
+        if self.table.options.deletion_vectors_enabled(False):
+            return None
+
+        current_exact_ranges = {
+            self._range_key(entry)
+            for entry in base_entries
+            if self._is_normal_row_id_file(entry.file)
+        }
+        candidates = [
+            entry
+            for entry in delta_entries
+            if self._is_rewrite_candidate(
+                entry, current_exact_ranges, latest_snapshot.next_row_id)
+        ]
+        if not candidates:
+            return None
+
+        candidate_ids = {id(entry) for entry in candidates}
+        if any(
+                self._is_dedicated_file(entry.file)
+                and entry.file.first_row_id is not None
+                and entry.file.first_row_id < latest_snapshot.next_row_id
+                for entry in delta_entries
+        ):
+            return None
+
+        if not self._ranges_are_still_covered(base_entries, candidates):
+            return None
+
+        affected_files = self._affected_current_files(base_entries, candidates)
+        affected_size = sum(file.file_size for file in affected_files)
+        if affected_size > self.max_rewrite_size:
+            raise RuntimeError(
+                "Automatic row-id conflict rewrite requires reading "
+                "{} bytes across {} current data file(s), exceeding "
+                "'data-evolution.row-id-conflict-rewrite.max-size'={} bytes."
+                .format(
+                    affected_size,
+                    len(affected_files),
+                    self.max_rewrite_size,
+                )
+            )
+
+        new_messages = []
+        try:
+            files_info = TableUpdateByRowId._files_info_from_entries(
+                self.table,
+                latest_snapshot.id,
+                base_entries,
+            )
+            groups = self._group_by_write_columns(candidates)
+            for column_names, entries in groups:
+                update_tables = [
+                    self._read_staged_update(entry, column_names)
+                    for entry in entries
+                ]
+                update_data = (
+                    update_tables[0]
+                    if len(update_tables) == 1
+                    else pa.concat_tables(update_tables)
+                )
+                row_ids = update_data[SpecialFields.ROW_ID.name].to_pylist()
+                if len(row_ids) != len(set(row_ids)):
+                    raise RuntimeError(
+                        "Automatic row-id conflict rewrite cannot merge "
+                        "overlapping staged files for columns {}.".format(
+                            column_names)
+                    )
+                updater = TableUpdateByRowId(
+                    self.table,
+                    self.commit_user,
+                    self.commit_identifier,
+                    _precomputed_files_info=files_info,
+                )
+                try:
+                    new_messages.extend(
+                        updater.update_columns(update_data, column_names)
+                    )
+                except Exception:
+                    self._abort(updater.commit_messages)
+                    raise
+        except Exception:
+            self._abort(new_messages)
+            raise
+
+        rewritten_entries = [
+            entry for entry in delta_entries if id(entry) not in candidate_ids
+        ]
+        rewritten_entries.extend(self._to_manifest_entries(new_messages))
+        return RowIdRewriteResult(
+            commit_entries=rewritten_entries,
+            rewritten_file_count=len(candidates),
+        )
+
+    def _is_rewrite_candidate(
+            self, entry, current_exact_ranges, next_row_id):
+        file = entry.file
+        return (
+            self._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 self._range_key(entry) not in current_exact_ranges
+        )
+
+    def _ranges_are_still_covered(self, base_entries, candidates):
+        current_ranges = {}
+        for entry in base_entries:
+            if not self._is_normal_row_id_file(entry.file):
+                continue
+            key = (tuple(entry.partition.values), entry.bucket)
+            current_ranges.setdefault(key, []).append(
+                entry.file.row_id_range())
+        current_ranges = {
+            key: Range.sort_and_merge_overlap(ranges, True, True)
+            for key, ranges in current_ranges.items()
+        }
+
+        for entry in candidates:
+            key = (tuple(entry.partition.values), entry.bucket)
+            if entry.file.row_id_range().exclude(
+                    current_ranges.get(key, [])):
+                return False
+        return True
+
+    def _affected_current_files(self, base_entries, candidates):
+        affected = {}
+        for base in base_entries:
+            if not self._is_normal_row_id_file(base.file):
+                continue
+            base_key = (tuple(base.partition.values), base.bucket)
+            for candidate in candidates:
+                candidate_key = (
+                    tuple(candidate.partition.values), candidate.bucket)
+                if (
+                    base_key == candidate_key
+                    and base.file.row_id_range().overlaps(
+                        candidate.file.row_id_range())
+                ):
+                    key = (
+                        base_key,
+                        base.file.external_path or base.file.file_path
+                        or base.file.file_name,
+                    )
+                    affected[key] = base.file
+                    break
+        return list(affected.values())
+
+    @staticmethod
+    def _group_by_write_columns(candidates):
+        groups = {}
+        for entry in candidates:
+            key = tuple(entry.file.write_cols)
+            groups.setdefault(key, []).append(entry)
+        return [
+            (list(columns), entries)
+            for columns, entries in groups.items()
+        ]
+
+    def _read_staged_update(self, entry, column_names):
+        read_fields = [self.table.field_dict[name] for name in column_names]
+        read_fields.append(SpecialFields.ROW_ID)
+        split = DataSplit(
+            files=[entry.file],
+            partition=entry.partition,
+            bucket=entry.bucket,
+            raw_convertible=True,
+        )
+        result = TableRead(
+            self.table,
+            predicate=None,
+            read_type=read_fields,
+        ).to_arrow([split])
+        if result.num_rows != entry.file.row_count:
+            raise RuntimeError(
+                "Automatic row-id conflict rewrite read {} rows from staged "
+                "file '{}', expected {}.".format(
+                    result.num_rows,
+                    entry.file.file_name,
+                    entry.file.row_count,
+                )
+            )
+        return result
+
+    def _to_manifest_entries(self, messages: List[CommitMessage]):
+        entries = []
+        for message in messages:
+            partition = GenericRow(
+                list(message.partition),
+                self.table.partition_keys_fields,
+            )
+            for file in message.new_files:
+                entries.append(ManifestEntry(
+                    kind=0,
+                    partition=partition,
+                    bucket=message.bucket,
+                    total_buckets=self.table.total_buckets,
+                    file=file,
+                ))
+        return entries
+
+    def _abort(self, messages):
+        for message in messages:
+            for file in message.new_files:
+                path = file.external_path or file.file_path
+                if path:
+                    self.table.file_io.delete_quietly(path)
+
+    @staticmethod
+    def _range_key(entry):
+        return (
+            tuple(entry.partition.values),
+            entry.bucket,
+            entry.file.first_row_id,
+            entry.file.row_count,
+        )
+
+    @staticmethod
+    def _is_normal_row_id_file(file):
+        return (
+            file.first_row_id is not None
+            and not RowIdConflictRewriter._is_dedicated_file(file)
+        )
+
+    @staticmethod
+    def _is_dedicated_file(file):
+        return (
+            DataFileMeta.is_blob_file(file.file_name)
+            or DataFileMeta.is_vector_file(file.file_name)
+        )
diff --git a/paimon-python/pypaimon/write/file_store_commit.py 
b/paimon-python/pypaimon/write/file_store_commit.py
index 8c7484071a..73f3d22429 100644
--- a/paimon-python/pypaimon/write/file_store_commit.py
+++ b/paimon-python/pypaimon/write/file_store_commit.py
@@ -41,6 +41,11 @@ from pypaimon.write.commit.commit_scanner import 
CommitScanner
 from pypaimon.write.commit.conflict_detection import (
     CommitConflictError,
     ConflictDetection,
+    RowIdExistenceConflict,
+)
+from pypaimon.write.commit.row_id_conflict_rewriter import (
+    RowIdConflictRewriter,
+    RowIdRewriteResult,
 )
 from pypaimon.write.commit.overwrite_changes_provider import 
OverwriteChangesProvider
 from pypaimon.table.special_fields import SpecialFields
@@ -79,6 +84,15 @@ class RetryResult(CommitResult):
         return False
 
 
+class RewriteResult(CommitResult):
+
+    def __init__(self, rewrite: RowIdRewriteResult):
+        self.rewrite = rewrite
+
+    def is_success(self) -> bool:
+        return False
+
+
 class FileStoreCommit:
     """
     Core commit logic for file store operations.
@@ -112,6 +126,9 @@ class FileStoreCommit:
         self.commit_timeout = table.options.commit_timeout()
         self.commit_min_retry_wait = table.options.commit_min_retry_wait()
         self.commit_max_retry_wait = table.options.commit_max_retry_wait()
+        self.row_id_conflict_rewrite_max_size = (
+            table.options.data_evolution_row_id_conflict_rewrite_max_size()
+        )
 
         self.commit_scanner = CommitScanner(table, self.manifest_list_manager)
 
@@ -360,10 +377,15 @@ class FileStoreCommit:
 
         retry_count = 0
         retry_result = None
+        rewritten_commit_entries = None
         start_time_ms = int(time.time() * 1000)
         while True:
             latest_snapshot = self.snapshot_manager.get_latest_snapshot()
-            commit_entries = commit_entries_plan(latest_snapshot)
+            commit_entries = (
+                rewritten_commit_entries
+                if rewritten_commit_entries is not None
+                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).
@@ -384,7 +406,22 @@ class FileStoreCommit:
                 hash_index_base_snapshot=hash_index_base_snapshot,
             )
 
-            if result.is_success():
+            if isinstance(result, RewriteResult):
+                rewritten_commit_entries = result.rewrite.commit_entries
+                self.conflict_detection._row_id_check_from_snapshot = (
+                    latest_snapshot.id
+                )
+                # No snapshot commit was attempted for the conflicting files,
+                # so the rewritten attempt is still deterministic.
+                retry_result = None
+                logger.info(
+                    "Rewrote %d stale row-id file(s) against snapshot %d "
+                    "before retrying commit to table %s.",
+                    result.rewrite.rewritten_file_count,
+                    latest_snapshot.id,
+                    self.table.identifier,
+                )
+            elif result.is_success():
                 commit_duration_ms = int(time.time() * 1000) - start_time_ms
                 if commit_kind == "OVERWRITE":
                     logger.info(
@@ -399,8 +436,8 @@ class FileStoreCommit:
                         commit_duration_ms,
                     )
                 break
-
-            retry_result = result
+            else:
+                retry_result = result
 
             elapsed_ms = int(time.time() * 1000) - start_time_ms
             if elapsed_ms > self.commit_timeout or retry_count >= 
self.commit_max_retries:
@@ -421,7 +458,7 @@ class FileStoreCommit:
                     f"after {elapsed_ms} millis with {retry_count} retries, "
                     f"there maybe exist commit conflicts between multiple 
jobs."
                 )
-                if retry_result.exception:
+                if retry_result is not None and retry_result.exception:
                     raise RuntimeError(error_msg) from retry_result.exception
                 else:
                     raise RuntimeError(error_msg)
@@ -505,6 +542,18 @@ class FileStoreCommit:
             )
 
             if conflict_exception is not None:
+                rewrite_result = self._try_rewrite_row_id_conflict(
+                    retry_result,
+                    conflict_exception,
+                    latest_snapshot,
+                    base_data_files,
+                    commit_entries,
+                    commit_kind,
+                    commit_identifier,
+                    changelog_entries,
+                )
+                if rewrite_result is not None:
+                    return RewriteResult(rewrite_result)
                 if allow_rollback and self.rollback is not None:
                     if self.rollback.try_to_rollback(latest_snapshot):
                         # Rolled back: base/snapshot no longer valid; next 
attempt
@@ -658,6 +707,49 @@ class FileStoreCommit:
 
         return SuccessResult()
 
+    def _try_rewrite_row_id_conflict(
+            self,
+            retry_result,
+            conflict_exception,
+            latest_snapshot,
+            base_data_files,
+            commit_entries,
+            commit_kind,
+            commit_identifier,
+            changelog_entries):
+        if not isinstance(conflict_exception, RowIdExistenceConflict):
+            return None
+        if commit_kind != "APPEND" or changelog_entries:
+            return None
+        if retry_result is not None and retry_result.exception is not None:
+            return None
+
+        non_compaction_conflict = (
+            self.conflict_detection.check_row_id_from_snapshot(
+                latest_snapshot,
+                commit_entries,
+                check_compaction=False,
+            )
+        )
+        if non_compaction_conflict is not None:
+            return None
+
+        try:
+            return RowIdConflictRewriter(
+                self.table,
+                self.commit_user,
+                commit_identifier,
+                self.row_id_conflict_rewrite_max_size,
+            ).rewrite(
+                latest_snapshot,
+                base_data_files,
+                commit_entries,
+            )
+        except RuntimeError as rewrite_error:
+            raise CommitConflictError(
+                "{} {}".format(conflict_exception, rewrite_error)
+            ) from conflict_exception
+
     def _write_manifest_files(self, commit_entries, base_name):
         return self.manifest_file_manager.rolling_write(
             commit_entries, self.manifest_target_size, base_name)
diff --git a/paimon-python/pypaimon/write/table_update_by_row_id.py 
b/paimon-python/pypaimon/write/table_update_by_row_id.py
index 8963bf4abc..b0caa6ad03 100644
--- a/paimon-python/pypaimon/write/table_update_by_row_id.py
+++ b/paimon-python/pypaimon/write/table_update_by_row_id.py
@@ -24,6 +24,10 @@ import pyarrow as pa
 import pyarrow.compute as pc
 
 from pypaimon.manifest.schema.data_file_meta import DataFileMeta
+from pypaimon.manifest.schema.manifest_entry import ManifestEntry
+from pypaimon.read.scanner.data_evolution_split_generator import (
+    DataEvolutionSplitGenerator,
+)
 from pypaimon.read.split import DataSplit
 from pypaimon.read.table_read import TableRead
 from pypaimon.schema.data_types import (
@@ -97,7 +101,7 @@ class TableUpdateByRowId:
         self.commit_messages: List[CommitMessage] = []
 
     def _snapshot_files_info(self) -> _FilesInfo:
-        """Internal: return the current snapshot's file index for broadcast."""
+        """Return the already loaded snapshot file index for broadcast."""
         return _FilesInfo(
             snapshot_id=self.snapshot_id,
             first_row_ids=self.first_row_ids,
@@ -115,8 +119,27 @@ class TableUpdateByRowId:
         """
         scan = self.table.new_read_builder().new_scan()
         plan = scan.plan_for_write()
-        splits = plan.splits()
-
+        snapshot_id = plan.snapshot_id if plan.snapshot_id is not None else -1
+        return self._files_info_from_splits(snapshot_id, plan.splits())
+
+    @classmethod
+    def _files_info_from_entries(
+            cls,
+            table,
+            snapshot_id: int,
+            entries: List[ManifestEntry],
+    ) -> _FilesInfo:
+        """Build a file index from an already resolved snapshot entry set."""
+        splits = DataEvolutionSplitGenerator(
+            table,
+            table.options.source_split_target_size(),
+            table.options.source_split_open_file_cost(),
+        ).create_splits(entries)
+        return cls._files_info_from_splits(snapshot_id, splits)
+
+    @classmethod
+    def _files_info_from_splits(
+            cls, snapshot_id: int, splits: List[DataSplit]) -> _FilesInfo:
         index: Dict[int, Tuple[DataSplit, List[DataFileMeta]]] = {}
         row_id_ranges: List[Range] = []
         for split in splits:
@@ -128,14 +151,19 @@ class TableUpdateByRowId:
                 if not DataFileMeta.is_blob_file(file.file_name)
             ]
             for file in split.files:
-                if file.first_row_id is None or 
DataFileMeta.is_blob_file(file.file_name):
+                if (
+                        file.first_row_id is None
+                        or DataFileMeta.is_blob_file(file.file_name)
+                ):
                     continue
                 row_id_ranges.append(file.row_id_range())
             for file in data_files:
                 target_files = [
                     target_file
                     for target_file in files_with_row_id
-                    if self._overlaps(file.row_id_range(), 
target_file.row_id_range())
+                    if cls._overlaps(
+                        file.row_id_range(), target_file.row_id_range()
+                    )
                 ]
 
                 entry = index.get(file.first_row_id)
@@ -143,7 +171,9 @@ class TableUpdateByRowId:
                     index[file.first_row_id] = (split, target_files)
                 else:
                     existing_files = entry[1]
-                    existing_names = {existing.file_name for existing in 
existing_files}
+                    existing_names = {
+                        existing.file_name for existing in existing_files
+                    }
                     existing_files.extend(
                         target_file
                         for target_file in target_files
@@ -155,7 +185,6 @@ class TableUpdateByRowId:
         else:
             merged = []
 
-        snapshot_id = plan.snapshot_id if plan.snapshot_id is not None else -1
         return _FilesInfo(
             snapshot_id=snapshot_id,
             first_row_ids=sorted(index.keys()),

Reply via email to