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 3120ac76f0 [python] Support row-count based file rolling for 
data-evolution append tables (#8863)
3120ac76f0 is described below

commit 3120ac76f07abc6df71dcca4564de4363fe3e845
Author: XiaoHongbo <[email protected]>
AuthorDate: Tue Jul 28 21:12:03 2026 +0800

    [python] Support row-count based file rolling for data-evolution append 
tables (#8863)
---
 docs/generated/core_configuration.html             |   2 +-
 .../main/java/org/apache/paimon/CoreOptions.java   |   5 +-
 .../pypaimon/common/options/core_options.py        |   4 +-
 .../tests/data_evolution_row_rolling_test.py       | 131 +++++++++++++++++++++
 .../pypaimon/tests/shard_table_updator_test.py     |  76 ++++++++++++
 paimon-python/pypaimon/write/file_store_write.py   |  17 ++-
 paimon-python/pypaimon/write/table_update.py       |   4 +
 paimon-python/pypaimon/write/writer/data_writer.py |  30 +++--
 8 files changed, 252 insertions(+), 17 deletions(-)

diff --git a/docs/generated/core_configuration.html 
b/docs/generated/core_configuration.html
index a5a2412092..a4ea3baecd 100644
--- a/docs/generated/core_configuration.html
+++ b/docs/generated/core_configuration.html
@@ -1739,7 +1739,7 @@ If the data size allocated for the sorting task is 
uneven,which may lead to perf
             <td><h5>target-file-row-num</h5></td>
             <td style="word-wrap: break-word;">9223372036854775807</td>
             <td>Long</td>
-            <td>Target number of rows per newly written data file; a file 
rolls when this or target-file-size is reached, whichever comes first. Enforced 
at bundle granularity, so a bundled write may exceed it by up to one bundle. 
Only constrains files at write time: compaction is size-based and may merge 
into larger files, and data-evolution compaction still produces a single file. 
Bounds per-file rows for wide columns to avoid data-evolution OOM. PyPaimon 
file-store writers do not supp [...]
+            <td>Target number of rows per newly written data file; a file 
rolls when this or target-file-size is reached, whichever comes first. Enforced 
at bundle granularity, so a bundled write may exceed it by up to one bundle. 
Only constrains files at write time: compaction is size-based and may merge 
into larger files, and data-evolution compaction still produces a single file. 
Bounds per-file rows for wide columns to avoid data-evolution OOM. PyPaimon 
supports this for data-evoluti [...]
         </tr>
         <tr>
             <td><h5>target-file-size</h5></td>
diff --git a/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java 
b/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java
index 1a8bd6755e..92a62c344a 100644
--- a/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java
+++ b/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java
@@ -870,8 +870,9 @@ public class CoreOptions implements Serializable {
                                     + "compaction is size-based and may merge 
into larger files, and "
                                     + "data-evolution compaction still 
produces a single file. Bounds "
                                     + "per-file rows for wide columns to avoid 
data-evolution OOM. "
-                                    + "PyPaimon file-store writers do not 
support this option and "
-                                    + "fail fast when it is enabled. Disabled 
by default.");
+                                    + "PyPaimon supports this for 
data-evolution append tables; its "
+                                    + "primary-key, blob and vector writers 
still fail fast when it "
+                                    + "is enabled. Disabled by default.");
 
     public static final ConfigOption<Double> COMPACTION_SMALL_FILE_RATIO =
             key("compaction.small-file-ratio")
diff --git a/paimon-python/pypaimon/common/options/core_options.py 
b/paimon-python/pypaimon/common/options/core_options.py
index 392ea653a0..2b168ee09f 100644
--- a/paimon-python/pypaimon/common/options/core_options.py
+++ b/paimon-python/pypaimon/common/options/core_options.py
@@ -396,8 +396,8 @@ class CoreOptions:
         .default_value((1 << 63) - 1)
         .with_description(
             "Target number of rows per newly written data file. PyPaimon 
format-table "
-            "writers split files at this limit; file-store writers fail fast 
when "
-            "this option is enabled."
+            "and data-evolution append-table writers split files at this 
limit; "
+            "primary-key, blob and vector writers fail fast when this option 
is enabled."
         )
     )
 
diff --git a/paimon-python/pypaimon/tests/data_evolution_row_rolling_test.py 
b/paimon-python/pypaimon/tests/data_evolution_row_rolling_test.py
new file mode 100644
index 0000000000..2dffcf493b
--- /dev/null
+++ b/paimon-python/pypaimon/tests/data_evolution_row_rolling_test.py
@@ -0,0 +1,131 @@
+# 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.
+
+import os
+import shutil
+import tempfile
+import unittest
+import uuid
+
+import pyarrow as pa
+
+from pypaimon import CatalogFactory, Schema
+
+
+class DataEvolutionRowRollingTest(unittest.TestCase):
+    """Row-count based data file rolling (target-file-row-num) for
+    data-evolution append tables."""
+
+    pa_schema = pa.schema([
+        ('id', pa.int32()),
+        ('name', pa.string()),
+    ])
+    de_options = {
+        'row-tracking.enabled': 'true',
+        'data-evolution.enabled': 'true',
+    }
+
+    @classmethod
+    def setUpClass(cls):
+        cls.tempdir = tempfile.mkdtemp()
+        cls.catalog = CatalogFactory.create(
+            {'warehouse': os.path.join(cls.tempdir, 'warehouse')})
+        cls.catalog.create_database('default', True)
+
+    @classmethod
+    def tearDownClass(cls):
+        shutil.rmtree(cls.tempdir, ignore_errors=True)
+
+    def _create(self, options):
+        name = f'default.roll_{uuid.uuid4().hex[:8]}'
+        self.catalog.create_table(
+            name, Schema.from_pyarrow_schema(self.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 _write_files(self, table, data):
+        """Write one Arrow table and return the committed DataFileMeta list."""
+        wb = table.new_batch_write_builder()
+        tw = wb.new_write()
+        tw.write_arrow(data)
+        msgs = tw.prepare_commit()
+        files = [f for m in msgs for f in m.new_files]
+        wb.new_commit().commit(msgs)
+        tw.close()
+        return files
+
+    def _read_ids(self, table):
+        rb = table.new_read_builder()
+        return sorted(
+            rb.new_read().to_arrow(rb.new_scan().plan().splits())
+            ['id'].to_pylist())
+
+    def test_rolls_when_row_count_exceeds_limit(self):
+        table = self._create({**self.de_options, 'target-file-row-num': '3'})
+        files = self._write_files(table, self._rows(10))
+        # 10 rows, limit 3 -> 3 full files + a 1-row remainder.
+        self.assertEqual([1, 3, 3, 3], sorted(f.row_count for f in files))
+        self.assertEqual(list(range(10)), self._read_ids(table))
+
+    def test_exact_multiple_rolls_evenly(self):
+        table = self._create({**self.de_options, 'target-file-row-num': '3'})
+        files = self._write_files(table, self._rows(6))
+        self.assertEqual([3, 3], sorted(f.row_count for f in files))
+        self.assertEqual(list(range(6)), self._read_ids(table))
+
+    def test_below_limit_is_single_file(self):
+        table = self._create({**self.de_options, 'target-file-row-num': '100'})
+        files = self._write_files(table, self._rows(10))
+        self.assertEqual([10], [f.row_count for f in files])
+
+    def test_unset_option_does_not_roll_by_rows(self):
+        table = self._create(self.de_options)
+        files = self._write_files(table, self._rows(50))
+        # No row limit -> a small batch stays one file (size rolling only).
+        self.assertEqual([50], [f.row_count for f in files])
+
+    def test_oversized_row_rolls_by_itself(self):
+        # Each row exceeds target-file-size: the size trigger rolls every row 
by
+        # itself even though target-file-row-num is larger.
+        table = self._create({
+            **self.de_options,
+            'target-file-row-num': '3',
+            'target-file-size': '100 b',
+        })
+        big = 'x' * 500
+        data = pa.Table.from_pydict(
+            {'id': list(range(4)), 'name': [big] * 4}, schema=self.pa_schema)
+        files = self._write_files(table, data)
+        self.assertEqual([1, 1, 1, 1], [f.row_count for f in files])
+        self.assertEqual(list(range(4)), self._read_ids(table))
+
+    def test_non_de_table_still_fails_fast(self):
+        table = self._create({'target-file-row-num': '3'})
+        wb = table.new_batch_write_builder()
+        tw = wb.new_write()
+        with self.assertRaisesRegex(
+                NotImplementedError, 'row-count based file rolling'):
+            tw.write_arrow(self._rows(4))
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/paimon-python/pypaimon/tests/shard_table_updator_test.py 
b/paimon-python/pypaimon/tests/shard_table_updator_test.py
index 00760c033c..7efb58085c 100644
--- a/paimon-python/pypaimon/tests/shard_table_updator_test.py
+++ b/paimon-python/pypaimon/tests/shard_table_updator_test.py
@@ -652,5 +652,81 @@ class ShardTableUpdatorTest(unittest.TestCase):
         )
 
 
+    def test_shard_update_ignores_target_file_row_num(self):
+        """Regression: a shard maps to exactly one output file, so
+        target-file-row-num must not split it. Before the fix the
+        directly-constructed AppendOnlyDataWriter honoured the option and
+        rolled the 5-row shard into three files, failing SingleWriter.end()
+        with "Should have one file."."""
+        table_schema = pa.schema([
+            ('a', pa.int32()),
+            ('b', pa.int32()),
+            ('c', pa.int32()),
+            ('d', pa.int32()),
+        ])
+        schema = Schema.from_pyarrow_schema(
+            table_schema,
+            options={'row-tracking.enabled': 'true', 'data-evolution.enabled': 
'true'},
+        )
+        name = self._create_unique_table_name('row_num_rolling')
+        self.catalog.create_table(name, schema, False)
+        table = self.catalog.get_table(name)
+
+        # One 5-row file for a, b, c.
+        wb = table.new_batch_write_builder()
+        tw = wb.new_write().with_write_type(['a', 'b', 'c'])
+        tc = wb.new_commit()
+        tw.write_arrow(pa.Table.from_pydict({
+            'a': [1, 2, 3, 4, 5],
+            'b': [10, 20, 30, 40, 50],
+            'c': [100, 200, 300, 400, 500],
+        }, schema=pa.schema([
+            ('a', pa.int32()), ('b', pa.int32()), ('c', pa.int32()),
+        ])))
+        tc.commit(tw.prepare_commit())
+        tw.close()
+        tc.close()
+
+        # Enable row-count rolling (limit 2). The shard still covers all 5
+        # rows and must emit a single file.
+        table = table.copy({'target-file-row-num': '2'})
+
+        wb = table.new_batch_write_builder()
+        upd = wb.new_update()
+        upd.with_read_projection(['a', 'b', 'c'])
+        upd.with_update_type(['d'])
+        shard = upd.new_shard_updator(0, 1)
+        reader = shard.arrow_reader()
+        for batch in iter(reader.read_next_batch, None):
+            a_ = batch.column('a').to_pylist()
+            b_ = batch.column('b').to_pylist()
+            c_ = batch.column('c').to_pylist()
+            shard.update_by_arrow_batch(pa.RecordBatch.from_pydict(
+                {'d': [c + b - a for a, b, c in zip(a_, b_, c_)]},
+                schema=pa.schema([('d', pa.int32())]),
+            ))
+
+        # Without the fix this raises "Should have one file."
+        commit_messages = shard.prepare_commit()
+        new_file_count = sum(len(m.new_files) for m in commit_messages)
+        self.assertEqual(
+            1, new_file_count, "shard update must write exactly one file")
+
+        tc = wb.new_commit()
+        tc.commit(commit_messages)
+        tc.close()
+
+        rb = table.new_read_builder()
+        tr = rb.new_read()
+        actual = tr.to_arrow(rb.new_scan().plan().splits())
+        expected = pa.Table.from_pydict({
+            'a': [1, 2, 3, 4, 5],
+            'b': [10, 20, 30, 40, 50],
+            'c': [100, 200, 300, 400, 500],
+            'd': [109, 218, 327, 436, 545],
+        }, schema=table_schema)
+        self.assertEqual(actual, expected)
+
+
 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 92fa5259d7..0cdd84664c 100644
--- a/paimon-python/pypaimon/write/file_store_write.py
+++ b/paimon-python/pypaimon/write/file_store_write.py
@@ -101,9 +101,20 @@ class FileStoreWrite:
             raise ValueError(
                 f"target-file-row-num should be at most {max_value}")
         if row_limit != max_value:
-            raise NotImplementedError(
-                "target-file-row-num is set on this table but pypaimon 
file-store writers do not support "
-                "row-count based file rolling yet; unset it or write with 
Java/Flink/Spark.")
+            # 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()))
+            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.")
 
         def max_seq_number():
             return self._seq_number_stats(partition).get(bucket, 1)
diff --git a/paimon-python/pypaimon/write/table_update.py 
b/paimon-python/pypaimon/write/table_update.py
index 8095c16c2a..9f03f482e3 100644
--- a/paimon-python/pypaimon/write/table_update.py
+++ b/paimon-python/pypaimon/write/table_update.py
@@ -729,7 +729,11 @@ class ShardTableUpdator:
             partition = item[0]
             row_range = item[1]
             writer = AppendOnlyDataWriter(self.table, partition, 0, 0, 
self.table.options, self.write_cols)
+            # A shard maps to exactly one output file, so disable both size- 
and
+            # row-count based rolling; otherwise target-file-row-num would 
split
+            # the shard and SingleWriter.end() fails with "Should have one 
file."
             writer.target_file_size = 
MemorySize.of_mebi_bytes(999999999).get_bytes()
+            writer.target_file_row_num = 
CoreOptions.TARGET_FILE_ROW_NUM.default_value()
             self.writer = SingleWriter(writer, partition, row_range.from_, 
row_range.to - row_range.from_ + 1)
 
 
diff --git a/paimon-python/pypaimon/write/writer/data_writer.py 
b/paimon-python/pypaimon/write/writer/data_writer.py
index 221f0f0498..3a191bc3f7 100644
--- a/paimon-python/pypaimon/write/writer/data_writer.py
+++ b/paimon-python/pypaimon/write/writer/data_writer.py
@@ -52,6 +52,10 @@ class DataWriter(ABC):
 
         self.options = options
         self.target_file_size = 
self.options.target_file_size(self.table.is_primary_key_table)
+        # Roll a file when it reaches target_file_row_num rows or 
target_file_size,
+        # whichever comes first. Defaults to the max long (disabled), so plain
+        # size-based rolling is unchanged unless the option is set.
+        self.target_file_row_num = self.options.target_file_row_num()
         # POSTPONE_BUCKET uses AVRO format, otherwise default to PARQUET
         default_format = (
             CoreOptions.FILE_FORMAT_AVRO
@@ -188,16 +192,24 @@ class DataWriter(ABC):
 
     def _check_and_roll_if_needed(self):
         while self.pending_data is not None:
-            current_size = self.pending_data.nbytes
-            if current_size <= self.target_file_size:
+            num_rows = self.pending_data.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:
+                size_split = self._find_optimal_split_point(
+                    self.pending_data, self.target_file_size)
+                # First row alone exceeds target_file_size: roll it by itself.
+                if size_split <= 0:
+                    size_split = 1
+                if size_split < split_row:
+                    split_row = size_split
+            if split_row <= 0 or split_row >= num_rows:
                 break
-            split_row = self._find_optimal_split_point(self.pending_data, 
self.target_file_size)
-            if split_row <= 0:
-                break
-            data_to_write = self.pending_data.slice(0, split_row)
-            remaining_data = self.pending_data.slice(split_row)
-            self._write_data_to_file(data_to_write)
-            self.pending_data = remaining_data
+            self._write_data_to_file(self.pending_data.slice(0, split_row))
+            self.pending_data = self.pending_data.slice(split_row)
 
     def _write_data_to_file(self, data: pa.Table):
         if data.num_rows == 0:

Reply via email to