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 2cd3305b41 [python] Read BlobStore listings in bounded batches (#10044)
2cd3305b41 is described below

commit 2cd3305b4176a56bd16ce08cd41c23024c3feafc
Author: chaoyang <[email protected]>
AuthorDate: Mon Sep 21 15:04:21 2026 +0800

    [python] Read BlobStore listings in bounded batches (#10044)
---
 docs/docs/pypaimon/blob-store.md                   |  5 +-
 paimon-python/pypaimon/multimodal/blob_store.py    | 33 +++++++----
 .../pypaimon/tests/multimodal_table_test.py        | 67 ++++++++++++++++++++++
 3 files changed, 93 insertions(+), 12 deletions(-)

diff --git a/docs/docs/pypaimon/blob-store.md b/docs/docs/pypaimon/blob-store.md
index 2875bcc8dd..faeb717d2f 100644
--- a/docs/docs/pypaimon/blob-store.md
+++ b/docs/docs/pypaimon/blob-store.md
@@ -133,7 +133,10 @@ object stream. `get_object`, `head_object`, and 
`list_objects` expose non-key,
 non-BLOB table columns through `columns`. These columns are all returned by
 default. Pass `columns` to return only selected columns, or `[]` to skip them.
 `list_objects` requires a non-negative `limit`; `limit=0` returns an empty
-list.
+list. It reads object metadata in batches and stops after collecting `limit`
+matching objects. Prefix filtering can still require scanning nonmatching rows;
+object payloads are not fetched. Without a limit, the returned list holds all
+matching objects.
 
 ```python
 obj = store.get_object("images/cat.jpg", range="bytes=0-1023")
diff --git a/paimon-python/pypaimon/multimodal/blob_store.py 
b/paimon-python/pypaimon/multimodal/blob_store.py
index 1c4e25d851..bed1ff22a0 100644
--- a/paimon-python/pypaimon/multimodal/blob_store.py
+++ b/paimon-python/pypaimon/multimodal/blob_store.py
@@ -201,18 +201,29 @@ class BlobStore:
                 raise ValueError("limit must be greater than or equal to 0.")
             if limit == 0:
                 return []
-        rows = self._read_rows(
-            include_blob=True,
-            columns=columns,
-        )
+        read_table = 
self._raw_table.copy({CoreOptions.BLOB_AS_DESCRIPTOR.key(): "true"})
+        read_builder = read_table.new_read_builder().with_projection(
+            self._projection(True, columns))
+        # A prefix filters the exposed key's string representation, including
+        # non-string keys. Only push the limit when every row is a match.
+        if limit is not None and prefix is None:
+            read_builder = read_builder.with_limit(limit)
+        reader = read_builder.new_read()._to_managed_arrow_batch_reader(
+            read_builder.new_scan().plan().splits())
         objects = []
-        for row in rows:
-            key = row[self.key_column]
-            if prefix is not None and not str(key).startswith(prefix):
-                continue
-            objects.append(self._row_to_info(row))
-            if limit is not None and len(objects) >= limit:
-                break
+        try:
+            for batch in reader:
+                data = batch.to_pydict()
+                for values in zip(*data.values()):
+                    row = dict(zip(data, values))
+                    key = row[self.key_column]
+                    if prefix is not None and not str(key).startswith(prefix):
+                        continue
+                    objects.append(self._row_to_info(row))
+                    if limit is not None and len(objects) >= limit:
+                        return objects
+        finally:
+            reader.close()
         return objects
 
     def delete_object(self, key) -> None:
diff --git a/paimon-python/pypaimon/tests/multimodal_table_test.py 
b/paimon-python/pypaimon/tests/multimodal_table_test.py
index e4264ae914..51edc266dc 100644
--- a/paimon-python/pypaimon/tests/multimodal_table_test.py
+++ b/paimon-python/pypaimon/tests/multimodal_table_test.py
@@ -609,6 +609,73 @@ class MultimodalTableTest(unittest.TestCase):
         store.delete_object("images/cat.jpg")
         self.assertEqual([], store.list_objects(prefix="images/"))
 
+    def test_blob_store_list_reads_batches_and_stops_at_limit(self):
+        from pypaimon.read.table_read import TableRead
+
+        table = self.conn.create_table(
+            "list_batches", schema=_schema({
+                "key": pa.string(), "image": pa.large_binary(), "owner": 
pa.string(),
+            }), options=dict(_PARQUET_OPTIONS, **{"read.batch-size": "2"}))
+        table.add([
+            {"key": key, "image": b"body", "owner": "alice"}
+            for key in ("other/a", "other/b", "images/a", "images/b", 
"images/c", "images/d")
+        ])
+        store = table.blobs(column="image")
+        original = TableRead._arrow_batch_generator
+        batches, closed = [], []
+
+        def tracked_read(read, *args, **kwargs):
+            reader = original(read, *args, **kwargs)
+            try:
+                for batch in reader:
+                    batches.append(batch.num_rows)
+                    yield batch
+            finally:
+                reader.close()
+                closed.append(True)
+
+        with patch.object(TableRead, "to_arrow", 
side_effect=AssertionError("full read")), \
+                patch.object(TableRead, "_arrow_batch_generator", 
tracked_read):
+            self.assertEqual([], store.list_objects(limit=0))
+            self.assertEqual([], batches)
+            listed = store.list_objects(prefix="images/", limit=1, 
columns=["owner"])
+            self.assertEqual(["images/a"], [obj.key for obj in listed])
+            self.assertEqual({"owner": "alice"}, listed[0].columns)
+            self.assertEqual(4, listed[0].size)
+            self.assertEqual([2, 2], batches)
+            self.assertEqual([True], closed)
+
+            batches.clear()
+            listed = store.list_objects(limit=1, columns=[])
+            self.assertEqual(1, len(listed))
+            self.assertEqual({}, listed[0].columns)
+            self.assertEqual([1], batches)
+            self.assertEqual(2, len(closed))
+
+            with patch.object(store, "_row_to_info", 
side_effect=ValueError("invalid descriptor")):
+                try:
+                    store.list_objects()
+                except ValueError as error:
+                    self.assertEqual("invalid descriptor", str(error))
+                    # Check while the traceback still holds the reader's frame.
+                    self.assertEqual(3, len(closed))
+                else:
+                    self.fail("Expected invalid descriptor")
+            self.assertEqual([], store.list_objects(prefix="absent/"))
+            self.assertEqual(4, len(closed))
+
+    def test_blob_store_list_preserves_non_string_prefix_semantics(self):
+        table = self.conn.create_table(
+            "numeric_keys", schema=_schema({"key": pa.int32(), "image": 
pa.large_binary()}),
+            options=_PARQUET_OPTIONS)
+        table.add([
+            {"key": value, "image": b"body"} for value in (None, 10, 20, 21)
+        ])
+        store = table.blobs(column="image")
+        self.assertEqual([None], [obj.key for obj in 
store.list_objects(prefix="N", limit=1)])
+        self.assertEqual([20], [obj.key for obj in 
store.list_objects(prefix="2", limit=1)])
+        self.assertEqual(4, len(store.list_objects(prefix="")))
+
     def test_blob_store_put_object_accepts_blob_without_materializing(self):
         from pypaimon.table.row.blob import Blob, BlobDescriptor
 

Reply via email to