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 e6613e9ac8 [python] Fix fragmented Parquet row groups in MAP 
shared-shredding writes (#10386)
e6613e9ac8 is described below

commit e6613e9ac8a59878072a46b75c26a11e2c32987d
Author: XiaoHongbo <[email protected]>
AuthorDate: Mon Oct 5 20:40:37 2026 +0800

    [python] Fix fragmented Parquet row groups in MAP shared-shredding writes 
(#10386)
---
 .../tests/map_shared_shredding_write_test.py       | 86 +++++++++++++++++--
 .../pypaimon/write/map_shared_shredding_writer.py  | 98 +++++++++++++++++++---
 2 files changed, 165 insertions(+), 19 deletions(-)

diff --git a/paimon-python/pypaimon/tests/map_shared_shredding_write_test.py 
b/paimon-python/pypaimon/tests/map_shared_shredding_write_test.py
index 2ca511d09e..2cbc44cace 100644
--- a/paimon-python/pypaimon/tests/map_shared_shredding_write_test.py
+++ b/paimon-python/pypaimon/tests/map_shared_shredding_write_test.py
@@ -277,18 +277,15 @@ class MapSharedShreddingWriteTest(unittest.TestCase):
         }, schema=self.arrow_schema)
         convert = _MapFieldConverter.convert
         write_table = pq.ParquetWriter.write_table
-        pending = []
         sizes = []
+        written = []
 
         def convert_batch(converter, column):
