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 a00fe7a9dc [python] Support target file row num for blob and vector 
writes (#9317)
a00fe7a9dc is described below

commit a00fe7a9dcd370a779105c62f377ef4b544b0a24
Author: Yann Byron <[email protected]>
AuthorDate: Fri Aug 21 17:29:21 2026 +0800

    [python] Support target file row num for blob and vector writes (#9317)
---
 .../tests/data_evolution_row_rolling_test.py       | 141 ++++++++++++++++++++-
 paimon-python/pypaimon/write/file_store_write.py   |  12 +-
 paimon-python/pypaimon/write/writer/blob_writer.py |  10 +-
 .../pypaimon/write/writer/data_vector_writer.py    |  56 +++++---
 .../write/writer/dedicated_format_writer.py        | 100 ++++++++++-----
 5 files changed, 259 insertions(+), 60 deletions(-)

diff --git a/paimon-python/pypaimon/tests/data_evolution_row_rolling_test.py 
b/paimon-python/pypaimon/tests/data_evolution_row_rolling_test.py
index 2dffcf493b..63bebc7173 100644
--- a/paimon-python/pypaimon/tests/data_evolution_row_rolling_test.py
+++ b/paimon-python/pypaimon/tests/data_evolution_row_rolling_test.py
@@ -24,6 +24,8 @@ import uuid
 import pyarrow as pa
 
 from pypaimon import CatalogFactory, Schema
+from pypaimon.common.uri_reader import FileUriReader
+from pypaimon.table.row.blob import Blob
 
 
 class DataEvolutionRowRollingTest(unittest.TestCase):
@@ -34,6 +36,19 @@ class DataEvolutionRowRollingTest(unittest.TestCase):
         ('id', pa.int32()),
         ('name', pa.string()),
     ])
+    blob_schema = pa.schema([
+        ('id', pa.int32()),
+        ('payload', pa.large_binary()),
+    ])
+    vector_schema = pa.schema([
+        ('id', pa.int32()),
+        ('embedding', pa.list_(pa.float32(), 3)),
+    ])
+    blob_vector_schema = pa.schema([
+        ('id', pa.int32()),
+        ('payload', pa.large_binary()),
+        ('embedding', pa.list_(pa.float32(), 3)),
+    ])
     de_options = {
         'row-tracking.enabled': 'true',
         'data-evolution.enabled': 'true',
@@ -57,11 +72,49 @@ class DataEvolutionRowRollingTest(unittest.TestCase):
             False)
         return self.catalog.get_table(name)
 
+    def _create_with_schema(self, pa_schema, options):
+        name = f'default.roll_{uuid.uuid4().hex[:8]}'
+        self.catalog.create_table(
+            name, Schema.from_pyarrow_schema(pa_schema, options=options),
+            False)
+        return self.catalog.get_table(name)
+
     def _rows(self, n):
         return pa.Table.from_pydict(
             {'id': list(range(n)), 'name': [f'n{i}' for i in range(n)]},
             schema=self.pa_schema)
 
+    def _blob_rows(self, n):
+        return pa.Table.from_pydict(
+            {
+                'id': list(range(n)),
+                'payload': [f'blob-{i}'.encode() for i in range(n)],
+            },
+            schema=self.blob_schema)
+
+    def _vector_rows(self, n):
+        return pa.Table.from_pydict(
+            {
+                'id': list(range(n)),
+                'embedding': [
+                    [float(i), float(i + 1), float(i + 2)]
+                    for i in range(n)
+                ],
+            },
+            schema=self.vector_schema)
+
+    def _blob_vector_rows(self, n):
+        return pa.Table.from_pydict(
+            {
+                'id': list(range(n)),
+                'payload': [f'blob-{i}'.encode() for i in range(n)],
+                'embedding': [
+                    [float(i), float(i + 1), float(i + 2)]
+                    for i in range(n)
+                ],
+            },
+            schema=self.blob_vector_schema)
+
     def _write_files(self, table, data):
         """Write one Arrow table and return the committed DataFileMeta list."""
         wb = table.new_batch_write_builder()
@@ -74,7 +127,7 @@ class DataEvolutionRowRollingTest(unittest.TestCase):
         return files
 
     def _read_ids(self, table):
-        rb = table.new_read_builder()
+        rb = table.new_read_builder().with_projection(['id'])
         return sorted(
             rb.new_read().to_arrow(rb.new_scan().plan().splits())
             ['id'].to_pylist())
@@ -126,6 +179,92 @@ class DataEvolutionRowRollingTest(unittest.TestCase):
                 NotImplementedError, 'row-count based file rolling'):
             tw.write_arrow(self._rows(4))
 
