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 ae83ef3182 [python] Reject overlapping row-id update batches (#9484)
ae83ef3182 is described below

commit ae83ef318293ff1234f2158e7c10bd5e752a1d42
Author: Yann Byron <[email protected]>
AuthorDate: Mon Aug 31 17:27:10 2026 +0800

    [python] Reject overlapping row-id update batches (#9484)
---
 paimon-python/pypaimon/tests/table_update_test.py  | 75 +++++++++++++++++++++-
 paimon-python/pypaimon/write/table_update.py       |  7 +-
 .../pypaimon/write/table_update_by_row_id.py       | 18 ++++++
 3 files changed, 98 insertions(+), 2 deletions(-)

diff --git a/paimon-python/pypaimon/tests/table_update_test.py 
b/paimon-python/pypaimon/tests/table_update_test.py
index 9b183be85d..130dd53686 100644
--- a/paimon-python/pypaimon/tests/table_update_test.py
+++ b/paimon-python/pypaimon/tests/table_update_test.py
@@ -32,6 +32,7 @@ from pypaimon.tests.data_evolution_test_helpers import (
     StreamModeMixin,
 )
 from pypaimon.write.table_update import BatchTableUpdate
+from pypaimon.write.table_update_by_row_id import TableUpdateByRowId
 
 
 # ======================================================================
@@ -71,6 +72,30 @@ def test_batch_row_id_update_batches_reuse_file_index():
     ]
 
 
+def test_batch_row_id_update_detects_overlap_before_write():
+    updater = TableUpdateByRowId.__new__(TableUpdateByRowId)
+    updater.table = mock.Mock(field_names=['age'])
+    updater.commit_messages = []
+    updater._updated_first_row_ids_by_column = {}
+
+    def route(data):
+        return data.append_column(
+            TableUpdateByRowId.FIRST_ROW_ID_COLUMN,
+            pa.array([0], type=pa.int64()),
+        )
+
+    with mock.patch.object(
+            updater, '_calculate_first_row_id', side_effect=route):
+        with mock.patch.object(updater, '_write_by_first_row_id') as write:
+            updater.update_columns(
+                pa.table({'_ROW_ID': [0], 'age': [26]}), ['age'])
+            with pytest.raises(ValueError, match='overlapping first_row_ids'):
+                updater.update_columns(
+                    pa.table({'_ROW_ID': [1], 'age': [31]}), ['age'])
+
+    assert write.call_count == 1
+
+
 class _TableUpdateTestBase(DataEvolutionTestBase):
     """Shared tests for ``TableUpdate.update_by_arrow_with_row_id``.
 
@@ -1606,6 +1631,50 @@ class _StreamModeMixin(StreamModeMixin):
 class TableUpdateBatchTest(_BatchModeMixin, _TableUpdateTestBase, 
unittest.TestCase):
     """All shared update tests under batch (``BatchWriteBuilder``) 
semantics."""
 
+    def test_update_batches_reject_same_file_and_abort_staged_files(self):
+        table = self._create_seeded_table()
+        before_files = self._list_table_files(table)
+        update = (
+            self._make_write_builder(table)
+            .new_update()
+            .with_update_type(['age'])
+        )
+
+        with self.assertRaisesRegex(
+                ValueError, "overlapping first_row_ids.*0"):
+            update.update_by_arrow_batches_with_row_id(iter([
+                pa.Table.from_pydict({'_ROW_ID': [0], 'age': [26]}),
+                pa.Table.from_pydict({'_ROW_ID': [1], 'age': [31]}),
+            ]))
+
+        self.assertEqual(before_files, self._list_table_files(table))
+        self.assertEqual(
+            [25, 30, 35, 40, 45],
+            self._read_all(table)['age'].to_pylist(),
+        )
+
+    def test_update_batches_allow_same_file_for_different_columns(self):
+        table = self._create_seeded_table()
+        builder = self._make_write_builder(table)
+        messages = (
+            builder.new_update()
+            .update_by_arrow_batches_with_row_id(iter([
+                pa.Table.from_pydict({'_ROW_ID': [0], 'age': [26]}),
+                pa.Table.from_pydict({'_ROW_ID': [1], 'city': ['Seattle']}),
+            ]))
+        )
+
+        commit = builder.new_commit()
+        commit.commit(messages)
+        commit.close()
+
+        rows = self._read_all(table)
+        self.assertEqual([26, 30, 35, 40, 45], rows['age'].to_pylist())
+        self.assertEqual(
+            ['NYC', 'Seattle', 'Chicago', 'Houston', 'Phoenix'],
+            rows['city'].to_pylist(),
+        )
+
     def test_callable_output_preserves_large_offset_chunks(self):
         from pypaimon.write.table_update import TableUpdate
         from pypaimon.write.table_update_by_row_id import TableUpdateByRowId
@@ -1629,13 +1698,17 @@ class TableUpdateBatchTest(_BatchModeMixin, 
_TableUpdateTestBase, unittest.TestC
         updater = TableUpdateByRowId.__new__(TableUpdateByRowId)
         updater.table = mock.Mock(field_names=['payload'])
         updater.commit_messages = []
+        updater._updated_first_row_ids_by_column = {}
         updates = pa.Table.from_arrays(
             [pa.array([1, 0], type=pa.int64()), result],
             names=['_ROW_ID', 'payload'],
         )
         with mock.patch.object(
                 updater, '_calculate_first_row_id',
-                side_effect=lambda data: data) as calculate:
+                side_effect=lambda data: data.append_column(
+                    TableUpdateByRowId.FIRST_ROW_ID_COLUMN,
+                    pa.array([0, 0], type=pa.int64()),
+                )) as calculate:
             with mock.patch.object(updater, '_write_by_first_row_id'):
                 updater.update_columns(updates, ['payload'])
 
diff --git a/paimon-python/pypaimon/write/table_update.py 
b/paimon-python/pypaimon/write/table_update.py
index a9404b32fe..c358644460 100644
--- a/paimon-python/pypaimon/write/table_update.py
+++ b/paimon-python/pypaimon/write/table_update.py
@@ -647,7 +647,12 @@ class BatchTableUpdate(TableUpdate):
     def update_by_arrow_batches_with_row_id(
             self, tables: Iterable[pa.Table]
     ) -> List[CommitMessage]:
-        """Apply row-id updates from batches using one target-file index."""
+        """Apply row-id updates from batches using one target-file index.
+
+        For each updated column, batches must target disjoint ``first_row_id``
+        file groups. Conflicting overlap is rejected and all files staged by
+        earlier batches are aborted.
+        """
         return self._update_by_arrow_batches_with_row_id(
             tables, BATCH_COMMIT_IDENTIFIER)
 
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 85ccfb4c6e..da9b1a8cdc 100644
--- a/paimon-python/pypaimon/write/table_update_by_row_id.py
+++ b/paimon-python/pypaimon/write/table_update_by_row_id.py
@@ -99,6 +99,7 @@ class TableUpdateByRowId:
         self.valid_row_id_ranges = info.valid_row_id_ranges
 
         self.commit_messages: List[CommitMessage] = []
+        self._updated_first_row_ids_by_column: Dict[str, Set[int]] = {}
 
     def _snapshot_files_info(self) -> _FilesInfo:
         """Return the already loaded snapshot file index for broadcast."""
@@ -219,7 +220,24 @@ class TableUpdateByRowId:
                 raise ValueError(f"Column {col_name} not found in table 
schema")
 
         data_with_first_row_id = self._calculate_first_row_id(data)
+        first_row_ids = set(
+            data_with_first_row_id[self.FIRST_ROW_ID_COLUMN].to_pylist()
+        )
+        overlapping = {}
+        for col_name in column_names:
+            ids = first_row_ids.intersection(
+                self._updated_first_row_ids_by_column.get(col_name, set()))
+            if ids:
+                overlapping[col_name] = sorted(ids)
+        if overlapping:
+            raise ValueError(
+                "Input batches contain overlapping first_row_ids by column: "
+                f"{overlapping}"
+            )
         self._write_by_first_row_id(data_with_first_row_id, column_names)
+        for col_name in column_names:
+            self._updated_first_row_ids_by_column.setdefault(
+                col_name, set()).update(first_row_ids)
 
         return self.commit_messages
 

Reply via email to