-            self.assertFalse(pending, 'physical batches were retained before 
writing')
-            pending.append(len(column))
             sizes.append(len(column))
             return convert(converter, column)
 
         def write_batch(writer, physical, *args, **kwargs):
-            self.assertEqual([physical.num_rows], pending)
-            pending.clear()
+            written.append(physical.num_rows)
             return write_table(writer, physical, *args, **kwargs)
 
         with patch.object(_MapFieldConverter, 'convert', convert_batch), \
@@ -296,6 +293,7 @@ class MapSharedShreddingWriteTest(unittest.TestCase):
             messages = self._write(table, data)
         self.assertEqual(5000, sum(sizes))
         self.assertLessEqual(max(sizes), 1024)
+        self.assertEqual([5000], written)
         self.assertEqual(1, len(messages[0].new_files))
         reader = table.new_read_builder().with_projection(["metrics['a']", 
"metrics['late']"])
         result = reader.new_read().to_arrow(reader.new_scan().plan().splits())
@@ -305,7 +303,7 @@ class MapSharedShreddingWriteTest(unittest.TestCase):
     def test_failed_stream_removes_partial_file(self):
         from pypaimon.write.map_shared_shredding_writer import 
MapSharedShreddingWriter
 
-        table = self._create_table('parquet', 2)
+        table = self._create_table('parquet', 2, {'file.block-size': '1 kb'})
         converter = MapSharedShreddingWriter(table.fields, table.options, 
'parquet', None)
         data = pa.Table.from_pydict({
             'id': list(range(2048)), 'metrics': [[('a', 1)]] * 2048,
@@ -351,13 +349,87 @@ class MapSharedShreddingWriteTest(unittest.TestCase):
                 metadata = pq.read_metadata(files[0].file_path)
                 layouts.append([metadata.row_group(i).num_rows
                                 for i in range(metadata.num_row_groups)])
-                self.assertEqual((count + 1023) // 1024, 
metadata.num_row_groups)
+                self.assertEqual(1, metadata.num_row_groups)
                 reader = table.new_read_builder().with_projection(['id', 
"metrics['a']"])
                 result = 
reader.new_read().to_arrow(reader.new_scan().plan().splits())
                 self.assertEqual(list(range(count)), 
result.column('id').to_pylist())
                 self.assertEqual(list(range(count)), 
result.column('metrics_a').to_pylist())
             self.assertEqual(layouts[0], layouts[1])
 
+    def test_physical_row_group_byte_and_row_limits(self):
+        from pypaimon.write.map_shared_shredding_writer import 
_physical_row_groups
+
+        data = pa.table({'id': pa.array(range(25), type=pa.int64())})
+        for chunks in ([data], [data.slice(i, 3) for i in range(0, 25, 3)]):
+            for target, max_rows, expected in (
+                    (80, 100, [10, 10, 5]), (800, 6, [6, 6, 6, 6, 1])):
+                groups = list(_physical_row_groups(iter(chunks), target, 
max_rows))
+                self.assertEqual(expected, [g.num_rows for g in groups])
+                self.assertTrue(all(g.nbytes <= target for g in groups))
+                self.assertTrue(pa.concat_tables(groups).equals(data))
+
+    def test_physical_row_groups_empty_nulls_and_oversized_row(self):
+        from pypaimon.write.map_shared_shredding_writer import 
_physical_row_groups
+
+        self.assertEqual([], list(_physical_row_groups(iter([]), 100)))
+        data = pa.table({'value': [None, 'x' * 300, '', 'tail']})
+        groups = list(_physical_row_groups(iter([data.slice(0, 0), data]), 64))
+        self.assertTrue(pa.concat_tables(groups).equals(data))
+        self.assertTrue(all(g.nbytes <= 64 or g.num_rows == 1 for g in groups))
+        self.assertEqual([1, 1, 2], [g.num_rows for g in groups])
+
+    def test_physical_row_groups_release_input_on_close(self):
+        from pypaimon.write.map_shared_shredding_writer import 
_physical_row_groups
+
+        closed = []
+
+        def batches():
+            try:
+                while True:
+                    yield pa.table({'id': [1, 2]})
+            finally:
+                closed.append(True)
+
+        groups = _physical_row_groups(batches(), 16)
+        next(groups)
+        groups.close()
+        self.assertEqual([True], closed)
+
+    def test_physical_row_groups_preserve_nested_shredding_metadata(self):
+        from pypaimon.write.map_shared_shredding_writer import 
_physical_row_groups
+
+        table = self._create_table('parquet', 2, {
+            'write.native.enabled': 'false', 'read.native.enabled': 'false',
+            'file.block-size': '4 kb',
+        })
+        maps = [[('a', i), ('b', None), ('overflow', i + 1)] for i in 
range(2500)]
+        data = pa.Table.from_pydict(
+            {'id': list(range(2500)), 'metrics': maps}, 
schema=self.arrow_schema)
+        captured = []
+
+        def collect(batches, target_bytes):
+            for group in _physical_row_groups(batches, target_bytes):
+                captured.append((group.num_rows, group.nbytes))
+                
self.assertTrue(is_shared_shredding(group.schema.field('metrics')))
+                yield group
+
+        with 
patch('pypaimon.write.map_shared_shredding_writer._physical_row_groups', 
collect):
+            messages = self._write(table, data)
+        self.assertTrue(all(size <= 4096 or rows == 1 for rows, size in 
captured))
+        metadata = pq.read_metadata(messages[0].new_files[0].file_path)
+        self.assertEqual([rows for rows, _ in captured],
+                         [metadata.row_group(i).num_rows for i in 
range(metadata.num_row_groups)])
+        reader = table.new_read_builder()
+        actual = reader.new_read().to_arrow(reader.new_scan().plan().splits())
+        self.assertEqual(maps, actual['metrics'].to_pylist())
+
+    def test_reject_nonpositive_row_group_target(self):
+        from pypaimon.write.map_shared_shredding_writer import 
MapSharedShreddingWriter
+
+        table = self._create_table('parquet', 2, {'file.block-size': '0 b'})
+        with self.assertRaisesRegex(ValueError, 'file.block-size must be 
positive'):
+            MapSharedShreddingWriter(table.fields, table.options, 'parquet', 
None)
+
     def test_reject_postpone_with_fixed_output_bucket(self):
         from pypaimon.write.writer.append_only_data_writer import 
AppendOnlyDataWriter
 
diff --git a/paimon-python/pypaimon/write/map_shared_shredding_writer.py 
b/paimon-python/pypaimon/write/map_shared_shredding_writer.py
index 539e9a0425..0302459e15 100644
--- a/paimon-python/pypaimon/write/map_shared_shredding_writer.py
+++ b/paimon-python/pypaimon/write/map_shared_shredding_writer.py
@@ -45,6 +45,9 @@ _PHYSICAL_COLUMN_PREFIX = "__col_"
 _FIELD_ID_BASE = 2147483647 // 4
 _FIELD_ID_DEPTH_LIMIT = 1 << 10
 _CONVERSION_BYTES = 8 * 1024 * 1024
+_ROW_GROUP_MAX_ROWS = 1024 * 1024
+# Match the default Parquet block target, measured here in physical Arrow 
bytes.
+_DEFAULT_ROW_GROUP_BYTES = 128 * 1024 * 1024
 
 
 class MapSharedShreddingWriter:
@@ -90,6 +93,11 @@ class MapSharedShreddingWriter:
         self._validate_format("file.format", file_format)
         self._validate_format("changelog.file.format", changelog_format)
         self._parquet_writer_options = create_parquet_writer_options(options)
+        block_size = options.file_block_size()
+        self._row_group_bytes = (block_size.get_bytes() if block_size is not 
None
+                                 else _DEFAULT_ROW_GROUP_BYTES)
+        if self._row_group_bytes <= 0:
+            raise ValueError('file.block-size must be positive')
         self._validate_compression("file.compression", 
options.file_compression())
         if options.bucket() == BucketMode.POSTPONE_BUCKET.value:
             raise ValueError(
@@ -107,7 +115,7 @@ class MapSharedShreddingWriter:
         return bool(self._fields)
 
     def write_parquet(self, file_io, path, data, compression, zstd_level):
-        """Plan key metadata, then write bounded physical batches without 
retaining them."""
+        """Convert small batches, buffering physical rows independently of 
conversion."""
         import pyarrow.parquet as pq
 
         fields = list(data.schema)
@@ -163,23 +171,28 @@ class MapSharedShreddingWriter:
         kwargs = dict(self._parquet_writer_options, compression=compression)
         if compression.lower() == 'zstd':
             kwargs['compression_level'] = zstd_level
+
+        def physical_batches():
+            for offset in range(0, data.num_rows, batch_rows):
+                for bounded in _bounded_batches(data.slice(offset, 
batch_rows)):
+                    batch = bounded.combine_chunks().to_batches()[0]
+                    columns = list(batch.columns)
+                    for index, converter in converters.items():
+                        columns[index] = converter.convert(columns[index])
+                    yield pa.Table.from_arrays(columns, schema=schema)
+
+        groups = _physical_row_groups(physical_batches(), 
self._row_group_bytes)
         try:
             with file_io.new_output_stream(path) as stream:
                 with pq.ParquetWriter(stream, schema, **kwargs) as writer:
-                    for offset in range(0, data.num_rows, batch_rows):
-                        for bounded in _bounded_batches(data.slice(offset, 
batch_rows)):
-                            # Coalesce only this bounded logical window, not 
the
-                            # file. Input calls must not define row groups.
-                            batch = bounded.combine_chunks().to_batches()[0]
-                            columns = list(batch.columns)
-                            for index, converter in converters.items():
-                                columns[index] = 
converter.convert(columns[index])
-                            physical = pa.Table.from_arrays(columns, 
schema=schema)
-                            writer.write_table(physical)
-                            del physical, columns
+                    for physical in groups:
+                        writer.write_table(physical, 
row_group_size=physical.num_rows)
+                        del physical
         except Exception:
             file_io.delete_quietly(path)
             raise
+        finally:
+            groups.close()
         return completed
 
     def file_completed(self, completed):
@@ -396,6 +409,67 @@ class _MapFieldConverter:
         return selected
 
 
+def _physical_row_groups(batches, target_bytes, max_rows=_ROW_GROUP_MAX_ROWS):
+    """Group converted batches without copying them into one contiguous Arrow 
table.
+
+    Bytes are an Arrow-side target, not compressed Parquet size or a process 
RSS
+    limit. One indivisible row may exceed it. In addition to the pending group,
+    the current conversion batch and Parquet encoder retain their own buffers.
+    """
+    pending = []
+    rows = size = 0
+    try:
+        for batch in batches:
+            offset = 0
+            while offset < batch.num_rows:
+                count = min(batch.num_rows - offset, max_rows - rows)
+                piece = _physical_slice(batch, offset, count)
+                available = target_bytes - size
+                if piece.nbytes > available:
+                    low, high = 0, count
+                    while low < high:
+                        middle = (low + high + 1) // 2
+                        if _physical_slice(batch, offset, middle).nbytes <= 
available:
+                            low = middle
+                        else:
+                            high = middle - 1
+                    if low == 0 and rows:
+                        yield pa.concat_tables(pending)
+                        pending.clear()
+                        rows = size = 0
+                        continue
+                    count = max(1, low)
+                    piece = _physical_slice(batch, offset, count)
+                pending.append(piece)
+                rows += count
+                size += piece.nbytes
+                offset += count
+                del piece
+                if rows >= max_rows or size >= target_bytes:
+                    yield pa.concat_tables(pending)
+                    pending.clear()
+                    rows = size = 0
+            del batch
+        if rows:
+            yield pa.concat_tables(pending)
+    finally:
+        pending.clear()
+        close = getattr(batches, 'close', None)
+        if close is not None:
+            close()
+
+
+def _physical_slice(batch, offset, count):
+    piece = batch.slice(offset, count)
+    # Arrow 6 accounts full backing buffers for slices. Compact partial slices
+    # there, as the row-ID update writer does, for bounded retained-byte 
accounting.
+    if int(pa.__version__.split('.')[0]) < 7:
+        piece = pa.Table.from_arrays([
+            pa.concat_arrays(column.chunks) for column in piece.columns
+        ], schema=piece.schema)
+    return piece
+
+
 def _bounded_batches(batch):
     # One oversized row is indivisible; bound the other logical batches before
     # expanding values into Python objects. The caller's input buffer is 
unchanged.

Reply via email to