+    def test_blob_writer_supports_target_file_row_num(self):
+        table = self._create_with_schema(
+            self.blob_schema,
+            {**self.de_options, 'target-file-row-num': '3'})
+
+        files = self._write_files(table, self._blob_rows(7))
+
+        data_rows = sorted(
+            f.row_count for f in files
+            if not f.file_name.endswith('.blob'))
+        blob_rows = sorted(
+            f.row_count for f in files
+            if f.file_name.endswith('.blob'))
+        self.assertEqual([1, 3, 3], data_rows)
+        self.assertEqual([1, 3, 3], blob_rows)
+        self.assertEqual(list(range(7)), self._read_ids(table))
+
+    def test_blob_consumer_descriptors_survive_abort_after_rolling(self):
+        table = self._create_with_schema(
+            self.blob_schema,
+            {**self.de_options, 'target-file-row-num': '3'})
+        descriptors = []
+
+        def consume(_, descriptor):
+            if descriptor is not None:
+                descriptors.append(descriptor)
+            return True
+
+        writer = table.new_batch_write_builder().new_write()
+        writer.with_blob_consumer(consume)
+        writer.write_arrow(self._blob_rows(7))
+        writer.abort()
+
+        self.assertEqual(7, len(descriptors))
+        uri_reader = FileUriReader(table.file_io)
+        for index, descriptor in enumerate(descriptors):
+            self.assertEqual(
+                f'blob-{index}'.encode(),
+                Blob.from_descriptor(uri_reader, descriptor).to_data())
+
+    def test_vector_writer_supports_target_file_row_num(self):
+        table = self._create_with_schema(
+            self.vector_schema,
+            {
+                **self.de_options,
+                'target-file-row-num': '3',
+                'vector.file.format': 'parquet',
+            })
+
+        files = self._write_files(table, self._vector_rows(7))
+
+        data_rows = sorted(
+            f.row_count for f in files
+            if '.vector.' not in f.file_name)
+        vector_rows = sorted(
+            f.row_count for f in files
+            if '.vector.' in f.file_name)
+        self.assertEqual([1, 3, 3], data_rows)
+        self.assertEqual([1, 3, 3], vector_rows)
+        self.assertEqual(list(range(7)), self._read_ids(table))
+
+    def test_dedicated_writer_rolls_blob_and_vector_together(self):
+        table = self._create_with_schema(
+            self.blob_vector_schema,
+            {
+                **self.de_options,
+                'target-file-row-num': '3',
+                'vector.file.format': 'parquet',
+            })
+
+        files = self._write_files(table, self._blob_vector_rows(7))
+
+        data_rows = sorted(
+            f.row_count for f in files
+            if not f.file_name.endswith('.blob') and '.vector.' not in 
f.file_name)
+        blob_rows = sorted(
+            f.row_count for f in files
+            if f.file_name.endswith('.blob'))
+        vector_rows = sorted(
+            f.row_count for f in files
+            if '.vector.' in f.file_name)
+        self.assertEqual([1, 3, 3], data_rows)
+        self.assertEqual([1, 3, 3], blob_rows)
+        self.assertEqual([1, 3, 3], vector_rows)
+        self.assertEqual(list(range(7)), self._read_ids(table))
+
 
 if __name__ == '__main__':
     unittest.main()
diff --git a/paimon-python/pypaimon/write/file_store_write.py 
b/paimon-python/pypaimon/write/file_store_write.py
index 9a1dd10721..df3022b37d 100644
--- a/paimon-python/pypaimon/write/file_store_write.py
+++ b/paimon-python/pypaimon/write/file_store_write.py
@@ -145,20 +145,14 @@ class FileStoreWrite:
             raise ValueError(
                 f"target-file-row-num should be at most {max_value}")
         if row_limit != max_value:
