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 9956a067fc [python] optimize blob bunch construction (#8859)
9956a067fc is described below

commit 9956a067fc8f6959050383559d74aa61bfae8389
Author: Faiz <[email protected]>
AuthorDate: Mon Jul 27 15:51:31 2026 +0800

    [python] optimize blob bunch construction (#8859)
---
 paimon-python/pypaimon/read/reader/field_bunch.py |  28 +++--
 paimon-python/pypaimon/read/split_read.py         |   4 +-
 paimon-python/pypaimon/tests/field_bunch_test.py  | 130 ++++++++++++++++++++++
 3 files changed, 151 insertions(+), 11 deletions(-)

diff --git a/paimon-python/pypaimon/read/reader/field_bunch.py 
b/paimon-python/pypaimon/read/reader/field_bunch.py
index f2e0ae756c..5474bf855a 100644
--- a/paimon-python/pypaimon/read/reader/field_bunch.py
+++ b/paimon-python/pypaimon/read/reader/field_bunch.py
@@ -144,13 +144,24 @@ class _SpecialFieldBunch(FieldBunch):
 class BlobBunch(_SpecialFieldBunch):
     """Files for partial field (blob files)."""
 
+    def __init__(self, expected_row_count: int, row_id_push_down: bool = 
False):
+        super().__init__(expected_row_count, row_id_push_down)
+        self._finished = False
+
     def add(self, file: DataFileMeta) -> None:
+        if self._finished:
+            raise ValueError("Cannot add a blob file to a finished blob 
bunch.")
         if not self._is_special_file(file.file_name):
             raise ValueError("Only blob file can be added to a blob bunch.")
         if self._files and file.write_cols != self._files[0].write_cols:
             raise ValueError("All files in a blob bunch should have the same 
write columns.")
 
         self._files.append(file)
+
+    def finish(self) -> None:
+        if self._finished:
+            return
+
         merged = Range.sort_and_merge_overlap(
             [blob_file.row_id_range() for blob_file in self._files],
             True,
@@ -162,22 +173,19 @@ class BlobBunch(_SpecialFieldBunch):
                 f"Blob files row count exceed the expect 
{self.expected_row_count}"
             )
 
-    def row_count(self) -> int:
-        merged = Range.sort_and_merge_overlap(
-            [blob_file.row_id_range() for blob_file in self._files],
-            True,
-            True,
-        )
-        row_count = sum(row_range.count() for row_range in merged)
         if not self.row_id_push_down:
             if len(merged) != 1:
                 raise ValueError("Blob file bunch should always contain a 
contiguous row range.")
-            if self.expected_row_count >= 0 and row_count != 
self.expected_row_count:
+            if self.expected_row_count >= 0 and self._row_count != 
self.expected_row_count:
                 raise ValueError(
                     "The merged row count of blob file bunch should be aligned 
"
-                    f"with normal files, expect {self.expected_row_count}, got 
{row_count}."
+                    f"with normal files, expect {self.expected_row_count}, got 
{self._row_count}."
                 )
-        return row_count
+        self._finished = True
+
+    def row_count(self) -> int:
+        self.finish()
+        return self._row_count
 
     def sequential_read_optimize(self) -> bool:
         if not self._files:
diff --git a/paimon-python/pypaimon/read/split_read.py 
b/paimon-python/pypaimon/read/split_read.py
index 71e6b623f4..77bbd44837 100644
--- a/paimon-python/pypaimon/read/split_read.py
+++ b/paimon-python/pypaimon/read/split_read.py
@@ -1386,7 +1386,9 @@ class DataEvolutionSplitRead(SplitRead):
                 fields_files.append(DataBunch(file))
                 row_count = file.row_count
 
-        fields_files.extend(blob_bunch_map.values())
+        for bunch in blob_bunch_map.values():
+            bunch.finish()
+            fields_files.append(bunch)
         fields_files.extend(vector_bunch_map.values())
         return fields_files
 
diff --git a/paimon-python/pypaimon/tests/field_bunch_test.py 
b/paimon-python/pypaimon/tests/field_bunch_test.py
new file mode 100644
index 0000000000..0b6772d284
--- /dev/null
+++ b/paimon-python/pypaimon/tests/field_bunch_test.py
@@ -0,0 +1,130 @@
+# 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 unittest
+from unittest import mock
+
+from pypaimon.read.reader.field_bunch import BlobBunch
+from pypaimon.utils.range import Range
+
+
+class _BlobFile:
+    def __init__(
+            self,
+            file_name: str,
+            first_row_id: int,
+            row_count: int,
+            write_cols=None):
+        self.file_name = file_name
+        self.first_row_id = first_row_id
+        self.row_count = row_count
+        self.write_cols = write_cols or ["blob_col"]
+
+    def row_id_range(self) -> Range:
+        return Range(
+            self.first_row_id,
+            self.first_row_id + self.row_count - 1,
+        )
+
+
+class BlobBunchTest(unittest.TestCase):
+
+    def test_finish_merges_ranges_once(self):
+        bunch = BlobBunch(expected_row_count=1000)
+        merge_ranges = Range.sort_and_merge_overlap
+
+        with mock.patch.object(
+                Range,
+                "sort_and_merge_overlap",
+                wraps=merge_ranges) as mocked_merge:
+            for index in range(1000):
+                bunch.add(_BlobFile(f"{index}.blob", index, 1))
+
+            self.assertEqual(0, mocked_merge.call_count)
+            bunch.finish()
+            self.assertEqual(1, mocked_merge.call_count)
+            self.assertEqual(1000, bunch.row_count())
+            self.assertEqual(1, mocked_merge.call_count)
+
+    def test_finish_preserves_overlapping_versions(self):
+        bunch = BlobBunch(expected_row_count=20)
+        files = [
+            _BlobFile("old.blob", 0, 10),
+            _BlobFile("new.blob", 0, 10),
+            _BlobFile("tail.blob", 10, 10),
+        ]
+        for file in files:
+            bunch.add(file)
+
+        bunch.finish()
+
+        self.assertEqual(files, bunch.files())
+        self.assertEqual(20, bunch.row_count())
+
+    def test_add_rejects_file_after_finish(self):
+        bunch = BlobBunch(expected_row_count=1)
+        bunch.add(_BlobFile("first.blob", 0, 1))
+        bunch.finish()
+
+        with self.assertRaisesRegex(ValueError, "finished blob bunch"):
+            bunch.add(_BlobFile("second.blob", 1, 1))
+
+    def test_finish_can_recover_after_validation_failure(self):
+        bunch = BlobBunch(expected_row_count=10)
+        bunch.add(_BlobFile("first.blob", 0, 5))
+        with self.assertRaisesRegex(ValueError, "aligned with normal files"):
+            bunch.finish()
+
+        bunch.add(_BlobFile("second.blob", 5, 5))
+
+        bunch.finish()
+        self.assertEqual(10, bunch.row_count())
+
+    def test_finish_rejects_gap_without_row_id_push_down(self):
+        bunch = BlobBunch(expected_row_count=20)
+        bunch.add(_BlobFile("left.blob", 0, 5))
+        bunch.add(_BlobFile("right.blob", 10, 10))
+
+        with self.assertRaisesRegex(ValueError, "contiguous row range"):
+            bunch.finish()
+
+    def test_finish_rejects_unaligned_count_without_row_id_push_down(self):
+        bunch = BlobBunch(expected_row_count=20)
+        bunch.add(_BlobFile("short.blob", 0, 10))
+
+        with self.assertRaisesRegex(ValueError, "aligned with normal files"):
+            bunch.finish()
+
+    def test_finish_allows_gap_with_row_id_push_down(self):
+        bunch = BlobBunch(expected_row_count=20, row_id_push_down=True)
+        bunch.add(_BlobFile("left.blob", 0, 5))
+        bunch.add(_BlobFile("right.blob", 10, 10))
+
+        bunch.finish()
+
+        self.assertEqual(15, bunch.row_count())
+
+    def test_finish_rejects_row_count_exceeding_expected(self):
+        bunch = BlobBunch(expected_row_count=10, row_id_push_down=True)
+        bunch.add(_BlobFile("too-large.blob", 0, 11))
+
+        with self.assertRaisesRegex(ValueError, "row count exceed"):
+            bunch.finish()
+
+
+if __name__ == "__main__":
+    unittest.main()

Reply via email to