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 5b1a46064d [python] Abort failed update-by-row-id writes (#8483)
5b1a46064d is described below

commit 5b1a46064d57abda8a0aa75ad266495965fe37b1
Author: QuakeWang <[email protected]>
AuthorDate: Tue Jul 7 15:21:18 2026 +0800

    [python] Abort failed update-by-row-id writes (#8483)
    
    `TableUpdateByRowId._write_group()` used to close writers when an
    exception happened after data/blob files had already been written but
    before commit messages were returned. `close()` preserves prepared
    files, so failed update-by-row-id jobs could leave orphan data or blob
    files even though no manifest commit happened.
    
    This PR tracks whether the write group completed successfully. On
    failure, it aborts the `FileStoreWrite` and any `BlobWriter`s so their
    already-written files are cleaned up. Successful writes still follow the
    existing close path.
---
 paimon-python/pypaimon/tests/table_update_test.py  | 54 ++++++++++++++++++++++
 .../pypaimon/write/table_update_by_row_id.py       | 16 +++++--
 2 files changed, 66 insertions(+), 4 deletions(-)

diff --git a/paimon-python/pypaimon/tests/table_update_test.py 
b/paimon-python/pypaimon/tests/table_update_test.py
index bb1feebd2d..adb08dd995 100644
--- a/paimon-python/pypaimon/tests/table_update_test.py
+++ b/paimon-python/pypaimon/tests/table_update_test.py
@@ -15,10 +15,12 @@
 # specific language governing permissions and limitations
 # under the License.
 
+import os
 import random
 import string
 import threading
 import unittest
+from unittest import mock
 
 import pyarrow as pa
 
@@ -1275,6 +1277,58 @@ class _StreamModeMixin(StreamModeMixin):
 class TableUpdateBatchTest(_BatchModeMixin, _TableUpdateTestBase, 
unittest.TestCase):
     """All shared update tests under batch (``BatchWriteBuilder``) 
semantics."""
 
+    def test_update_by_row_id_aborts_files_after_prepare_commit_failure(self):
+        from pypaimon.write.table_update_by_row_id import TableUpdateByRowId
+
+        table_schema = pa.schema([
+            ('id', pa.int32()),
+            ('age', pa.int32()),
+            ('picture', pa.large_binary()),
+        ])
+        table = self._create_table(pa_schema=table_schema)
+        self._write_arrow(table, pa.Table.from_pydict({
+            'id': [1, 2],
+            'age': [10, 20],
+            'picture': [b'blob-1', b'blob-2'],
+        }, schema=table_schema))
+
+        rb = table.new_read_builder().with_projection(['id', '_ROW_ID'])
+        row_ids = rb.new_read().to_arrow(
+            rb.new_scan().plan().splits()).sort_by('id')['_ROW_ID']
+        before_files = self._list_table_files(table)
+
+        def fail_after_prepare_commit(
+                new_files, first_row_id, column_names, blob_columns):
+            raise RuntimeError("forced failure after prepare_commit")
+
+        wb = self._make_write_builder(table)
+        tu = wb.new_update().with_update_type(['age', 'picture'])
+        with mock.patch.object(
+                TableUpdateByRowId,
+                '_assign_update_file_metadata',
+                new=staticmethod(fail_after_prepare_commit)):
+            with self.assertRaisesRegex(
+                    RuntimeError, "forced failure after prepare_commit"):
+                self._apply_update(tu, pa.Table.from_pydict({
+                    '_ROW_ID': [row_ids[0].as_py()],
+                    'age': [99],
+                    'picture': [b'updated-blob'],
+                }, schema=pa.schema([
+                    ('_ROW_ID', pa.int64()),
+                    ('age', pa.int32()),
+                    ('picture', pa.large_binary()),
+                ])), self._next_commit_id())
+
+        self.assertEqual(before_files, self._list_table_files(table))
+
+    @staticmethod
+    def _list_table_files(table):
+        return {
+            os.path.relpath(os.path.join(root, name), table.table_path)
+            for root, _dirs, files in os.walk(table.table_path)
+            for name in files
+        }
+
 
 class TableUpdateStreamTest(_StreamModeMixin, _TableUpdateTestBase, 
unittest.TestCase):
     """All shared update tests under stream (``StreamWriteBuilder``) semantics,
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 623668ad11..0142407993 100644
--- a/paimon-python/pypaimon/write/table_update_by_row_id.py
+++ b/paimon-python/pypaimon/write/table_update_by_row_id.py
@@ -538,6 +538,7 @@ class TableUpdateByRowId:
         new_files = []
         file_store_write = None
         blob_writers = []
+        success = False
         try:
             if merged_data is not None:
                 file_store_write = FileStoreWrite(self.table, self.commit_user)
@@ -575,11 +576,18 @@ class TableUpdateByRowId:
                         check_from_snapshot=self.snapshot_id,
                     )
                 )
+            success = True
         finally:
-            if file_store_write is not None:
-                file_store_write.close()
-            for blob_writer in blob_writers:
-                blob_writer.close()
+            if success:
+                if file_store_write is not None:
+                    file_store_write.close()
+                for blob_writer in blob_writers:
+                    blob_writer.close()
+            else:
+                if file_store_write is not None:
+                    file_store_write.abort()
+                for blob_writer in blob_writers:
+                    blob_writer.abort()
 
     @staticmethod
     def _assign_update_file_metadata(new_files: List[DataFileMeta], 
first_row_id: int,

Reply via email to