-            # Row-count rolling is implemented in the base append writer only.
-            # DE (data-evolution) append tables are the target; primary-key,
-            # blob and vector writers override rolling and are not supported 
yet.
             row_rolling_supported = (
                 self.table.options.data_evolution_enabled()
-                and not self.table.is_primary_key_table
-                and not self._has_blob_columns()
-                and not (self._has_vector_columns()
-                         and options.with_vector_format()))
+                and not self.table.is_primary_key_table)
             if not row_rolling_supported:
                 raise NotImplementedError(
                     "target-file-row-num is set on this table but pypaimon 
supports row-count "
-                    "based file rolling only for data-evolution append tables 
(no primary key, "
-                    "blob or vector columns); unset it or write with 
Java/Flink/Spark.")
+                    "based file rolling only for data-evolution append tables 
(no primary key); "
+                    "unset it or write with Java/Flink/Spark.")
 
         def max_seq_number():
             return self._seq_number_stats(partition).get(bucket, 1)
diff --git a/paimon-python/pypaimon/write/writer/blob_writer.py 
b/paimon-python/pypaimon/write/writer/blob_writer.py
index c5bf143728..40d301c2f1 100644
--- a/paimon-python/pypaimon/write/writer/blob_writer.py
+++ b/paimon-python/pypaimon/write/writer/blob_writer.py
@@ -113,7 +113,10 @@ class BlobWriter(AppendOnlyDataWriter):
         if self.current_writer is None:
             return False
 
-        return 
self.current_writer.reach_target_size(self.blob_target_file_size)
+        return (
+            self.current_writer.row_count >= self.target_file_row_num
+            or 
self.current_writer.reach_target_size(self.blob_target_file_size)
+        )
 
     def close_current_writer(self):
         """Close current writer and create metadata."""
@@ -237,6 +240,9 @@ class BlobWriter(AppendOnlyDataWriter):
         # Call parent to handle pending_data fallback.
         super().close()
 
+    def delete_file_upon_abort(self) -> bool:
+        return self._blob_consumer is None
+
     def abort(self):
         if self.current_writer is not None:
             try:
@@ -245,7 +251,7 @@ class BlobWriter(AppendOnlyDataWriter):
                 logger.warning(f"Error aborting blob writer: {e}", exc_info=e)
             self.current_writer = None
             self.current_file_path = None
-        if self._blob_consumer is not None:
+        if not self.delete_file_upon_abort():
             self.pending_data = None
             self.committed_files.clear()
         else:
diff --git a/paimon-python/pypaimon/write/writer/data_vector_writer.py 
b/paimon-python/pypaimon/write/writer/data_vector_writer.py
index b474957870..72291cb738 100644
--- a/paimon-python/pypaimon/write/writer/data_vector_writer.py
+++ b/paimon-python/pypaimon/write/writer/data_vector_writer.py
@@ -110,27 +110,43 @@ class DataVectorWriter(DataWriter):
 
     def write(self, data: pa.RecordBatch):
         try:
-            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 self.vector_writer is not None and vector_data is not None and 
vector_data.num_rows > 0:
-                self.vector_writer.write(vector_data)
-
-            self.record_count += data.num_rows
-
-            if self._should_roll_normal():
-                self._close_current_writers()
+            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()
+                if capacity <= 0:
+                    self._close_current_writers()
+                    capacity = self.target_file_row_num
+                length = min(capacity, data.num_rows - offset)
+                self._write_batch(data.slice(offset, length))
+                offset += length
 
         except Exception as e:
             logger.error("Exception occurs when writing data. Cleaning up.", 
exc_info=e)
             self.abort()
             raise e
 
+    def _write_batch(self, data: pa.RecordBatch):
+        if data.num_rows == 0:
+            return
+
+        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 self.vector_writer is not None and vector_data is not None and 
vector_data.num_rows > 0:
+            self.vector_writer.write(vector_data)
+
+        self.record_count += data.num_rows
+
+        if self._should_roll_normal():
+            self._close_current_writers()
+
     def prepare_commit(self) -> List[DataFileMeta]:
         self._close_current_writers()
         return self.committed_files.copy()
@@ -174,10 +190,19 @@ class DataVectorWriter(DataWriter):
     def _should_roll_normal(self) -> bool:
         if self.pending_normal_data is None:
             return False
+        if self.pending_normal_data.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 0
+
     def _close_current_writers(self):
         has_normal = self.pending_normal_data is not None and 
self.pending_normal_data.num_rows > 0
 
@@ -195,6 +220,7 @@ class DataVectorWriter(DataWriter):
             self.vector_writer.committed_files.clear()
 
         self.pending_normal_data = None
+        self.record_count = 0
 
     def _write_normal_data_to_file(self, data: pa.Table) -> 
Optional[DataFileMeta]:
         if data.num_rows == 0:
diff --git a/paimon-python/pypaimon/write/writer/dedicated_format_writer.py 
b/paimon-python/pypaimon/write/writer/dedicated_format_writer.py
index b7118e0507..ae73880fd0 100644
--- a/paimon-python/pypaimon/write/writer/dedicated_format_writer.py
+++ b/paimon-python/pypaimon/write/writer/dedicated_format_writer.py
@@ -136,6 +136,7 @@ class DedicatedFormatWriter(DataWriter):
 
         # Track pending data for normal data only
         self.pending_normal_data: Optional[pa.Table] = None
+        self._committed_files_to_delete_on_abort: List[DataFileMeta] = []
 
         # Initialize blob writers for each blob-file column.
         from pypaimon.write.writer.blob_writer import BlobWriter
@@ -200,39 +201,55 @@ class DedicatedFormatWriter(DataWriter):
 
     def write(self, data: pa.RecordBatch):
         try:
-            # Split data into normal, blob, and vector parts
-            normal_data, blob_data_map, vector_data = self._split_data(data)
-            self._validate_inline_stored_fields_input(data)
-
-            # 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)
-
-            # Write blob-file columns to dedicated blob writers.
-            for blob_column, blob_data in blob_data_map.items():
-                if blob_data is not None and blob_data.num_rows > 0:
-                    self.blob_writers[blob_column].write(blob_data)
-
-            # Write vector columns to dedicated vector writer.
-            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)
-
-            self.record_count += data.num_rows
-
-            # Check if normal data rolling is needed
-            if self._should_roll_normal():
-                # When normal data rolls, close both writers and fetch blob 
metadata
-                self._close_current_writers()
+            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()
+                if capacity <= 0:
+                    self._close_current_writers()
+                    capacity = self.target_file_row_num
+                length = min(capacity, data.num_rows - offset)
+                self._write_batch(data.slice(offset, length))
+                offset += length
 
         except Exception as e:
             logger.error("Exception occurs when writing data. Cleaning up.", 
exc_info=e)
             self.abort()
             raise e
 
