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 9a46844453 [python] Support concurrent blob reads for raw and Daft
APIs (#8406)
9a46844453 is described below
commit 9a4684445343c44f14bdf0a1fd0fbc5b0816c7cf
Author: XiaoHongbo <[email protected]>
AuthorDate: Thu Jul 2 18:15:15 2026 +0800
[python] Support concurrent blob reads for raw and Daft APIs (#8406)
Native daft File.open() reads blobs one at a time and rebuilds a client
per call while holding the GIL, so threads/async give no speedup. Add
pypaimon-backed alternatives that use a shared FileIO with concurrent
ranged reads (pread/read_at release the GIL during network I/O)
---
docs/docs/pypaimon/blob.md | 55 ++++----
docs/docs/pypaimon/daft.md | 71 +++++------
paimon-python/pypaimon/common/file_io.py | 114 +++++++++++++++++
paimon-python/pypaimon/daft/__init__.py | 4 +-
paimon-python/pypaimon/daft/daft_blob_read.py | 99 +++++++++++++++
.../read/reader/blob_descriptor_convert_reader.py | 16 ++-
.../pypaimon/read/reader/format_blob_reader.py | 23 +++-
paimon-python/pypaimon/read/split_read.py | 11 +-
paimon-python/pypaimon/read/table_read.py | 58 +++++++--
paimon-python/pypaimon/tests/blob_test.py | 139 ++++++++++++++++++++-
.../pypaimon/tests/daft/daft_blob_read_test.py | 133 ++++++++++++++++++++
.../pypaimon/tests/reader_parallel_test.py | 4 +-
12 files changed, 642 insertions(+), 85 deletions(-)
diff --git a/docs/docs/pypaimon/blob.md b/docs/docs/pypaimon/blob.md
index 9147a96873..ddb9c40be4 100644
--- a/docs/docs/pypaimon/blob.md
+++ b/docs/docs/pypaimon/blob.md
@@ -78,14 +78,32 @@ writer.close()
## Reading Blob Data
-Use `row.get_blob(pos)` to access blob columns. It returns a `Blob` object
-regardless of how the blob is stored.
+### Batch reading (recommended)
+
+Use `to_arrow_batch_reader` to read blob data in batches. Set
+`blob_parallelism` to enable concurrent blob reads within each batch:
```python
read_builder = table.new_read_builder()
splits = read_builder.new_scan().plan().splits()
read = read_builder.new_read()
+for batch in read.to_arrow_batch_reader(splits, blob_parallelism=16):
+ for i in range(len(batch)):
+ image_bytes = batch['image'][i].as_py()
+```
+
+Or read all data into a single Arrow Table:
+
+```python
+arrow_table = read.to_arrow(splits, blob_parallelism=16)
+```
+
+### Row-by-row reading
+
+Use `row.get_blob(pos)` to access blob columns one row at a time:
+
+```python
for row in read.to_iterator(splits):
blob = row.get_blob(2)
if blob is None:
@@ -93,38 +111,31 @@ for row in read.to_iterator(splits):
data = blob.to_data()
```
-## Streaming for Large Blobs
-
-`blob.new_input_stream()` returns a file-like object. Whether it is
-genuinely lazy depends on how the table is configured:
-
-- Default mode (`blob-as-descriptor=false`): the read path materialises
- the payload before it reaches `row.get_blob(pos)`. `Blob` is a
- `BlobData` and `new_input_stream()` wraps the in-memory bytes — not
- true streaming. For large blobs this can still OOM.
-- Descriptor mode (`blob-as-descriptor=true`): the read path preserves
- the descriptor. `Blob` is a `BlobRef` and `new_input_stream()` opens
- the underlying file on demand.
+### Streaming / partial reads
-This mirrors Java's `BlobFormatReader` semantics.
-
-For genuine on-demand streaming of large blobs (videos, model weights),
-use `table.copy` to set `blob-as-descriptor=true` before reading:
+For true on-demand streaming (large blobs like videos or model weights),
+set `blob-as-descriptor=true` so blob values are kept as lightweight
+references instead of being materialized into memory:
```python
-table = catalog.get_table('my_db.image_table')
table = table.copy({'blob-as-descriptor': 'true'})
read_builder = table.new_read_builder()
splits = read_builder.new_scan().plan().splits()
read = read_builder.new_read()
-# Reads now return BlobRef whose new_input_stream() is lazy.
for row in read.to_iterator(splits):
- with row.get_blob(2).new_input_stream() as stream:
- chunk = stream.read(1024)
+ blob = row.get_blob(2)
+ if blob is None:
+ continue
+ with blob.new_input_stream() as stream:
+ chunk = stream.read(4096)
```
+Without `blob-as-descriptor=true`, blob values are materialized before
+`row.get_blob(...)` returns; `new_input_stream()` then reads from
+in-memory bytes, not from storage.
+
## Lower-level: `Blob.from_bytes`
When you already have raw or descriptor bytes (for example from a custom
diff --git a/docs/docs/pypaimon/daft.md b/docs/docs/pypaimon/daft.md
index c553bc2fbd..0c2c118f0f 100644
--- a/docs/docs/pypaimon/daft.md
+++ b/docs/docs/pypaimon/daft.md
@@ -268,69 +268,58 @@ result = df.with_column("size", file_length(col("image")))
result.show()
```
-To read actual blob content (e.g., decode an image), use `file.open()`.
-Only filtered rows trigger I/O:
+### Reading blob content
-```python
[email protected]
-def decode_image(file: daft.File) -> str:
- with file.open() as f:
- data = f.read()
- return f"decoded {len(data)} bytes"
-
-result = df.with_column("info", decode_image(col("image")))
-result.show()
-```
-
-### Parallel blob reading
-
-By default, `@daft.func` processes rows sequentially. To read blobs
-concurrently within a single worker, use an async UDF with
-`max_concurrency` and `asyncio.to_thread` (because `file.open().read()`
-is synchronous blocking I/O):
+Use `read_blob` to read a blob `File` column to `binary` bytes. It reads blobs
+concurrently, so it is much faster than a per-row `file.open()` loop:
```python
-import asyncio
+from daft import col
+from pypaimon.daft import read_paimon, read_blob
-def _read_blob(file: daft.File | None) -> str | None:
- if file is None:
- return None
- with file.open() as f:
- data = f.read()
- return f"decoded {len(data)} bytes"
+catalog_options = {"warehouse": "oss://my-bucket/warehouse"}
+table = "my_db.image_table"
[email protected](max_concurrency=8)
-async def decode_image(file: daft.File | None) -> str | None:
- return await asyncio.to_thread(_read_blob, file)
+df = read_paimon(table, catalog_options)
+df = df.where(col("id") < 100) # filter before reading bytes
-result = df.with_column("info", decode_image(col("image")))
+result = df.select(
+ col("id"),
+ read_blob(col("image"), catalog_options, table).alias("image_bytes"),
+)
result.show()
```
-When running on Ray, the UDF calls are distributed across Ray workers
-automatically — each worker processes its partition in parallel, giving you
-batch-level concurrency without any extra code.
+Tune concurrency with `max_concurrency` (default 64).
-### Streaming (chunk) reads
+### Streaming / partial reads with `open_blob`
-`file.open()` returns a seekable stream that supports `read(size)`, so you can
-process large blobs in chunks without loading everything into memory:
+For large blobs (videos, model weights) where you don't want to load
+everything into memory, use `open_blob` to get a seekable stream:
```python
+import daft
+from daft import col
+from pypaimon.daft import read_paimon, open_blob
+
+catalog_options = {"warehouse": "oss://my-bucket/warehouse"}
+table = "my_db.image_table"
+
+df = read_paimon(table, catalog_options)
+
@daft.func(return_dtype=daft.DataType.binary())
def first_4k(file: daft.File) -> bytes | None:
if file is None:
return None
- with file.open() as f:
+ with open_blob(file, catalog_options, table) as f:
return f.read(4096)
result = df.with_column("header", first_4k(col("image")))
-result.show()
```
-See [Blob Storage — Streaming for Large
Blobs](./blob#streaming-for-large-blobs)
-for more details on the underlying `OffsetInputStream` API (`read(size)` /
-`seek()` / `tell()`).
+`open_blob` is a drop-in replacement for `file.open()` for streaming an
+individual large blob. It reads one blob per call, so for bulk reads of a
+whole column prefer `read_blob`.
## Catalog Abstraction
diff --git a/paimon-python/pypaimon/common/file_io.py
b/paimon-python/pypaimon/common/file_io.py
index 9f35140108..6978914f07 100644
--- a/paimon-python/pypaimon/common/file_io.py
+++ b/paimon-python/pypaimon/common/file_io.py
@@ -48,6 +48,37 @@ def pread(stream, length: int, offset: int) -> bytes:
return os.pread(stream.fileno(), length, offset)
+# Coalescing bounds: merge same-file ranges whose gap is within GAP, capping a
+# merged read at SPAN so threads stay busy and memory stays bounded.
+_COALESCE_GAP = 1 << 20
+_COALESCE_SPAN = 8 << 20
+
+
+def _coalesce_ranges(items, max_gap, max_span):
+ """Group ``(idx, path, offset, length)`` (length >= 0) into merged spans:
+ ``[(path, span_offset, span_length, [(idx, offset, length), ...])]``."""
+ from collections import defaultdict
+ by_path = defaultdict(list)
+ for it in items:
+ by_path[it[1]].append(it)
+ spans = []
+ for path, group in by_path.items():
+ group.sort(key=lambda x: x[2])
+ cur, start, end = [], None, None
+ for idx, _, off, length in group:
+ stop = off + length
+ if cur and off - end <= max_gap and stop - start <= max_span:
+ cur.append((idx, off, length))
+ end = max(end, stop)
+ else:
+ if cur:
+ spans.append((path, start, end - start, cur))
+ cur, start, end = [(idx, off, length)], off, stop
+ if cur:
+ spans.append((path, start, end - start, cur))
+ return spans
+
+
class FileIO(ABC):
"""
File IO interface to read and write files.
@@ -132,6 +163,89 @@ class FileIO(ABC):
else:
self.mkdirs(path)
+ def read_file_range(self, path, offset, length):
+ """Read a byte range. Thread-safe. ``length < 0`` = read to EOF (pread
+ can't express that, so seek + read)."""
+ stream = self.new_input_stream(path)
+ try:
+ if length >= 0 and supports_pread(stream):
+ return pread(stream, length, offset)
+ stream.seek(offset)
+ return stream.read() if length < 0 else stream.read(length)
+ finally:
+ stream.close()
+
+ def read_ranges_coalesced(self, ranges, parallelism,
+ max_gap=_COALESCE_GAP, max_span=_COALESCE_SPAN):
+ """Read ``ranges`` (each ``None`` or ``(path, offset, length)``),
returning
+ bytes in the same order. Same-file nearby ranges are merged into one
read
+ to cut round trips, then sliced; reads run on a thread pool. Negative
+ length (read to EOF) is read on its own, never merged.
+
+ A failed read propagates and aborts the whole batch (unlike a per-row
+ ``file.open()`` loop that fails one row at a time).
+ """
+ from concurrent.futures import ThreadPoolExecutor
+ # Threads write disjoint results[idx]; safe under the GIL (no list
resize).
+ results: List[Optional[bytes]] = [None] * len(ranges)
+ coalescible, singletons = [], []
+ for i, r in enumerate(ranges):
+ # None path/offset/length => null blob, leave result None.
+ if r is None or r[0] is None or r[1] is None or r[2] is None:
+ continue
+ path, offset, length = r
+ if length < 0: # unknown length => read to EOF, never coalesced
+ singletons.append((i, path, offset, length))
+ else:
+ coalescible.append((i, path, offset, length))
+
+ spans = _coalesce_ranges(coalescible, max_gap, max_span)
+
+ def _run(task):
+ kind, payload = task
+ if kind == "span":
+ path, span_off, span_len, members = payload
+ buf = self.read_file_range(path, span_off, span_len)
+ for idx, off, length in members:
+ s = off - span_off
+ results[idx] = buf[s:s + length]
+ else:
+ idx, path, off, length = payload
+ results[idx] = self.read_file_range(path, off, length)
+
+ tasks = [("span", s) for s in spans] + [("one", g) for g in singletons]
+ if not tasks:
+ return results
+ workers = max(1, min(parallelism, len(tasks)))
+ with ThreadPoolExecutor(workers) as pool:
+ list(pool.map(_run, tasks))
+ return results
+
+ def read_blobs_concurrent(self, blobs, parallelism):
+ """Read a list of Blobs concurrently, coalescing same-file ranged
reads.
+
+ ``BlobRef`` values expose a file range and are coalesced; in-memory
+ ``BlobData`` values are returned directly.
+ """
+ from pypaimon.table.row.blob import BlobRef
+ results: List[Optional[bytes]] = [None] * len(blobs)
+ ranges: List[Optional[tuple]] = [None] * len(blobs)
+ inmem = []
+ for i, b in enumerate(blobs):
+ if b is None:
+ continue
+ if isinstance(b, BlobRef):
+ d = b.to_descriptor()
+ ranges[i] = (d.uri, d.offset, d.length)
+ else:
+ inmem.append((i, b))
+ for i, v in enumerate(self.read_ranges_coalesced(ranges, parallelism)):
+ if v is not None:
+ results[i] = v
+ for idx, b in inmem:
+ results[idx] = b.to_data()
+ return results
+
def read_file_utf8(self, path: str) -> str:
with self.new_input_stream(path) as input_stream:
return input_stream.read().decode('utf-8')
diff --git a/paimon-python/pypaimon/daft/__init__.py
b/paimon-python/pypaimon/daft/__init__.py
index b854830173..5749a6d3e7 100644
--- a/paimon-python/pypaimon/daft/__init__.py
+++ b/paimon-python/pypaimon/daft/__init__.py
@@ -17,8 +17,10 @@
################################################################################
from pypaimon.daft.daft_paimon import explain_paimon_scan, read_paimon,
write_paimon
+from pypaimon.daft.daft_blob_read import open_blob, read_blob
-__all__ = ["explain_paimon_scan", "read_paimon", "write_paimon",
"PaimonCatalog", "PaimonTable"]
+__all__ = ["explain_paimon_scan", "read_paimon", "write_paimon", "read_blob",
+ "open_blob", "PaimonCatalog", "PaimonTable"]
def __getattr__(name):
diff --git a/paimon-python/pypaimon/daft/daft_blob_read.py
b/paimon-python/pypaimon/daft/daft_blob_read.py
new file mode 100644
index 0000000000..f27600b0fb
--- /dev/null
+++ b/paimon-python/pypaimon/daft/daft_blob_read.py
@@ -0,0 +1,99 @@
+################################################################################
+# 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.
+################################################################################
+
+"""Blob I/O helpers for the Daft integration."""
+
+from __future__ import annotations
+
+from functools import lru_cache
+from typing import BinaryIO, Dict
+
+
+@lru_cache(maxsize=16)
+def _get_file_io(catalog_key: tuple, table: str):
+ from pypaimon.catalog.catalog_factory import CatalogFactory
+ catalog_options = dict(catalog_key)
+ return CatalogFactory.create(catalog_options).get_table(table).file_io
+
+
+def _cache_key(catalog_options: Dict[str, str]) -> tuple:
+ return tuple(sorted(catalog_options.items()))
+
+
+def _file_range_fields():
+ from pypaimon.daft.daft_compat import file_range_position_field,
file_range_size_field
+ return file_range_position_field(), file_range_size_field()
+
+
+def _range_attr(file, name):
+ # Prefer _inner accessors: they read the embedded range; public File.size
+ # does a network stat (~200ms/blob).
+ inner = getattr(file, "_inner", None)
+ src = inner if inner is not None and hasattr(inner, name) else file
+ v = getattr(src, name)
+ return v() if callable(v) else v
+
+
+def _resolve_file_range(file, pos_field=None, size_field=None):
+ if pos_field is None:
+ pos_field, size_field = _file_range_fields()
+ return (_range_attr(file, "path"),
+ _range_attr(file, pos_field),
+ _range_attr(file, size_field))
+
+
+def open_blob(file, catalog_options: Dict[str, str], table: str) -> BinaryIO:
+ """Open a Daft ``File`` as a seekable stream via pypaimon's FileIO."""
+ from pypaimon.table.row.blob import OffsetInputStream
+ path, offset, length = _resolve_file_range(file)
+ fio = _get_file_io(_cache_key(catalog_options), table)
+ stream = fio.new_input_stream(path)
+ try:
+ return OffsetInputStream(stream, offset, length)
+ except Exception:
+ stream.close()
+ raise
+
+
+def read_blob(column, catalog_options: Dict[str, str], table: str, *,
max_concurrency: int = 64):
+ """Read a blob ``File`` column to ``binary`` bytes via concurrent ranged
reads."""
+ import daft
+ from daft import DataType
+
+ @daft.func.batch(return_dtype=DataType.binary())
+ def _read_blobs(files):
+ pos_field, size_field = _file_range_fields()
+ # Vectorized field extraction: per-File attr access (to_pylist +
+ # f.path/.position/.size) is GIL-bound and serializes the pool. daft
+ # File is an arrow ExtensionArray; .storage holds url/offset/size.
+ try:
+ struct = files.to_arrow()
+ struct = getattr(struct, "storage", struct)
+ ranges = list(zip(struct.field("url").to_pylist(),
+ struct.field(pos_field).to_pylist(),
+ struct.field(size_field).to_pylist()))
+ except (AttributeError, KeyError, TypeError, ValueError):
+ # Unexpected column layout: fall back to per-File resolution
(cheap).
+ ranges = [None if f is None else _resolve_file_range(f, pos_field,
size_field)
+ for f in files.to_pylist()]
+
+ fio = _get_file_io(_cache_key(catalog_options), table)
+ # Coalesce same-file adjacent reads to cut per-request round trips.
+ return fio.read_ranges_coalesced(ranges, max_concurrency)
+
+ return _read_blobs(column)
diff --git
a/paimon-python/pypaimon/read/reader/blob_descriptor_convert_reader.py
b/paimon-python/pypaimon/read/reader/blob_descriptor_convert_reader.py
index 12e975bae5..92b30ebbc4 100644
--- a/paimon-python/pypaimon/read/reader/blob_descriptor_convert_reader.py
+++ b/paimon-python/pypaimon/read/reader/blob_descriptor_convert_reader.py
@@ -41,7 +41,8 @@ class BlobInlineConvertReader(RecordBatchReader):
"""
def __init__(self, inner: RecordBatchReader, table,
- prescan_reader_factory: Optional[Callable[[Set[str]],
RecordBatchReader]] = None):
+ prescan_reader_factory: Optional[Callable[[Set[str]],
RecordBatchReader]] = None,
+ blob_parallelism: int = 1):
"""
Args:
inner: The main data reader (reads all columns).
@@ -50,10 +51,12 @@ class BlobInlineConvertReader(RecordBatchReader):
reader projecting only the specified field names. Used for
prescan to collect BlobViewStructs without reading all columns.
Signature: (field_names: Set[str]) -> RecordBatchReader
+ blob_parallelism: number of threads for concurrent blob reads.
"""
self._inner = inner
self._table = table
self._prescan_reader_factory = prescan_reader_factory
+ self._blob_parallelism = blob_parallelism
self.file_io = inner.file_io
self.blob_field_indices = inner.blob_field_indices
# Preserve original BlobViewStruct bytes when resolve disabled: skip
both
@@ -170,10 +173,13 @@ class BlobInlineConvertReader(RecordBatchReader):
if field_name not in batch.schema.names:
continue
values = [self._normalize_blob_to_bytes(v) for v in
batch.column(field_name).to_pylist()]
- converted_values = []
- for value in values:
- blob = Blob.from_bytes(value, self._table.file_io)
- converted_values.append(blob.to_data() if blob else None)
+ blobs = [Blob.from_bytes(v, self._table.file_io) for v in values]
+
+ if self._blob_parallelism > 1:
+ converted_values = self._table.file_io.read_blobs_concurrent(
+ blobs, self._blob_parallelism)
+ else:
+ converted_values = [b.to_data() if b else None for b in blobs]
column_idx = batch.schema.names.index(field_name)
batch = batch.set_column(
diff --git a/paimon-python/pypaimon/read/reader/format_blob_reader.py
b/paimon-python/pypaimon/read/reader/format_blob_reader.py
index 52f197097f..377d257062 100644
--- a/paimon-python/pypaimon/read/reader/format_blob_reader.py
+++ b/paimon-python/pypaimon/read/reader/format_blob_reader.py
@@ -37,12 +37,14 @@ class FormatBlobReader(RecordBatchReader):
def __init__(self, file_io: FileIO, file_path: str, read_fields: List[str],
full_fields: List[DataField], push_down_predicate: Any,
blob_as_descriptor: bool,
- batch_size: int = 1024, row_indices: Optional[Any] = None):
+ batch_size: int = 1024, row_indices: Optional[Any] = None,
+ blob_parallelism: int = 1):
self._file_io = file_io
self._file_path = file_path
self._push_down_predicate = push_down_predicate
self._blob_as_descriptor = blob_as_descriptor
self._batch_size = batch_size
+ self._blob_parallelism = blob_parallelism
# Initialize the low-level blob format reader
self.file_path = file_path
@@ -57,7 +59,9 @@ class FormatBlobReader(RecordBatchReader):
self._input_stream = file_io.new_input_stream(file_path)
self._read_index()
self._apply_row_indices(row_indices)
- if self._blob_as_descriptor:
+ # Drop the shared stream: descriptor/concurrent reads yield
BlobRefs
+ # that each open their own stream (one stream isn't thread-safe).
+ if self._blob_as_descriptor or self._blob_parallelism > 1:
self._input_stream.close()
self._input_stream = None
@@ -96,6 +100,7 @@ class FormatBlobReader(RecordBatchReader):
# Collect records for this batch
pydict_data = {name: [] for name in self._fields}
records_in_batch = 0
+ blobs_to_resolve = []
try:
while True:
@@ -112,6 +117,10 @@ class FormatBlobReader(RecordBatchReader):
)
elif self._blob_as_descriptor:
pydict_data[field_name].append(blob.to_descriptor().serialize())
+ elif self._blob_parallelism > 1:
+ idx = len(pydict_data[field_name])
+ pydict_data[field_name].append(None)
+ blobs_to_resolve.append((field_name, idx, blob))
else:
pydict_data[field_name].append(blob.to_data())
@@ -120,9 +129,11 @@ class FormatBlobReader(RecordBatchReader):
break
except StopIteration:
- # Stop immediately when StopIteration occurs
pass
+ if blobs_to_resolve:
+ self._resolve_blobs_concurrent(pydict_data, blobs_to_resolve)
+
if records_in_batch == 0:
return None
@@ -145,6 +156,12 @@ class FormatBlobReader(RecordBatchReader):
else:
return None
+ def _resolve_blobs_concurrent(self, pydict_data, blobs_to_resolve):
+ blobs = [item[2] for item in blobs_to_resolve]
+ results = self._file_io.read_blobs_concurrent(blobs,
self._blob_parallelism)
+ for (field_name, idx, _), data in zip(blobs_to_resolve, results):
+ pydict_data[field_name][idx] = data
+
def close(self):
self._blob_iterator = None
if self._input_stream is not None:
diff --git a/paimon-python/pypaimon/read/split_read.py
b/paimon-python/pypaimon/read/split_read.py
index fce5378956..dd9058c55f 100644
--- a/paimon-python/pypaimon/read/split_read.py
+++ b/paimon-python/pypaimon/read/split_read.py
@@ -124,6 +124,7 @@ class SplitRead(ABC):
self.value_arity = len(read_type)
self.nested_name_paths = nested_name_paths
self.limit = limit
+ self._blob_parallelism = 1
# Snapshot the raw value-side schema before _create_key_value_fields
# wraps it, so MergeFileSplitRead can hand per-value-field nullable
# flags to merge functions that enforce NOT-NULL on every add().
@@ -284,10 +285,12 @@ class SplitRead(ABC):
raise NotImplementedError(
"Nested-field projection is not supported on BLOB files")
blob_as_descriptor =
CoreOptions.blob_as_descriptor(self.table.options)
+ blob_parallelism = getattr(self, '_blob_parallelism', 1)
format_reader = FormatBlobReader(self.table.file_io, file_path,
read_file_fields,
self.read_fields,
read_arrow_predicate, blob_as_descriptor,
batch_size=batch_size,
- row_indices=row_indices)
+ row_indices=row_indices,
+ blob_parallelism=blob_parallelism)
elif file_format == CoreOptions.FILE_FORMAT_LANCE:
if has_nested:
raise NotImplementedError(
@@ -1003,9 +1006,11 @@ class DataEvolutionSplitRead(SplitRead):
self.table.options))
or (not CoreOptions.blob_as_descriptor(self.table.options)
and
CoreOptions.blob_descriptor_fields(self.table.options))):
+ blob_parallelism = getattr(self, '_blob_parallelism', 1)
reader = BlobInlineConvertReader(
reader, self.table,
- prescan_reader_factory=lambda names:
self._create_prescan_reader(names))
+ prescan_reader_factory=lambda names:
self._create_prescan_reader(names),
+ blob_parallelism=blob_parallelism)
return reader
@@ -1313,6 +1318,7 @@ class DataEvolutionSplitRead(SplitRead):
return None
file_path = file.external_path if file.external_path else
file.file_path
+ blob_parallelism = getattr(self, '_blob_parallelism', 1)
return FormatBlobReader(
self.table.file_io,
file_path,
@@ -1322,6 +1328,7 @@ class DataEvolutionSplitRead(SplitRead):
CoreOptions.blob_as_descriptor(self.table.options),
batch_size=self.table.options.read_batch_size(),
row_indices=row_indices,
+ blob_parallelism=blob_parallelism,
)
def _split_field_bunches(self, need_merge_files: List[DataFileMeta]) ->
List[FieldBunch]:
diff --git a/paimon-python/pypaimon/read/table_read.py
b/paimon-python/pypaimon/read/table_read.py
index 67159a69c7..b19fff109d 100644
--- a/paimon-python/pypaimon/read/table_read.py
+++ b/paimon-python/pypaimon/read/table_read.py
@@ -72,6 +72,11 @@ class _RemainingRows:
class TableRead:
"""Implementation of TableRead for native Python reading."""
+ # Cap on peak concurrent blob reads across the whole parallel read: split
+ # workers (P) each spin up blob_parallelism (B) blob threads, so peak
+ # connections ~= P*B. Shrink per-split B to keep the product bounded.
+ _MAX_TOTAL_BLOB_WORKERS = 64
+
def __init__(
self,
table,
@@ -120,11 +125,13 @@ class TableRead:
return _record_generator()
- def to_arrow_batch_reader(self, splits: List[Split]) ->
pyarrow.ipc.RecordBatchReader:
+ def to_arrow_batch_reader(self, splits: List[Split],
+ blob_parallelism: Optional[int] = None) ->
pyarrow.ipc.RecordBatchReader:
+ effective_bp = self._resolve_blob_parallelism(blob_parallelism)
schema = PyarrowFieldParser.from_paimon_schema(self.read_type)
if self.include_row_kind:
schema = self._add_row_kind_to_schema(schema)
- batch_iterator = self._arrow_batch_generator(splits, schema)
+ batch_iterator = self._arrow_batch_generator(splits, schema,
effective_bp)
return pyarrow.ipc.RecordBatchReader.from_batches(schema,
batch_iterator)
@staticmethod
@@ -154,6 +161,7 @@ class TableRead:
self,
splits: List[Split],
parallelism: Optional[int] = None,
+ blob_parallelism: Optional[int] = None,
) -> Optional[pyarrow.Table]:
"""Read ``splits`` into a single arrow ``Table``.
@@ -166,7 +174,16 @@ class TableRead:
``>= 2`` enables a thread pool that reads splits
concurrently and assembles the final table in input order.
Must be ``>= 1``.
+ blob_parallelism: number of threads for concurrent blob reads
+ within each batch. ``None`` or ``1`` (default) reads blobs
+ serially; ``>= 2`` uses a thread pool with ``pread`` for
+ concurrent ranged reads. GIL is released during I/O. On the
+ parallel path, peak blob threads (``parallelism`` *
+ ``blob_parallelism``) are capped at
+ ``_MAX_TOTAL_BLOB_WORKERS``; per-split ``blob_parallelism`` is
+ shrunk to stay within it.
"""
+ effective_bp = self._resolve_blob_parallelism(blob_parallelism)
# TODO: default read.parallelism to min(splits, cpu_count()) once
stable
effective = self._resolve_parallelism(parallelism)
schema = PyarrowFieldParser.from_paimon_schema(self.read_type)
@@ -174,9 +191,9 @@ class TableRead:
schema = self._add_row_kind_to_schema(schema)
if self._should_run_parallel(splits, effective):
- return self._to_arrow_parallel(splits, schema, effective)
+ return self._to_arrow_parallel(splits, schema, effective,
effective_bp)
- batch_reader = self.to_arrow_batch_reader(splits)
+ batch_reader = self.to_arrow_batch_reader(splits,
blob_parallelism=effective_bp)
table_list = []
for batch in iter(batch_reader.read_next_batch, None):
@@ -189,7 +206,8 @@ class TableRead:
else:
return pyarrow.Table.from_batches(table_list)
- def _arrow_batch_generator(self, splits: List[Split], schema:
pyarrow.Schema) -> Iterator[pyarrow.RecordBatch]:
+ def _arrow_batch_generator(self, splits: List[Split], schema:
pyarrow.Schema,
+ blob_parallelism: int = 1) ->
Iterator[pyarrow.RecordBatch]:
chunk_size = 65536
# ``remaining`` tracks how many rows we are still allowed to emit
# across all splits. ``None`` means unlimited.
@@ -198,7 +216,7 @@ class TableRead:
for split in splits:
if remaining is not None and remaining <= 0:
break
- reader = self._create_split_read(split).create_reader()
+ reader = self._create_split_read(split,
blob_parallelism).create_reader()
try:
if isinstance(reader, RecordBatchReader):
for batch in iter(reader.read_arrow_batch, None):
@@ -266,6 +284,21 @@ class TableRead:
raise ValueError(f"{source} must be >= 1, got {value}")
return value
+ @staticmethod
+ def _resolve_blob_parallelism(runtime: Optional[int]) -> int:
+ if runtime is None:
+ return 1
+ if runtime < 1:
+ raise ValueError(f"blob_parallelism must be >= 1, got {runtime}")
+ return runtime
+
+ @classmethod
+ def _cap_blob_parallelism(cls, workers: int, blob_parallelism: int) -> int:
+ """Shrink per-split blob_parallelism so workers*B <= cap (peak
reads)."""
+ if blob_parallelism <= 1 or workers * blob_parallelism <=
cls._MAX_TOTAL_BLOB_WORKERS:
+ return blob_parallelism
+ return max(1, cls._MAX_TOTAL_BLOB_WORKERS // workers)
+
def _should_run_parallel(
self,
splits: List[Split],
@@ -284,6 +317,7 @@ class TableRead:
splits: List[Split],
schema: pyarrow.Schema,
effective: int,
+ blob_parallelism: int = 1,
) -> pyarrow.Table:
"""Read ``splits`` concurrently and assemble the result in input order.
@@ -296,6 +330,7 @@ class TableRead:
remaining_state = _RemainingRows(self.limit)
results: List[Optional[List[pyarrow.RecordBatch]]] = [None] *
len(splits)
workers = min(effective, len(splits))
+ blob_parallelism = self._cap_blob_parallelism(workers,
blob_parallelism)
with ThreadPoolExecutor(
max_workers=workers,
thread_name_prefix="pypaimon-read",
@@ -306,6 +341,7 @@ class TableRead:
split,
schema,
remaining_state,
+ blob_parallelism,
): idx
for idx, split in enumerate(splits)
}
@@ -333,6 +369,7 @@ class TableRead:
split: Split,
schema: pyarrow.Schema,
remaining_state: _RemainingRows,
+ blob_parallelism: int = 1,
) -> List[pyarrow.RecordBatch]:
"""Read a single split into arrow batches under soft-stop control.
@@ -342,7 +379,7 @@ class TableRead:
"""
chunk_size = 65536
out: List[pyarrow.RecordBatch] = []
- reader = self._create_split_read(split).create_reader()
+ reader = self._create_split_read(split,
blob_parallelism).create_reader()
try:
if isinstance(reader, RecordBatchReader):
for batch in iter(reader.read_arrow_batch, None):
@@ -583,7 +620,12 @@ class TableRead:
dataset = TorchDataset(self, splits)
return dataset
- def _create_split_read(self, split: Split) -> SplitRead:
+ def _create_split_read(self, split: Split, blob_parallelism: int = 1) ->
SplitRead:
+ sr = self._build_split_read(split)
+ sr._blob_parallelism = blob_parallelism
+ return sr
+
+ def _build_split_read(self, split: Split) -> SplitRead:
if self.table.is_primary_key_table and not split.raw_convertible:
inner_read_type = self.read_type
outer_extract_name_paths: Optional[List[List[str]]] = None
diff --git a/paimon-python/pypaimon/tests/blob_test.py
b/paimon-python/pypaimon/tests/blob_test.py
index 37217f8b7c..ac31c399f3 100644
--- a/paimon-python/pypaimon/tests/blob_test.py
+++ b/paimon-python/pypaimon/tests/blob_test.py
@@ -25,7 +25,7 @@ from pathlib import Path
import pyarrow as pa
-from pypaimon import CatalogFactory
+from pypaimon import CatalogFactory, Schema
from pypaimon.common.file_io import FileIO
from pypaimon.filesystem.local_file_io import LocalFileIO
from pypaimon.common.options import Options
@@ -1489,6 +1489,143 @@ class BlobEndToEndTest(unittest.TestCase):
reader.close()
+class BlobParallelismTest(unittest.TestCase):
+
+ def setUp(self):
+ self.temp_dir = tempfile.mkdtemp()
+ self.catalog = CatalogFactory.create({'warehouse':
os.path.join(self.temp_dir, 'wh')})
+ self.catalog.create_database('default', True)
+ pa_schema = pa.schema([('id', pa.int32()), ('img', pa.large_binary())])
+ self.catalog.create_table('default.bp_test',
Schema.from_pyarrow_schema(
+ pa_schema, options={'row-tracking.enabled': 'true',
'data-evolution.enabled': 'true'}), False)
+ self.payloads = [os.urandom(512) for _ in range(20)]
+ t = self.catalog.get_table('default.bp_test')
+ w = t.new_batch_write_builder().new_write()
+ w.write_arrow(pa.Table.from_pydict(
+ {'id': list(range(20)), 'img': self.payloads}, schema=pa_schema))
+ t.new_batch_write_builder().new_commit().commit(w.prepare_commit())
+ w.close()
+
+ def tearDown(self):
+ shutil.rmtree(self.temp_dir, ignore_errors=True)
+
+ def test_to_arrow_blob_parallelism(self):
+ t = self.catalog.get_table('default.bp_test')
+ rb = t.new_read_builder()
+ splits = rb.new_scan().plan().splits()
+ serial = rb.new_read().to_arrow(splits)
+ parallel = rb.new_read().to_arrow(splits, blob_parallelism=4)
+ self.assertEqual(serial.num_rows, parallel.num_rows)
+ for i in range(serial.num_rows):
+ self.assertEqual(serial['img'][i].as_py(),
parallel['img'][i].as_py())
+
+ def test_to_arrow_batch_reader_blob_parallelism(self):
+ t = self.catalog.get_table('default.bp_test')
+ rb = t.new_read_builder()
+ splits = rb.new_scan().plan().splits()
+ serial = rb.new_read().to_arrow(splits)
+ batches = []
+ for batch in rb.new_read().to_arrow_batch_reader(splits,
blob_parallelism=4):
+ batches.append(batch)
+ parallel = pa.Table.from_batches(batches)
+ self.assertEqual(serial.num_rows, parallel.num_rows)
+ for i in range(serial.num_rows):
+ self.assertEqual(serial['img'][i].as_py(),
parallel['img'][i].as_py())
+
+ def test_blob_parallelism_with_projection(self):
+ t = self.catalog.get_table('default.bp_test')
+ rb = t.new_read_builder()
+ rb = rb.with_projection(['id', 'img'])
+ splits = rb.new_scan().plan().splits()
+ result = rb.new_read().to_arrow(splits, blob_parallelism=4)
+ self.assertEqual(result.column_names, ['id', 'img'])
+ got = dict(zip(result['id'].to_pylist(), result['img'].to_pylist()))
+ for i in range(20):
+ self.assertEqual(got[i], self.payloads[i])
+
+
+class CapBlobParallelismTest(unittest.TestCase):
+ """Peak blob threads on the parallel path (workers * blob_parallelism)
+ must stay within TableRead._MAX_TOTAL_BLOB_WORKERS."""
+
+ def test_cap(self):
+ from pypaimon.read.table_read import TableRead
+ cap = TableRead._MAX_TOTAL_BLOB_WORKERS
+ f = TableRead._cap_blob_parallelism
+ self.assertEqual(f(1, 1), 1) # serial blobs, untouched
+ self.assertEqual(f(16, 1), 1) # B<=1 untouched
+ self.assertEqual(f(4, 8), 8) # 32 <= cap, untouched
+ self.assertEqual(f(16, 16), cap // 16) # 256 -> shrink to cap/workers
+ self.assertEqual(f(cap, 2), 1) # workers==cap -> 1
+ self.assertEqual(f(cap + 100, 2), 1) # workers>cap -> floor to 1
+ for w in (2, 4, 8, 16, 32, 64):
+ self.assertLessEqual(w * f(w, 999), cap)
+
+
+class CoalesceRangesTest(unittest.TestCase):
+ """read_ranges_coalesced merges same-file adjacent reads into fewer
requests
+ (JingsongLi's IO-merging suggestion) while returning identical bytes."""
+
+ def test_coalesce_ranges_grouping(self):
+ from pypaimon.common.file_io import _coalesce_ranges
+ items = [(0, "a", 0, 10), (1, "a", 10, 10), (2, "a", 1000, 10), (3,
"b", 0, 5)]
+ # a:[0,20) merged, a:[1000,1010) split by gap, b:[0,5) separate file
+ spans = _coalesce_ranges(items, max_gap=100, max_span=1 << 30)
+ self.assertEqual(len(spans), 3)
+ self.assertEqual(sorted(i for _, _, _, mem in spans for i, _, _ in
mem), [0, 1, 2, 3])
+ # max_span forces a split even when contiguous
+ self.assertEqual(len(_coalesce_ranges(
+ [(0, "a", 0, 10), (1, "a", 10, 10)], max_gap=100, max_span=15)), 2)
+
+ def test_read_ranges_coalesced(self):
+ from pypaimon.common.file_io import FileIO
+ data = bytes(range(256)) * 4 # 1024 bytes
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ path = os.path.join(tmp_dir, "f.bin")
+ with open(path, 'wb') as f:
+ f.write(data)
+ fio = FileIO.get(f"file://{tmp_dir}", {})
+ ranges = [(path, 0, 10), (path, 10, 10), None, (path, 500, 20),
+ (path, 100, -1), (path, None, None)]
+ got = fio.read_ranges_coalesced(ranges, parallelism=4)
+ self.assertEqual(got[0], data[0:10])
+ self.assertEqual(got[1], data[10:20]) # contiguous with got[0],
merged
+ self.assertIsNone(got[2])
+ self.assertEqual(got[3], data[500:520])
+ self.assertEqual(got[4], data[100:]) # length -1 => read to EOF
+ self.assertIsNone(got[5]) # None offset/length =>
skipped
+
+
+class ReadFileRangeTest(unittest.TestCase):
+ """read_file_range must accept length == -1 (read to EOF) -- the valid
+ unknown-length BlobDescriptor case -- not pass -1 into pread."""
+
+ def test_negative_length_reads_to_eof(self):
+ from pypaimon.common.file_io import FileIO
+ data = bytes(range(64))
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ path = os.path.join(tmp_dir, "blob.bin")
+ with open(path, 'wb') as f:
+ f.write(data)
+ file_io = FileIO.get(f"file://{tmp_dir}", {})
+ # -1: offset to EOF; positive: exact range (pread)
+ self.assertEqual(file_io.read_file_range(path, 0, -1), data)
+ self.assertEqual(file_io.read_file_range(path, 10, -1), data[10:])
+ self.assertEqual(file_io.read_file_range(path, 10, 8), data[10:18])
+
+ def test_descriptor_negative_length_roundtrip(self):
+ from pypaimon.common.file_io import FileIO
+ data = b"actual blob content"
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ path = os.path.join(tmp_dir, "blob.bin")
+ with open(path, 'wb') as f:
+ f.write(data)
+ file_io = FileIO.get(f"file://{tmp_dir}", {})
+ blob = Blob.from_bytes(BlobDescriptor(path, 0, -1).serialize(),
file_io)
+ self.assertIsInstance(blob, BlobRef)
+ self.assertEqual(blob.to_data(), data)
+
+
class OffsetInputStreamTest(unittest.TestCase):
def setUp(self):
diff --git a/paimon-python/pypaimon/tests/daft/daft_blob_read_test.py
b/paimon-python/pypaimon/tests/daft/daft_blob_read_test.py
new file mode 100644
index 0000000000..59c2a37b10
--- /dev/null
+++ b/paimon-python/pypaimon/tests/daft/daft_blob_read_test.py
@@ -0,0 +1,133 @@
+################################################################################
+# 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 pyarrow as pa
+import pytest
+
+pypaimon = pytest.importorskip("pypaimon")
+daft = pytest.importorskip("daft")
+
+from daft import col
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.daft import open_blob, read_blob, read_paimon
+from pypaimon.daft.daft_compat import has_file_range_reads
+
+
[email protected](has_file_range_reads(), "installed Daft lacks File range
reads")
+class DaftReadBlobTest(unittest.TestCase):
+ """read_blob reads a blob File column to bytes via pypaimon's FileIO and
must
+ return exactly the same bytes as the native per-row File.open() path."""
+
+ N = 40
+
+ def setUp(self):
+ self.tempdir = tempfile.mkdtemp()
+ self.catalog_options = {"warehouse": os.path.join(self.tempdir, "wh")}
+ self.catalog = CatalogFactory.create(self.catalog_options)
+ self.catalog.create_database("default", True)
+ self.table = "default.blob_read_test"
+ pa_schema = pa.schema([("id", pa.int32()), ("content",
pa.large_binary())])
+ self.catalog.create_table(self.table, Schema.from_pyarrow_schema(
+ pa_schema, options={"row-tracking.enabled": "true",
"data-evolution.enabled": "true"}), False)
+ self.payloads = [bytes([i & 0xFF]) + os.urandom(1023) for i in
range(self.N)]
+ t = self.catalog.get_table(self.table)
+ w = t.new_batch_write_builder().new_write()
+ w.write_arrow(pa.Table.from_pydict(
+ {"id": list(range(self.N)), "content": self.payloads},
schema=pa_schema))
+ t.new_batch_write_builder().new_commit().commit(w.prepare_commit())
+ w.close()
+
+ def tearDown(self):
+ shutil.rmtree(self.tempdir, ignore_errors=True)
+
+ def test_read_blob_matches_file_open(self):
+ df = read_paimon(self.table, self.catalog_options)
+ # content is exposed as a lazy File (default read path unchanged)
+ self.assertEqual(str(df.schema()["content"].dtype), "File[Unknown]")
+
+ # new path: read_blob -> bytes
+ out = df.select(col("id"), read_blob(col("content"),
self.catalog_options, self.table)
+ .alias("img")).collect().to_pydict()
+ got = dict(zip(out["id"], out["img"]))
+ for i in range(self.N):
+ self.assertEqual(got[i], self.payloads[i])
+
+ # original path still works and yields identical bytes
+ for r in read_paimon(self.table,
self.catalog_options).select(col("id"), col("content")).to_pylist():
+ with r["content"].open() as h:
+ self.assertEqual(h.read(), self.payloads[r["id"]])
+
+ def test_open_blob_full_read(self):
+ rows = read_paimon(self.table, self.catalog_options).select(
+ col("id"), col("content")).to_pylist()
+ for r in rows:
+ with open_blob(r["content"], self.catalog_options, self.table) as
f:
+ self.assertEqual(f.read(), self.payloads[r["id"]])
+
+ def test_open_blob_partial_read(self):
+ rows = read_paimon(self.table, self.catalog_options).select(
+ col("id"), col("content")).to_pylist()
+ for r in rows:
+ with open_blob(r["content"], self.catalog_options, self.table) as
f:
+ header = f.read(16)
+ self.assertEqual(header, self.payloads[r["id"]][:16])
+
+ def test_open_blob_seek(self):
+ r = read_paimon(self.table, self.catalog_options).select(
+ col("id"), col("content")).to_pylist()[0]
+ with open_blob(r["content"], self.catalog_options, self.table) as f:
+ f.seek(100)
+ self.assertEqual(f.tell(), 100)
+ chunk = f.read(50)
+ self.assertEqual(chunk, self.payloads[r["id"]][100:150])
+
+ def test_no_file_size_stat(self):
+ """File.size() does a network stat; read_blob and open_blob must
resolve
+ ranges from embedded metadata (file._inner) and never call it."""
+ rows = read_paimon(self.table, self.catalog_options).select(
+ col("id"), col("content")).to_pylist()
+ file_cls = type(rows[0]["content"])
+ calls = {"n": 0}
+ orig = file_cls.size
+
+ def boom(self, *a, **k):
+ calls["n"] += 1
+ raise AssertionError("File.size() should not be called")
+
+ file_cls.size = boom
+ try:
+ with open_blob(rows[0]["content"], self.catalog_options,
self.table) as f:
+ self.assertEqual(f.read(), self.payloads[rows[0]["id"]])
+ out = read_paimon(self.table, self.catalog_options).select(
+ col("id"), read_blob(col("content"), self.catalog_options,
self.table).alias("img")
+ ).collect().to_pydict()
+ got = dict(zip(out["id"], out["img"]))
+ for i in range(self.N):
+ self.assertEqual(got[i], self.payloads[i])
+ finally:
+ file_cls.size = orig
+ self.assertEqual(calls["n"], 0)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/paimon-python/pypaimon/tests/reader_parallel_test.py
b/paimon-python/pypaimon/tests/reader_parallel_test.py
index 692d1c5b48..adad137341 100644
--- a/paimon-python/pypaimon/tests/reader_parallel_test.py
+++ b/paimon-python/pypaimon/tests/reader_parallel_test.py
@@ -282,13 +282,13 @@ class ParallelReaderAppendOnlyTest(unittest.TestCase):
call_counter = {'n': 0}
lock = threading.Lock()
- def flaky(self_, split):
+ def flaky(self_, split, blob_parallelism=1):
with lock:
call_counter['n'] += 1
idx = call_counter['n']
if idx == 2:
raise RuntimeError("simulated reader failure")
- return original_create(self_, split)
+ return original_create(self_, split, blob_parallelism)
with mock.patch.object(TableRead, '_create_split_read', flaky):
read = rb.new_read()