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 8100277d75 [python] Fix quadratic cost of writing multiple batches 
(#9375)
8100277d75 is described below

commit 8100277d75f6744013fab6cbedbf1d9c758fef66
Author: Yu Gan <[email protected]>
AuthorDate: Wed Aug 26 11:42:09 2026 +0800

    [python] Fix quadratic cost of writing multiple batches (#9375)
---
 .../pypaimon/tests/test_write_merge_buffer.py      | 116 ++-
 .../tests/write/changelog_producer_test.py         |  66 ++
 .../pypaimon/tests/write/table_write_test.py       |   6 +-
 .../pypaimon/tests/write/write_buffer_test.py      | 801 +++++++++++++++++++++
 paimon-python/pypaimon/write/writer/blob_writer.py |  18 +-
 .../pypaimon/write/writer/data_vector_writer.py    |  84 ++-
 paimon-python/pypaimon/write/writer/data_writer.py | 292 +++++---
 .../write/writer/dedicated_format_writer.py        | 107 ++-
 .../pypaimon/write/writer/key_value_data_writer.py |  63 +-
 .../pypaimon/write/writer/write_buffer.py          | 101 +++
 10 files changed, 1419 insertions(+), 235 deletions(-)

diff --git a/paimon-python/pypaimon/tests/test_write_merge_buffer.py 
b/paimon-python/pypaimon/tests/test_write_merge_buffer.py
index 031d1a3a02..b3ee177a39 100644
--- a/paimon-python/pypaimon/tests/test_write_merge_buffer.py
+++ b/paimon-python/pypaimon/tests/test_write_merge_buffer.py
@@ -19,9 +19,9 @@
 """Unit tests for ``KeyValueDataWriter`` buffer behaviour.
 
 Covers the fold algorithm (`_merge_pending_by_pk`), the flush lifecycle
-(`_flush_all` empties the buffer + clears pending_data), and the
-roll-write helper (`_roll_write` splits oversized buffers across
-multiple files). Drives a thin harness that bypasses
+(`_flush_all` drains the buffer), and the roll-write helper
+(`_roll_write` splits oversized buffers across multiple files).
+Drives a thin harness that bypasses
 ``DataWriter.__init__`` so tests can exercise these paths without
 spinning up the real catalog/write stack.
 """
@@ -36,6 +36,7 @@ from pypaimon.read.reader.deduplicate_merge_function import \
 from pypaimon.read.reader.partial_update_merge_function import \
     PartialUpdateMergeFunction
 from pypaimon.write.writer.key_value_data_writer import KeyValueDataWriter
+from pypaimon.write.writer.write_buffer import WriteBuffer
 
 
 # Layout matches what ``KeyValueDataWriter._add_system_fields`` emits:
@@ -87,7 +88,7 @@ class _Harness(KeyValueDataWriter):
         # Large enough that ``_check_and_roll_if_needed`` does not
         # trigger on its own in tests that don't care about rolling.
         self.target_file_size = target_file_size
-        self.pending_data = None
+        self._buffer = WriteBuffer(self._merge_data)
         self.committed_files = []
         self.written_chunks = []
 
@@ -133,13 +134,13 @@ class WriteMergeBufferTest(unittest.TestCase):
 
     def test_dedupe_nullable_pk_uses_null_safe_equality_and_nulls_first(self):
         writer = _Harness(DeduplicateMergeFunction())
-        writer.pending_data = pa.Table.from_pylist(
+        writer._buffer.append(pa.Table.from_pylist(
             [_row(2, 1, 'two', None),
              _row(None, 2, 'null-old', None),
              _row(1, 3, 'one', None),
              _row(None, 4, 'null-new', None)],
             schema=_NULLABLE_PK_SCHEMA,
-        )
+        ))
 
         writer._flush_all()
 
@@ -290,18 +291,18 @@ class WriteMergeBufferTest(unittest.TestCase):
         # responsible for sorting before folding, so unsorted input is
         # the right stress case.
         writer = _Harness(DeduplicateMergeFunction())
-        writer.pending_data = pa.Table.from_pylist(
+        writer._buffer.append(pa.Table.from_pylist(
             [_row(2, 5, 'B2-new', None),
              _row(1, 2, 'A1-mid', None),
              _row(1, 1, 'A1-old', None),
              _row(2, 4, 'B2-old', None),
              _row(1, 3, 'A1-new', None)],
             schema=_SCHEMA,
-        )
+        ))
         writer._flush_all()
 
         # Buffer cleared.
-        self.assertIsNone(writer.pending_data)
+        self.assertTrue(writer._buffer.is_empty)
         # Exactly one file written (size well under target).
         self.assertEqual(len(writer.written_chunks), 1)
         flushed = writer.written_chunks[0]
@@ -314,15 +315,14 @@ class WriteMergeBufferTest(unittest.TestCase):
 
     def test_flush_all_on_empty_buffer_is_noop(self):
         writer = _Harness(DeduplicateMergeFunction())
-        writer.pending_data = None
         writer._flush_all()
-        self.assertIsNone(writer.pending_data)
+        self.assertTrue(writer._buffer.is_empty)
         self.assertEqual(writer.written_chunks, [])
 
     def test_flush_all_clears_buffer_even_when_fold_drops_everything(self):
         # MergeFunction that returns None for every group; verifies
-        # ``_flush_all`` still resets ``pending_data`` so a subsequent
-        # write starts from a clean slate.
+        # ``_flush_all`` still drains the buffer so a subsequent write
+        # starts from a clean slate.
         class DropAll:
             def reset(self):
                 pass
@@ -334,12 +334,12 @@ class WriteMergeBufferTest(unittest.TestCase):
                 return None
 
         writer = _Harness(DropAll())
-        writer.pending_data = pa.Table.from_pylist(
+        writer._buffer.append(pa.Table.from_pylist(
             [_row(1, 1, 'A', None), _row(1, 2, 'B', None)],
             schema=_SCHEMA,
-        )
+        ))
         writer._flush_all()
-        self.assertIsNone(writer.pending_data)
+        self.assertTrue(writer._buffer.is_empty)
         self.assertEqual(writer.written_chunks, [])
 
     # -- _roll_write ------------------------------------------------------
@@ -377,6 +377,90 @@ class WriteMergeBufferTest(unittest.TestCase):
         for chunk in writer.written_chunks[:-1]:
             self.assertLessEqual(chunk.nbytes, target)
 
+    def test_roll_write_leaves_only_unwritten_rows_when_a_file_fails(self):
+        # A flush spans several files, so it cannot be all-or-nothing: keeping
+        # every row would make the retry rewrite what the first files already
+        # took. The buffer has to hold the remainder and nothing else.
+        rows = [_row(i, i, 'x' * 64, 'y' * 64) for i in range(1, 401)]
+        data = pa.Table.from_pylist(rows, schema=_SCHEMA)
+
+        class _FailOnSecondFile(_Harness):
+            """Fails once, on the second file, then writes normally."""
+
+            failed = False
+
+            def _write_data_to_file(self, chunk):
+                if not self.failed and len(self.written_chunks) == 1:
+                    self.failed = True
+                    raise IOError('transient storage failure')
+                super()._write_data_to_file(chunk)
+
+        writer = _FailOnSecondFile(DeduplicateMergeFunction(),
+                                   target_file_size=data.nbytes // 4)
+        writer._buffer.append(data)
+        with self.assertRaises(IOError):
+            writer._flush_all()
+
+        written = sum(c.num_rows for c in writer.written_chunks)
+        self.assertEqual(len(writer.written_chunks), 1)
+        self.assertEqual(written + writer._buffer.num_rows, data.num_rows)
+
+        # The retry picks up exactly where the failure left off.
+        writer._flush_all()
+        self.assertTrue(writer._buffer.is_empty)
+        self.assertEqual(sum(c.num_rows for c in writer.written_chunks),
+                         data.num_rows)
+        flushed_ids = [r['id'] for c in writer.written_chunks
+                       for r in c.to_pylist()]
+        self.assertEqual(sorted(flushed_ids), [r['id'] for r in rows])
+
+
+class KeyValueWriteBufferTest(unittest.TestCase):
+    """The write path must not fold the buffer on every write.
+
+    ``_check_and_roll_if_needed`` flushes on size alone, and it reads that size
+    from the buffer's running total, so a write that stays under
+    ``target_file_size`` never needs the buffered batches as one table. Folding
+    there anyway would make writing N batches O(N^2): each fold leaves one more
+    chunk per column for the next ``pa.Table.nbytes`` walk to visit.
+    """
+
+    def test_writes_under_target_size_do_not_fold(self):
+        writer = _Harness(DeduplicateMergeFunction())
+        writer.sequence_generator = _StubSeqGen()
+        batch = pa.RecordBatch.from_pylist(
+            [{'id': 1, 'a': 'A', 'b': None}],
+            schema=pa.schema([
+                pa.field('id', pa.int64(), nullable=False),
+                pa.field('a', pa.string()),
+                pa.field('b', pa.string()),
+            ]),
+        )
+
+        folds = []
+        original = pa.concat_tables
+
+        def counting(*args, **kwargs):
+            folds.append(1)
+            return original(*args, **kwargs)
+
+        pa.concat_tables = counting
+        try:
+            for _ in range(100):
+                writer.write(batch)
+        finally:
+            pa.concat_tables = original
+
+        self.assertEqual(folds, [])
+        self.assertEqual(writer.written_chunks, [])
+        self.assertEqual(writer.pending_row_count, 100)
+
+        # The rows are all still there; flushing folds them exactly once.
+        writer._flush_all()
+        self.assertEqual(len(writer.written_chunks), 1)
+        # Dedup collapses the 100 same-PK rows to one.
+        self.assertEqual(writer.written_chunks[0].num_rows, 1)
+
 
 class _StubSeqGen:
     """Stand-in for ``SequenceGenerator`` so the harness can call
diff --git a/paimon-python/pypaimon/tests/write/changelog_producer_test.py 
b/paimon-python/pypaimon/tests/write/changelog_producer_test.py
index b789593038..2e2b2e20da 100644
--- a/paimon-python/pypaimon/tests/write/changelog_producer_test.py
+++ b/paimon-python/pypaimon/tests/write/changelog_producer_test.py
@@ -267,6 +267,72 @@ class ChangelogProducerTest(unittest.TestCase):
         table_write.close()
         table_commit.close()
 
+    def test_failed_changelog_write_leaves_nothing_to_commit(self):
+        """A data file and its changelog are committed together or not at all.
+
+        The data file used to be recorded before its changelog was written, so 
a
+        changelog failure left the meta committed while the rows stayed 
buffered
+        for the retry -- and the retry then wrote a second data file covering
+        rows the first meta already claimed. Committing both metas would double
+        every row in the snapshot.
+        """
+        table = self._create_table(
+            'test_changelog_atomic',
+            options={'changelog-producer': 'input', 'bucket': '1'}
+        )
+        # Streaming write: ``BatchTableWrite`` refuses a second prepare_commit,
+        # and the retry is the whole point here.
+        write_builder = table.new_stream_write_builder()
+        table_write = write_builder.new_write()
+        table_commit = write_builder.new_commit()
+
+        original_write_parquet = table.file_io.write_parquet
+        state = {'failed': False}
+
+        def failing_write_parquet(path, data, **kwargs):
+            if not state['failed'] and '/changelog-' in str(path):
+                state['failed'] = True
+                raise IOError('transient storage failure')
+            return original_write_parquet(path, data, **kwargs)
+
+        table.file_io.write_parquet = failing_write_parquet
+        try:
+            table_write.write_arrow(self._sample_data())
+            with self.assertRaises(IOError):
+                table_write.prepare_commit(0)
+
+            bucket_dir = os.path.join(
+                self.warehouse, 'default.db', 'test_changelog_atomic',
+                'dt=p1', 'bucket-0')
+            self.assertEqual(glob.glob(os.path.join(bucket_dir, 'data-*')), [],
+                             "The data file must not outlive its failed 
changelog")
+            self.assertEqual(glob.glob(os.path.join(bucket_dir, 
'changelog-*')), [],
+                             "A half-written changelog must not be left 
behind")
+
+            messages = table_write.prepare_commit(0)
+        finally:
+            table.file_io.write_parquet = original_write_parquet
+
+        self.assertTrue(state['failed'], "the changelog write never failed")
+        # One data file and one changelog for the 3 rows, not two of each.
+        self.assertEqual(len(glob.glob(os.path.join(bucket_dir, 'data-*'))), 1)
+        self.assertEqual(len(glob.glob(os.path.join(bucket_dir, 
'changelog-*'))), 1)
+        new_files = [meta for msg in messages for meta in msg.new_files]
+        changelog_files = [meta for msg in messages for meta in 
msg.changelog_files]
+        self.assertEqual(len(new_files), 1)
+        self.assertEqual(len(changelog_files), 1)
+        self.assertEqual(sum(meta.row_count for meta in new_files), 3)
+
+        table_commit.commit(messages, 0)
+        read_builder = table.new_read_builder()
+        actual = read_builder.new_read().to_arrow(
+            read_builder.new_scan().plan().splits())
+        self.assertEqual(actual.num_rows, 3, "the retry must not duplicate 
rows")
+        self.assertEqual(sorted(actual.column('user_id').to_pylist()), [1, 2, 
3])
+
+        table_write.close()
+        table_commit.close()
+
     def test_reject_changelog_producer_on_append_only_table(self):
         append_schema = pa.schema([
             ('user_id', pa.int32()),
diff --git a/paimon-python/pypaimon/tests/write/table_write_test.py 
b/paimon-python/pypaimon/tests/write/table_write_test.py
index d7c12735e4..aa46603d7d 100644
--- a/paimon-python/pypaimon/tests/write/table_write_test.py
+++ b/paimon-python/pypaimon/tests/write/table_write_test.py
@@ -1958,9 +1958,9 @@ class TableWriteTest(unittest.TestCase):
         )
         writer.write(big_batch)
 
-        pending_rows = writer.pending_data.num_rows if writer.pending_data is 
not None else 0
+        pending_rows = writer.pending_row_count
         committed_rows = sum(f.row_count for f in writer.committed_files)
         self.assertEqual(committed_rows + pending_rows, num_rows)
         self.assertGreater(len(writer.committed_files), 0)
-        if writer.pending_data is not None:
-            self.assertLessEqual(writer.pending_data.nbytes, target)
+        if pending_rows > 0:
+            self.assertLessEqual(writer._buffer.materialize().nbytes, target)
diff --git a/paimon-python/pypaimon/tests/write/write_buffer_test.py 
b/paimon-python/pypaimon/tests/write/write_buffer_test.py
new file mode 100644
index 0000000000..434f328897
--- /dev/null
+++ b/paimon-python/pypaimon/tests/write/write_buffer_test.py
@@ -0,0 +1,801 @@
+# 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.
+
+"""Tests for ``WriteBuffer`` and the deferred-fold write path.
+
+The regression these guard: ``DataWriter.write`` used to fold every incoming
+batch into its buffer and then measure the result, which made writing N batches
+O(N^2) -- ``pa.concat_tables`` leaves N chunks per column and the
+``pa.Table.nbytes`` walk behind each rolling decision re-visits all of them.
+Folding is now deferred, so the tests below assert both halves of that: the
+fold count no longer scales with the number of writes, and every rolling
+trigger the eager path had still fires.
+"""
+
+import contextlib
+import unittest
+
+import pyarrow as pa
+
+from pypaimon.write.writer.append_only_data_writer import AppendOnlyDataWriter
+from pypaimon.write.writer.data_vector_writer import DataVectorWriter
+from pypaimon.write.writer.dedicated_format_writer import DedicatedFormatWriter
+from pypaimon.write.writer.write_buffer import WriteBuffer
+
+_SCHEMA = pa.schema([
+    pa.field('id', pa.int64(), nullable=False),
+    pa.field('name', pa.string()),
+])
+
+# Anything past this is effectively "no rolling"; matches how
+# ``target_file_row_num`` defaults to the max long when the option is unset.
+_NO_LIMIT = 2 ** 63 - 1
+
+
+def _batch(start: int, num_rows: int) -> pa.RecordBatch:
+    return pa.RecordBatch.from_pydict(
+        {
+            'id': list(range(start, start + num_rows)),
+            'name': ['n%d' % i for i in range(start, start + num_rows)],
+        },
+        schema=_SCHEMA,
+    )
+
+
+def _table(start: int, num_rows: int) -> pa.Table:
+    return pa.Table.from_batches([_batch(start, num_rows)])
+
+
+# Same columns and types as ``_SCHEMA``, but ``id`` is nullable. This is one of
+# the differences ``TableWrite._validate_pyarrow_schema`` lets through (it only
+# compares field types) while ``pa.concat_tables`` rejects it.
+_NULLABLE_SCHEMA = pa.schema([
+    pa.field('id', pa.int64()),
+    pa.field('name', pa.string()),
+])
+
+# ``_SCHEMA`` carrying metadata, which neither ``Schema.equals`` nor
+# ``concat_tables`` looks at.
+_ANNOTATED_SCHEMA = pa.schema(
+    [
+        pa.field('id', pa.int64(), nullable=False, metadata={b'k': b'v'}),
+        pa.field('name', pa.string()),
+    ],
+    metadata={b'origin': b'test'},
+)
+
+
+def _batch_with(schema: pa.Schema, start: int, num_rows: int) -> 
pa.RecordBatch:
+    return pa.RecordBatch.from_pydict(
+        {
+            'id': list(range(start, start + num_rows)),
+            'name': ['n%d' % i for i in range(start, start + num_rows)],
+        },
+        schema=schema,
+    )
+
+
+def _table_with(schema: pa.Schema, start: int, num_rows: int) -> pa.Table:
+    return pa.Table.from_batches([_batch_with(schema, start, num_rows)])
+
+
[email protected]
+def _count_folds():
+    """Count the concat calls a fold performs, as a list of one entry each.
+
+    ``pa.concat_tables`` is the only way the buffer or a writer's
+    ``_merge_data`` collapses tables, so its call count is the fold count. The
+    eager path called it once per write; the deferred path calls it once per
+    ``materialize`` that has something to fold.
+    """
+    calls = []
+    original = pa.concat_tables
+
+    def counting(*args, **kwargs):
+        calls.append(1)
+        return original(*args, **kwargs)
+
+    pa.concat_tables = counting
+    try:
+        yield calls
+    finally:
+        pa.concat_tables = original
+
+
+class _Harness(AppendOnlyDataWriter):
+    """Append-only writer with the file layer stubbed out.
+
+    Bypasses ``DataWriter.__init__`` -- which needs a real table, catalog and
+    file IO -- and sets up only what the write/roll path reads.
+    """
+
+    def __init__(self, target_file_size: int = _NO_LIMIT,
+                 target_file_row_num: int = _NO_LIMIT):
+        self.target_file_size = target_file_size
+        self.target_file_row_num = target_file_row_num
+        self._buffer = WriteBuffer(self._merge_data)
+        self.committed_files = []
+        self.written_chunks = []
+        self.aborted = False
+
+    def _write_data_to_file(self, data: pa.Table):
+        self.written_chunks.append(data)
+
+    def abort(self):
+        # The real ``abort`` deletes the committed files through file IO this
+        # harness has none of; record that it ran and do the rest.
+        self.aborted = True
+        self._buffer.reset()
+        self.committed_files.clear()
+
+
+class WriteBufferTest(unittest.TestCase):
+
+    def _buffer(self):
+        return WriteBuffer(lambda a, b: pa.concat_tables([a, b]))
+
+    def test_append_tracks_counts_without_folding(self):
+        buffer = self._buffer()
+        with _count_folds() as folds:
+            for i in range(50):
+                buffer.append(_table(i * 10, 10))
+        self.assertEqual(folds, [])
+        self.assertEqual(buffer.num_rows, 500)
+        self.assertFalse(buffer.is_empty)
+
+    def test_running_nbytes_matches_the_folded_table(self):
+        # The gate on the write path trusts the running sum, so it has to agree
+        # with what the folded table reports -- separate tables share no Arrow
+        # buffers, so the per-table sizes add up exactly.
+        buffer = self._buffer()
+        for i in range(10):
+            buffer.append(_table(i * 10, 10))
+        running = buffer.nbytes
+        self.assertEqual(running, buffer.materialize().nbytes)
+        self.assertEqual(running, buffer.nbytes)
+
+    def test_materialize_folds_once_and_is_idempotent(self):
+        buffer = self._buffer()
+        for i in range(20):
+            buffer.append(_table(i, 1))
+        with _count_folds() as folds:
+            first = buffer.materialize()
+            second = buffer.materialize()
+        # One concat for the 20 appended tables, and nothing on re-read.
+        self.assertEqual(len(folds), 1)
+        self.assertIs(first, second)
+        self.assertEqual(first.num_rows, 20)
+        self.assertEqual(first.column('id').to_pylist(), list(range(20)))
+
+    def test_single_append_skips_the_concat_entirely(self):
+        buffer = self._buffer()
+        buffer.append(_table(0, 5))
+        with _count_folds() as folds:
+            self.assertEqual(buffer.materialize().num_rows, 5)
+        self.assertEqual(folds, [])
+
+    def test_append_after_materialize_goes_through_merge(self):
+        merged = []
+
+        def merge(existing, new):
+            merged.append((existing.num_rows, new.num_rows))
+            return pa.concat_tables([existing, new])
+
+        buffer = WriteBuffer(merge)
+        buffer.append(_table(0, 3))
+        buffer.materialize()
+        buffer.append(_table(3, 4))
+        self.assertEqual(buffer.materialize().num_rows, 7)
+        self.assertEqual(merged, [(3, 4)])
+
+    def test_empty_buffer_materializes_to_none(self):
+        buffer = self._buffer()
+        self.assertTrue(buffer.is_empty)
+        self.assertIsNone(buffer.materialize())
+        self.assertEqual(buffer.nbytes, 0)
+        self.assertEqual(buffer.num_rows, 0)
+
+    def test_reset_replaces_contents_and_remeasures(self):
+        buffer = self._buffer()
+        buffer.append(_table(0, 100))
+        replacement = _table(0, 7)
+        buffer.reset(replacement)
+        self.assertIs(buffer.materialize(), replacement)
+        self.assertEqual(buffer.num_rows, 7)
+        self.assertEqual(buffer.nbytes, replacement.nbytes)
+
+    def test_reset_to_none_empties_the_buffer(self):
+        buffer = self._buffer()
+        buffer.append(_table(0, 100))
+        buffer.reset()
+        self.assertTrue(buffer.is_empty)
+        self.assertIsNone(buffer.materialize())
+        self.assertEqual(buffer.num_rows, 0)
+        self.assertEqual(buffer.nbytes, 0)
+
+    def test_zero_row_table_is_not_reported_empty(self):
+        # ``is_empty`` has to distinguish "nothing set" from "an empty table 
was
+        # set", because the writers use it to decide whether there is anything
+        # to roll at all.
+        buffer = self._buffer()
+        buffer.reset(_table(0, 0))
+        self.assertFalse(buffer.is_empty)
+        self.assertEqual(buffer.num_rows, 0)
+        self.assertIsNotNone(buffer.materialize())
+
+
+class SchemaGuardTest(unittest.TestCase):
+    """The guard keeps a mismatch failing where the eager fold failed.
+
+    Folding on append meant a batch ``concat_tables`` could not accept raised
+    inside ``DataWriter.write``, which aborts and cleans up. Deferring the fold
+    would otherwise push that failure out to ``prepare_commit``, which has no
+    such handler, so ``append`` rejects up front exactly what concat rejects.
+    """
+
+    def _buffer(self):
+        return WriteBuffer(lambda a, b: pa.concat_tables([a, b]))
+
+    def test_append_rejects_a_schema_concat_would_reject(self):
+        buffer = self._buffer()
+        buffer.append(_table(0, 3))
+        with self.assertRaises(ValueError) as caught:
+            buffer.append(_table_with(_NULLABLE_SCHEMA, 3, 3))
+        self.assertIn('schema differs', str(caught.exception))
+        # And a rejected batch leaves the running counts describing the rows
+        # that are actually buffered.
+        self.assertEqual(buffer.num_rows, 3)
+        self.assertEqual(buffer.materialize().num_rows, 3)
+
+    def test_append_rejects_a_mismatch_after_a_materialize(self):
+        # ``materialize`` rebases the schema onto the folded table, so the
+        # second half of a buffer's life is guarded too.
+        buffer = self._buffer()
+        buffer.append(_table(0, 3))
+        buffer.materialize()
+        with self.assertRaises(ValueError):
+            buffer.append(_table_with(_NULLABLE_SCHEMA, 3, 3))
+
+    def test_append_accepts_a_metadata_only_difference(self):
+        # ``concat_tables`` ignores schema and field metadata, so the guard has
+        # to as well or it would reject batches the old path folded fine.
+        buffer = self._buffer()
+        buffer.append(_table(0, 3))
+        buffer.append(_table_with(_ANNOTATED_SCHEMA, 3, 4))
+        self.assertEqual(buffer.materialize().num_rows, 7)
+
+    def test_reset_rebases_the_schema(self):
+        # A writer that resets to a new table starts a new file; batches
+        # matching that table's schema have to be accepted afterwards.
+        buffer = self._buffer()
+        buffer.append(_table(0, 3))
+        buffer.reset(_table_with(_NULLABLE_SCHEMA, 0, 2))
+        buffer.append(_table_with(_NULLABLE_SCHEMA, 2, 2))
+        self.assertEqual(buffer.materialize().num_rows, 4)
+        with self.assertRaises(ValueError):
+            buffer.append(_table(4, 1))
+
+    def test_first_append_after_an_empty_reset_sets_the_schema(self):
+        buffer = self._buffer()
+        buffer.append(_table(0, 3))
+        buffer.reset()
+        buffer.append(_table_with(_NULLABLE_SCHEMA, 0, 3))
+        self.assertEqual(buffer.materialize().num_rows, 3)
+
+
+class DeferredFoldWritePathTest(unittest.TestCase):
+
+    def test_many_small_writes_fold_a_constant_number_of_times(self):
+        # The regression: with an eager fold this was 199 concats for 200
+        # writes, each walking a buffer one chunk longer than the last.
+        writer = _Harness()
+        with _count_folds() as folds:
+            for i in range(200):
+                writer.write(_batch(i * 5, 5))
+        self.assertEqual(folds, [])
+        # Reading the buffer folds it -- once, not once per write.
+        with _count_folds() as folds:
+            pending = writer._buffer.materialize()
+        self.assertEqual(len(folds), 1)
+        self.assertEqual(pending.num_rows, 1000)
+        self.assertEqual(pending.column('id').to_pylist(), list(range(1000)))
+
+    def test_row_num_rolling_still_fires(self):
+        # ``target_file_row_num`` postdates the eager fold, so a byte-only gate
+        # would leave row-based rolling silently dead for tables that set it
+        # while staying well under target_file_size.
+        writer = _Harness(target_file_row_num=10)
+        for i in range(10):
+            writer.write(_batch(i * 3, 3))
+        self.assertEqual([c.num_rows for c in writer.written_chunks],
+                         [10, 10])
+        self.assertEqual(writer.pending_row_count, 10)
+        self.assertEqual(
+            [row for c in writer.written_chunks
+             for row in c.column('id').to_pylist()],
+            list(range(20)),
+        )
+
+    def test_row_num_rolling_splits_a_single_oversized_write(self):
+        writer = _Harness(target_file_row_num=4)
+        writer.write(_batch(0, 14))
+        self.assertEqual([c.num_rows for c in writer.written_chunks],
+                         [4, 4, 4])
+        self.assertEqual(writer.pending_row_count, 2)
+
+    def test_size_rolling_still_bounds_written_files(self):
+        target = _table(0, 100).nbytes // 4
+        writer = _Harness(target_file_size=target)
+        for i in range(20):
+            writer.write(_batch(i * 5, 5))
+        self.assertGreaterEqual(len(writer.written_chunks), 3)
+        for chunk in writer.written_chunks:
+            self.assertLessEqual(chunk.nbytes, target)
+        written = sum(c.num_rows for c in writer.written_chunks)
+        self.assertEqual(written + writer.pending_row_count, 100)
+
+    def test_row_that_alone_exceeds_target_size_is_rolled_by_itself(self):
+        writer = _Harness(target_file_size=1)
+        writer.write(_batch(0, 3))
+        # Rolls the first two rows one at a time; the last stays buffered
+        # because the loop stops once the buffer is down to a single row.
+        self.assertEqual([c.num_rows for c in writer.written_chunks], [1, 1])
+        self.assertEqual(writer.pending_row_count, 1)
+
+    def test_rolling_does_not_refold_on_every_subsequent_write(self):
+        # After a roll the remainder goes back into the buffer, which
+        # re-measures it. If that left the roll condition stuck open, every
+        # later write would fold again and the quadratic would be back.
+        writer = _Harness(target_file_row_num=8)
+        writer.write(_batch(0, 12))
+        # Rolled 8 rows, 4 left buffered.
+        self.assertEqual(len(writer.written_chunks), 1)
+        with _count_folds() as folds:
+            for i in range(20):
+                writer.write(_batch(100 + i, 1))
+        # 4 buffered + 20 single-row writes crosses 8 rows exactly twice, so
+        # only two of those 20 writes fold anything. Asserting a bound rather
+        # than an exact count: how many concats one fold takes is up to
+        # ``WriteBuffer``, but it must not be one per write.
+        self.assertLess(len(folds), 20)
+        self.assertEqual(len(writer.written_chunks), 3)
+
+    def test_pending_row_count_does_not_fold(self):
+        # The composite writers read this on every write to size the next
+        # slice; folding here would bring the quadratic back through the side
+        # door.
+        writer = _Harness()
+        with _count_folds() as folds:
+            for i in range(30):
+                writer.write(_batch(i, 1))
+                self.assertEqual(writer.pending_row_count, i + 1)
+        self.assertEqual(folds, [])
+
+    def test_prepare_commit_flushes_the_deferred_buffer(self):
+        writer = _Harness()
+        for i in range(10):
+            writer.write(_batch(i * 2, 2))
+        writer.prepare_commit()
+        self.assertEqual([c.num_rows for c in writer.written_chunks], [20])
+        self.assertTrue(writer._buffer.is_empty)
+
+    def test_close_flushes_the_deferred_buffer(self):
+        writer = _Harness()
+        for i in range(10):
+            writer.write(_batch(i * 2, 2))
+        writer.close()
+        self.assertEqual([c.num_rows for c in writer.written_chunks], [20])
+        self.assertTrue(writer._buffer.is_empty)
+
+    def test_mismatched_schema_fails_the_write_that_carries_it(self):
+        # Not ``prepare_commit``: only ``write`` aborts, so a failure deferred
+        # to commit time would leave the files rolled so far orphaned.
+        writer = _Harness()
+        writer.write(_batch(0, 3))
+        with self.assertRaises(ValueError):
+            writer.write(_batch_with(_NULLABLE_SCHEMA, 3, 3))
+        self.assertTrue(writer.aborted)
+
+
+class FlushFailureTest(unittest.TestCase):
+    """A failed flush leaves the rows buffered for the retry.
+
+    ``StreamTableWrite`` is reusable, so a transient storage error followed by
+    another ``prepare_commit`` on the same writer has to write the same rows,
+    not silently skip them. Draining the buffer before the write would lose
+    them.
+    """
+
+    class _FailOnceHarness(_Harness):
+        def __init__(self, *args, **kwargs):
+            super().__init__(*args, **kwargs)
+            self.fail_next = True
+
+        def _write_data_to_file(self, data: pa.Table):
+            if self.fail_next:
+                self.fail_next = False
+                raise IOError('transient storage failure')
+            super()._write_data_to_file(data)
+
+    def test_failed_prepare_commit_keeps_the_rows_for_the_retry(self):
+        writer = self._FailOnceHarness()
+        for i in range(3):
+            writer.write(_batch(i, 1))
+        with self.assertRaises(IOError):
+            writer.prepare_commit()
+        self.assertEqual(writer.pending_row_count, 3)
+        writer.prepare_commit()
+        self.assertEqual([c.num_rows for c in writer.written_chunks], [3])
+        self.assertEqual(writer.pending_row_count, 0)
+
+    class _FailOnceVectorHarness(DataVectorWriter):
+        """``_close_current_writers`` with the file layer stubbed out.
+
+        No ``vector_writer``, so this covers the normal half on its own; the
+        point is only where the buffer is cleared relative to the write.
+        """
+
+        def __init__(self):
+            self.target_file_size = _NO_LIMIT
+            self.target_file_row_num = _NO_LIMIT
+            self.record_count = 0
+            self.vector_writer = None
+            self._normal_buffer = WriteBuffer(self._merge_data)
+            self.committed_files = []
+            self.written = []
+            self.fail_next = True
+
+        def _write_normal_data_to_file(self, data: pa.Table):
+            if self.fail_next:
+                self.fail_next = False
+                raise IOError('transient storage failure')
+            self.written.append(data)
+            return object()
+
+    def test_failed_normal_flush_keeps_the_rows_for_the_retry(self):
+        # Otherwise the retry finds no normal_meta, flushes the sidecars alone
+        # and skips the row-count check, committing sidecar-only metadata.
+        writer = self._FailOnceVectorHarness()
+        writer._normal_buffer.append(_table(0, 3))
+        with self.assertRaises(IOError):
+            writer._close_current_writers()
+        self.assertEqual(writer._normal_buffer.num_rows, 3)
+        writer._close_current_writers()
+        self.assertEqual([t.num_rows for t in writer.written], [3])
+        self.assertEqual(writer._normal_buffer.num_rows, 0)
+
+
+class _StubMeta:
+    """The handful of ``DataFileMeta`` fields the flush and abort paths 
read."""
+
+    def __init__(self, row_count: int, file_name: str):
+        self.row_count = row_count
+        self.file_name = file_name
+        self.file_path = '/warehouse/%s' % file_name
+        self.external_path = None
+        self.extra_files = []
+
+
+class _RecordingFileIO:
+    def __init__(self):
+        self.deleted = []
+
+    def delete_quietly(self, path):
+        self.deleted.append(path)
+
+
+class _StubSidecarWriter:
+    """A blob/vector writer that fails its first ``prepare_commit``.
+
+    Models the real ones in the way that matters here: a failure produces no
+    metadata, and because the sub-writer drains its own buffer as it writes, a
+    later call returns whatever has landed so far -- so the parent must be able
+    to harvest the same metas twice without double-counting them.
+    """
+
+    def __init__(self, row_count: int, file_name: str, fail_times: int = 0,
+                 delete_on_abort: bool = True):
+        self.committed_files = []
+        self.pending_row_count = 0
+        self.prepare_commit_calls = 0
+        self.aborted = False
+        self._row_count = row_count
+        self._file_name = file_name
+        self._fail_times = fail_times
+        self._delete_on_abort = delete_on_abort
+
+    def prepare_commit(self):
+        self.prepare_commit_calls += 1
+        if self._fail_times > 0:
+            self._fail_times -= 1
+            raise IOError('transient sidecar failure')
+        if not self.committed_files:
+            self.committed_files.append(
+                _StubMeta(self._row_count, self._file_name))
+        return self.committed_files.copy()
+
+    def delete_file_upon_abort(self):
+        return self._delete_on_abort
+
+    def abort(self):
+        self.aborted = True
+        self.committed_files.clear()
+
+
+class CompositeFlushResumeTest(unittest.TestCase):
+    """A composite flush publishes all of its files or none of them.
+
+    One flush writes the normal data file and then the blob/vector sidecars. 
The
+    sidecar writers drain their own buffers as they go, so a failure part way
+    through cannot be rolled back -- deleting the sidecars that already landed
+    would lose rows nothing can replay. So the flush resumes instead: the 
normal
+    rows stay buffered until their file lands, the landed file is remembered so
+    the retry skips it, and no metadata is published until every phase is done.
+
+    Publishing the normal meta before the sidecars ran, as the code used to,
+    left the retry writing a second copy of rows the first meta already covered
+    -- and ``_validate_consistency`` then checked the sidecars against that
+    second copy only.
+    """
+
+    class _VectorHarness(DataVectorWriter):
+        def __init__(self, vector_writer, fail_normal_times: int = 0):
+            self.target_file_size = _NO_LIMIT
+            self.target_file_row_num = _NO_LIMIT
+            self.record_count = 0
+            self.vector_writer = vector_writer
+            self.normal_column_names = ['id', 'name']
+            self.vector_write_columns = []
+            self._normal_buffer = WriteBuffer(self._merge_data)
+            self._buffer = WriteBuffer(self._merge_data)
+            self.committed_files = []
+            self.committed_changelog_files = []
+            self.file_io = _RecordingFileIO()
+            self.written = []
+            self._fail_normal_times = fail_normal_times
+
+        def _write_normal_data_to_file(self, data: pa.Table):
+            if self._fail_normal_times > 0:
+                self._fail_normal_times -= 1
+                raise IOError('transient storage failure')
+            self.written.append(data)
+            return _StubMeta(data.num_rows, 'data-%d' % len(self.written))
+
+    class _DedicatedHarness(DedicatedFormatWriter):
+        def __init__(self, blob_writers, vector_writer=None):
+            self.target_file_size = _NO_LIMIT
+            self.target_file_row_num = _NO_LIMIT
+            self.record_count = 0
+            self.blob_writers = blob_writers
+            self.blob_file_column_names = list(blob_writers)
+            self.vector_writer = vector_writer
+            self._normal_buffer = WriteBuffer(self._merge_normal_data)
+            self._buffer = WriteBuffer(self._merge_normal_data)
+            self.committed_files = []
+            self._committed_files_to_delete_on_abort = []
+            self.file_io = _RecordingFileIO()
+            self.written = []
+
+        def _write_normal_data_to_file(self, data: pa.Table):
+            self.written.append(data)
+            return _StubMeta(data.num_rows, 'data-%d' % len(self.written))
+
+    def test_failed_sidecar_publishes_nothing_and_the_retry_resumes(self):
+        vector = _StubSidecarWriter(3, 'vector-0', fail_times=1)
+        writer = self._VectorHarness(vector)
+        writer._normal_buffer.append(_table(0, 3))
+
+        with self.assertRaises(IOError):
+            writer._close_current_writers()
+        # The normal file landed, so the rows are no longer buffered -- but
+        # nothing is committed and the file is remembered for the retry.
+        self.assertEqual([t.num_rows for t in writer.written], [3])
+        self.assertEqual(writer.committed_files, [])
+        self.assertEqual(writer._normal_buffer.num_rows, 0)
+        self.assertIsNotNone(writer._pending_normal_meta)
+
+        writer._close_current_writers()
+        # Still one normal file: the retry resumed at the sidecar phase rather
+        # than writing the same 3 rows again.
+        self.assertEqual([t.num_rows for t in writer.written], [3])
+        self.assertEqual([m.file_name for m in writer.committed_files],
+                         ['data-1', 'vector-0'])
+        self.assertIsNone(writer._pending_normal_meta)
+        # Harvested once the flush completed, and cleared only then.
+        self.assertEqual(vector.committed_files, [])
+
+    def test_successful_flush_publishes_normal_then_sidecars(self):
+        vector = _StubSidecarWriter(3, 'vector-0')
+        writer = self._VectorHarness(vector)
+        writer._normal_buffer.append(_table(0, 3))
+        writer._close_current_writers()
+        self.assertEqual([m.file_name for m in writer.committed_files],
+                         ['data-1', 'vector-0'])
+        self.assertEqual(vector.committed_files, [])
+        self.assertIsNone(writer._pending_normal_meta)
+        self.assertEqual(writer.record_count, 0)
+
+    def test_failed_normal_write_keeps_the_rows_and_publishes_nothing(self):
+        # The other half of the same rule: the sidecars are never reached, so
+        # the rows have to stay where a retry can find them.
+        vector = _StubSidecarWriter(3, 'vector-0')
+        writer = self._VectorHarness(vector, fail_normal_times=1)
+        writer._normal_buffer.append(_table(0, 3))
+        with self.assertRaises(IOError):
+            writer._close_current_writers()
+        self.assertEqual(writer._normal_buffer.num_rows, 3)
+        self.assertEqual(writer.committed_files, [])
+        self.assertIsNone(writer._pending_normal_meta)
+        self.assertEqual(vector.prepare_commit_calls, 0)
+
+        writer._close_current_writers()
+        self.assertEqual([t.num_rows for t in writer.written], [3])
+        self.assertEqual([m.file_name for m in writer.committed_files],
+                         ['data-1', 'vector-0'])
+
+    def test_write_is_rejected_while_a_flush_is_unfinished(self):
+        # Rows appended between a failed flush and its retry would belong to no
+        # file: the resumed flush skips the normal write, while the sidecar
+        # writer would drain them -- breaking the row-count check.
+        vector = _StubSidecarWriter(3, 'vector-0', fail_times=1)
+        writer = self._VectorHarness(vector)
+        writer._normal_buffer.append(_table(0, 3))
+        with self.assertRaises(IOError):
+            writer._close_current_writers()
+
+        with self.assertRaises(RuntimeError) as caught:
+            writer.write(_batch(3, 1))
+        self.assertIn('Cannot write', str(caught.exception))
+        # Rejecting does not abort, so the flush is still resumable.
+        self.assertIsNotNone(writer._pending_normal_meta)
+        writer._close_current_writers()
+        self.assertEqual([t.num_rows for t in writer.written], [3])
+
+    def test_abort_deletes_the_unpublished_normal_file(self):
+        # It is in no committed list, so abort has to know about it separately
+        # or it leaks a data file no snapshot references.
+        vector = _StubSidecarWriter(3, 'vector-0', fail_times=1)
+        writer = self._VectorHarness(vector)
+        writer._normal_buffer.append(_table(0, 3))
+        with self.assertRaises(IOError):
+            writer._close_current_writers()
+
+        writer.abort()
+        self.assertEqual(writer.file_io.deleted, ['/warehouse/data-1'])
+        self.assertIsNone(writer._pending_normal_meta)
+        self.assertTrue(vector.aborted)
+
+    def test_dedicated_writer_failed_blob_phase_publishes_nothing(self):
+        blob = _StubSidecarWriter(3, 'blob-0', fail_times=1)
+        writer = self._DedicatedHarness({'payload': blob})
+        writer._normal_buffer.append(_table(0, 3))
+
+        with self.assertRaises(IOError):
+            writer._close_current_writers()
+        self.assertEqual([t.num_rows for t in writer.written], [3])
+        self.assertEqual(writer.committed_files, [])
+        # Already tracked for abort, since no committed list holds it yet.
+        self.assertEqual(
+            [m.file_name for m in writer._committed_files_to_delete_on_abort],
+            ['data-1'])
+
+        writer._close_current_writers()
+        self.assertEqual([t.num_rows for t in writer.written], [3])
+        self.assertEqual([m.file_name for m in writer.committed_files],
+                         ['data-1', 'blob-0'])
+        self.assertEqual(blob.committed_files, [])
+        self.assertIsNone(writer._pending_normal_meta)
+
+    def test_dedicated_writer_keeps_the_documented_meta_order(self):
+        blob = _StubSidecarWriter(3, 'blob-0')
+        vector = _StubSidecarWriter(3, 'vector-0')
+        writer = self._DedicatedHarness({'payload': blob}, vector)
+        writer._normal_buffer.append(_table(0, 3))
+        writer._close_current_writers()
+        self.assertEqual([m.file_name for m in writer.committed_files],
+                         ['data-1', 'blob-0', 'vector-0'])
+        self.assertEqual(
+            [m.file_name for m in writer._committed_files_to_delete_on_abort],
+            ['data-1', 'blob-0', 'vector-0'])
+
+    def test_dedicated_writer_respects_the_blob_delete_policy(self):
+        # Externally managed blob files are not the writer's to delete, so they
+        # must stay out of the abort list even now that it is filled at publish
+        # time rather than as each sidecar lands.
+        blob = _StubSidecarWriter(3, 'blob-0', delete_on_abort=False)
+        writer = self._DedicatedHarness({'payload': blob})
+        writer._normal_buffer.append(_table(0, 3))
+        writer._close_current_writers()
+        self.assertEqual([m.file_name for m in writer.committed_files],
+                         ['data-1', 'blob-0'])
+        self.assertEqual(
+            [m.file_name for m in writer._committed_files_to_delete_on_abort],
+            ['data-1'])
+
+
+class VectorNormalBufferTest(unittest.TestCase):
+    """``DataVectorWriter`` keeps its own buffer for the normal columns."""
+
+    class _VectorHarness(DataVectorWriter):
+        """Only the fields ``_should_roll_normal`` reads.
+
+        ``CHECK_ROLLING_RECORD_CNT`` is 1 rather than the real 1000 so the size
+        branch is reached on every write instead of being short-circuited by 
the
+        periodic check.
+        """
+
+        CHECK_ROLLING_RECORD_CNT = 1
+
+        def __init__(self, target_file_size: int = _NO_LIMIT):
+            self.target_file_size = target_file_size
+            self.target_file_row_num = _NO_LIMIT
+            self.record_count = 0
+            self.vector_writer = None
+            self._normal_buffer = WriteBuffer(self._merge_data)
+            self._buffer = WriteBuffer(self._merge_data)
+
+    def test_should_roll_normal_does_not_fold(self):
+        writer = self._VectorHarness()
+        with _count_folds() as folds:
+            for i in range(50):
+                writer._normal_buffer.append(_table(i * 5, 5))
+                writer.record_count += 5
+                self.assertFalse(writer._should_roll_normal())
+        self.assertEqual(folds, [])
+        self.assertEqual(writer._normal_buffer.num_rows, 250)
+
+    def test_should_roll_normal_fires_off_the_running_size(self):
+        writer = self._VectorHarness(target_file_size=_table(0, 20).nbytes)
+        writer.record_count = 1
+        for i in range(10):
+            writer._normal_buffer.append(_table(i * 5, 5))
+            if writer._should_roll_normal():
+                break
+        self.assertTrue(writer._should_roll_normal())
+        # Fires before the buffer grows far past the target, i.e. off the
+        # accumulated size rather than at some arbitrary later point.
+        self.assertLessEqual(writer._normal_buffer.num_rows, 30)
+
+    def test_pending_row_count_reports_the_normal_buffer(self):
+        # The inherited property reads the base ``_buffer``, which this writer
+        # never fills, so it has to be overridden or it always answers zero.
+        writer = self._VectorHarness()
+        self.assertEqual(writer.pending_row_count, 0)
+        writer._normal_buffer.append(_table(0, 7))
+        self.assertEqual(writer.pending_row_count, 7)
+        self.assertTrue(writer._buffer.is_empty)
+
+    def test_pending_row_count_falls_back_to_the_vector_writer(self):
+        # A table whose columns are all vectors buffers nothing normal, so the
+        # count has to come from the sidecar instead of reading as zero.
+        writer = self._VectorHarness()
+        writer.vector_writer = _StubSidecarWriter(0, 'vector-0')
+        writer.vector_writer.pending_row_count = 4
+        self.assertEqual(writer.pending_row_count, 4)
+        writer._normal_buffer.append(_table(0, 7))
+        self.assertEqual(writer.pending_row_count, 7)
+
+    def 
test_dedicated_writer_pending_row_count_reports_the_normal_buffer(self):
+        writer = CompositeFlushResumeTest._DedicatedHarness({})
+        self.assertEqual(writer.pending_row_count, 0)
+        writer._normal_buffer.append(_table(0, 3))
+        self.assertEqual(writer.pending_row_count, 3)
+        self.assertTrue(writer._buffer.is_empty)
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/paimon-python/pypaimon/write/writer/blob_writer.py 
b/paimon-python/pypaimon/write/writer/blob_writer.py
index 40d301c2f1..276968eee2 100644
--- a/paimon-python/pypaimon/write/writer/blob_writer.py
+++ b/paimon-python/pypaimon/write/writer/blob_writer.py
@@ -58,21 +58,23 @@ class BlobWriter(AppendOnlyDataWriter):
         logger.info(f"Initialized BlobWriter with blob file format, 
blob_target_file_size={self.blob_target_file_size}")
 
     def _check_and_roll_if_needed(self):
-        if self.pending_data is None:
+        # Rolling here is driven by the size of the external blobs, which the
+        # buffered descriptors say nothing about, so there is no cheap count to
+        # check first: every write drains the buffer.
+        pending = self._buffer.take()
+        if pending is None:
             return
 
         # Always write blob rows one-by-one so rolling uses actual blob bytes 
size rather than
         # in-memory serialized descriptor size.
-        for i in range(self.pending_data.num_rows):
-            row_data = self.pending_data.slice(i, 1)
+        for i in range(pending.num_rows):
+            row_data = pending.slice(i, 1)
             self._write_row_to_file(row_data)
             self.record_count += 1
 
             if self.rolling_file():
                 self.close_current_writer()
 
-        self.pending_data = None
-
     def _write_row_to_file(self, row_data: pa.Table):
         """Write a single row to the current blob file. Opens a new file if 
needed."""
         if row_data.num_rows == 0:
@@ -228,7 +230,7 @@ class BlobWriter(AppendOnlyDataWriter):
         if self.current_writer is not None:
             self.close_current_writer()
 
-        # Call parent to handle pending_data fallback.
+        # Call parent to flush anything left in the buffer.
         return super().prepare_commit()
 
     def close(self):
@@ -237,7 +239,7 @@ class BlobWriter(AppendOnlyDataWriter):
         if self.current_writer is not None:
             self.close_current_writer()
 
-        # Call parent to handle pending_data fallback.
+        # Call parent to flush anything left in the buffer.
         super().close()
 
     def delete_file_upon_abort(self) -> bool:
@@ -252,7 +254,7 @@ class BlobWriter(AppendOnlyDataWriter):
             self.current_writer = None
             self.current_file_path = None
         if not self.delete_file_upon_abort():
-            self.pending_data = None
+            self._buffer.reset()
             self.committed_files.clear()
         else:
             super().abort()
diff --git a/paimon-python/pypaimon/write/writer/data_vector_writer.py 
b/paimon-python/pypaimon/write/writer/data_vector_writer.py
index 72291cb738..0aa4c51810 100644
--- a/paimon-python/pypaimon/write/writer/data_vector_writer.py
+++ b/paimon-python/pypaimon/write/writer/data_vector_writer.py
@@ -28,6 +28,7 @@ from pypaimon.manifest.schema.simple_stats import SimpleStats
 from pypaimon.schema.data_types import VectorType
 from pypaimon.table.row.generic_row import GenericRow
 from pypaimon.write.writer.data_writer import DataWriter
+from pypaimon.write.writer.write_buffer import WriteBuffer
 
 logger = logging.getLogger(__name__)
 
@@ -80,7 +81,13 @@ class DataVectorWriter(DataWriter):
 
         self.record_count = 0
         self.closed = False
-        self.pending_normal_data: Optional[pa.Table] = None
+        # Normal columns are buffered separately from the vector columns, which
+        # the vector writer owns.
+        self._normal_buffer = WriteBuffer(self._merge_data)
+        # A normal data file that landed while a later phase of the same flush
+        # failed. Held so the retry resumes at that phase instead of writing 
the
+        # rows a second time.
+        self._pending_normal_meta: Optional[DataFileMeta] = None
 
         from pypaimon.write.writer.vector_writer import VectorWriter
         self.vector_writer: Optional[VectorWriter] = None
@@ -109,12 +116,13 @@ class DataVectorWriter(DataWriter):
         return pa.concat_tables([existing_data, new_data])
 
     def write(self, data: pa.RecordBatch):
+        self._require_finished_flush()
         try:
             offset = 0
             # _write_batch keeps normal and vector pending rows in lockstep
             # and closes both writers when the shared row limit is reached.
             while offset < data.num_rows:
-                capacity = self.target_file_row_num - self._current_row_count()
+                capacity = self.target_file_row_num - self.pending_row_count
                 if capacity <= 0:
                     self._close_current_writers()
                     capacity = self.target_file_row_num
@@ -133,11 +141,8 @@ class DataVectorWriter(DataWriter):
 
         normal_data, vector_data = self._split_data(data)
 
-        processed_normal = pa.Table.from_batches([normal_data]) if normal_data 
is not None else None
-        if self.pending_normal_data is None:
-            self.pending_normal_data = processed_normal
-        elif processed_normal is not None:
-            self.pending_normal_data = 
pa.concat_tables([self.pending_normal_data, processed_normal])
+        if normal_data is not None:
+            self._normal_buffer.append(pa.Table.from_batches([normal_data]))
 
         if self.vector_writer is not None and vector_data is not None and 
vector_data.num_rows > 0:
             self.vector_writer.write(vector_data)
@@ -162,12 +167,12 @@ class DataVectorWriter(DataWriter):
             raise
         finally:
             self.closed = True
-            self.pending_normal_data = None
+            self._normal_buffer.reset()
 
     def abort(self):
         if self.vector_writer is not None:
             self.vector_writer.abort()
-        self.pending_normal_data = None
+        self._normal_buffer.reset()
         super().abort()
 
     def _split_data(self, data: pa.RecordBatch) -> Tuple[pa.RecordBatch, 
pa.RecordBatch]:
@@ -188,38 +193,59 @@ class DataVectorWriter(DataWriter):
         return normal_data, vector_data
 
     def _should_roll_normal(self) -> bool:
-        if self.pending_normal_data is None:
+        # Runs on every write, so it answers from the running counts only.
+        if self._normal_buffer.is_empty:
             return False
-        if self.pending_normal_data.num_rows >= self.target_file_row_num:
+        if self._normal_buffer.num_rows >= self.target_file_row_num:
             return True
         if self.record_count % self.CHECK_ROLLING_RECORD_CNT != 0:
             return False
-        return self.pending_normal_data.nbytes > self.target_file_size
-
-    def _current_row_count(self) -> int:
-        if self.pending_normal_data is not None:
-            return self.pending_normal_data.num_rows
-        if self.vector_writer is not None and self.vector_writer.pending_data 
is not None:
-            return self.vector_writer.pending_data.num_rows
+        return self._normal_buffer.nbytes > self.target_file_size
+
+    @property
+    def pending_row_count(self) -> int:
+        # Overrides the base property, which reads a buffer this writer never
+        # fills. Normal and vector rows are kept in lockstep, so either half
+        # answers for the pair; the vector writer is asked only when the table
+        # has no normal columns at all.
+        if not self._normal_buffer.is_empty:
+            return self._normal_buffer.num_rows
+        if self.vector_writer is not None:
+            # Running count, not a folded buffer: this runs on every write.
+            return self.vector_writer.pending_row_count
         return 0
 
     def _close_current_writers(self):
-        has_normal = self.pending_normal_data is not None and 
self.pending_normal_data.num_rows > 0
-
-        normal_meta = None
-        if has_normal:
-            normal_meta = 
self._write_normal_data_to_file(self.pending_normal_data)
-            self.committed_files.append(normal_meta)
+        # A flush spans the normal file and the vector sidecars, and the vector
+        # writer drains its own buffer as it goes, so its half cannot be 
replayed
+        # from scratch. Two rules make a retry resume rather than restart: the
+        # normal rows stay buffered until their file lands, and once it has
+        # landed the file is remembered instead of the rows. Nothing reaches
+        # ``committed_files`` until every phase has succeeded, so a retry never
+        # finds a half-published flush.
+        normal_meta = self._pending_normal_meta
+        if normal_meta is None:
+            normal_data = self._normal_buffer.materialize()
+            if normal_data is not None and normal_data.num_rows > 0:
+                normal_meta = self._write_normal_data_to_file(normal_data)
+                self._pending_normal_meta = normal_meta
+            self._normal_buffer.reset()
 
+        vector_metas = []
         if self.vector_writer is not None:
             vector_metas = self.vector_writer.prepare_commit()
-            if vector_metas:
-                if normal_meta is not None:
-                    self._validate_consistency(normal_meta, vector_metas)
-                self.committed_files.extend(vector_metas)
+            if vector_metas and normal_meta is not None:
+                self._validate_consistency(normal_meta, vector_metas)
+
+        if normal_meta is not None:
+            self.committed_files.append(normal_meta)
+        self.committed_files.extend(vector_metas)
+        if self.vector_writer is not None:
+            # Cleared only now: until the flush completes, a retry has to be 
able
+            # to harvest the same metas again.
             self.vector_writer.committed_files.clear()
 
-        self.pending_normal_data = None
+        self._pending_normal_meta = None
         self.record_count = 0
 
     def _write_normal_data_to_file(self, data: pa.Table) -> 
Optional[DataFileMeta]:
diff --git a/paimon-python/pypaimon/write/writer/data_writer.py 
b/paimon-python/pypaimon/write/writer/data_writer.py
index e34127ddaf..24a67186c3 100644
--- a/paimon-python/pypaimon/write/writer/data_writer.py
+++ b/paimon-python/pypaimon/write/writer/data_writer.py
@@ -30,6 +30,7 @@ from pypaimon.schema.data_types import PyarrowFieldParser
 from pypaimon.table.bucket_mode import BucketMode
 from pypaimon.table.row.generic_row import GenericRow
 from pypaimon.write.writer.mosaic_writer_options import 
create_mosaic_writer_options
+from pypaimon.write.writer.write_buffer import WriteBuffer
 
 
 class DataWriter(ABC):
@@ -72,7 +73,7 @@ class DataWriter(ABC):
         )
         self.sequence_generator = SequenceGenerator(max_seq_number)
 
-        self.pending_data: Optional[pa.Table] = None
+        self._buffer = WriteBuffer(self._merge_data)
         self.committed_files: List[DataFileMeta] = []
         self.committed_changelog_files: List[DataFileMeta] = []
         self.changelog_producer = changelog_producer
@@ -104,15 +105,38 @@ class DataWriter(ABC):
         # the table schema is fixed for the lifetime of this writer.
         self._paimon_field_id: Dict[str, int] = {pf.name: pf.id for pf in 
self.table.fields}
 
+    # Set by the composite writers when a flush landed its normal data file 
but a
+    # later phase of the same flush failed; see their 
``_close_current_writers``.
+    _pending_normal_meta: Optional[DataFileMeta] = None
+
+    @property
+    def pending_row_count(self) -> int:
+        """Rows held for the file being written, not yet in a finished file.
+
+        The composite writers override this: they keep their normal rows in
+        ``_normal_buffer`` and hand the rest to their sub-writers, so the base
+        ``_buffer`` stays empty for them.
+        """
+        return self._buffer.num_rows
+
+    def _require_finished_flush(self):
+        """Refuse to buffer more rows while a flush is only half done.
+
+        A composite flush writes the normal data file first and the sidecars
+        after. Once that file is on disk it covers exactly the rows flushed so
+        far, so rows appended before the retry finishes would belong to no file
+        the resumed flush writes.
+        """
+        if self._pending_normal_meta is not None:
+            raise RuntimeError(
+                "Cannot write: a previous flush left a data file that no 
commit "
+                "has taken yet. Retry prepare_commit() to finish that flush, 
or "
+                "abort() this writer.")
+
     def write(self, data: pa.RecordBatch):
         try:
             processed_data = self._process_data(data)
-
-            if self.pending_data is None:
-                self.pending_data = processed_data
-            else:
-                self.pending_data = self._merge_data(self.pending_data, 
processed_data)
-
+            self._buffer.append(processed_data)
             self._check_and_roll_if_needed()
         except Exception as e:
             import logging
@@ -122,9 +146,11 @@ class DataWriter(ABC):
             raise e
 
     def prepare_commit(self) -> List[DataFileMeta]:
-        if self.pending_data is not None and self.pending_data.num_rows > 0:
-            self._write_data_to_file(self.pending_data)
-            self.pending_data = None
+        if self._buffer.num_rows > 0:
+            # Clear only once the write lands: a caller that retries
+            # prepare_commit after a failed write has to still find its rows.
+            self._write_data_to_file(self._buffer.materialize())
+            self._buffer.reset()
 
         return self.committed_files.copy()
 
@@ -133,8 +159,8 @@ class DataWriter(ABC):
 
     def close(self):
         try:
-            if self.pending_data is not None and self.pending_data.num_rows > 
0:
-                self._write_data_to_file(self.pending_data)
+            if self._buffer.num_rows > 0:
+                self._write_data_to_file(self._buffer.materialize())
         except Exception as e:
             import logging
             logger = logging.getLogger(__name__)
@@ -142,7 +168,7 @@ class DataWriter(ABC):
             self.abort()
             raise e
         finally:
-            self.pending_data = None
+            self._buffer.reset()
             # Note: Don't clear committed_files in close() - they should be 
returned by prepare_commit()
 
     def abort(self):
@@ -150,10 +176,15 @@ class DataWriter(ABC):
         Abort all writers and clean up resources. This method should be called 
when an error occurs
         during writing. It deletes any files that were written and cleans up 
resources.
         """
-        self._delete_committed_files(self.committed_files + 
self.committed_changelog_files)
+        to_delete = self.committed_files + self.committed_changelog_files
+        if self._pending_normal_meta is not None:
+            # No list tracks this one: it landed but its flush never published 
it.
+            to_delete.append(self._pending_normal_meta)
+            self._pending_normal_meta = None
+        self._delete_committed_files(to_delete)
 
         # Clean up resources
-        self.pending_data = None
+        self._buffer.reset()
         self.committed_files.clear()
         self.committed_changelog_files.clear()
 
@@ -191,16 +222,20 @@ class DataWriter(ABC):
         return -1, -1
 
     def _check_and_roll_if_needed(self):
-        while self.pending_data is not None:
-            num_rows = self.pending_data.num_rows
+        # Neither trigger can fire below these thresholds, so the running 
counts
+        # rule out rolling -- the common case -- without concatenating 
anything.
+        while (self._buffer.nbytes > self.target_file_size
+                or self._buffer.num_rows > self.target_file_row_num):
+            pending = self._buffer.materialize()
+            num_rows = pending.num_rows
             # Row-count trigger: keep at most target_file_row_num rows per 
file.
             split_row = num_rows
             if num_rows > self.target_file_row_num:
                 split_row = self.target_file_row_num
             # Size trigger: roll earlier if the size split point comes first.
-            if self.pending_data.nbytes > self.target_file_size:
+            if pending.nbytes > self.target_file_size:
                 size_split = self._find_optimal_split_point(
-                    self.pending_data, self.target_file_size)
+                    pending, self.target_file_size)
                 # First row alone exceeds target_file_size: roll it by itself.
                 if size_split <= 0:
                     size_split = 1
@@ -208,8 +243,8 @@ class DataWriter(ABC):
                     split_row = size_split
             if split_row <= 0 or split_row >= num_rows:
                 break
-            self._write_data_to_file(self.pending_data.slice(0, split_row))
-            self.pending_data = self.pending_data.slice(split_row)
+            self._write_data_to_file(pending.slice(0, split_row))
+            self._buffer.reset(pending.slice(split_row))
 
     def _write_data_to_file(self, data: pa.Table):
         if data.num_rows == 0:
@@ -223,9 +258,16 @@ class DataWriter(ABC):
         logical_data = data
         extra_files = []
         row_sidecar_path = None
+        changelog_meta = None
         if self._variant_shredding:
             data = self._apply_variant_shredding(data)
 
+        # One data file means up to three files on disk -- the data file, its 
row
+        # sidecar and its changelog -- and none of them is committed until all 
of
+        # them have landed. A caller that retries the flush still holds these
+        # rows in its buffer, so publishing the data file before the changelog
+        # exists would make the retry write a second copy of rows the first 
meta
+        # already covers.
         try:
             if self.file_format == CoreOptions.FILE_FORMAT_PARQUET:
                 self.file_io.write_parquet(file_path, data, 
compression=self.compression, zstd_level=self.zstd_level)
@@ -255,74 +297,80 @@ class DataWriter(ABC):
                     fields=self._row_sidecar_fields(logical_data),
                     zstd_level=self.zstd_level)
                 extra_files.append(row_sidecar_name)
+
+            # min key & max key
+
+            selected_table = data.select(self.trimmed_primary_keys)
+            key_columns_batch = selected_table.to_batches()[0]
+            min_key_row_batch = key_columns_batch.slice(0, 1)
+            max_key_row_batch = 
key_columns_batch.slice(key_columns_batch.num_rows - 1, 1)
+            min_key = [col.to_pylist()[0] for col in min_key_row_batch.columns]
+            max_key = [col.to_pylist()[0] for col in max_key_row_batch.columns]
+
+            # key stats & value stats
+            value_stats_enabled = self.options.metadata_stats_enabled()
+            if value_stats_enabled:
+                stats_fields = self.table.fields if 
self.table.is_primary_key_table \
+                    else PyarrowFieldParser.to_paimon_schema(data.schema)
+            else:
+                stats_fields = self.table.trimmed_primary_keys_fields
+            column_stats = {
+                field.name: self._get_column_stats(data, field.name)
+                for field in stats_fields
+            }
+            key_fields = self.trimmed_primary_keys_fields
+            key_stats = self._collect_value_stats(data, key_fields, 
column_stats)
+            if not self.options.primary_key_nullable() and not all(
+                    count == 0 for count in key_stats.null_counts):
+                raise RuntimeError("Primary key should not be null")
+
+            value_fields = stats_fields if value_stats_enabled else []
+            value_stats = self._collect_value_stats(data, value_fields, 
column_stats)
+
+            # Read the range without advancing it: the advance belongs with the
+            # append below, so a retried flush derives the same range.
+            min_seq = self.sequence_generator.start
+            max_seq = self.sequence_generator.current
+            creation_time = Timestamp.now()
+            data_meta = DataFileMeta.create(
+                file_name=file_name,
+                file_size=self.file_io.get_file_size(file_path),
+                row_count=data.num_rows,
+                min_key=GenericRow(min_key, self.trimmed_primary_keys_fields),
+                max_key=GenericRow(max_key, self.trimmed_primary_keys_fields),
+                key_stats=key_stats,
+                value_stats=value_stats,
+                min_sequence_number=min_seq,
+                max_sequence_number=max_seq,
+                schema_id=self.table.table_schema.id,
+                level=0,
+                extra_files=extra_files,
+                creation_time=creation_time,
+                delete_row_count=0,
+                file_source=0,
+                value_stats_cols=None if value_stats_enabled else [],
+                external_path=external_path_str,
+                first_row_id=None,
+                write_cols=self.write_cols,
+                file_path=file_path,
+            )
+
+            if self.changelog_producer == ChangelogProducer.INPUT:
+                changelog_meta = self._write_changelog_file(
+                    data, min_key, max_key, key_stats, value_stats,
+                    min_seq, max_seq, creation_time,
+                    value_stats_enabled, external_path_str is not None,
+                )
         except Exception:
             self.file_io.delete_quietly(file_path)
             if row_sidecar_path is not None:
                 self.file_io.delete_quietly(row_sidecar_path)
             raise
 
-        # min key & max key
-
-        selected_table = data.select(self.trimmed_primary_keys)
-        key_columns_batch = selected_table.to_batches()[0]
-        min_key_row_batch = key_columns_batch.slice(0, 1)
-        max_key_row_batch = key_columns_batch.slice(key_columns_batch.num_rows 
- 1, 1)
-        min_key = [col.to_pylist()[0] for col in min_key_row_batch.columns]
-        max_key = [col.to_pylist()[0] for col in max_key_row_batch.columns]
-
-        # key stats & value stats
-        value_stats_enabled = self.options.metadata_stats_enabled()
-        if value_stats_enabled:
-            stats_fields = self.table.fields if 
self.table.is_primary_key_table \
-                else PyarrowFieldParser.to_paimon_schema(data.schema)
-        else:
-            stats_fields = self.table.trimmed_primary_keys_fields
-        column_stats = {
-            field.name: self._get_column_stats(data, field.name)
-            for field in stats_fields
-        }
-        key_fields = self.trimmed_primary_keys_fields
-        key_stats = self._collect_value_stats(data, key_fields, column_stats)
-        if not self.options.primary_key_nullable() and not all(
-                count == 0 for count in key_stats.null_counts):
-            raise RuntimeError("Primary key should not be null")
-
-        value_fields = stats_fields if value_stats_enabled else []
-        value_stats = self._collect_value_stats(data, value_fields, 
column_stats)
-
-        min_seq = self.sequence_generator.start
-        max_seq = self.sequence_generator.current
         self.sequence_generator.start = self.sequence_generator.current
-        creation_time = Timestamp.now()
-        self.committed_files.append(DataFileMeta.create(
-            file_name=file_name,
-            file_size=self.file_io.get_file_size(file_path),
-            row_count=data.num_rows,
-            min_key=GenericRow(min_key, self.trimmed_primary_keys_fields),
-            max_key=GenericRow(max_key, self.trimmed_primary_keys_fields),
-            key_stats=key_stats,
-            value_stats=value_stats,
-            min_sequence_number=min_seq,
-            max_sequence_number=max_seq,
-            schema_id=self.table.table_schema.id,
-            level=0,
-            extra_files=extra_files,
-            creation_time=creation_time,
-            delete_row_count=0,
-            file_source=0,
-            value_stats_cols=None if value_stats_enabled else [],
-            external_path=external_path_str,
-            first_row_id=None,
-            write_cols=self.write_cols,
-            file_path=file_path,
-        ))
-
-        if self.changelog_producer == ChangelogProducer.INPUT:
-            self._write_changelog_file(
-                data, min_key, max_key, key_stats, value_stats,
-                min_seq, max_seq, creation_time,
-                value_stats_enabled, external_path_str is not None,
-            )
+        self.committed_files.append(data_meta)
+        if changelog_meta is not None:
+            self.committed_changelog_files.append(changelog_meta)
 
     def _apply_variant_shredding(self, data: pa.Table) -> pa.Table:
         """Transform VARIANT columns into shredded Parquet format.
@@ -351,48 +399,58 @@ class DataWriter(ABC):
 
     def _write_changelog_file(self, data, min_key, max_key, key_stats, 
value_stats,
                               min_seq, max_seq, creation_time,
-                              value_stats_enabled, is_external):
+                              value_stats_enabled, is_external) -> 
DataFileMeta:
+        """Write the changelog file for one data file and return its meta.
+
+        The caller appends the returned meta only once the whole data file has
+        landed, so a failure here leaves nothing behind: no meta to commit, and
+        no file on disk either.
+        """
         cl_fmt = self.changelog_file_format
         changelog_file_name = f"changelog-{uuid.uuid4()}-0.{cl_fmt}"
         changelog_file_path = self._generate_file_path(changelog_file_name)
 
         changelog_external_path = changelog_file_path if is_external else None
 
-        if cl_fmt == CoreOptions.FILE_FORMAT_PARQUET:
-            self.file_io.write_parquet(changelog_file_path, data, 
compression=self.compression,
+        try:
+            if cl_fmt == CoreOptions.FILE_FORMAT_PARQUET:
+                self.file_io.write_parquet(changelog_file_path, data, 
compression=self.compression,
+                                           zstd_level=self.zstd_level)
+            elif cl_fmt == CoreOptions.FILE_FORMAT_ORC:
+                self.file_io.write_orc(changelog_file_path, data, 
compression=self.compression,
                                        zstd_level=self.zstd_level)
-        elif cl_fmt == CoreOptions.FILE_FORMAT_ORC:
-            self.file_io.write_orc(changelog_file_path, data, 
compression=self.compression,
-                                   zstd_level=self.zstd_level)
-        elif cl_fmt == CoreOptions.FILE_FORMAT_AVRO:
-            self.file_io.write_avro(changelog_file_path, data, 
compression=self.compression,
-                                    zstd_level=self.zstd_level)
-        else:
-            raise ValueError(f"Unsupported changelog file format: {cl_fmt}. "
-                             f"Supported formats: parquet, orc, avro.")
-
-        self.committed_changelog_files.append(DataFileMeta.create(
-            file_name=changelog_file_name,
-            file_size=self.file_io.get_file_size(changelog_file_path),
-            row_count=data.num_rows,
-            min_key=GenericRow(min_key, self.trimmed_primary_keys_fields),
-            max_key=GenericRow(max_key, self.trimmed_primary_keys_fields),
-            key_stats=key_stats,
-            value_stats=value_stats,
-            min_sequence_number=min_seq,
-            max_sequence_number=max_seq,
-            schema_id=self.table.table_schema.id,
-            level=0,
-            extra_files=[],
-            creation_time=creation_time,
-            delete_row_count=0,
-            file_source=0,
-            value_stats_cols=None if value_stats_enabled else [],
-            external_path=changelog_external_path,
-            first_row_id=None,
-            write_cols=self.write_cols,
-            file_path=changelog_file_path,
-        ))
+            elif cl_fmt == CoreOptions.FILE_FORMAT_AVRO:
+                self.file_io.write_avro(changelog_file_path, data, 
compression=self.compression,
+                                        zstd_level=self.zstd_level)
+            else:
+                raise ValueError(f"Unsupported changelog file format: 
{cl_fmt}. "
+                                 f"Supported formats: parquet, orc, avro.")
+
+            return DataFileMeta.create(
+                file_name=changelog_file_name,
+                file_size=self.file_io.get_file_size(changelog_file_path),
+                row_count=data.num_rows,
+                min_key=GenericRow(min_key, self.trimmed_primary_keys_fields),
+                max_key=GenericRow(max_key, self.trimmed_primary_keys_fields),
+                key_stats=key_stats,
+                value_stats=value_stats,
+                min_sequence_number=min_seq,
+                max_sequence_number=max_seq,
+                schema_id=self.table.table_schema.id,
+                level=0,
+                extra_files=[],
+                creation_time=creation_time,
+                delete_row_count=0,
+                file_source=0,
+                value_stats_cols=None if value_stats_enabled else [],
+                external_path=changelog_external_path,
+                first_row_id=None,
+                write_cols=self.write_cols,
+                file_path=changelog_file_path,
+            )
+        except Exception:
+            self.file_io.delete_quietly(changelog_file_path)
+            raise
 
     def _generate_file_path(self, file_name: str) -> str:
         if self.external_path_provider:
diff --git a/paimon-python/pypaimon/write/writer/dedicated_format_writer.py 
b/paimon-python/pypaimon/write/writer/dedicated_format_writer.py
index ae73880fd0..001b0f9f69 100644
--- a/paimon-python/pypaimon/write/writer/dedicated_format_writer.py
+++ b/paimon-python/pypaimon/write/writer/dedicated_format_writer.py
@@ -39,6 +39,7 @@ from pypaimon.write.row_utils import (
     row_values_to_arrow_table,
 )
 from pypaimon.write.writer.data_writer import DataWriter
+from pypaimon.write.writer.write_buffer import WriteBuffer
 
 logger = logging.getLogger(__name__)
 
@@ -134,9 +135,14 @@ class DedicatedFormatWriter(DataWriter):
         self.record_count = 0
         self.closed = False
 
-        # Track pending data for normal data only
-        self.pending_normal_data: Optional[pa.Table] = None
+        # Normal columns are buffered separately from the blob and vector
+        # columns, which their own writers own.
+        self._normal_buffer = WriteBuffer(self._merge_normal_data)
         self._committed_files_to_delete_on_abort: List[DataFileMeta] = []
+        # A normal data file that landed while a later phase of the same flush
+        # failed. Held so the retry resumes at that phase instead of writing 
the
+        # rows a second time.
+        self._pending_normal_meta: Optional[DataFileMeta] = None
 
         # Initialize blob writers for each blob-file column.
         from pypaimon.write.writer.blob_writer import BlobWriter
@@ -200,12 +206,15 @@ class DedicatedFormatWriter(DataWriter):
         return self._merge_normal_data(existing_data, new_data)
 
     def write(self, data: pa.RecordBatch):
+        # Outside the try on purpose: rejecting the write must not abort the
+        # writer, or the unfinished flush would lose its chance to be retried.
+        self._require_finished_flush()
         try:
             offset = 0
             # _write_batch keeps normal/blob/vector pending rows in lockstep
             # and closes all writers when the shared row limit is reached.
             while offset < data.num_rows:
-                capacity = self.target_file_row_num - self._current_row_count()
+                capacity = self.target_file_row_num - self.pending_row_count
                 if capacity <= 0:
                     self._close_current_writers()
                     capacity = self.target_file_row_num
@@ -229,10 +238,7 @@ class DedicatedFormatWriter(DataWriter):
         # Process and accumulate normal data (may be None for partial writes)
         processed_normal = self._process_normal_data(normal_data)
         if processed_normal is not None:
-            if self.pending_normal_data is None:
-                self.pending_normal_data = processed_normal
-            else:
-                self.pending_normal_data = 
self._merge_normal_data(self.pending_normal_data, processed_normal)
+            self._normal_buffer.append(processed_normal)
 
         # Write blob-file columns to dedicated blob writers.
         for blob_column, blob_data in blob_data_map.items():
@@ -251,6 +257,7 @@ class DedicatedFormatWriter(DataWriter):
             self._close_current_writers()
 
     def write_row(self, row):
+        self._require_finished_flush()
         try:
             values_by_name = row_to_named_values(
                 row, self.table.table_schema.fields)
@@ -274,11 +281,7 @@ class DedicatedFormatWriter(DataWriter):
                 ).to_batches()[0]
                 processed_normal = self._process_normal_data(normal_data)
                 if processed_normal is not None:
-                    if self.pending_normal_data is None:
-                        self.pending_normal_data = processed_normal
-                    else:
-                        self.pending_normal_data = self._merge_normal_data(
-                            self.pending_normal_data, processed_normal)
+                    self._normal_buffer.append(processed_normal)
 
             for blob_column in self.blob_file_column_names:
                 arrow_type = PyarrowFieldParser.from_paimon_type(
@@ -344,7 +347,7 @@ class DedicatedFormatWriter(DataWriter):
             raise
         finally:
             self.closed = True
-            self.pending_normal_data = None
+            self._normal_buffer.reset()
 
     def abort(self):
         """Abort all writers and clean up resources."""
@@ -352,9 +355,12 @@ class DedicatedFormatWriter(DataWriter):
             blob_writer.abort()
         if self.vector_writer is not None:
             self.vector_writer.abort()
+        # An unpublished normal file is already in the delete list, added when 
it
+        # landed, so there is nothing left to resume.
+        self._pending_normal_meta = None
         self._delete_committed_files(self._committed_files_to_delete_on_abort)
-        self.pending_normal_data = None
-        self.pending_data = None
+        self._normal_buffer.reset()
+        self._buffer.reset()
         self.committed_files.clear()
         self._committed_files_to_delete_on_abort.clear()
 
@@ -465,10 +471,11 @@ class DedicatedFormatWriter(DataWriter):
         return pa.concat_tables([existing_data, new_data])
 
     def _should_roll_normal(self) -> bool:
-        if self.pending_normal_data is None:
+        # Runs on every write, so it answers from the running counts only.
+        if self._normal_buffer.is_empty:
             return False
 
-        if self.pending_normal_data.num_rows >= self.target_file_row_num:
+        if self._normal_buffer.num_rows >= self.target_file_row_num:
             return True
 
         # Check rolling condition periodically (every CHECK_ROLLING_RECORD_CNT 
records)
@@ -476,28 +483,46 @@ class DedicatedFormatWriter(DataWriter):
             return False
 
         # Check if normal data exceeds target size
-        current_size = self.pending_normal_data.nbytes
-        return current_size > self.target_file_size
-
-    def _current_row_count(self) -> int:
-        if self.pending_normal_data is not None:
-            return self.pending_normal_data.num_rows
+        return self._normal_buffer.nbytes > self.target_file_size
+
+    @property
+    def pending_row_count(self) -> int:
+        # Overrides the base property, which reads a buffer this writer never
+        # fills. Normal, blob and vector rows are kept in lockstep, so any half
+        # answers for all of them; the sidecars are asked only when the table
+        # has no normal columns at all.
+        if not self._normal_buffer.is_empty:
+            return self._normal_buffer.num_rows
         for blob_writer in self.blob_writers.values():
             if blob_writer.current_writer is not None:
                 return blob_writer.current_writer.row_count
-        if self.vector_writer is not None and self.vector_writer.pending_data 
is not None:
-            return self.vector_writer.pending_data.num_rows
+        if self.vector_writer is not None:
+            # Running count, not a folded buffer: this runs on every write.
+            return self.vector_writer.pending_row_count
         return 0
 
     def _close_current_writers(self):
         """Close normal, blob, and vector writers; add metadata in order: 
normal, blob, vector."""
-        normal_meta = None
-        if self.pending_normal_data is not None and 
self.pending_normal_data.num_rows > 0:
-            normal_meta = 
self._write_normal_data_to_file(self.pending_normal_data)
-            self.committed_files.append(normal_meta)
-            self._committed_files_to_delete_on_abort.append(normal_meta)
+        # A flush spans the normal file and every blob/vector sidecar, and the
+        # sidecar writers drain their own buffers as they go, so their half 
cannot
+        # be replayed from scratch. Two rules make a retry resume rather than
+        # restart: the normal rows stay buffered until their file lands, and 
once
+        # it has landed the file is remembered instead of the rows. Nothing
+        # reaches ``committed_files`` until every phase has succeeded, so a 
retry
+        # never finds a half-published flush.
+        normal_meta = self._pending_normal_meta
+        if normal_meta is None:
+            normal_data = self._normal_buffer.materialize()
+            if normal_data is not None and normal_data.num_rows > 0:
+                normal_meta = self._write_normal_data_to_file(normal_data)
+                self._pending_normal_meta = normal_meta
+                # Tracked for abort right away: until the flush publishes it, 
this
+                # file is in no other list.
+                self._committed_files_to_delete_on_abort.append(normal_meta)
+            self._normal_buffer.reset()
 
         blob_metas = []
+        deletable_blob_metas = []
         for blob_column in self.blob_file_column_names:
             blob_writer = self.blob_writers[blob_column]
             writer_metas = blob_writer.prepare_commit()
@@ -505,20 +530,30 @@ class DedicatedFormatWriter(DataWriter):
                 self._validate_consistency(normal_meta, writer_metas, 
blob_column)
             blob_metas.extend(writer_metas)
             if blob_writer.delete_file_upon_abort():
-                self._committed_files_to_delete_on_abort.extend(writer_metas)
-            blob_writer.committed_files.clear()
-        self.committed_files.extend(blob_metas)
+                deletable_blob_metas.extend(writer_metas)
 
         vector_metas = []
         if self.vector_writer is not None:
             vector_metas = self.vector_writer.prepare_commit()
             if vector_metas and normal_meta is not None:
                 self._validate_consistency(normal_meta, vector_metas, 'vector')
-            self.committed_files.extend(vector_metas)
-            self._committed_files_to_delete_on_abort.extend(vector_metas)
+
+        # Every phase landed; publish in order: normal, blob, vector.
+        if normal_meta is not None:
+            self.committed_files.append(normal_meta)
+        self.committed_files.extend(blob_metas)
+        self.committed_files.extend(vector_metas)
+        self._committed_files_to_delete_on_abort.extend(deletable_blob_metas)
+        self._committed_files_to_delete_on_abort.extend(vector_metas)
+        # The sub-writers' metas are cleared only now: before the flush 
completes,
+        # a retry has to be able to harvest the same ones again, and an abort 
has
+        # to find them so each sub-writer can apply its own delete policy.
+        for blob_column in self.blob_file_column_names:
+            self.blob_writers[blob_column].committed_files.clear()
+        if self.vector_writer is not None:
             self.vector_writer.committed_files.clear()
 
-        self.pending_normal_data = None
+        self._pending_normal_meta = None
         self.record_count = 0
 
         if normal_meta is not None or blob_metas or vector_metas:
diff --git a/paimon-python/pypaimon/write/writer/key_value_data_writer.py 
b/paimon-python/pypaimon/write/writer/key_value_data_writer.py
index 86b6ad365a..1f1537f6d1 100644
--- a/paimon-python/pypaimon/write/writer/key_value_data_writer.py
+++ b/paimon-python/pypaimon/write/writer/key_value_data_writer.py
@@ -31,7 +31,7 @@ from pypaimon.write.writer.data_writer import DataWriter
 class KeyValueDataWriter(DataWriter):
     """Data writer for primary key tables with system fields and sorting.
 
-    Accumulates incoming batches in ``pending_data`` without sorting or
+    Accumulates incoming batches in the write buffer without sorting or
     folding on the write path. Sort and ``MergeFunction``-based fold
     are deferred to flush time (``_flush_all``), where the result is
     roll-written into one or more data files. This enforces the LSM
@@ -52,9 +52,9 @@ class KeyValueDataWriter(DataWriter):
 
     def _process_data(self, data: pa.RecordBatch) -> pa.Table:
         # No sort here: sorting once at flush is strictly cheaper than
-        # per-batch sort + a final global sort. ``pending_data`` ends
-        # up as a concat of unsorted batches; ``_flush_all`` sorts it
-        # exactly once before folding.
+        # per-batch sort + a final global sort. The buffer ends up as a
+        # concat of unsorted batches; ``_flush_all`` sorts it exactly
+        # once before folding.
         enhanced_data = self._add_system_fields(data)
         return pa.Table.from_batches([enhanced_data])
 
@@ -64,31 +64,30 @@ class KeyValueDataWriter(DataWriter):
         return pa.concat_tables([existing_data, new_data])
 
     def prepare_commit(self) -> List[DataFileMeta]:
-        if self.pending_data is not None and self.pending_data.num_rows > 0:
+        if self._buffer.num_rows > 0:
             self._flush_all()
-        # ``_flush_all`` leaves ``pending_data = None``, so super's
-        # prepare_commit just returns ``committed_files``.
+        # ``_flush_all`` empties the buffer, so super's prepare_commit just
+        # returns ``committed_files``.
         return super().prepare_commit()
 
     def _check_and_roll_if_needed(self):
-        # Buffer overflowed target_file_size: sort + fold + roll-write
-        # the whole buffer as multiple files in one pass. Unlike the
-        # base class's slice loop, we never keep a slice remainder in
-        # ``pending_data`` -- flush empties the buffer outright.
-        if (self.pending_data is not None
-                and self.pending_data.num_rows > 0
-                and self.pending_data.nbytes > self.target_file_size):
+        # Buffer overflowed target_file_size: sort + fold + roll-write the 
whole
+        # buffer as multiple files in one pass. Unlike the base class's slice
+        # loop, a successful flush leaves no remainder -- it empties the buffer
+        # outright. There is no row-count trigger here, and none is needed:
+        # ``FileStoreWrite`` rejects target-file-row-num on primary key tables.
+        if self._buffer.num_rows > 0 and self._buffer.nbytes > 
self.target_file_size:
             self._flush_all()
 
     def close(self):
         # Override the base ``close`` because its straight
-        # ``_write_data_to_file(pending_data)`` would land an unsorted,
-        # un-folded buffer on disk -- violating the file-internal
+        # ``_write_data_to_file`` of the whole buffer would land an unsorted,
+        # un-folded table on disk -- violating the file-internal
         # PK-unique invariant. Route the final flush through
         # ``_flush_all`` so the contract holds even on the
         # close-without-prepare_commit path.
         try:
-            if self.pending_data is not None and self.pending_data.num_rows > 
0:
+            if self._buffer.num_rows > 0:
                 self._flush_all()
         except Exception as e:
             import logging
@@ -99,24 +98,29 @@ class KeyValueDataWriter(DataWriter):
             self.abort()
             raise e
         finally:
-            self.pending_data = None
+            self._buffer.reset()
 
     def _flush_all(self) -> None:
         """Sort + fold the entire buffer, then roll-write as files.
 
-        On return, ``pending_data is None`` and every flushed chunk
-        has been recorded in ``committed_files``. The buffer is
-        always fully drained per flush: no slice remainder is
-        carried back into ``pending_data``.
+        On return, the buffer is empty and every flushed chunk has been
+        recorded in ``committed_files``. If a file write fails, the buffer is
+        left holding exactly the rows no file has taken yet, so a retried
+        flush neither loses nor duplicates them.
         """
-        if self.pending_data is None or self.pending_data.num_rows == 0:
-            self.pending_data = None
+        pending = self._buffer.materialize()
+        if pending is None or pending.num_rows == 0:
+            self._buffer.reset()
             return
-        sorted_data = self._sort_by_primary_key(self.pending_data)
+        sorted_data = self._sort_by_primary_key(pending)
         folded = self._merge_pending_by_pk(sorted_data)
-        self.pending_data = None
         if folded.num_rows == 0:
+            self._buffer.reset()
             return
+        # Park the folded rows in the buffer for ``_roll_write`` to drain. Both
+        # the sort and the fold are idempotent on their own output -- PKs are
+        # unique once folded -- so a retry over what is left is still correct.
+        self._buffer.reset(folded)
         self._roll_write(folded)
 
     def _roll_write(self, data: pa.Table) -> None:
@@ -127,10 +131,15 @@ class KeyValueDataWriter(DataWriter):
         size does not violate the LSM file-internal invariant.
         Reuses ``_find_optimal_split_point`` / ``_write_data_to_file``
         from the base class.
+
+        The buffer is narrowed to the rows still unwritten after each file, so
+        a failure part way through leaves the remainder -- and only the
+        remainder -- for whoever flushes next.
         """
         while data.num_rows > 0:
             if data.nbytes <= self.target_file_size:
                 self._write_data_to_file(data)
+                self._buffer.reset()
                 return
             split_row = self._find_optimal_split_point(
                 data, self.target_file_size)
@@ -138,9 +147,11 @@ class KeyValueDataWriter(DataWriter):
                 # Single row already exceeds target_file_size; nothing
                 # to gain from further slicing, write it as-is.
                 self._write_data_to_file(data)
+                self._buffer.reset()
                 return
             self._write_data_to_file(data.slice(0, split_row))
             data = data.slice(split_row)
+            self._buffer.reset(data)
 
     def _merge_pending_by_pk(self, data: pa.Table) -> pa.Table:
         """Fold same-PK runs in ``data`` using ``self._merge_function``.
diff --git a/paimon-python/pypaimon/write/writer/write_buffer.py 
b/paimon-python/pypaimon/write/writer/write_buffer.py
new file mode 100644
index 0000000000..b07a899fba
--- /dev/null
+++ b/paimon-python/pypaimon/write/writer/write_buffer.py
@@ -0,0 +1,101 @@
+# 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 typing import Callable, List, Optional
+
+import pyarrow as pa
+
+
+class WriteBuffer:
+    """Accumulates Arrow tables, concatenating them only when asked.
+
+    ``append`` adds a table's row count and size to running totals, so a writer
+    can answer its rolling check without touching Arrow. ``materialize`` and
+    ``take`` are the operations that concatenate.
+    """
+
+    def __init__(self, merge: Callable[[pa.Table, pa.Table], pa.Table]):
+        # ``merge(existing, new)`` combines two tables the way the owning 
writer
+        # wants, and runs once per ``materialize`` rather than once per append.
+        self._merge = merge
+        self._table: Optional[pa.Table] = None
+        self._appended: List[pa.Table] = []
+        self._schema: Optional[pa.Schema] = None
+        # ``concat_tables`` only collects chunks and ``nbytes`` sums the 
buffers
+        # they reference, so this running total is what the fold will report.
+        self.nbytes = 0
+        self.num_rows = 0
+
+    @property
+    def is_empty(self) -> bool:
+        """True when nothing has been appended and no table has been set.
+
+        Distinct from ``num_rows == 0``, which a zero-row table also satisfies.
+        """
+        return self._table is None and not self._appended
+
+    def append(self, data: pa.Table) -> None:
+        # ``concat_tables`` rejects any schema difference while ``TableWrite``
+        # admits a few (differing nullability, ``binary`` vs
+        # ``fixed_size_binary``). Reject here so those keep failing in
+        # ``write``, which aborts, instead of in ``prepare_commit``, which does
+        # not. ``Schema.equals`` ignores metadata, and so does concat.
+        if self._schema is None:
+            self._schema = data.schema
+        elif not data.schema.equals(self._schema):
+            raise ValueError(
+                "Cannot buffer a batch whose schema differs from the batches "
+                f"already buffered.\nBuffered schema is: {self._schema}\n"
+                f"Incoming schema is: {data.schema}")
+        self._appended.append(data)
+        self.nbytes += data.nbytes
+        self.num_rows += data.num_rows
+
+    def materialize(self) -> Optional[pa.Table]:
+        """Concatenate everything appended so far into one table and return it.
+
+        Returns None while the buffer is empty, and is a no-op when called 
again
+        with nothing appended since.
+        """
+        if self._appended:
+            folded = (self._appended[0] if len(self._appended) == 1
+                      else pa.concat_tables(self._appended))
+            self._appended = []
+            self._table = (folded if self._table is None
+                           else self._merge(self._table, folded))
+            self._schema = self._table.schema
+            self.nbytes = self._table.nbytes
+            self.num_rows = self._table.num_rows
+        return self._table
+
+    def take(self) -> Optional[pa.Table]:
+        """Return everything buffered as one table and empty the buffer."""
+        table = self.materialize()
+        self.reset()
+        return table
+
+    def reset(self, table: Optional[pa.Table] = None) -> None:
+        """Replace the contents with ``table``, or empty the buffer.
+
+        ``table`` is measured from scratch because the usual caller passes a
+        slice of the table the running totals were describing.
+        """
+        self._appended = []
+        self._table = table
+        self._schema = None if table is None else table.schema
+        self.nbytes = 0 if table is None else table.nbytes
+        self.num_rows = 0 if table is None else table.num_rows

Reply via email to