+    def _write_batch(self, data: pa.RecordBatch):
+        if data.num_rows == 0:
+            return
+
+        # Split data into normal, blob, and vector parts
+        normal_data, blob_data_map, vector_data = self._split_data(data)
+        self._validate_inline_stored_fields_input(data)
+
+        # 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)
+
+        # Write blob-file columns to dedicated blob writers.
+        for blob_column, blob_data in blob_data_map.items():
+            if blob_data is not None and blob_data.num_rows > 0:
+                self.blob_writers[blob_column].write(blob_data)
+
+        # Write vector columns to dedicated vector writer.
+        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)
+
+        self.record_count += data.num_rows
+
+        # Check if normal data rolling is needed
+        if self._should_roll_normal():
+            # When normal data rolls, close both writers and fetch blob 
metadata
+            self._close_current_writers()
+
     def write_row(self, row):
         try:
             values_by_name = row_to_named_values(
@@ -335,14 +352,11 @@ class DedicatedFormatWriter(DataWriter):
             blob_writer.abort()
         if self.vector_writer is not None:
             self.vector_writer.abort()
-        committed_non_blob_files = [
-            file_meta for file_meta in self.committed_files
-            if not DataFileMeta.is_blob_file(file_meta.file_name)
-        ]
-        self._delete_committed_files(committed_non_blob_files)
+        self._delete_committed_files(self._committed_files_to_delete_on_abort)
         self.pending_normal_data = None
         self.pending_data = None
         self.committed_files.clear()
+        self._committed_files_to_delete_on_abort.clear()
 
     def _split_data(self, data: pa.RecordBatch) -> Tuple[
             Optional[pa.RecordBatch], Dict[str, pa.RecordBatch], 
Optional[pa.RecordBatch]]:
@@ -454,6 +468,9 @@ class DedicatedFormatWriter(DataWriter):
         if self.pending_normal_data is None:
             return False
 
+        if self.pending_normal_data.num_rows >= self.target_file_row_num:
+            return True
+
         # Check rolling condition periodically (every CHECK_ROLLING_RECORD_CNT 
records)
         if self.record_count % self.CHECK_ROLLING_RECORD_CNT != 0:
             return False
@@ -462,19 +479,34 @@ class DedicatedFormatWriter(DataWriter):
         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
+        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
+        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)
 
         blob_metas = []
         for blob_column in self.blob_file_column_names:
-            writer_metas = self.blob_writers[blob_column].prepare_commit()
+            blob_writer = self.blob_writers[blob_column]
+            writer_metas = blob_writer.prepare_commit()
             if normal_meta is not None:
                 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)
 
         vector_metas = []
@@ -483,9 +515,11 @@ class DedicatedFormatWriter(DataWriter):
             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)
             self.vector_writer.committed_files.clear()
 
         self.pending_normal_data = None
+        self.record_count = 0
 
         if normal_meta is not None or blob_metas or vector_metas:
             normal_name = normal_meta.file_name if normal_meta is not None 
else '<none>'

Reply via email to