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 6b29174a82 [python] Enable native BLOB collection writes and packed 
video reads (#10399)
6b29174a82 is described below

commit 6b29174a82a7017be60922589a96d0a4e5ebbd18
Author: Jingsong Lee <[email protected]>
AuthorDate: Tue Oct 6 16:57:18 2026 +0800

    [python] Enable native BLOB collection writes and packed video reads 
(#10399)
---
 paimon-python/README.md                            |  15 +-
 paimon-python/pypaimon/daft/daft_datasink.py       |   6 +
 paimon-python/pypaimon/index/dynamic_bucket.py     |   5 +-
 .../pypaimon/ray/data_evolution_merge_join.py      |   8 +-
 .../pypaimon/read/reader/field_indices.py          |   3 +
 paimon-python/pypaimon/read/split_read.py          |   5 +-
 paimon-python/pypaimon/read/table_read.py          |   4 +-
 paimon-python/pypaimon/tests/blob_table_test.py    |   4 +-
 .../pypaimon/tests/daft/daft_sink_test.py          |  40 +++
 .../pypaimon/tests/data_evolution_formats_test.py  |  32 +-
 .../pypaimon/tests/deletion_vector_path_test.py    |   5 +-
 .../pypaimon/tests/file_store_commit_test.py       |  99 +++---
 .../pypaimon/tests/multimodal_lerobot_test.py      |  15 +-
 .../tests/native_blob_collection_write_test.py     | 354 +++++++++++++++++++++
 .../pypaimon/tests/native_blob_write_test.py       |  14 +-
 .../tests/native_cross_partition_write_test.py     |   4 +-
 .../pypaimon/tests/native_dynamic_write_test.py    |   4 +-
 .../pypaimon/tests/native_nested_update_test.py    |   4 +-
 .../tests/native_postpone_fixed_write_test.py      |   7 +-
 .../pypaimon/tests/native_postpone_write_test.py   |   2 +-
 paimon-python/pypaimon/tests/native_read_test.py   |   7 +-
 paimon-python/pypaimon/tests/native_update_test.py |   5 +-
 .../pypaimon/tests/native_video_read_test.py       | 194 +++++++++++
 .../pypaimon/tests/py36/ao_simple_test.py          |   6 +-
 .../tests/ray_data_evolution_merge_into_test.py    |   8 +-
 .../tests/ray_row_id_conflict_rewriter_test.py     |   6 +-
 paimon-python/pypaimon/tests/ray_sink_test.py      |   6 +-
 paimon-python/pypaimon/tests/table_commit_test.py  |  65 ++++
 paimon-python/pypaimon/tests/table_update_test.py  | 112 ++++---
 .../pypaimon/tests/table_upsert_by_key_test.py     |  69 ++++
 .../tests/write/changelog_producer_test.py         |   6 +-
 .../pypaimon/tests/write/dynamic_bucket_test.py    |  49 ++-
 .../pypaimon/tests/write/table_write_test.py       |  12 +-
 .../pypaimon/tests/write/write_buffer_test.py      |   1 +
 .../write/commit/row_id_conflict_rewriter.py       |  13 +-
 paimon-python/pypaimon/write/commit_message.py     |   1 -
 paimon-python/pypaimon/write/file_store_commit.py  |  89 +++---
 paimon-python/pypaimon/write/file_store_write.py   |  16 +-
 paimon-python/pypaimon/write/native_update.py      |   5 +-
 paimon-python/pypaimon/write/native_write.py       |  29 +-
 paimon-python/pypaimon/write/ray_datasink.py       |   4 +
 paimon-python/pypaimon/write/table_commit.py       |  10 +-
 paimon-python/pypaimon/write/table_update.py       |   8 +-
 .../pypaimon/write/table_update_by_row_id.py       |   3 +
 paimon-python/pypaimon/write/table_write.py        |  15 +-
 .../pypaimon/write/writer/composite_data_writer.py |  11 +
 paimon-python/pypaimon/write/writer/data_writer.py |   4 +-
 47 files changed, 1107 insertions(+), 277 deletions(-)

diff --git a/paimon-python/README.md b/paimon-python/README.md
index 42d38e0561..dedf06db7d 100644
--- a/paimon-python/README.md
+++ b/paimon-python/README.md
@@ -248,13 +248,14 @@ BLOB table selects the Python writer before any native 
data is written. Switchin
 to row writes after native Arrow writes is rejected. Use 
`write.native.enabled=false` when
 mixing Arrow batches and Python `Blob` objects in one writer.
 
-An explicit native writer `abort()` also deletes prepared files that have not
-been passed to a PyPaimon committer. Calling `close()` instead releases those
-files to the caller without deleting them; use `commit.abort(messages)` to
-discard them after closing the writer. Once a commit attempt starts, writer
-abort preserves its files even if the attempt raises, because a snapshot may
-already reference them. Stream writers retain cleanup ownership only for
-messages that have not been submitted to a committer.
+A successful `prepare_commit()` transfers file ownership to the caller.
+Writer `abort()` and `close()` preserve those prepared files and may clean only
+outputs still owned by the writer. Explicit `commit.abort(messages)` deletes
+newly written data, changelog, sidecars and added indexes, matching Java's
+Paimon Table committer. Call it only when those messages are known to be
+uncommitted and will never be submitted. Never call it after a commit with an
+unknown outcome: publication may have succeeded before its response failed.
+Internal failure paths preserve prepared files instead of calling commit abort.
 
 Both native options are disabled by default.
 
diff --git a/paimon-python/pypaimon/daft/daft_datasink.py 
b/paimon-python/pypaimon/daft/daft_datasink.py
index 1486832a26..1376eebe2b 100644
--- a/paimon-python/pypaimon/daft/daft_datasink.py
+++ b/paimon-python/pypaimon/daft/daft_datasink.py
@@ -256,6 +256,7 @@ class PaimonDataSink(DataSink[list[Any]]):
 
     def write(self, micropartitions: Iterator[MicroPartition]) -> 
Iterator[WriteResult[list[Any]]]:
         table_write = self._write_builder.new_write()
+        commit_messages = []
 
         total_rows = 0
         total_bytes = 0
@@ -278,6 +279,8 @@ class PaimonDataSink(DataSink[list[Any]]):
                 table_write.abort()
             except Exception:
                 logger.warning("Failed to abort Daft Paimon table write.", 
exc_info=True)
+            # Never delete files from CommitMessage on failure.
+            # A commit can succeed even when its response raises an exception.
             raise
 
         yield WriteResult(
@@ -717,6 +720,7 @@ def make_group_write_udf(
                 ignore_existing=mode == "overwrite",
                 base_snapshot_id=group_base_snapshot_id,
             )
+        commit_messages = []
         try:
             if precomputed_bucket:
                 offset = 0
@@ -749,6 +753,8 @@ def make_group_write_udf(
                     "Failed to abort Daft Paimon group write.",
                     exc_info=True,
                 )
+            # Never delete files from CommitMessage on failure.
+            # A commit can succeed even when its response raises an exception.
             raise
 
         return Series.from_arrow(
diff --git a/paimon-python/pypaimon/index/dynamic_bucket.py 
b/paimon-python/pypaimon/index/dynamic_bucket.py
index f89862875c..a2838a9494 100644
--- a/paimon-python/pypaimon/index/dynamic_bucket.py
+++ b/paimon-python/pypaimon/index/dynamic_bucket.py
@@ -444,6 +444,7 @@ class DynamicBucketIndexMaintainer:
 
     def prepare_commit(self) -> Dict[Tuple[Tuple, int], 
DynamicBucketIndexChanges]:
         changes: Dict[Tuple[Tuple, int], DynamicBucketIndexChanges] = {}
+        prepared_states = {}
         for key, (hashes, old_entry, modified) in self._states.items():
             if not modified:
                 continue
@@ -460,7 +461,9 @@ class DynamicBucketIndexMaintainer:
                     )
                 ] if old_entry is not None else [],
             )
-            self._states[key] = (hashes, new_entry, False)
+            prepared_states[key] = (hashes, new_entry, False)
+        # A failed bucket must not make a retry forget earlier additions.
+        self._states.update(prepared_states)
         return changes
 
     def release_prepared(self) -> None:
diff --git a/paimon-python/pypaimon/ray/data_evolution_merge_join.py 
b/paimon-python/pypaimon/ray/data_evolution_merge_join.py
index 01d4f4228d..3a261259a7 100644
--- a/paimon-python/pypaimon/ray/data_evolution_merge_join.py
+++ b/paimon-python/pypaimon/ray/data_evolution_merge_join.py
@@ -345,7 +345,6 @@ def _self_merge_aliases(batch: pa.Table, row_id_name: str) 
-> pa.Table:
 def _apply_self_merge_update_group(context, file_group, collect_row_ids):
     """Read, transform, and stage one complete first-row-id file group."""
     from pypaimon.read.table_read import TableRead
-    from pypaimon.write.file_store_commit import _abort_commit_messages
     from pypaimon.write.row_id_file_index import RowIdFileIndex
 
     table_read = TableRead(
@@ -394,7 +393,8 @@ def _apply_self_merge_update_group(context, file_group, 
collect_row_ids):
     try:
         messages = updater.update_columns(updates, context.update_cols)
     except Exception:
-        _abort_commit_messages(context.table, updater.commit_messages)
+        # Never delete files from CommitMessage on failure.
+        # A commit can succeed even when its response raises an exception.
         raise
     return messages, updates.num_rows, row_ids
 
@@ -466,8 +466,8 @@ def distributed_self_merge_update_apply(
             submit_next()
 
     if first_error is not None:
-        from pypaimon.write.file_store_commit import _abort_commit_messages
-        _abort_commit_messages(plan.table, messages)
+        # Never delete files from CommitMessage on failure.
+        # A commit can succeed even when its response raises an exception.
         raise first_error
     return messages, num_updated, row_ids
 
diff --git a/paimon-python/pypaimon/read/reader/field_indices.py 
b/paimon-python/pypaimon/read/reader/field_indices.py
index 78180e707b..b5a6690a6b 100644
--- a/paimon-python/pypaimon/read/reader/field_indices.py
+++ b/paimon-python/pypaimon/read/reader/field_indices.py
@@ -41,6 +41,9 @@ def descriptor_field_names_for_table(table) -> Set[str]:
     from pypaimon.common.options.core_options import CoreOptions
 
     names = set(CoreOptions.blob_descriptor_fields(table.options))
+    # Video readers return lazy frame references even in payload mode. Keep
+    # their frame/index locators through row filters and result projection.
+    names |= CoreOptions.video_frame_fields(table.options)
     if CoreOptions.blob_as_descriptor(table.options):
         names |= CoreOptions.blob_view_fields(table.options)
     return names
diff --git a/paimon-python/pypaimon/read/split_read.py 
b/paimon-python/pypaimon/read/split_read.py
index d518ca5abe..7107692b3d 100644
--- a/paimon-python/pypaimon/read/split_read.py
+++ b/paimon-python/pypaimon/read/split_read.py
@@ -108,9 +108,10 @@ def deferred_blob_field_names(table, read_fields: 
List[DataField],
             or CoreOptions.blob_as_descriptor(table.options)):
         return set()
 
-    inline_fields = (
+    non_payload_fields = (
         CoreOptions.blob_descriptor_fields(table.options)
         | CoreOptions.blob_view_fields(table.options)
+        | CoreOptions.video_frame_fields(table.options)
     )
     predicate_fields = (
         predicate_field_names(predicate) if predicate is not None else set()
@@ -118,7 +119,7 @@ def deferred_blob_field_names(table, read_fields: 
List[DataField],
     return {
         read_fields[index].name
         for index in blob_field_indices(read_fields)
-        if read_fields[index].name not in inline_fields
+        if read_fields[index].name not in non_payload_fields
         and read_fields[index].name not in predicate_fields
     }
 
diff --git a/paimon-python/pypaimon/read/table_read.py 
b/paimon-python/pypaimon/read/table_read.py
index dadbb893e1..e94df3f0af 100644
--- a/paimon-python/pypaimon/read/table_read.py
+++ b/paimon-python/pypaimon/read/table_read.py
@@ -60,7 +60,7 @@ _NATIVE_READ_FILE_FORMATS = frozenset({
 })
 _NATIVE_READ_FILE_SUFFIXES = tuple(
     '.%s' % file_format for file_format in _NATIVE_READ_FILE_FORMATS)
-_NATIVE_BLOB_FILE_SUFFIX = '.blob'
+_NATIVE_BLOB_FILE_SUFFIXES = ('.blob', '.video')
 
 
 class _ClosableArrowBatchReader:
@@ -810,7 +810,7 @@ class TableRead:
             file_name = data_file.file_name.lower()
             if ('.vector.' not in file_name
                     and not file_name.endswith(_NATIVE_READ_FILE_SUFFIXES)
-                    and not file_name.endswith(_NATIVE_BLOB_FILE_SUFFIX)):
+                    and not file_name.endswith(_NATIVE_BLOB_FILE_SUFFIXES)):
                 return False
         return True
 
diff --git a/paimon-python/pypaimon/tests/blob_table_test.py 
b/paimon-python/pypaimon/tests/blob_table_test.py
index 41b86fd57b..6ba1a7d0af 100755
--- a/paimon-python/pypaimon/tests/blob_table_test.py
+++ b/paimon-python/pypaimon/tests/blob_table_test.py
@@ -1748,7 +1748,7 @@ class DedicatedFormatWriterTest(unittest.TestCase):
             'payloads': pa.array([[('bad', None)]], type=map_blob_type),
         }, schema=pa_schema)
         invalid_writer = table.new_batch_write_builder().new_write()
-        with self.assertRaisesRegex(ValueError, "does not allow null values"):
+        with self.assertRaisesRegex(ValueError, "does not allow null 
values|non-nullable"):
             invalid_writer.write_arrow(invalid_data)
         invalid_writer.abort()
 
@@ -1821,7 +1821,7 @@ class DedicatedFormatWriterTest(unittest.TestCase):
             'payloads': pa.array([[b'bad', None]], type=array_blob_type),
         }, schema=pa_schema)
         invalid_writer = table.new_batch_write_builder().new_write()
-        with self.assertRaisesRegex(ValueError, "does not allow null 
elements"):
+        with self.assertRaisesRegex(ValueError, "does not allow null 
elements|cannot contain nulls"):
             invalid_writer.write_arrow(invalid_data)
         invalid_writer.abort()
 
diff --git a/paimon-python/pypaimon/tests/daft/daft_sink_test.py 
b/paimon-python/pypaimon/tests/daft/daft_sink_test.py
index f6a2b95ce0..bf14da8a28 100644
--- a/paimon-python/pypaimon/tests/daft/daft_sink_test.py
+++ b/paimon-python/pypaimon/tests/daft/daft_sink_test.py
@@ -403,6 +403,46 @@ def test_write_paimon_pk_table(pk_table):
     assert result.column("id").to_pylist() == [1, 2, 3]
 
 
[email protected]_write
[email protected]('grouped', [False, True])
+def 
test_dedicated_close_failure_preserves_prepared_files(local_paimon_catalog, 
grouped):
+    from unittest.mock import patch
+    from pypaimon.daft.daft_datasink import make_group_write_udf
+    from pypaimon.write.writer.dedicated_format_writer import 
DedicatedFormatWriter
+
+    catalog, _ = local_paimon_catalog
+    arrow_schema = pa.schema([('id', pa.int32()), ('payload', 
pa.large_binary())])
+    catalog.create_table('test_db.close_failure', 
pypaimon.Schema.from_pyarrow_schema(
+        arrow_schema, options={'row-tracking.enabled': 'true', 
'data-evolution.enabled': 'true',
+                               'write.native.enabled': 'false'}), False)
+    table = catalog.get_table('test_db.close_failure')
+    data = pa.table({'id': [1, 2, 3], 'payload': [b'a', b'b', b'c']}, 
schema=arrow_schema)
+    original_close = DedicatedFormatWriter._close_current_writers
+    calls = []
+
+    def fail_after_prepare(writer):
+        calls.append(writer)
+        if len(calls) == 1:
+            return original_close(writer)
+        raise RuntimeError('Close failed after prepare')
+
+    with patch.object(DedicatedFormatWriter, '_close_current_writers', 
fail_after_prepare):
+        with pytest.raises(RuntimeError, match='Close failed after prepare'):
+            if grouped:
+                # Exercise the worker body synchronously so the injected close
+                # error reaches the same writer which prepared the files.
+                with patch('daft.func.batch', side_effect=lambda **kwargs: 
lambda function: function):
+                    write_group = make_group_write_udf(table, 'append', None)
+                write_group(*(daft.Series.from_arrow(column.combine_chunks()) 
for column in data.columns))
+            else:
+                
list(PaimonDataSink(table).write(iter([MicroPartition.from_arrow(data)])))
+    assert len(calls) == 2
+    assert {os.path.splitext(name)[1]
+            for _, _, names in os.walk(table.table_path) for name in names
+            if name.endswith(('.parquet', '.blob'))} == {'.parquet', '.blob'}
+    assert table.snapshot_manager().get_latest_snapshot() is None
+
+
 # ---------------------------------------------------------------------------
 # Schema conversion tests
 # ---------------------------------------------------------------------------
diff --git a/paimon-python/pypaimon/tests/data_evolution_formats_test.py 
b/paimon-python/pypaimon/tests/data_evolution_formats_test.py
index d3fb26ceec..f5844de37c 100644
--- a/paimon-python/pypaimon/tests/data_evolution_formats_test.py
+++ b/paimon-python/pypaimon/tests/data_evolution_formats_test.py
@@ -540,7 +540,8 @@ class DataEvolutionFormatsTest(unittest.TestCase):
         self.assertEqual(actual.column('id').to_pylist(), [1, 2, 3])
         self.assertEqual(actual.column('payload').to_pylist(), blobs)
 
-    def test_blob_abort_deletes_uncommitted_files(self):
+    @pytest.mark.python_write
+    def test_blob_abort_preserves_prepared_files_until_committer_aborts(self):
         pa_schema = pa.schema([
             ('id', pa.int32()),
             ('payload', pa.large_binary()),
@@ -569,6 +570,13 @@ class DataEvolutionFormatsTest(unittest.TestCase):
 
         writer.abort()
 
+        for file_meta in all_files:
+            self.assertTrue(table.file_io.exists(self._file_path(file_meta)))
+        commit = table.new_batch_write_builder().new_commit()
+        try:
+            commit.abort(commit_messages)
+        finally:
+            commit.close()
         for file_meta in all_files:
             self.assertFalse(
                 table.file_io.exists(self._file_path(file_meta)),
@@ -902,7 +910,8 @@ class DataEvolutionFormatsTest(unittest.TestCase):
     # Vector (vortex) file format for embedding columns
     # ------------------------------------------------------------------
 
-    def test_vector_abort_deletes_uncommitted_files(self):
+    @pytest.mark.python_write
+    def 
test_vector_abort_preserves_prepared_files_until_committer_aborts(self):
         pa_schema = pa.schema([
             ('id', pa.int64()),
             ('embed', pa.list_(pa.float32(), 3)),
@@ -935,13 +944,23 @@ class DataEvolutionFormatsTest(unittest.TestCase):
 
         writer.abort()
 
+        # prepare_commit hands these files to the committer, as Java's
+        # drainIncrement does. The writer no longer owns their cleanup.
+        for file_meta in all_files:
+            self.assertTrue(table.file_io.exists(self._file_path(file_meta)))
+        commit = table.new_batch_write_builder().new_commit()
+        try:
+            commit.abort(commit_messages)
+        finally:
+            commit.close()
         for file_meta in all_files:
             self.assertFalse(
                 table.file_io.exists(self._file_path(file_meta)),
                 f"Expected abort to delete {file_meta.file_name}",
             )
 
-    def test_vector_close_failure_after_prepare_raises(self):
+    @pytest.mark.python_write
+    def test_vector_close_failure_preserves_prepared_files(self):
         from unittest.mock import patch
 
         pa_schema = pa.schema([
@@ -977,6 +996,13 @@ class DataEvolutionFormatsTest(unittest.TestCase):
             with self.assertRaisesRegex(RuntimeError, "Close error"):
                 writer.close()
 
+        for file_meta in all_files:
+            self.assertTrue(table.file_io.exists(self._file_path(file_meta)))
+        commit = table.new_batch_write_builder().new_commit()
+        try:
+            commit.abort(commit_messages)
+        finally:
+            commit.close()
         for file_meta in all_files:
             self.assertFalse(
                 table.file_io.exists(self._file_path(file_meta)),
diff --git a/paimon-python/pypaimon/tests/deletion_vector_path_test.py 
b/paimon-python/pypaimon/tests/deletion_vector_path_test.py
index 9ed9b6179d..d1f53116e9 100644
--- a/paimon-python/pypaimon/tests/deletion_vector_path_test.py
+++ b/paimon-python/pypaimon/tests/deletion_vector_path_test.py
@@ -34,7 +34,6 @@ from pypaimon import CatalogFactory, Schema
 from pypaimon.manifest.index_manifest_file import IndexManifestFile
 from pypaimon.read.native_plan import native_plan, native_version_at_least
 from pypaimon.schema.data_types import AtomicType
-from pypaimon.write.file_store_commit import _abort_commit_messages
 from pypaimon.write.commit_message import CommitMessage
 from pypaimon.write.table_delete import TableDeleteByRowId
 
@@ -205,7 +204,7 @@ def 
test_floating_bucket_dv_does_not_search_python_directory(tmp_path, file_uri,
 
 
 @pytest.mark.parametrize('layout', ['bucket', 'bucket-external', 
'global-external'])
-def test_abort_removes_uncommitted_dv_from_its_actual_directory(tmp_path, 
layout):
+def 
test_explicit_abort_removes_uncommitted_dv_from_its_actual_directory(tmp_path, 
layout):
     table = _table(tmp_path, layout, 'a/b')
     _delete(table, [0])
     builder = table.new_batch_write_builder()
@@ -213,7 +212,7 @@ def 
test_abort_removes_uncommitted_dv_from_its_actual_directory(tmp_path, layout
     uncommitted = [entry for message in messages for entry in 
message.index_adds]
     assert uncommitted
     files_before = set(tmp_path.rglob('index-*'))
-    _abort_commit_messages(table, messages)
+    builder.new_commit().abort(messages)
     files_after = set(tmp_path.rglob('index-*'))
     removed = {path.name for path in files_before - files_after}
     assert removed == {entry.index_file.file_name for entry in uncommitted}
diff --git a/paimon-python/pypaimon/tests/file_store_commit_test.py 
b/paimon-python/pypaimon/tests/file_store_commit_test.py
index 674b590bed..39c0649c8c 100644
--- a/paimon-python/pypaimon/tests/file_store_commit_test.py
+++ b/paimon-python/pypaimon/tests/file_store_commit_test.py
@@ -40,7 +40,6 @@ from pypaimon.write.file_store_commit import (
     FileStoreCommit,
     RollbackRetryResult,
     RewriteResult,
-    _abort_commit_messages,
     _reject_compact_increment,
     _row_id_check_from_messages,
 )
@@ -79,67 +78,53 @@ class TestRowIdCheckFromMessages(unittest.TestCase):
                 None, [CommitMessage((), 0, [], check_from_snapshot=-1)], 1)
 
 
-class TestAbortCommitMessages(unittest.TestCase):
+class TestExplicitCommitAbort(unittest.TestCase):
 
-    @staticmethod
-    def _file_meta(**kwargs):
-        return DataFileMeta(
-            file_name='data.parquet', file_size=1, row_count=1,
-            min_key=None, max_key=None, key_stats=None, value_stats=None,
-            min_sequence_number=0, max_sequence_number=0, schema_id=0,
-            level=0, extra_files=kwargs.pop('extra_files', []), **kwargs)
-
-    def test_reconstructs_local_path_after_wire_decode(self):
-        table = Mock()
-        table.path_factory.return_value.bucket_path.return_value = 
'/table/p=1/bucket-0'
-        file = self._file_meta()
-        message = CommitMessage((1,), 0, [file])
-        _abort_commit_messages(table, [message])
-        table.file_io.delete_quietly.assert_called_once_with(
-            '/table/p=1/bucket-0/data.parquet')
-
-    def test_reconstructs_aligned_sidecars_after_wire_decode(self):
-        table = Mock()
-        table.path_factory.return_value.bucket_path.return_value = 
'/table/p=1/bucket-0'
-        file = self._file_meta(extra_files=['data.parquet.index'])
-        _abort_commit_messages(table, [CommitMessage((1,), 0, [file])])
-        self.assertEqual(table.file_io.delete_quietly.call_args_list, [
-            unittest.mock.call('/table/p=1/bucket-0/data.parquet'),
-            unittest.mock.call('/table/p=1/bucket-0/data.parquet.index'),
-        ])
-
-    def test_deletes_literal_external_paths_and_preserves_metadata(self):
+    def 
test_abort_deletes_new_data_sidecars_changelog_compact_and_indexes_only(self):
         with TemporaryDirectory() as directory:
             parent = Path(directory) / 'pt=a%2Fb%25%3F%23'
             parent.mkdir()
-            paths = [parent / 'data.parquet', parent / 'data.parquet.index']
+            paths = [parent / name for name in (
+                'data.parquet', 'data.parquet.index', 'changelog.parquet',
+                'compact-before.parquet', 'compact-after.parquet',
+                'compact-changelog.parquet', 'index-file', 'old-index')]
             for path in paths:
-                path.touch()
-            external_path = 'file:' + paths[0].as_posix()
-            file = self._file_meta(external_path=external_path, 
extra_files=[paths[1].name])
-            table = Mock(file_io=LocalFileIO())
-            _abort_commit_messages(table, [CommitMessage(('a/b%?#',), 0, 
[file])])
-            self.assertFalse(any(path.exists() for path in paths))
-            self.assertEqual(file.external_path, external_path)
-            self.assertEqual(file.extra_files, [paths[1].name])
-            table.path_factory.assert_not_called()
-
-    def test_index_path_failure_does_not_escape_abort(self):
-        table = Mock()
-        table.path_factory.side_effect = RuntimeError("path lookup failed")
-        index_file = Mock(file_name="index-file", external_path=None)
-        message = Mock(
-            new_files=[],
-            changelog_files=[],
-            index_adds=[Mock(index_file=index_file)],
-            compact_after=[],
-            compact_changelog_files=[],
-            compact_index_adds=[],
-        )
-
-        with self.assertLogs(
-                'pypaimon.write.file_store_commit', level='WARNING'):
-            _abort_commit_messages(table, [message])
+                path.write_bytes(b'keep')
+
+            def meta(path, **kwargs):
+                return DataFileMeta(
+                    file_name=path.name, file_size=4, row_count=1,
+                    min_key=None, max_key=None, key_stats=None, 
value_stats=None,
+                    min_sequence_number=0, max_sequence_number=0, schema_id=0,
+                    level=0, extra_files=kwargs.pop('extra_files', []),
+                    external_path='file:' + path.as_posix(), **kwargs)
+
+            index_entry = Mock(index_file=Mock(index_type='BTREE', 
external_path=str(paths[-2])))
+            old_index = Mock(index_file=Mock(index_type='BTREE', 
external_path=str(paths[-1])))
+            message = CommitMessage(
+                ('a/b%?#',), 0, [meta(paths[0], extra_files=[paths[1].name])],
+                deleted_files=[meta(paths[3])], 
changelog_files=[meta(paths[2])],
+                compact_before=[meta(paths[3])], 
compact_after=[meta(paths[4])],
+                compact_changelog_files=[meta(paths[5])], 
index_adds=[index_entry],
+                index_deletes=[old_index], compact_index_adds=[index_entry],
+                compact_index_deletes=[old_index])
+            commit = FileStoreCommit.__new__(FileStoreCommit)
+            commit.table = Mock(file_io=LocalFileIO())
+            commit.abort([message])
+            commit.abort([message])
+            for i, path in enumerate(paths):
+                self.assertEqual(path.exists(), i in (3, len(paths) - 1), 
str(path))
+
+    def test_abort_continues_after_storage_cleanup_errors(self):
+        commit = FileStoreCommit.__new__(FileStoreCommit)
+        commit.table = Mock()
+        commit.table.file_io.delete_quietly.side_effect = [OSError('cleanup 
failed'), None]
+        first = Mock()
+        first.collect_files.return_value = ['/first']
+        second = Mock()
+        second.collect_files.return_value = ['/second']
+        commit.abort([CommitMessage((), 0, [first, second])])
+        self.assertEqual(commit.table.file_io.delete_quietly.call_count, 2)
 
 
 class TestFileStoreCommitRowTracking(unittest.TestCase):
diff --git a/paimon-python/pypaimon/tests/multimodal_lerobot_test.py 
b/paimon-python/pypaimon/tests/multimodal_lerobot_test.py
index 748378f7a8..73e39a74b9 100644
--- a/paimon-python/pypaimon/tests/multimodal_lerobot_test.py
+++ b/paimon-python/pypaimon/tests/multimodal_lerobot_test.py
@@ -2859,7 +2859,6 @@ class LeRobotValidationTest(unittest.TestCase):
             target_frame_values = {
                 55: 168, 56: 216, 61: 56, 62: 88, 63: 120,
             }
-            expected_frame_values = [168, 216, 56, 88, 120]
 
             info_dir = temp_dir / "meta"
             info_dir.mkdir()
@@ -2900,6 +2899,14 @@ class LeRobotValidationTest(unittest.TestCase):
                         container.mux(packet)
                 for packet in stream.encode():
                     container.mux(packet)
+            # MPEG4 is lossy: compare imported frames with the source video's
+            # decoded pixels rather than its uncompressed input images.
+            with av.open(str(video_path)) as container:
+                expected_frame_values = [
+                    float(frame.to_ndarray(format="rgb24").mean())
+                    for index, frame in enumerate(container.decode(video=0))
+                    if index in target_frame_values
+                ]
             camera_b_path = (
                 temp_dir / "videos/camera_b/chunk-000/file-000.mp4")
             camera_b_path.parent.mkdir(parents=True)
@@ -2999,7 +3006,7 @@ class LeRobotValidationTest(unittest.TestCase):
             np.testing.assert_allclose(
                 [row["decoded"][0] for row in decoded_rows],
                 expected_frame_values,
-                atol=5,
+                atol=1e-6,
             )
             np.testing.assert_allclose(
                 [row["decoded"][1] for row in decoded_rows],
@@ -3066,8 +3073,8 @@ class LeRobotValidationTest(unittest.TestCase):
                         float(middle["camera"][0].mean()) * 255,
                         float(middle["camera"][1].mean()) * 255,
                     ],
-                    [56, 88],
-                    atol=5,
+                    expected_frame_values[2:4],
+                    atol=1e-4,
                 )
                 self.assertEqual(
                     [False, False], middle["camera_is_pad"].tolist())
diff --git a/paimon-python/pypaimon/tests/native_blob_collection_write_test.py 
b/paimon-python/pypaimon/tests/native_blob_collection_write_test.py
new file mode 100644
index 0000000000..16525c5493
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_blob_collection_write_test.py
@@ -0,0 +1,354 @@
+# 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.
+
+"""Blob Arrow writes must retain row identity across physical file rolls."""
+
+import pyarrow as pa
+import pytest
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.schema.data_types import AtomicType, ArrayType, DataField, 
MapType, PyarrowFieldParser
+from pypaimon.table.row.blob import BlobDescriptor
+from pypaimon.write.native_write import NativeTableWrite
+from pypaimon.tests.native_blob_write_test import _read, _physical_files
+
+pytestmark = pytest.mark.native_plan
+
+
+def _table(tmp_path, native, external=False, optimize=True, catalog=None, 
partitioned=False, not_null=False):
+    if catalog is None:
+        catalog = CatalogFactory.create({'warehouse': str(tmp_path / 
'warehouse')})
+    catalog.create_database('db', True)
+    blob = AtomicType('BLOB', nullable=not not_null)
+    fields = [DataField(0, 'id', AtomicType('INT')),
+              DataField(1, 'items', ArrayType(True, blob)),
+              DataField(2, 'attrs', MapType(True, AtomicType('STRING'), blob))]
+    options = {'row-tracking.enabled': 'true', 'data-evolution.enabled': 
'true',
+               'write.native.enabled': str(native).lower(), 
'target-file-row-num': '2',
+               'blob.target-file-size': '32 B',
+               'data-evolution.write-cols-optimization.enabled': 
str(optimize).lower()}
+    if external:
+        options['data-file.external-paths'] = (tmp_path / 'external').as_uri()
+    schema = Schema(fields=fields, options=options, partition_keys=['id'] if 
partitioned else [])
+    catalog.create_table('db.t', schema, False)
+    return catalog.get_table('db.t')
+
+
+def _data(table, tmp_path, descriptor, start):
+    payload = b'payload' * 10
+    if descriptor:
+        source = tmp_path / 'payload'
+        source.write_bytes(b'prefix' + payload)
+        value = BlobDescriptor(str(source), 6, -1).serialize()
+    else:
+        value = payload
+    rows = [{'id': -1, 'items': [b'ignored'], 'attrs': [('ignored', 
b'ignored')]},
+            {'id': start, 'items': [value, None, b''], 'attrs': [('first', 
value), ('', b''), ('null', None)]},
+            {'id': start + 1, 'items': None, 'attrs': None},
+            {'id': start + 2, 'items': [], 'attrs': []}]
+    data = pa.Table.from_pylist(rows, 
schema=PyarrowFieldParser.from_paimon_schema(table.fields)).slice(1)
+    expected = [{'id': start, 'items': [payload, None, b''],
+                 'attrs': [('first', payload), ('', b''), ('null', None)]}, 
rows[2], rows[3]]
+    return data, expected
+
+
[email protected]('native_write', [False, True])
[email protected]('external', [False, True])
[email protected]('descriptor', [False, True])
[email protected]('stream', [False, True])
+def test_collection_blob_write_cross_reads_and_rolls(tmp_path, native_write, 
external, descriptor, stream):
+    table = _table(tmp_path, native_write, external)
+    builder = table.new_stream_write_builder() if stream else 
table.new_batch_write_builder()
+    writer, commit = builder.new_write(), builder.new_commit()
+    expected = []
+    try:
+        assert isinstance(writer, NativeTableWrite) == native_write
+        for identifier in range(1, 3 if stream else 2):
+            data, rows = _data(table, tmp_path, descriptor, identifier * 3)
+            writer.write_arrow(data)
+            expected.extend(rows)
+            messages = writer.prepare_commit(identifier) if stream else 
writer.prepare_commit()
+            files = [file for message in messages for file in 
message.new_files]
+            for name in ('items', 'attrs'):
+                dedicated = [file for file in files if file.write_cols == 
[name]]
+                assert dedicated and all(file.file_name.endswith('.blob') for 
file in dedicated)
+                assert sum(file.row_count for file in dedicated) == 3
+            commit.commit(messages, identifier) if stream else 
commit.commit(messages)
+            for planner in (False, True):
+                for reader in (False, True):
+                    assert _read(table, planner, reader) == expected
+            if native_write:
+                assert writer._python_writer is None
+    finally:
+        writer.close()
+        commit.close()
+
+
[email protected]('native_commit', [False, True])
[email protected]('optimize', [False, True])
+def test_collection_blob_descriptors_are_payload_ranges(tmp_path, 
native_rest_catalog, native_commit, optimize):
+    table = _table(tmp_path, True, optimize=optimize, 
catalog=native_rest_catalog).copy(
+        {'commit.native.enabled': str(native_commit).lower()})
+    data, expected = _data(table, tmp_path, False, 1)
+    builder = table.new_batch_write_builder()
+    writer, commit = builder.new_write(), builder.new_commit()
+    try:
+        assert isinstance(writer, NativeTableWrite)
+        writer.write_arrow(data)
+        messages = writer.prepare_commit()
+        normal = [file for message in messages for file in message.new_files 
if file.file_name.endswith('.parquet')]
+        assert all(file.write_cols == (None if optimize else ['id']) for file 
in normal)
+        commit.commit(messages)
+        assert (commit._native_commit is not None) == native_commit
+        for reader in (False, True):
+            rows = _read(table.copy({'blob-as-descriptor': 'true'}), True, 
reader)
+            assert rows[1]['items'] is None and rows[2]['items'] == []
+            assert rows[1]['attrs'] is None and rows[2]['attrs'] == []
+            for descriptors, values in ((rows[0]['items'], 
expected[0]['items']),
+                                        ([item for _, item in 
rows[0]['attrs']],
+                                         [item for _, item in 
expected[0]['attrs']])):
+                for encoded, payload in zip(descriptors, values):
+                    if payload is None:
+                        assert encoded is None
+                    else:
+                        descriptor = BlobDescriptor.deserialize(encoded)
+                        assert descriptor.uri.endswith('.blob') and 
descriptor.length == len(payload)
+                        from urllib.parse import urlparse
+                        with open(urlparse(descriptor.uri).path, 'rb') as 
source:
+                            source.seek(descriptor.offset)
+                            assert source.read(descriptor.length) == payload
+    finally:
+        writer.close()
+        commit.close()
+
+
[email protected]('action', ['close', 'abort', 'failed-write', 
'short-read'])
+def test_collection_blob_failed_writes_and_abort_clean_all_groups(tmp_path, 
action):
+    table = _table(tmp_path, True)
+    writer = table.new_batch_write_builder().new_write()
+    try:
+        assert isinstance(writer, NativeTableWrite)
+        data, _ = _data(table, tmp_path, False, 1)
+        writer.write_arrow(data)
+        assert {path.suffix for path in _physical_files(tmp_path)} == 
{'.parquet', '.blob'}
+        if action in ('failed-write', 'short-read'):
+            if action == 'short-read':
+                source = tmp_path / 'truncated'
+                source.write_bytes(b'x' * (8 * 1024 * 1024))
+                missing = BlobDescriptor(str(source), 0, 8 * 1024 * 1024 + 
1).serialize()
+            else:
+                missing = BlobDescriptor(str(tmp_path / 'missing'), 0, 
10).serialize()
+            data = pa.table({'id': [5], 'items': [[b'already-copied', 
missing]],
+                             'attrs': [[('x', b'ok')]]}, schema=data.schema)
+            with pytest.raises(Exception):
+                writer.write_arrow(data)
+        else:
+            getattr(writer, action)()
+        assert not _physical_files(tmp_path)
+        assert table.snapshot_manager().get_latest_snapshot() is None
+    finally:
+        writer.close()
+
+
[email protected]('native_write', [False, True])
[email protected]('action', ['abort', 'failed-write'])
+def test_stream_collection_failure_keeps_already_committed_groups(tmp_path, 
native_write, action):
+    table = _table(tmp_path, native_write)
+    builder = table.new_stream_write_builder()
+    writer, commit = builder.new_write(), builder.new_commit()
+    try:
+        data, expected = _data(table, tmp_path, False, 1)
+        writer.write_arrow(data)
+        messages = writer.prepare_commit(1)
+        committed = {file.file_name for message in messages for file in 
message.new_files}
+        commit.commit(messages, 1)
+        assert writer.prepare_commit(2) == []
+        if action == 'failed-write':
+            missing = BlobDescriptor(str(tmp_path / 'missing'), 0, 
1).serialize()
+            with pytest.raises(Exception):
+                writer.write_arrow(pa.table({'id': [10], 'items': [[b'ok', 
missing]], 'attrs': [[]]},
+                                            schema=data.schema))
+        else:
+            writer.write_arrow(data)
+            writer.abort()
+        assert committed <= {path.name for path in _physical_files(tmp_path)}
+        for native_read in (False, True):
+            assert _read(table, True, native_read) == expected
+    finally:
+        writer.close()
+        commit.close()
+
+
[email protected]('native_write', [False, True])
[email protected]('native_commit', [False, True])
[email protected]('stream', [False, True])
[email protected]('published', [False, True])
+def test_collection_commit_exception_preserves_files(
+        tmp_path, native_rest_catalog, native_write, native_commit, stream, 
published):
+    from unittest.mock import Mock, patch
+
+    table = _table(tmp_path, native_write, external=True, 
catalog=native_rest_catalog).copy(
+        {'commit.native.enabled': str(native_commit).lower()})
+    builder = table.new_stream_write_builder() if stream else 
table.new_batch_write_builder()
+    writer, commit = builder.new_write(), builder.new_commit()
+    expected = []
+    try:
+        assert isinstance(writer, NativeTableWrite) == native_write
+        if stream:
+            data, rows = _data(table, tmp_path, True, 1)
+            writer.write_arrow(data)
+            commit.commit(writer.prepare_commit(1), 1)
+            expected.extend(rows)
+
+        data, rows = _data(table, tmp_path, True, 4)
+        writer.write_arrow(data)
+        messages = writer.prepare_commit(2) if stream else 
writer.prepare_commit()
+        files = _physical_files(tmp_path)
+        assert {path.suffix for path in files} >= {'.parquet', '.blob'}
+        if native_commit:
+            prepared = commit._prepare_native_commit(messages)
+            assert prepared is not None
+            native, native_messages = prepared
+            proxy = Mock(wraps=native)
+            publish = native.commit
+        else:
+            publish = commit.file_store_commit.commit
+
+        def fail_commit(*args, **kwargs):
+            if published:
+                publish(*args, **kwargs)
+            raise OSError('Commit outcome is unknown')
+
+        if native_commit:
+            proxy.commit.side_effect = fail_commit
+            failing_commit = patch.object(
+                commit, '_prepare_native_commit', return_value=(proxy, 
native_messages))
+        else:
+            failing_commit = patch.object(commit.file_store_commit, 'commit', 
side_effect=fail_commit)
+        with failing_commit, \
+                patch.object(commit.file_store_commit, 'abort', 
side_effect=AssertionError('Unsafe abort')):
+            with pytest.raises(OSError, match='Commit outcome is unknown'):
+                commit.commit(messages, 2) if stream else 
commit.commit(messages)
+        if native_commit:
+            proxy.commit.assert_called_once()
+            proxy.abort.assert_not_called()
+
+        # An unknown commit outcome must never trigger explicit commit abort.
+        writer.abort()
+        writer.close()
+        assert _physical_files(tmp_path) == files
+        assert (tmp_path / 'payload').exists()
+        if published:
+            expected.extend(rows)
+        latest = table.snapshot_manager().get_latest_snapshot()
+        assert (latest.id if latest is not None else 0) == int(stream) + 
int(published)
+        for native_read in (False, True):
+            read_builder = table.copy({'read.native.enabled': 
str(native_read).lower()}).new_read_builder()
+            actual = 
read_builder.new_read().to_arrow(read_builder.new_scan().plan().splits())
+            assert actual.sort_by('id').to_pylist() == expected
+    finally:
+        writer.close()
+        commit.close()
+
+
[email protected]('projection', [
+    ['id', "attrs['first']", "attrs['null']", "attrs['missing']"],
+    {'payload': "attrs['first']", 'array': 'items'},
+    ['attrs', "attrs['first']", 'id'],
+])
+def test_native_collection_projection_keeps_selected_blob_keys(tmp_path, 
projection):
+    from unittest.mock import patch
+
+    table = _table(tmp_path, True)
+    data, _ = _data(table, tmp_path, False, 1)
+    builder = table.new_batch_write_builder()
+    writer, commit = builder.new_write(), builder.new_commit()
+    try:
+        writer.write_arrow(data)
+        commit.commit(writer.prepare_commit())
+    finally:
+        writer.close()
+        commit.close()
+    for native in (False, True):
+        read_table = table.copy({'read.native.enabled': str(native).lower()})
+        read_builder = 
read_table.new_read_builder().with_projection(projection)
+        read = read_builder.new_read()
+        splits = read_builder.new_scan().plan().splits()
+        if native:
+            with patch.object(read, '_create_split_read', 
side_effect=AssertionError('Python fallback')):
+                result = read.to_arrow(splits)
+        else:
+            expected = read.to_arrow(splits)
+            continue
+        assert result.schema == expected.schema
+        assert result.to_pylist() == expected.to_pylist()
+
+
+def 
test_collection_partial_partition_prepare_keeps_cleanup_ownership(tmp_path):
+    from unittest.mock import patch
+    from pypaimon.write.writer.composite_data_writer import CompositeDataWriter
+
+    table = _table(tmp_path, False, partitioned=True)
+    writer = table.new_batch_write_builder().new_write()
+    try:
+        data, _ = _data(table, tmp_path, False, 1)
+        writer.write_arrow(data)
+        partitions = list(writer.file_store_write.data_writers.values())
+        assert len(partitions) == 3 and all(isinstance(partition, 
CompositeDataWriter) for partition in partitions)
+        with patch.object(partitions[1], 'prepare_commit', 
side_effect=RuntimeError('Second partition failed')):
+            with pytest.raises(RuntimeError, match='Second partition failed'):
+                writer.prepare_commit()
+        assert partitions[0].committed_files
+        assert _physical_files(tmp_path)
+        writer.abort()
+        assert not _physical_files(tmp_path)
+        assert table.snapshot_manager().get_latest_snapshot() is None
+    finally:
+        writer.close()
+
+
[email protected]('native_write', [False, True])
[email protected]('sliced', [False, True])
[email protected]('aliases', [False, True])
+def test_not_null_collection_children_ignore_masked_and_unreferenced_values(
+        tmp_path, native_write, sliced, aliases):
+    table = _table(tmp_path, native_write, not_null=True)
+    offsets = pa.array([0, 1, 2, 3, 4], type=pa.int32())
+    mask = pa.array([True, False, True, False])
+    values = pa.array([None, b'first', None, b'last'], type=pa.large_binary())
+    items = pa.ListArray.from_arrays(
+        offsets, values, mask=mask,
+        type=pa.list_(pa.field('item' if aliases else 'element', 
pa.large_binary(), nullable=False)))
+    attrs = pa.MapArray.from_arrays(
+        offsets, pa.array(['hidden', 'first', 'hidden', 'last']), values, 
mask=mask,
+        type=pa.map_(pa.field('source_key' if aliases else 'key', pa.string(), 
nullable=False),
+                     pa.field('source_value' if aliases else 'value', 
pa.large_binary(), nullable=False)))
+    data = pa.table({'id': pa.array(range(4), type=pa.int32()), 'items': 
items, 'attrs': attrs})
+    if sliced:
+        data = data.slice(1, 1)
+    data.validate(full=True)
+    builder = table.new_batch_write_builder()
+    writer, commit = builder.new_write(), builder.new_commit()
+    try:
+        assert isinstance(writer, NativeTableWrite) == native_write
+        writer.write_arrow(data)
+        commit.commit(writer.prepare_commit())
+        for native_read in (False, True):
+            assert _read(table, True, native_read) == data.to_pylist()
+    finally:
+        writer.close()
+        commit.close()
diff --git a/paimon-python/pypaimon/tests/native_blob_write_test.py 
b/paimon-python/pypaimon/tests/native_blob_write_test.py
index d78167e33a..9bdfd24fe1 100644
--- a/paimon-python/pypaimon/tests/native_blob_write_test.py
+++ b/paimon-python/pypaimon/tests/native_blob_write_test.py
@@ -150,7 +150,7 @@ def 
test_native_blob_cleanup_includes_completed_groups(tmp_path, external, actio
 
 @pytest.mark.parametrize('external', [False, True])
 @pytest.mark.parametrize('stream', [False, True])
-def test_native_blob_abort_removes_prepared_and_outstanding_files(tmp_path, 
external, stream):
+def 
test_native_blob_abort_preserves_prepared_and_removes_outstanding_files(tmp_path,
 external, stream):
     options = {'data-file.path-directory': 'data/nested'}
     if external:
         options['data-file.external-paths'] = (tmp_path / 'external').as_uri()
@@ -163,11 +163,13 @@ def 
test_native_blob_abort_removes_prepared_and_outstanding_files(tmp_path, exte
             writer.write_arrow(_data(identifier * 2))
             messages = writer.prepare_commit(identifier) if stream else 
writer.prepare_commit()
             assert messages
-        assert {path.suffix for path in _physical_files(tmp_path)} == 
{'.parquet', '.blob', '.index'}
+        prepared = _physical_files(tmp_path)
+        assert {path.suffix for path in prepared} == {'.parquet', '.blob', 
'.index'}
         writer.write_arrow(_data(6))
+        assert _physical_files(tmp_path) > prepared
         writer.abort()
         writer.abort()
-        assert not _physical_files(tmp_path)
+        assert _physical_files(tmp_path) == prepared
         assert table.snapshot_manager().get_latest_snapshot() is None
     finally:
         writer.close()
@@ -198,7 +200,7 @@ def 
test_native_blob_close_releases_prepared_files_to_committer(tmp_path, extern
 
 @pytest.mark.parametrize('external', [False, True])
 @pytest.mark.parametrize('commit_native_option', [False, True])
-def test_native_blob_stream_abort_preserves_submitted_files(tmp_path, 
external, commit_native_option):
+def test_native_blob_stream_abort_preserves_all_prepared_files(tmp_path, 
external, commit_native_option):
     options = {'commit.native.enabled': str(commit_native_option).lower()}
     if external:
         options['data-file.external-paths'] = (tmp_path / 'external').as_uri()
@@ -211,10 +213,12 @@ def 
test_native_blob_stream_abort_preserves_submitted_files(tmp_path, external,
         submitted = _physical_files(tmp_path)
         writer.write_arrow(_data(2))
         assert writer.prepare_commit(2)
+        prepared = _physical_files(tmp_path)
+        assert prepared > submitted
         writer.write_arrow(_data(4))
         assert _physical_files(tmp_path) > submitted
         writer.abort()
-        assert _physical_files(tmp_path) == submitted
+        assert _physical_files(tmp_path) == prepared
         for planner in (False, True):
             for reader in (False, True):
                 assert _read(table, planner, reader) == _data().to_pylist()
diff --git a/paimon-python/pypaimon/tests/native_cross_partition_write_test.py 
b/paimon-python/pypaimon/tests/native_cross_partition_write_test.py
index 04b6ae1059..edd9d963c7 100644
--- a/paimon-python/pypaimon/tests/native_cross_partition_write_test.py
+++ b/paimon-python/pypaimon/tests/native_cross_partition_write_test.py
@@ -154,7 +154,7 @@ def test_cross_partition_rowkind_and_row_input(tmp_path):
 
 
 @pytest.mark.parametrize('committed', [False, True])
-def test_cross_partition_abort_preserves_committed_files(tmp_path, committed):
+def 
test_cross_partition_abort_preserves_prepared_and_committed_files(tmp_path, 
committed):
     table = _table(tmp_path)
     builder = table.new_batch_write_builder()
     writer, commit = builder.new_write(), builder.new_commit()
@@ -167,7 +167,7 @@ def 
test_cross_partition_abort_preserves_committed_files(tmp_path, committed):
         if committed:
             commit.commit(messages)
         writer.abort()
-        assert all(table.file_io.exists(p) == committed for p in paths)
+        assert all(table.file_io.exists(p) for p in paths)
     finally:
         writer.close()
         commit.close()
diff --git a/paimon-python/pypaimon/tests/native_dynamic_write_test.py 
b/paimon-python/pypaimon/tests/native_dynamic_write_test.py
index 11ec98ddbf..ef84c1b73b 100644
--- a/paimon-python/pypaimon/tests/native_dynamic_write_test.py
+++ b/paimon-python/pypaimon/tests/native_dynamic_write_test.py
@@ -172,8 +172,8 @@ def test_dynamic_abort_index_ownership(tmp_path, 
bucket_local, commit_first):
     if commit_first:
         builder.new_commit().commit(messages)
     writer.abort()
-    assert table.file_io.exists(path) == commit_first
-    assert all(table.file_io.exists(f.file_path) == commit_first
+    assert table.file_io.exists(path)
+    assert all(table.file_io.exists(f.file_path)
                for m in messages for f in m.new_files)
 
 
diff --git a/paimon-python/pypaimon/tests/native_nested_update_test.py 
b/paimon-python/pypaimon/tests/native_nested_update_test.py
index db691e94f6..042c08acac 100644
--- a/paimon-python/pypaimon/tests/native_nested_update_test.py
+++ b/paimon-python/pypaimon/tests/native_nested_update_test.py
@@ -257,7 +257,7 @@ def test_view_pairs_update_map(tmp_path, native, layout):
 @pytest.mark.parametrize('native', [False, True])
 @pytest.mark.parametrize('data_type,values', CASES)
 @pytest.mark.parametrize('overlap', [False, True])
-def test_nested_grouped_updates_abort_overlap(tmp_path, native, data_type, 
values, overlap):
+def test_nested_grouped_updates_preserve_files_on_overlap(tmp_path, native, 
data_type, values, overlap):
     table, _ = _table(tmp_path, data_type, native)
     builder = table.new_batch_write_builder()
     update = builder.new_update().with_update_type(['value'])
@@ -267,7 +267,7 @@ def test_nested_grouped_updates_abort_overlap(tmp_path, 
native, data_type, value
         if overlap:
             with pytest.raises(ValueError):
                 update.update_by_arrow_batches_with_row_id(iter([data, data]))
-            assert set(tmp_path.rglob('*.parquet')) == before
+            assert set(tmp_path.rglob('*.parquet')) > before
         else:
             messages = 
update.update_by_arrow_batches_with_row_id(iter([data.slice(0, 0), data]))
             _commit(builder, messages, False)
diff --git a/paimon-python/pypaimon/tests/native_postpone_fixed_write_test.py 
b/paimon-python/pypaimon/tests/native_postpone_fixed_write_test.py
index d9dafe6148..7ac8d52b8e 100644
--- a/paimon-python/pypaimon/tests/native_postpone_fixed_write_test.py
+++ b/paimon-python/pypaimon/tests/native_postpone_fixed_write_test.py
@@ -185,7 +185,7 @@ def 
test_provided_plan_does_not_fill_missing_partitions_from_default(tmp_path):
 
 
 @pytest.mark.parametrize('prepared', [False, True])
-def test_abort_and_close_keep_commit_message_ownership(tmp_path, prepared):
+def test_abort_and_close_preserve_prepared_files(tmp_path, prepared):
     table = _table(tmp_path)
     builder = table.new_postpone_fixed_bucket_write_builder()
     writer = builder.new_write()
@@ -197,7 +197,10 @@ def 
test_abort_and_close_keep_commit_message_ownership(tmp_path, prepared):
         assert paths and all(table.file_io.exists(path) for path in paths)
     writer.abort()
     writer.close()
-    assert all(not table.file_io.exists(path) for path in paths)
+    if prepared:
+        assert all(table.file_io.exists(path) for path in paths)
+    else:
+        assert not list(tmp_path.rglob('*.parquet'))
     assert table.snapshot_manager().get_latest_snapshot() is None
 
 
diff --git a/paimon-python/pypaimon/tests/native_postpone_write_test.py 
b/paimon-python/pypaimon/tests/native_postpone_write_test.py
index fcd6d2eaaf..94f70e69fc 100644
--- a/paimon-python/pypaimon/tests/native_postpone_write_test.py
+++ b/paimon-python/pypaimon/tests/native_postpone_write_test.py
@@ -134,7 +134,7 @@ def test_postpone_native_overwrite_and_abort(tmp_path, 
native_rest_catalog, nati
         messages = writer.prepare_commit()
         paths = [f.file_path for m in messages for f in m.new_files]
         writer.abort()
-        assert paths and all(not table.file_io.exists(path) for path in paths)
+        assert paths and all(table.file_io.exists(path) for path in paths)
     finally:
         writer.close()
 
diff --git a/paimon-python/pypaimon/tests/native_read_test.py 
b/paimon-python/pypaimon/tests/native_read_test.py
index bc28794225..c076fa2666 100644
--- a/paimon-python/pypaimon/tests/native_read_test.py
+++ b/paimon-python/pypaimon/tests/native_read_test.py
@@ -899,7 +899,7 @@ def test_native_avro_read_uses_native_path():
 def test_native_read_falls_back_for_unsupported_dedicated_file():
     read = _table_read()
     schema = pa.schema([('id', pa.int32())])
-    split = _Split('camera.video')
+    split = _Split('camera.unsupported')
     split._native_split = object()
 
     with patch('pypaimon.read.native_plan.native_read') as native:
@@ -908,10 +908,11 @@ def 
test_native_read_falls_back_for_unsupported_dedicated_file():
     native.assert_not_called()
 
 
-def test_native_read_supports_blob_file_and_forwards_parallelism():
[email protected]('file_name', ['picture.blob', 'camera.video'])
+def 
test_native_read_supports_blob_and_video_files_and_forwards_parallelism(file_name):
     read = _table_read()
     schema = pa.schema([('id', pa.int32())])
-    split = _Split('picture.blob')
+    split = _Split(file_name)
     split._native_split = object()
 
     with patch(
diff --git a/paimon-python/pypaimon/tests/native_update_test.py 
b/paimon-python/pypaimon/tests/native_update_test.py
index 1c01054aef..cf4e138bbf 100644
--- a/paimon-python/pypaimon/tests/native_update_test.py
+++ b/paimon-python/pypaimon/tests/native_update_test.py
@@ -373,8 +373,9 @@ def 
test_native_batch_update_preserves_input_table_boundaries(tmp_path):
                   .update_by_arrow_batches_with_row_id(interleaved_tables()))
     assert staged and all(message.check_from_snapshot == read_snapshot_id
                           for message in staged)
-    from pypaimon.write.file_store_commit import _abort_commit_messages
-    _abort_commit_messages(table, staged)
+    # Keep staged files: the outcome of a concurrent commit can be unknown.
+    assert all(table.file_io.exists(file.physical_path())
+               for message in staged for file in message.new_files)
 
 
 def test_native_predicate_update_invokes_callable_by_file_group(tmp_path):
diff --git a/paimon-python/pypaimon/tests/native_video_read_test.py 
b/paimon-python/pypaimon/tests/native_video_read_test.py
new file mode 100644
index 0000000000..1ab9de78cc
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_video_read_test.py
@@ -0,0 +1,194 @@
+# 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 unittest.mock import patch
+from urllib.parse import urlparse
+
+import pyarrow as pa
+import pytest
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.schema.data_types import AtomicType, DataField
+from pypaimon.table.row.blob import Blob, VideoFrameDescriptor
+from pypaimon.table.row.generic_row import GenericRow
+from pypaimon.table.row.video_keyframe_index import VideoKeyframeIndex
+
+pytestmark = pytest.mark.native_plan
+
+
+def _table(tmp_path, multiple, external=False, catalog=None):
+    if catalog is None:
+        catalog = CatalogFactory.create({'warehouse': str(tmp_path / 
'warehouse')})
+    catalog.create_database('db', True)
+    names = ['left', 'right'] if multiple else ['left']
+    fields = [DataField(0, 'id', AtomicType('INT'))]
+    fields.extend(DataField(index + 1, name, AtomicType('BLOB')) for index, 
name in enumerate(names))
+    options = {'row-tracking.enabled': 'true', 'data-evolution.enabled': 
'true',
+               'video-frame-field': ','.join(names), 'write.native.enabled': 
'false'}
+    if external:
+        options['data-file.external-paths'] = (tmp_path / 'external').as_uri()
+    catalog.create_table('db.t', Schema(fields=fields, options=options), False)
+    table = catalog.get_table('db.t')
+    mapping = VideoKeyframeIndex([(0, 1)], [(0, 0, 0)]).serialize()
+    sources = []
+    for ordinal in range(4 if multiple else 2):
+        source = tmp_path / ('source-%d.mp4' % ordinal)
+        source.write_bytes(bytes([ordinal + 1]) * 10 + mapping)
+        sources.append(source.as_uri())
+    builder = table.new_batch_write_builder()
+    writer, commit = builder.new_write(), builder.new_commit()
+    try:
+        for row, (ordinal, frame) in enumerate([(0, 2), (0, 3), (None, None), 
(1, 7), (1, 8), (0, 10)]):
+            values = [row]
+            for index in range(len(names)):
+                if ordinal is None:
+                    values.append(None)
+                else:
+                    uri = sources[ordinal + index * 2]
+                    descriptor = VideoFrameDescriptor(uri, 0, 10, frame, 10, 
len(mapping))
+                    
values.append(Blob.from_descriptor(table.file_io.uri_reader_factory.create(uri),
 descriptor))
+            writer.write_row(GenericRow(values, table.fields))
+        commit.commit(writer.prepare_commit())
+    finally:
+        writer.close()
+        commit.close()
+    return table
+
+
+def _read(table, native, projection=None, filtered=False, limit=None):
+    table = table.copy({'read.native.enabled': str(native).lower(),
+                        'scan.native-plan.enabled': str(native).lower()})
+    builder = table.new_read_builder()
+    if projection is not None:
+        builder.with_projection(projection)
+    if filtered:
+        
builder.with_filter(table.new_read_builder().new_predicate_builder().greater_than('id',
 0))
+    if limit is not None:
+        builder.with_limit(limit)
+    read = builder.new_read()
+    splits = builder.new_scan().plan().splits()
+    if native:
+        with patch.object(read, '_create_split_read', 
side_effect=AssertionError('Python video read fallback')):
+            return read.to_arrow(splits), 
read.to_arrow_batch_reader(splits).read_all()
+    return read.to_arrow(splits), read.to_arrow_batch_reader(splits).read_all()
+
+
[email protected]('multiple', [False, True])
[email protected]('external', [False, True])
[email protected]('descriptor_mode', [False, True])
+def test_native_video_reads_match_java_lazy_frame_and_keyframe_descriptors(
+        tmp_path, multiple, external, descriptor_mode):
+    table = _table(tmp_path, multiple, external).copy({'blob-as-descriptor': 
str(descriptor_mode).lower()})
+    expected = sorted(_read(table, False)[0].to_pylist(), key=lambda row: 
row['id'])
+    for result in _read(table, True):
+        rows = sorted(result.to_pylist(), key=lambda row: row['id'])
+        assert rows == expected and len(rows) == 6
+        for name in ('left', 'right') if multiple else ('left',):
+            assert rows[2][name] is None
+            frames = [VideoFrameDescriptor.deserialize(row[name]) for row in 
rows if row[name] is not None]
+            assert [frame.frame_index for frame in frames] == [2, 3, 7, 8, 10]
+            assert frames[0].payload_descriptor == 
frames[1].payload_descriptor == frames[4].payload_descriptor
+            assert frames[2].payload_descriptor == frames[3].payload_descriptor
+            for frame in frames:
+                index = frame.keyframe_index_descriptor
+                assert index is not None
+                with open(urlparse(index.uri).path, 'rb') as source:
+                    source.seek(index.offset)
+                    assert 
VideoKeyframeIndex.deserialize(source.read(index.length)).keyframes == ((0, 0, 
0),)
+
+
[email protected]('projection', [[], ['id'], {'frame': 'left'}, 
['left', 'left', 'id']])
[email protected]('limit', [None, 3])
+def 
test_native_video_projection_filter_and_limit_preserve_logical_rows(tmp_path, 
projection, limit):
+    table = _table(tmp_path, True)
+    expected = _read(table, False, projection, True, limit)[0]
+    for result in _read(table, True, projection, True, limit):
+        assert result.schema == expected.schema
+        assert result.to_pylist() == expected.to_pylist()
+        assert result.num_rows == expected.num_rows
+
+
+def test_native_update_of_normal_columns_keeps_packed_video_frames(tmp_path, 
native_rest_catalog):
+    table = _table(tmp_path, False, catalog=native_rest_catalog).copy(
+        {'write.native.enabled': 'true', 'commit.native.enabled': 'true'})
+    before = _read(table, True)[0].to_pylist()
+    builder = table.new_batch_write_builder()
+    update = builder.new_update().with_update_type(['id'])
+    with 
patch('pypaimon.write.table_update.BatchTableUpdate._update_by_arrow_batches_with_row_id',
+               side_effect=AssertionError('Python update fallback')):
+        messages = update.update_by_arrow_batches_with_row_id(iter([
+            pa.table({'_ROW_ID': [0], 'id': [20]}, 
schema=pa.schema([('_ROW_ID', pa.int64()), ('id', pa.int32())]))]))
+    commit = builder.new_commit()
+    try:
+        commit.commit(messages)
+        assert commit._native_commit is not None
+    finally:
+        commit.close()
+    after = _read(table, True)[0].to_pylist()
+    assert sorted(row['id'] for row in after) == [1, 2, 3, 4, 5, 20]
+    assert [row['left'] for row in after] == [row['left'] for row in before]
+
+
+def 
test_native_video_placeholder_falls_back_but_null_stops_older_frames(tmp_path):
+    from dataclasses import replace
+    from pypaimon.common.delta_varint_compressor import DeltaVarintCompressor
+    from pypaimon.read.split import DataSplit
+    import struct
+
+    table = _table(tmp_path, False).copy({'read.native.enabled': 'true'})
+    builder = table.new_read_builder()
+    split = builder.new_scan().plan().splits()[0]
+    read = builder.new_read()
+    before = read.to_arrow([split]).to_pylist()
+    original = next(file for file in split.files if 
file.file_name.endswith('.video'))
+    indexes = [[], [], [1, 1, 1, 3], [-2, -1, -2, -2], [0, 0, 0, 0]]
+    compressed = [DeltaVarintCompressor.compress(values) for values in indexes]
+    data = b''.join(compressed) + struct.pack('<IIIIIIB', *(len(index) for 
index in compressed), 0x4F454449, 1)
+    newer = tmp_path / 'newest.video'
+    newer.write_bytes(data)
+    added = replace(original, file_name=newer.name, file_path=str(newer), 
external_path=str(newer),
+                    file_size=len(data), 
min_sequence_number=original.max_sequence_number + 1,
+                    max_sequence_number=original.max_sequence_number + 1)
+    combined = DataSplit(split.files + [added], split.partition, split.bucket,
+                         snapshot_id=split.snapshot_id, 
bucket_path=split.bucket_path,
+                         total_buckets=split.total_buckets)
+    expected = [dict(row) for row in before]
+    expected[1]['left'] = None
+    with patch.object(read, '_create_split_read', 
side_effect=AssertionError('Python fallback')):
+        for result in (read.to_arrow([combined]), 
read.to_arrow_batch_reader([combined]).read_all()):
+            assert result.to_pylist() == expected
+
+
+def test_native_video_deletion_vectors_filter_both_arrow_readers(tmp_path, 
native_rest_catalog):
+    from pypaimon.write.table_update import TableDeleteByRowId
+
+    table = _table(tmp_path, True, catalog=native_rest_catalog).copy(
+        {'deletion-vectors.enabled': 'true', 'write.native.enabled': 'true', 
'commit.native.enabled': 'true'})
+    builder = table.new_batch_write_builder()
+    with patch.object(TableDeleteByRowId, 'delete',
+                      side_effect=AssertionError('Python delete fallback')):
+        messages = builder.new_update().delete_by_row_id([0, 2])
+    commit = builder.new_commit()
+    try:
+        commit.commit(messages)
+        assert commit._native_commit is not None
+    finally:
+        commit.close()
+    for result in _read(table, True):
+        rows = sorted(result.to_pylist(), key=lambda row: row['id'])
+        assert [row['id'] for row in rows] == [1, 3, 4, 5]
+        assert all(VideoFrameDescriptor.deserialize(row[name]).frame_index in 
(3, 7, 8, 10)
+                   for row in rows for name in ('left', 'right'))
diff --git a/paimon-python/pypaimon/tests/py36/ao_simple_test.py 
b/paimon-python/pypaimon/tests/py36/ao_simple_test.py
index 662d0b720e..db81161ebb 100644
--- a/paimon-python/pypaimon/tests/py36/ao_simple_test.py
+++ b/paimon-python/pypaimon/tests/py36/ao_simple_test.py
@@ -440,7 +440,7 @@ class AOSimpleTest(RESTBaseTest):
         }
         pa_table = pa.Table.from_pydict(data1, schema=self.pa_schema)
         table_write.write_arrow(pa_table)
-        table_write.prepare_commit(0)
+        cm = table_write.prepare_commit(0)
         # write 2
         data2 = {
             'user_id': [5, 6, 7, 8],
@@ -450,7 +450,7 @@ class AOSimpleTest(RESTBaseTest):
         }
         pa_table = pa.Table.from_pydict(data2, schema=self.pa_schema)
         table_write.write_arrow(pa_table)
-        table_write.prepare_commit(1)
+        cm.extend(table_write.prepare_commit(1))
         # write 3
         data3 = {
             'user_id': [9, 10],
@@ -460,7 +460,7 @@ class AOSimpleTest(RESTBaseTest):
         }
         pa_table = pa.Table.from_pydict(data3, schema=self.pa_schema)
         table_write.write_arrow(pa_table)
-        cm = table_write.prepare_commit(2)
+        cm.extend(table_write.prepare_commit(2))
         # commit
         table_commit.commit(cm, 2)
         table_write.close()
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 cea154d5e4..84fdbd0d89 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
@@ -3232,7 +3232,6 @@ class RayDataEvolutionMergeIntoTest(unittest.TestCase):
     def test_self_merge_filters_file_group_in_batches(self):
         from pypaimon.ray import data_evolution_merge_into as merge_module
         from pypaimon.ray import data_evolution_merge_join as join_module
-        from pypaimon.write.file_store_commit import _abort_commit_messages
 
         options = dict(self.de_options)
         options['read.batch-size'] = '2'
@@ -3316,9 +3315,10 @@ class RayDataEvolutionMergeIntoTest(unittest.TestCase):
             self.assertEqual(count, 3)
             self.assertEqual(row_ids, [1, 3, 5])
         finally:
-            _abort_commit_messages(table, messages)
+            # Do not delete staged messages: another commit attempt may use 
them.
+            pass
 
-    def test_self_merge_update_aborts_other_groups_after_failure(self):
+    def test_self_merge_update_preserves_other_groups_after_failure(self):
         from pypaimon.ray import data_evolution_merge_into as merge_module
         from pypaimon.ray.data_evolution_merge_join import (
             distributed_self_merge_update_apply,
@@ -3371,7 +3371,7 @@ class RayDataEvolutionMergeIntoTest(unittest.TestCase):
         after = set()
         for root, _, files in os.walk(self.warehouse):
             after.update(os.path.join(root, name) for name in files)
-        self.assertEqual(before, after)
+        self.assertLess(before, after)
 
     @unittest.skipIf(_SKIP_CONDITION, _SKIP_REASON)
     def test_self_merge_callable_assignment(self):
diff --git a/paimon-python/pypaimon/tests/ray_row_id_conflict_rewriter_test.py 
b/paimon-python/pypaimon/tests/ray_row_id_conflict_rewriter_test.py
index 2273a9c97a..acc012f5a4 100644
--- a/paimon-python/pypaimon/tests/ray_row_id_conflict_rewriter_test.py
+++ b/paimon-python/pypaimon/tests/ray_row_id_conflict_rewriter_test.py
@@ -223,7 +223,7 @@ class RayRowIdConflictRewriterTest(unittest.TestCase):
         ), patch(
             'pypaimon.ray.row_id_conflict_rewriter._retry_wait',
         ), patch(
-            'pypaimon.write.file_store_commit._abort_commit_messages',
+            'pypaimon.write.file_store_commit.FileStoreCommit.abort',
         ) as abort_messages, self.assertRaises(RuntimeError) as context:
             commit_self_merge_with_compaction_retry(
                 table,
@@ -260,7 +260,7 @@ class RayRowIdConflictRewriterTest(unittest.TestCase):
         ), patch(
             'pypaimon.ray.row_id_conflict_rewriter._retry_wait',
         ), patch(
-            'pypaimon.write.file_store_commit._abort_commit_messages',
+            'pypaimon.write.file_store_commit.FileStoreCommit.abort',
         ) as abort_messages, self.assertRaises(
             RowIdExistenceConflict,
         ) as context:
@@ -295,7 +295,7 @@ class RayRowIdConflictRewriterTest(unittest.TestCase):
         ), patch(
             'pypaimon.ray.row_id_conflict_rewriter._retry_wait',
         ), patch(
-            'pypaimon.write.file_store_commit._abort_commit_messages',
+            'pypaimon.write.file_store_commit.FileStoreCommit.abort',
         ) as abort_messages, self.assertRaises(RuntimeError) as context:
             commit_self_merge_with_compaction_retry(
                 table,
diff --git a/paimon-python/pypaimon/tests/ray_sink_test.py 
b/paimon-python/pypaimon/tests/ray_sink_test.py
index 6153b37014..3870ab336f 100644
--- a/paimon-python/pypaimon/tests/ray_sink_test.py
+++ b/paimon-python/pypaimon/tests/ray_sink_test.py
@@ -363,7 +363,7 @@ class RaySinkTest(unittest.TestCase):
         self.assertEqual({2}, {message.total_buckets for message in messages})
 
     @pytest.mark.python_write
-    def 
test_write_does_not_return_prepared_messages_when_dedicated_close_aborts(self):
+    def test_write_failure_preserves_prepared_dedicated_files(self):
         from pypaimon.write.writer.dedicated_format_writer import 
DedicatedFormatWriter
 
         pa_schema = pa.schema([
@@ -400,7 +400,9 @@ class RaySinkTest(unittest.TestCase):
                 datasink.write([data_table], ctx)
 
         self.assertEqual(close_current_calls['count'], 2)
-        self.assertEqual([], self._data_files_under(table))
+        self.assertEqual({'.parquet', '.blob'},
+                         {os.path.splitext(path)[1] for path in 
self._data_files_under(table)})
+        self.assertIsNone(table.snapshot_manager().get_latest_snapshot())
 
     def test_on_write_complete(self):
         from ray.data.datasource.datasink import WriteResult
diff --git a/paimon-python/pypaimon/tests/table_commit_test.py 
b/paimon-python/pypaimon/tests/table_commit_test.py
index 82cd49291c..d606a94331 100644
--- a/paimon-python/pypaimon/tests/table_commit_test.py
+++ b/paimon-python/pypaimon/tests/table_commit_test.py
@@ -28,6 +28,71 @@ from pypaimon.write.table_commit import BatchTableCommit, 
StreamTableCommit
 
 class TestTableCommit(unittest.TestCase):
 
+    @parameterized.expand([
+        (mode, stream, published)
+        for mode in ('append', 'fixed', 'dynamic')
+        for stream in (False, True)
+        for published in (False, True)
+    ])
+    def test_writer_abort_preserves_files_after_commit_exception(self, mode, 
stream, published):
+        import pyarrow as pa
+        from unittest.mock import patch
+        from pypaimon import CatalogFactory, Schema
+
+        with TemporaryDirectory(prefix='paimon-uncertain-commit-') as 
warehouse:
+            catalog = CatalogFactory.create({'warehouse': warehouse})
+            catalog.create_database('default', True)
+            options = {'write.native.enabled': 'false', 
'commit.native.enabled': 'false',
+                       'read.native.enabled': 'false', 
'scan.native-plan.enabled': 'false'}
+            primary_keys = []
+            if mode != 'append':
+                primary_keys = ['id']
+                options.update({'bucket': '1' if mode == 'fixed' else '-1',
+                                'changelog-producer': 'input'})
+            data = pa.table({'id': pa.array([1, 2], pa.int64()), 'value': 
['a', 'b']})
+            catalog.create_table('default.t', Schema.from_pyarrow_schema(
+                data.schema, options=options, primary_keys=primary_keys), 
False)
+            table = catalog.get_table('default.t')
+            builder = table.new_stream_write_builder() if stream else 
table.new_batch_write_builder()
+            writer, commit = builder.new_write(), builder.new_commit()
+            try:
+                writer.write_arrow(data)
+                messages = writer.prepare_commit(1) if stream else 
writer.prepare_commit()
+                paths = [file.physical_path() for message in messages
+                         for file in message.new_files + 
message.changelog_files]
+                if mode != 'append':
+                    self.assertTrue(any(message.changelog_files for message in 
messages))
+                if mode == 'dynamic':
+                    self.assertTrue(any(message.index_adds for message in 
messages))
+                    paths.extend(table.path_factory().bucket_index_path(
+                        tuple(entry.partition.values), entry.bucket, 
entry.index_file, table.file_io)
+                        for message in messages for entry in 
message.index_adds)
+                self.assertTrue(paths)
+                publish = commit.file_store_commit.commit
+
+                def fail_commit(*args, **kwargs):
+                    if published:
+                        publish(*args, **kwargs)
+                    raise OSError('Commit outcome is unknown')
+
+                with patch.object(commit.file_store_commit, 'commit', 
side_effect=fail_commit):
+                    with self.assertRaisesRegex(OSError, 'Commit outcome is 
unknown'):
+                        commit.commit(messages, 1) if stream else 
commit.commit(messages)
+                # An unknown commit outcome must never trigger explicit commit 
abort.
+                writer.abort()
+                writer.close()
+                for path in paths:
+                    self.assertTrue(table.file_io.exists(path), 'Deleted 
prepared file: ' + path)
+                snapshot = table.snapshot_manager().get_latest_snapshot()
+                self.assertEqual(snapshot.id if snapshot else 0, 
int(published))
+                if published:
+                    read_builder = table.new_read_builder()
+                    actual = 
read_builder.new_read().to_arrow(read_builder.new_scan().plan().splits())
+                    self.assertEqual(actual.sort_by('id').to_pylist(), 
data.to_pylist())
+            finally:
+                writer.close()
+                commit.close()
+
     def test_empty_append_snapshot_is_opt_in_and_can_be_tagged(self):
         import pyarrow as pa
         import pypaimon.multimodal as pmm
diff --git a/paimon-python/pypaimon/tests/table_update_test.py 
b/paimon-python/pypaimon/tests/table_update_test.py
index 262b9726c7..6af386546b 100644
--- a/paimon-python/pypaimon/tests/table_update_test.py
+++ b/paimon-python/pypaimon/tests/table_update_test.py
@@ -370,7 +370,7 @@ class _TableUpdateTestBase(DataEvolutionTestBase):
         )
 
     @pytest.mark.python_write
-    def test_predicate_update_aborts_groups_after_later_failure(self):
+    def test_predicate_update_preserves_groups_after_later_failure(self):
         from pypaimon.write.table_update_by_row_id import TableUpdateByRowId
 
         table = self._create_seeded_table()
@@ -401,9 +401,10 @@ class _TableUpdateTestBase(DataEvolutionTestBase):
                 )
 
         self.assertEqual(2, calls)
-        self.assertEqual(before_files, self._list_table_files(table))
+        self.assertLess(before_files, self._list_table_files(table))
 
-    def test_callable_update_aborts_groups_after_later_failure(self):
+    @pytest.mark.python_write
+    def test_callable_update_preserves_groups_after_later_failure(self):
         table = self._create_seeded_table()
         before_files = self._list_table_files(table)
         calls = 0
@@ -424,7 +425,7 @@ class _TableUpdateTestBase(DataEvolutionTestBase):
             )
 
         self.assertEqual(2, calls)
-        self.assertEqual(before_files, self._list_table_files(table))
+        self.assertLess(before_files, self._list_table_files(table))
 
     def test_array_assignment_spans_file_groups(self):
         table = self._create_seeded_table()
@@ -1617,6 +1618,60 @@ class _TableUpdateTestBase(DataEvolutionTestBase):
             [[('a', None)], [('k2', 'dict_val')], [('b', '2')]],
             result4['meta'].to_pylist())
 
+    @pytest.mark.python_write
+    def 
test_update_by_row_id_preserves_files_after_prepare_commit_failure(self):
+        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)
+
+        prepared_files = []
+
+        def fail_after_prepare_commit(
+                new_files, first_row_id, column_names, blob_columns):
+            prepared_files.extend(new_files)
+            raise RuntimeError("forced failure after prepare_commit")
+
+        for columns in (['age'], ['picture'], ['age', 'picture']):
+            with self.subTest(columns=columns):
+                wb = self._make_write_builder(table)
+                tu = wb.new_update().with_update_type(columns)
+                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()),
+                        ])).select(['_ROW_ID'] + columns), 
self._next_commit_id())
+
+                self.assertLess(before_files, self._list_table_files(table))
+                self.assertTrue(prepared_files)
+                self.assertTrue(all(table.file_io.exists(file.physical_path())
+                                    for file in prepared_files))
+                self.assertEqual([10, 20], 
self._read_all(table)['age'].to_pylist())
+                self.assertEqual(
+                    [b'blob-1', b'blob-2'], 
self._read_all(table)['picture'].to_pylist())
+
 
 # ======================================================================
 # Mode-specific mixins (add the ``update_by_arrow_with_row_id`` primitive)
@@ -1668,7 +1723,8 @@ 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):
+    @pytest.mark.python_write
+    def test_update_batches_reject_same_file_and_preserve_staged_files(self):
         table = self._create_seeded_table()
         before_files = self._list_table_files(table)
         update = (
@@ -1684,7 +1740,7 @@ class TableUpdateBatchTest(_BatchModeMixin, 
_TableUpdateTestBase, unittest.TestC
                 pa.Table.from_pydict({'_ROW_ID': [1], 'age': [31]}),
             ]))
 
-        self.assertEqual(before_files, self._list_table_files(table))
+        self.assertLess(before_files, self._list_table_files(table))
         self.assertEqual(
             [25, 30, 35, 40, 45],
             self._read_all(table)['age'].to_pylist(),
@@ -1807,50 +1863,6 @@ class TableUpdateBatchTest(_BatchModeMixin, 
_TableUpdateTestBase, unittest.TestC
         result = self._read_all(table).sort_by('id')
         self.assertEqual([100, 30, 35, 40, 45], result['age'].to_pylist())
 
-    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))
-
 
 class TableUpdateStreamTest(_StreamModeMixin, _TableUpdateTestBase, 
unittest.TestCase):
     """All shared update tests under stream (``StreamWriteBuilder``) semantics,
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 8833b1fc88..63ebe839e1 100644
--- a/paimon-python/pypaimon/tests/table_upsert_by_key_test.py
+++ b/paimon-python/pypaimon/tests/table_upsert_by_key_test.py
@@ -646,6 +646,75 @@ class _TableUpsertByKeyTestBase(DataEvolutionTestBase):
         self.assertEqual(('Dave', 40, 'Houston'), rows[4])
         self.assertTrue(all(os.path.exists(path) for path in stale_paths))
 
+    @pytest.mark.python_write
+    @pytest.mark.python_commit
+    def 
test_failed_commit_rewrite_preserves_prepared_groups_and_can_retry(self):
+        from pypaimon.write.table_update_by_row_id import TableUpdateByRowId
+
+        for fail_after_prepare in (False, True):
+            with self.subTest(fail_after_prepare=fail_after_prepare):
+                table = self._create_table()
+                original_data = pa.Table.from_pydict({
+                    'id': [1, 2, 3, 4],
+                    'name': ['Alice', 'Bob', 'Carol', 'Dave'],
+                    'age': [25, 30, 35, 40],
+                    'city': ['NYC', 'LA', 'Chicago', 'Houston'],
+                }, schema=self.pa_schema)
+                self._write_arrow(table, original_data.slice(0, 2))
+                self._write_arrow(table, original_data.slice(2, 2))
+                wb = self._make_write_builder(table)
+                cid = self._next_commit_id()
+                messages = []
+                for columns, data in [
+                    (['age'], {'id': [2], 'name': ['ignored'],
+                               'age': [31], 'city': ['ignored']}),
+                    (['city'], {'id': [3], 'name': ['ignored'],
+                                'age': [0], 'city': ['Chicago2']}),
+                ]:
+                    update = wb.new_update().with_update_type(columns)
+                    messages.extend(self._apply_upsert(
+                        update, pa.Table.from_pydict(data, 
schema=self.pa_schema), ['id'], cid))
+                staged_paths = [file.file_path for message in messages for 
file in message.new_files]
+                self._compact_all_data_files(table)
+                snapshot_id = table.snapshot_manager().get_latest_snapshot().id
+                prepared_paths = []
+                calls = []
+                original_update = TableUpdateByRowId.update_columns
+
+                def fail_second_group(updater, data, columns):
+                    calls.append(columns)
+                    if len(calls) == 2 and not fail_after_prepare:
+                        raise RuntimeError('second rewrite group failed')
+                    result = original_update(updater, data, columns)
+                    prepared_paths.extend(file.file_path for message in result 
for file in message.new_files)
+                    if len(calls) == 2:
+                        raise RuntimeError('second rewrite group failed')
+                    return result
+
+                commit = wb.new_commit()
+                try:
+                    with mock.patch.object(TableUpdateByRowId, 
'update_columns', fail_second_group):
+                        with self.assertRaisesRegex(RuntimeError, 'second 
rewrite group failed'):
+                            self._apply_commit(commit, messages, cid)
+                    # Preserve messages for retry; do not explicitly discard 
them after a failed commit.
+                finally:
+                    commit.close()
+                self.assertEqual(2, len(calls))
+                self.assertEqual(2 if fail_after_prepare else 1, 
len(prepared_paths))
+                self.assertTrue(all(os.path.exists(path) for path in 
staged_paths + prepared_paths))
+                self.assertEqual(snapshot_id, 
table.snapshot_manager().get_latest_snapshot().id)
+                
self.assertTrue(original_data.equals(self._read_all(table).sort_by('id')))
+
+                commit = wb.new_commit()
+                try:
+                    self._apply_commit(commit, messages, cid)
+                finally:
+                    commit.close()
+                rows = {row['id']: row for row in 
self._read_all(table).to_pylist()}
+                self.assertEqual(31, rows[2]['age'])
+                self.assertEqual('Chicago2', rows[3]['city'])
+                self.assertTrue(all(os.path.exists(path) for path in 
staged_paths + prepared_paths))
+
     def test_commit_rewrite_uses_checked_base_entries(self):
         table = self._create_table()
         self._write_arrow(table, pa.Table.from_pydict({
diff --git a/paimon-python/pypaimon/tests/write/changelog_producer_test.py 
b/paimon-python/pypaimon/tests/write/changelog_producer_test.py
index 0ac4610ee8..f59b32d803 100644
--- a/paimon-python/pypaimon/tests/write/changelog_producer_test.py
+++ b/paimon-python/pypaimon/tests/write/changelog_producer_test.py
@@ -310,7 +310,7 @@ class ChangelogProducerTest(unittest.TestCase):
         self.assertIsNotNone(snapshot.changelog_manifest_list)
         self.assertEqual(snapshot.changelog_record_count, 2)
 
-    def test_abort_cleans_up_changelog_files(self):
+    def test_explicit_abort_deletes_prepared_data_and_changelog_files(self):
         table = self._create_table(
             'test_input_abort',
             options={'changelog-producer': 'input', 'bucket': '1'}
@@ -331,8 +331,8 @@ class ChangelogProducerTest(unittest.TestCase):
 
         data_files_after = glob.glob(os.path.join(bucket_dir, 'data-*'))
         changelog_files_after = glob.glob(os.path.join(bucket_dir, 
'changelog-*'))
-        self.assertEqual(len(data_files_after), 0, "Data files should be 
cleaned up after abort")
-        self.assertEqual(len(changelog_files_after), 0, "Changelog files 
should be cleaned up after abort")
+        self.assertFalse(data_files_after)
+        self.assertFalse(changelog_files_after)
 
         table_write.close()
         table_commit.close()
diff --git a/paimon-python/pypaimon/tests/write/dynamic_bucket_test.py 
b/paimon-python/pypaimon/tests/write/dynamic_bucket_test.py
index 1ce9541e8c..218532bbbf 100644
--- a/paimon-python/pypaimon/tests/write/dynamic_bucket_test.py
+++ b/paimon-python/pypaimon/tests/write/dynamic_bucket_test.py
@@ -402,7 +402,7 @@ class DynamicBucketTest(unittest.TestCase):
             ):
                 table.new_batch_write_builder().new_write()
 
-    def test_batch_writer_abort_after_prepare_deletes_hash_index(self):
+    def test_batch_writer_abort_after_prepare_preserves_hash_index(self):
         with tempfile.TemporaryDirectory() as root:
             table = self._create_table(root, 'abort_prepared')
             writer = table.new_batch_write_builder().new_write()
@@ -418,7 +418,52 @@ class DynamicBucketTest(unittest.TestCase):
 
             writer.abort()
 
-            self.assertFalse(table.file_io.exists(index_path))
+            self.assertTrue(table.file_io.exists(index_path))
+
+    @pytest.mark.python_write
+    def test_failed_hash_index_prepare_retries_the_complete_increment(self):
+        for failed_call in (1, 2):
+            with self.subTest(failed_call=failed_call), 
tempfile.TemporaryDirectory() as root:
+                table = self._create_table(root, 'retry_prepare', 
target_row_num=1).copy({
+                    'write.native.enabled': 'false', 'commit.native.enabled': 
'false',
+                    'read.native.enabled': 'false', 
'scan.native-plan.enabled': 'false',
+                    'changelog-producer': 'input'})
+                builder = table.new_stream_write_builder()
+                writer, commit = builder.new_write(), builder.new_commit()
+                data = pa.table({'id': [1, 2, 3], 'value': ['v-1', 'v-2', 
'v-3']})
+                try:
+                    writer.write_arrow(data)
+                    maintainer = writer.row_key_extractor._index_maintainer
+                    bucket_count = len(maintainer._states)
+                    self.assertGreaterEqual(bucket_count, 2)
+                    write_index = maintainer._write_index
+                    calls = 0
+
+                    def fail_index(*args):
+                        nonlocal calls
+                        calls += 1
+                        if calls == failed_call:
+                            raise OSError('HASH index prepare failed')
+                        return write_index(*args)
+
+                    with patch.object(maintainer, '_write_index', 
side_effect=fail_index):
+                        with self.assertRaisesRegex(OSError, 'HASH index 
prepare failed'):
+                            writer.prepare_commit(1)
+                    
self.assertIsNone(table.snapshot_manager().get_latest_snapshot())
+                    messages = writer.prepare_commit(1)
+                    self.assertEqual(3, sum(file.row_count for message in 
messages
+                                            for file in message.new_files))
+                    self.assertEqual(3, sum(file.row_count for message in 
messages
+                                            for file in 
message.changelog_files))
+                    self.assertEqual(bucket_count, sum(len(message.index_adds) 
for message in messages))
+                    self.assertEqual([], writer.prepare_commit(2))
+                    commit.commit(messages, 1)
+                    writer.abort()
+                    self.assertEqual(data.to_pydict(), 
self._read_arrow(table).sort_by('id').to_pydict())
+                    self.assertEqual(bucket_count, 
len(self._hash_indexes(table)))
+                finally:
+                    writer.close()
+                    commit.close()
 
     @pytest.mark.python_write
     def test_stream_writer_releases_prepared_hash_index_ownership(self):
diff --git a/paimon-python/pypaimon/tests/write/table_write_test.py 
b/paimon-python/pypaimon/tests/write/table_write_test.py
index 4d751ce59e..5b48d2ef3b 100644
--- a/paimon-python/pypaimon/tests/write/table_write_test.py
+++ b/paimon-python/pypaimon/tests/write/table_write_test.py
@@ -614,7 +614,7 @@ class TableWriteTest(unittest.TestCase):
         }
         pa_table = pa.Table.from_pydict(data1, schema=self.pa_schema)
         table_write.write_arrow(pa_table)
-        table_write.prepare_commit(0)
+        cm = table_write.prepare_commit(0)
         # write 2
         data2 = {
             'user_id': [5, 6, 7, 8],
@@ -624,7 +624,7 @@ class TableWriteTest(unittest.TestCase):
         }
         pa_table = pa.Table.from_pydict(data2, schema=self.pa_schema)
         table_write.write_arrow(pa_table)
-        table_write.prepare_commit(1)
+        cm.extend(table_write.prepare_commit(1))
         # write 3
         data3 = {
             'user_id': [9, 10],
@@ -634,7 +634,7 @@ class TableWriteTest(unittest.TestCase):
         }
         pa_table = pa.Table.from_pydict(data3, schema=self.pa_schema)
         table_write.write_arrow(pa_table)
-        cm = table_write.prepare_commit(2)
+        cm.extend(table_write.prepare_commit(2))
         # commit
         table_commit.commit(cm, 2)
         table_write.close()
@@ -740,7 +740,7 @@ class TableWriteTest(unittest.TestCase):
         }
         pa_table = pa.Table.from_pydict(data1, schema=self.pk_pa_schema)
         table_write.write_arrow(pa_table)
-        table_write.prepare_commit(0)
+        cm = table_write.prepare_commit(0)
         # write 2
         data2 = {
             'user_id': [5, 6, 7, 8],
@@ -750,7 +750,7 @@ class TableWriteTest(unittest.TestCase):
         }
         pa_table = pa.Table.from_pydict(data2, schema=self.pk_pa_schema)
         table_write.write_arrow(pa_table)
-        table_write.prepare_commit(1)
+        cm.extend(table_write.prepare_commit(1))
         # write 3
         data3 = {
             'user_id': [9, 10],
@@ -760,7 +760,7 @@ class TableWriteTest(unittest.TestCase):
         }
         pa_table = pa.Table.from_pydict(data3, schema=self.pk_pa_schema)
         table_write.write_arrow(pa_table)
-        cm = table_write.prepare_commit(2)
+        cm.extend(table_write.prepare_commit(2))
         # commit
         table_commit.commit(cm, 2)
         table_write.close()
diff --git a/paimon-python/pypaimon/tests/write/write_buffer_test.py 
b/paimon-python/pypaimon/tests/write/write_buffer_test.py
index 942798b75a..566f163bdc 100644
--- a/paimon-python/pypaimon/tests/write/write_buffer_test.py
+++ b/paimon-python/pypaimon/tests/write/write_buffer_test.py
@@ -513,6 +513,7 @@ class _StubSidecarWriter:
     def __init__(self, row_count: int, file_name: str, fail_times: int = 0,
                  delete_on_abort: bool = True):
         self.committed_files = []
+        self.committed_changelog_files = []
         self.pending_row_count = 0
         self.prepare_commit_calls = 0
         self.aborted = False
diff --git a/paimon-python/pypaimon/write/commit/row_id_conflict_rewriter.py 
b/paimon-python/pypaimon/write/commit/row_id_conflict_rewriter.py
index 3fdaf916c6..fa0efbb8d2 100644
--- a/paimon-python/pypaimon/write/commit/row_id_conflict_rewriter.py
+++ b/paimon-python/pypaimon/write/commit/row_id_conflict_rewriter.py
@@ -136,10 +136,12 @@ class RowIdConflictRewriter:
                         updater.update_columns(update_data, column_names)
                     )
                 except Exception:
-                    self._abort(updater.commit_messages)
+                    # Never delete files from CommitMessage on failure.
+                    # A commit can succeed even when its response raises an 
exception.
                     raise
         except Exception:
-            self._abort(new_messages)
+            # Preserve prepared outputs from earlier groups as well.
+            # Never infer deletion ownership from CommitMessage.
             raise
 
         rewritten_entries = [
@@ -271,13 +273,6 @@ class RowIdConflictRewriter:
                 ))
         return entries
 
-    def _abort(self, messages):
-        for message in messages:
-            for file in message.new_files:
-                path = file.physical_path()
-                if path:
-                    self.table.file_io.delete_quietly(path)
-
     @staticmethod
     def _range_key(entry):
         return (
diff --git a/paimon-python/pypaimon/write/commit_message.py 
b/paimon-python/pypaimon/write/commit_message.py
index 0a25ba5cf4..1325260cd2 100644
--- a/paimon-python/pypaimon/write/commit_message.py
+++ b/paimon-python/pypaimon/write/commit_message.py
@@ -41,7 +41,6 @@ class CommitMessage:
     compact_changelog_files: List[DataFileMeta] = field(default_factory=list)
     compact_index_adds: List['IndexManifestEntry'] = 
field(default_factory=list)
     compact_index_deletes: List['IndexManifestEntry'] = 
field(default_factory=list)
-    _native_write_pending: bool = field(default=False, init=False, repr=False, 
compare=False)
 
     def is_empty(self):
         return (
diff --git a/paimon-python/pypaimon/write/file_store_commit.py 
b/paimon-python/pypaimon/write/file_store_commit.py
index 5259be2461..03e718e707 100644
--- a/paimon-python/pypaimon/write/file_store_commit.py
+++ b/paimon-python/pypaimon/write/file_store_commit.py
@@ -85,48 +85,6 @@ def _reject_compact_increment(messages: List[CommitMessage]):
                 'Committing a compact increment requires a separate COMPACT 
snapshot.')
 
 
-def _abort_commit_messages(table, commit_messages: List[CommitMessage]):
-    """Delete files created by messages known to be uncommitted."""
-    for message in commit_messages:
-        for file in (list(message.new_files) + list(message.changelog_files)
-                     + list(message.compact_after)
-                     + list(message.compact_changelog_files)):
-            path = None
-            try:
-                bucket_path = None if file.physical_path() else 
table.path_factory().bucket_path(
-                    tuple(message.partition), message.bucket)
-                for path in file.collect_files(bucket_path):
-                    table.file_io.delete_quietly(path)
-            except Exception as error:
-                logger.warning(
-                    "Failed to clean up file %s during abort: %s",
-                    path,
-                    error,
-                )
-        for entry in message.index_adds + message.compact_index_adds:
-            file_name = None
-            try:
-                index_file = entry.index_file
-                file_name = index_file.file_name
-                if index_file.index_type in ('DELETION_VECTORS', 'HASH'):
-                    path = table.path_factory().bucket_index_path(
-                        tuple(entry.partition.values), entry.bucket, 
index_file, table.file_io)
-                else:
-                    path = (
-                        index_file.external_path
-                        or table.path_factory()
-                        .global_index_path_factory()
-                        .to_path(file_name)
-                    )
-                table.file_io.delete_quietly(path)
-            except Exception as error:
-                logger.warning(
-                    "Failed to clean up index file %s during abort: %s",
-                    file_name,
-                    error,
-                )
-
-
 class CommitResult:
     """Base class for commit results."""
 
@@ -1062,8 +1020,51 @@ class FileStoreCommit:
             
self.table.file_io.delete_quietly(f"{manifest_path}/{index_manifest}")
 
     def abort(self, commit_messages: List[CommitMessage]):
-        """Abort commit and delete files. Uses external_path if available to 
ensure proper scheme handling."""
-        _abort_commit_messages(self.table, commit_messages)
+        """Delete files for an explicitly abandoned, known-uncommitted write.
+
+        Mirrors Java FileStoreCommitImpl.abort: delete new data, changelog,
+        compaction outputs and added indexes, preserving deleted inputs.
+        Never call after a commit whose outcome is unknown. Internal error
+        paths must preserve prepared files instead of calling this method.
+        """
+        for message in commit_messages:
+            for file in (list(message.new_files) + 
list(message.changelog_files)
+                         + list(message.compact_after)
+                         + list(message.compact_changelog_files)):
+                path = None
+                try:
+                    bucket_path = None if file.physical_path() else 
self.table.path_factory().bucket_path(
+                        tuple(message.partition), message.bucket)
+                    for path in file.collect_files(bucket_path):
+                        self.table.file_io.delete_quietly(path)
+                except Exception as error:
+                    logger.warning(
+                        "Failed to clean up file %s during abort: %s",
+                        path,
+                        error,
+                    )
+            for entry in message.index_adds + message.compact_index_adds:
+                file_name = None
+                try:
+                    index_file = entry.index_file
+                    file_name = index_file.file_name
+                    if index_file.index_type in ('DELETION_VECTORS', 'HASH'):
+                        path = self.table.path_factory().bucket_index_path(
+                            tuple(entry.partition.values), entry.bucket, 
index_file, self.table.file_io)
+                    else:
+                        path = (
+                            index_file.external_path
+                            or self.table.path_factory()
+                            .global_index_path_factory()
+                            .to_path(file_name)
+                        )
+                    self.table.file_io.delete_quietly(path)
+                except Exception as error:
+                    logger.warning(
+                        "Failed to clean up index file %s during abort: %s",
+                        file_name,
+                        error,
+                    )
 
     def close(self):
         """Close the FileStoreCommit and release resources."""
diff --git a/paimon-python/pypaimon/write/file_store_write.py 
b/paimon-python/pypaimon/write/file_store_write.py
index 02ddd0898d..599e301eeb 100644
--- a/paimon-python/pypaimon/write/file_store_write.py
+++ b/paimon-python/pypaimon/write/file_store_write.py
@@ -322,6 +322,12 @@ class FileStoreWrite:
         return any(isinstance(f.type, VectorType) for f in 
self.table.table_schema.fields)
 
     def prepare_commit(self, commit_identifier) -> List[CommitMessage]:
+        messages = self._prepare_commit_messages(commit_identifier)
+        self._release_prepared_files()
+        return messages
+
+    def _prepare_commit_messages(self, commit_identifier) -> 
List[CommitMessage]:
+        """Collect files while their parent is still preparing the complete 
increment."""
         self.commit_identifier = commit_identifier
         commit_messages = []
         for (partition, bucket), writer in self.data_writers.items():
@@ -336,15 +342,13 @@ class FileStoreWrite:
                     total_buckets=self._runtime_total_buckets.get(partition),
                 )
                 commit_messages.append(commit_message)
-        self._release_prepared_postpone_files()
         return commit_messages
 
-    def _release_prepared_postpone_files(self):
+    def _release_prepared_files(self):
         # Hand off only after every partition prepared successfully. Until
         # then, close/abort must still clean up files from earlier partitions.
         for writer in self.data_writers.values():
-            if isinstance(writer, PostponeDataWriter):
-                writer._release_prepared_files()
+            writer._release_prepared_files()
 
     def close(self):
         """Close all data writers and clean up resources."""
@@ -409,8 +413,8 @@ class PostponeFixedBucketFileStoreWrite(FileStoreWrite):
             return {}
         return super()._load_seq_number_stats(partition)
 
-    def prepare_commit(self, commit_identifier):
-        messages = super().prepare_commit(commit_identifier)
+    def _prepare_commit_messages(self, commit_identifier):
+        messages = super()._prepare_commit_messages(commit_identifier)
         for message in messages:
             message.check_from_snapshot = self._check_from_snapshot
         return messages
diff --git a/paimon-python/pypaimon/write/native_update.py 
b/paimon-python/pypaimon/write/native_update.py
index 1aee337e65..f291369bc4 100644
--- a/paimon-python/pypaimon/write/native_update.py
+++ b/paimon-python/pypaimon/write/native_update.py
@@ -42,7 +42,6 @@ def _native_row_id_table(table):
             or table.options.file_format() != 'parquet'
             or table.options.data_evolution_row_sidecar_enabled(False)
             or table.options.with_vector_format()
-            or table.options.video_frame_fields()
             or table.options.changelog_producer() != ChangelogProducer.NONE
             or any(table.options.options.contains_key(key) for key in 
SCAN_KEYS)
             or not native_write_available()):
@@ -139,7 +138,9 @@ def _native_update_columns_supported(table, columns):
 
 def create_native_upsert(table, commit_user, data, keys, columns):
     """Prepare one core upsert from full Arrow rows or named row values."""
-    if not _native_update_columns_supported(table, columns):
+    if table.options.video_frame_fields() or not 
_native_update_columns_supported(table, columns):
+        # An upsert can append unmatched rows; packed video writing is still
+        # provided by the Python writer.
         return None
     native_table = _native_row_id_table(table)
     if native_table is None:
diff --git a/paimon-python/pypaimon/write/native_write.py 
b/paimon-python/pypaimon/write/native_write.py
index a69cd3e1ed..81943baefd 100644
--- a/paimon-python/pypaimon/write/native_write.py
+++ b/paimon-python/pypaimon/write/native_write.py
@@ -22,9 +22,8 @@ import pyarrow as pa
 
 from pypaimon.common.options.core_options import MergeEngine
 from pypaimon.schema.arrow_schema import arrow_schemas_compatible, 
normalize_arrow_strings
-from pypaimon.schema.data_types import PyarrowFieldParser, is_blob_file_field, 
is_blob_type
+from pypaimon.schema.data_types import PyarrowFieldParser, is_blob_file_field
 from pypaimon.table.bucket_mode import BucketMode
-from pypaimon.write.file_store_commit import _abort_commit_messages
 from pypaimon.write.native_commit import (
     create_native_write_table, from_native_commit_messages,
 )
@@ -72,11 +71,7 @@ def create_native_write(table, commit_user, 
static_partition=None, stream=False,
             or table.options.file_format() != 'parquet'
             # Rust cannot encode these partition keys yet.
             or not _native_partition_types_supported(schema, 
table.partition_keys)
-            # Append dedicated files currently support top-level scalar Blob 
fields.
-            or table.options.video_frame_fields()
-            or (not table.is_primary_key_table
-                and any(is_blob_file_field(field) and not 
is_blob_type(field.type)
-                        for field in table.table_schema.fields))):
+            or table.options.video_frame_fields()):
         return None
     native_table = create_native_write_table(table)
     if native_table is None:
@@ -115,7 +110,6 @@ class NativeTableWrite:
         self._native_writer = native_writer
         self._python_writer = None
         self._written = False
-        self._prepared_messages = []
         self._schema = 
PyarrowFieldParser.from_paimon_schema(table.table_schema.fields)
 
     def _switch_to_python(self):
@@ -196,13 +190,7 @@ class NativeTableWrite:
             if commit_identifier is not None:
                 raise TypeError('BatchTableWrite.prepare_commit accepts no 
identifier')
             messages = self._native_writer.prepare_commit()
-        messages = from_native_commit_messages(self.table, messages)
-        self._prepared_messages = [message for message in 
self._prepared_messages
-                                   if message._native_write_pending]
-        for message in messages:
-            message._native_write_pending = True
-        self._prepared_messages.extend(messages)
-        return messages
+        return from_native_commit_messages(self.table, messages)
 
     def close(self):
         if self._python_writer is not None:
@@ -210,18 +198,15 @@ class NativeTableWrite:
         elif self._native_writer is not None:
             self._native_writer.close()
             self._native_writer = None
-        self._prepared_messages.clear()
 
     def abort(self):
         if self._python_writer is not None:
             self._python_writer.abort()
         else:
-            messages = [message for message in self._prepared_messages
-                        if message._native_write_pending]
-            try:
-                self.close()
-            finally:
-                _abort_commit_messages(self.table, messages)
+            # Closing Rust cleans only files still owned by its writer.
+            # Never delete prepared CommitMessage files: publication can
+            # succeed even when its response raises an exception.
+            self.close()
 
 
 class NativePostponeFixedBucketTableWrite(NativeTableWrite):
diff --git a/paimon-python/pypaimon/write/ray_datasink.py 
b/paimon-python/pypaimon/write/ray_datasink.py
index 796f69947e..edfdcb90ba 100644
--- a/paimon-python/pypaimon/write/ray_datasink.py
+++ b/paimon-python/pypaimon/write/ray_datasink.py
@@ -192,6 +192,10 @@ class PaimonDatasink(_DatasinkBase):
                         f"Error aborting worker-side table_write: 
{abort_error}",
                         exc_info=abort_error
                     )
+            # prepare_commit has handed these files off, but a failed close
+            # prevents this worker from returning them to the coordinator.
+            # Never delete files from CommitMessage on failure.
+            # A commit can succeed even when its response raises an exception.
             raise
 
     @staticmethod
diff --git a/paimon-python/pypaimon/write/table_commit.py 
b/paimon-python/pypaimon/write/table_commit.py
index 486dcbcff7..894d616fba 100644
--- a/paimon-python/pypaimon/write/table_commit.py
+++ b/paimon-python/pypaimon/write/table_commit.py
@@ -66,9 +66,7 @@ class TableCommit:
             commit_messages: List[CommitMessage],
             commit_identifier: int = BATCH_COMMIT_IDENTIFIER,
             snapshot_properties: Optional[Dict[str, str]] = None):
-        """Release native-writer cleanup ownership before publication can 
start."""
-        for message in commit_messages:
-            message._native_write_pending = False
+        """Publish prepared messages while preserving files on any failure."""
         non_empty_messages = [msg for msg in commit_messages if not 
msg.is_empty()]
         commit_kwargs = {
             "commit_messages": non_empty_messages,
@@ -139,6 +137,12 @@ class TableCommit:
             return None
 
     def abort(self, commit_messages: List[CommitMessage]):
+        """Delete explicitly abandoned, known-uncommitted files.
+
+        Never call after a commit whose outcome is unknown. Publication may
+        have succeeded before its response failed. Internal failure paths
+        must preserve prepared files instead of calling this method.
+        """
         prepared = self._prepare_native_commit(commit_messages)
         if prepared is not None:
             native, messages = prepared
diff --git a/paimon-python/pypaimon/write/table_update.py 
b/paimon-python/pypaimon/write/table_update.py
index a2177c2efe..c3f8159fe5 100644
--- a/paimon-python/pypaimon/write/table_update.py
+++ b/paimon-python/pypaimon/write/table_update.py
@@ -47,7 +47,6 @@ from pypaimon.snapshot.snapshot import BATCH_COMMIT_IDENTIFIER
 from pypaimon.snapshot.time_travel_util import SCAN_KEYS, TimeTravelUtil
 from pypaimon.table.special_fields import SpecialFields
 from pypaimon.write.commit_message import CommitMessage
-from pypaimon.write.file_store_commit import _abort_commit_messages
 from pypaimon.write.row_id_file_index import RowIdFileIndex
 from pypaimon.write.table_delete import TableDeleteByRowId
 from pypaimon.write.table_update_by_row_id import TableUpdateByRowId
@@ -216,8 +215,8 @@ class TableUpdate:
                 updater.update_columns(table, cols)
             return [] if updater is None else updater.commit_messages
         except Exception:
-            if updater is not None:
-                _abort_commit_messages(self.table, updater.commit_messages)
+            # Never delete files from CommitMessage on failure.
+            # A commit can succeed even when its response raises an exception.
             raise
 
     def _upsert_by_arrow_with_key(
@@ -356,7 +355,8 @@ class TableUpdate:
                         update_table, list(assignments.keys())
                     )
         except Exception:
-            _abort_commit_messages(self.table, updater.commit_messages)
+            # Never delete files from CommitMessage on failure.
+            # A commit can succeed even when its response raises an exception.
             raise
         return updater.commit_messages
 
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 32de0a2a98..103e4400de 100644
--- a/paimon-python/pypaimon/write/table_update_by_row_id.py
+++ b/paimon-python/pypaimon/write/table_update_by_row_id.py
@@ -948,6 +948,7 @@ class TableUpdateByRowId:
                 for value in values:
                     blob_writer.write_blob(value, arrow_type)
                 new_files.extend(blob_writer.prepare_commit())
+                blob_writer._release_prepared_files()
 
             if new_files:
                 self._assign_update_file_metadata(
@@ -970,6 +971,8 @@ class TableUpdateByRowId:
             else:
                 if file_store_write is not None:
                     file_store_write.abort()
+                # Never delete files from CommitMessage on failure.
+                # A commit can succeed even when its response raises an 
exception.
                 for blob_writer in blob_writers:
                     blob_writer.abort()
 
diff --git a/paimon-python/pypaimon/write/table_write.py 
b/paimon-python/pypaimon/write/table_write.py
index 21fdce5598..e982248dbd 100644
--- a/paimon-python/pypaimon/write/table_write.py
+++ b/paimon-python/pypaimon/write/table_write.py
@@ -316,12 +316,9 @@ class TableWrite:
                 abort()
 
     def _prepare_commit(self, commit_identifier) -> List[CommitMessage]:
-        commit_messages = 
self.file_store_write.prepare_commit(commit_identifier)
+        commit_messages = 
self.file_store_write._prepare_commit_messages(commit_identifier)
         prepare_indexes = getattr(self.row_key_extractor, "prepare_commit", 
None)
-        if prepare_indexes is None:
-            return commit_messages
-
-        index_changes = prepare_indexes()
+        index_changes = prepare_indexes() if prepare_indexes is not None else 
{}
         messages_by_bucket = {
             (tuple(message.partition), message.bucket): message
             for message in commit_messages
@@ -338,6 +335,10 @@ class TableWrite:
                 messages_by_bucket[(partition, bucket)] = message
             message.index_adds.extend(changes.additions)
             message.index_deletes.extend(changes.deletions)
+        # Hand off data, changelog and HASH indexes together. Until every
+        # preparation succeeds, retain the metadata for retry and abort.
+        self.file_store_write._release_prepared_files()
+        self._release_prepared_indexes()
         return commit_messages
 
     def _release_prepared_indexes(self) -> None:
@@ -403,6 +404,4 @@ class StreamTableWrite(TableWrite):
         super().__init__(table, commit_user, None)
 
     def prepare_commit(self, commit_identifier) -> List[CommitMessage]:
-        messages = self._prepare_commit(commit_identifier)
-        self._release_prepared_indexes()
-        return messages
+        return self._prepare_commit(commit_identifier)
diff --git a/paimon-python/pypaimon/write/writer/composite_data_writer.py 
b/paimon-python/pypaimon/write/writer/composite_data_writer.py
index 9cf8abf29c..a9d5c66e6f 100644
--- a/paimon-python/pypaimon/write/writer/composite_data_writer.py
+++ b/paimon-python/pypaimon/write/writer/composite_data_writer.py
@@ -51,6 +51,17 @@ class CompositeDataWriter(DataWriter):
         self._close_current_writers()
         return self.committed_files.copy()
 
+    def _release_prepared_files(self) -> List[DataFileMeta]:
+        """Hand a successfully prepared group to its committer, as Java does.
+
+        Until every partition prepares, retain files for retry/abort. Once the
+        parent accepts the messages, later checkpoints must neither resend
+        those files nor delete them when another write fails.
+        """
+        owned_files = super()._release_prepared_files()
+        self._committed_files_to_delete_on_abort.clear()
+        return owned_files
+
     def close(self):
         if self.closed:
             return
diff --git a/paimon-python/pypaimon/write/writer/data_writer.py 
b/paimon-python/pypaimon/write/writer/data_writer.py
index a00287caf7..e8f178b974 100644
--- a/paimon-python/pypaimon/write/writer/data_writer.py
+++ b/paimon-python/pypaimon/write/writer/data_writer.py
@@ -157,8 +157,10 @@ class DataWriter(ABC):
         Call only after all sidecars are prepared and validated. Until then, 
this
         writer retains its metadata for retry and its responsibility for 
cleanup.
         """
-        owned_files = self.committed_files.copy() if 
self.delete_file_upon_abort() else []
+        owned_files = (self.committed_files + self.committed_changelog_files
+                       if self.delete_file_upon_abort() else [])
         self.committed_files.clear()
+        self.committed_changelog_files.clear()
         return owned_files
 
     def delete_file_upon_abort(self) -> bool:

Reply via email to