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 865b2c96c2 [python][ray] Integrate Ray for distributed multimodal 
table reads (#8455)
865b2c96c2 is described below

commit 865b2c96c2f794e1a743d8a417caf2a654cfb17d
Author: XiaoHongbo <[email protected]>
AuthorDate: Sat Jul 4 21:10:35 2026 +0800

    [python][ray] Integrate Ray for distributed multimodal table reads (#8455)
    
    Multimodal tables define a very simple API for reading data, but it only
    runs in local mode. This PR adds two small
      Ray APIs to distribute multimodal table reads:
    
    - **`scan().to_ray()`** — read filtered rows as a `ray.data.Dataset`;
    BLOB columns come back as lightweight descriptors, not payloads.
    - **`pypaimon.ray.map_blobs(ds, blob_cols, fn, parallelism=,
    batch_size=)`** — fetch BLOB bytes on the workers and hand
    `(scalar_batch, blobs)` to `fn`, so payloads are consumed in-place,
    never materialised on the driver.
    
      ```python
      ds = table.scan().where(...).select(cols).to_ray(concurrency=8)
    result = map_blobs(ds, blob_cols, process, parallelism=16,
    batch_size=512)
      ```
---
 docs/docs/pypaimon/multimodal-api.mdx              |  48 ++++++
 paimon-python/pypaimon/multimodal/blob_read.py     |  61 +++++++
 paimon-python/pypaimon/multimodal/query.py         | 104 ++++++++----
 paimon-python/pypaimon/multimodal/table.py         |  19 +++
 paimon-python/pypaimon/ray/__init__.py             |   3 +-
 paimon-python/pypaimon/ray/ray_paimon.py           | 144 ++++++++++++++++-
 .../pypaimon/read/datasource/ray_datasource.py     |   1 +
 .../pypaimon/tests/multimodal_table_test.py        | 180 +++++++++++++++++++++
 8 files changed, 526 insertions(+), 34 deletions(-)

diff --git a/docs/docs/pypaimon/multimodal-api.mdx 
b/docs/docs/pypaimon/multimodal-api.mdx
index b2b63ab19f..72cf84f48c 100644
--- a/docs/docs/pypaimon/multimodal-api.mdx
+++ b/docs/docs/pypaimon/multimodal-api.mdx
@@ -383,6 +383,54 @@ Notes:
   few large reads); scattered point reads coalesce less.
 - Blob reads are available only on `scan()`, not on the `search()` queries.
 
+### Distributed BLOB processing with Ray
+
+For larger jobs, read descriptors with `to_ray()`, then fetch and process BLOB
+bytes on Ray workers with `map_with_blobs`.
+
+```python
+import ray
+import pyarrow as pa
+import pypaimon.multimodal as pm
+
+ray.init(num_cpus=8)
+
+conn = pm.connect(database="default", options={"warehouse": 
"file:///tmp/warehouse"})
+docs = conn.get_table("docs")
+
+ids = ["a", "b", "c"]
+scalar_cols = ["id", "category"]
+blob_cols = ["image"]
+in_clause = ", ".join(f"'{i}'" for i in ids)
+
+
+def process_batch(scalar_batch, blobs):
+    images = blobs["image"]
+    # Decode, infer, write samples, or train here.
+    return pa.table({"rows": [scalar_batch.num_rows]})
+
+
+ds = (
+    docs.scan()
+    .where(f"id IN ({in_clause})")
+    .select(scalar_cols + blob_cols)
+    .to_ray()
+)
+
+# Optional Ray transforms are fine if BLOB descriptor columns remain.
+# ds = ds.filter(lambda row: row["category"] == "lake")
+
+result_ds = docs.map_with_blobs(ds, blob_cols, process_batch)
+```
+
+Notes:
+
+- `process_batch` must return a small Ray-compatible batch; return an empty
+  `pyarrow.Table` for side-effect-only jobs.
+- Avoid returning raw BLOB bytes, which would materialize payloads in Ray's
+  object store.
+- Tune `to_ray(...)` and `map_with_blobs(...)` parameters only when needed.
+
 ## Row IDs
 
 Multimodal tables enable `row-tracking.enabled` by default, so each row has a
diff --git a/paimon-python/pypaimon/multimodal/blob_read.py 
b/paimon-python/pypaimon/multimodal/blob_read.py
new file mode 100644
index 0000000000..2db4621b19
--- /dev/null
+++ b/paimon-python/pypaimon/multimodal/blob_read.py
@@ -0,0 +1,61 @@
+# 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.
+
+"""Shared helpers for materialising multimodal BLOB descriptor columns."""
+
+
+def fetch_blob_bodies(file_io, data, blob_cols, parallelism):
+    """Fetch BLOB payload bytes for descriptor/inline/null cells.
+
+    ``data`` is a ``dict`` mapping each BLOB column name to row-aligned cells.
+    Each cell may be serialized ``BlobDescriptor`` bytes, inline payload bytes,
+    or ``None``. Returned values preserve row order and are grouped per column.
+    """
+    from pypaimon.table.row.blob import BlobDescriptor, BlobViewStruct
+
+    ranges = []
+    inline = {}
+    index = 0
+    for col in blob_cols:
+        for value in data[col]:
+            if value is None:
+                ranges.append(None)
+            else:
+                raw = bytes(value)
+                if BlobViewStruct.is_blob_view_struct(raw):
+                    raise ValueError(
+                        "read_blobs does not support unresolved blob-view 
columns; "
+                        "read such a column on its own, or enable blob-view 
resolution.")
+                if BlobDescriptor.is_blob_descriptor(raw):
+                    descriptor = BlobDescriptor.deserialize(raw)
+                    ranges.append((descriptor.uri, descriptor.offset, 
descriptor.length))
+                else:
+                    ranges.append(None)
+                    inline[index] = raw
+            index += 1
+
+    fetched = file_io.read_ranges_coalesced(ranges, parallelism)
+    for index, raw in inline.items():
+        fetched[index] = raw
+
+    bodies = {}
+    offset = 0
+    for col in blob_cols:
+        count = len(data[col])
+        bodies[col] = fetched[offset:offset + count]
+        offset += count
+    return bodies
diff --git a/paimon-python/pypaimon/multimodal/query.py 
b/paimon-python/pypaimon/multimodal/query.py
index 0ea45b7df5..e164f419fa 100644
--- a/paimon-python/pypaimon/multimodal/query.py
+++ b/paimon-python/pypaimon/multimodal/query.py
@@ -15,14 +15,19 @@
 # specific language governing permissions and limitations
 # under the License.
 
-from typing import Callable, Dict, List, Optional, Tuple
+from typing import Any, Callable, Dict, List, Optional, Tuple
 
 import pyarrow as pa
 
 from pypaimon.common.where_parser import parse_where_clause
+from pypaimon.multimodal.blob_read import fetch_blob_bodies
 from pypaimon.table.special_fields import SpecialFields
 
 
+def _select_arrow_columns(batch, columns):
+    return batch.select(columns)
+
+
 class ScanQuery:
     """Chainable scan wrapper for MultimodalTable."""
 
@@ -100,6 +105,38 @@ class ScanQuery:
     def to_list(self) -> List[dict]:
         return self.to_arrow().to_pylist()
 
+    def to_ray(
+            self,
+            *,
+            ray_remote_args: Optional[Dict[str, Any]] = None,
+            concurrency: Optional[int] = None,
+            override_num_blocks: Optional[int] = None,
+            **read_args):
+        """Read this scan as a Ray Dataset.
+
+        BLOB columns are read as serialized descriptors. Use
+        ``table.map_with_blobs(...)`` to fetch payload bytes on Ray workers.
+        """
+        if self._result_factory is not None:
+            raise TypeError("to_ray is only supported on scan(), not search 
queries.")
+
+        read_builder, file_io, visible_columns = 
self._blob_descriptor_query_read_builder()
+        plan = read_builder.new_scan().plan()
+        ds = read_builder.new_read().to_ray(
+            plan.splits(),
+            ray_remote_args=ray_remote_args,
+            concurrency=concurrency,
+            override_num_blocks=override_num_blocks,
+            **read_args)
+        if visible_columns is not None:
+            ds = ds.map_batches(
+                _select_arrow_columns,
+                fn_kwargs={"columns": visible_columns},
+                batch_format="pyarrow")
+        setattr(ds, "_paimon_blob_file_io", file_io)
+        setattr(ds, "_paimon_blob_columns", self._all_blob_columns())
+        return ds
+
     def read_blobs(
             self, columns=None, *, parallelism: int = 64
     ) -> Tuple[pa.Table, Dict[str, List[Optional[bytes]]]]:
@@ -146,6 +183,36 @@ class ScanQuery:
             if hasattr(reader, "close"):
                 reader.close()
 
+    def _blob_descriptor_query_read_builder(self):
+        from pypaimon.common.options.core_options import CoreOptions
+        read_table = self._table.copy({
+            CoreOptions.BLOB_AS_DESCRIPTOR.key(): "true"
+        })
+        read_builder = read_table.new_read_builder()
+        if self._predicate is not None:
+            read_builder = read_builder.with_filter(self._predicate)
+        projection = self._effective_projection()
+        if projection is not None:
+            internal_projection = list(projection)
+            for name in self._predicate_fields():
+                if name not in internal_projection:
+                    internal_projection.append(name)
+            read_builder = read_builder.with_projection(internal_projection)
+        if self._limit is not None:
+            read_builder = read_builder.with_limit(self._limit)
+        if projection is None:
+            return read_builder, read_table.file_io, None
+        internal_column_names = [field.name for field in 
read_builder.read_type()]
+        visible_columns = self._projected_output_columns(read_table, 
projection)
+        if visible_columns == internal_column_names:
+            visible_columns = None
+        return read_builder, read_table.file_io, visible_columns
+
+    @staticmethod
+    def _projected_output_columns(table, projection):
+        builder = table.new_read_builder().with_projection(projection)
+        return [field.name for field in builder.read_type()]
+
     def _blob_descriptor_read_builder(self, blob_cols: List[str]):
         """Blob-as-descriptor read builder with this query's 
filter/projection/limit;
         returns ``(read_builder, file_io)``."""
@@ -172,37 +239,7 @@ class ScanQuery:
         # ``FileIO.get(uri, catalog_options)`` off the raw options (no merged 
token),
         # failing with "endpoint should be non-empty" / "Init credential 
failed" unless
         # the caller also passes fs.oss.* -- which users should not have to.
-        from pypaimon.table.row.blob import BlobDescriptor, BlobViewStruct
-        ranges = []
-        inline = {}  # cell index -> blob stored inline (returned as-is, no 
ranged read)
-        i = 0
-        for col in blob_cols:
-            for value in data[col]:
-                if value is None:
-                    ranges.append(None)
-                else:
-                    raw = bytes(value)
-                    if BlobViewStruct.is_blob_view_struct(raw):
-                        raise ValueError(
-                            "read_blobs does not support unresolved blob-view 
columns; "
-                            "read such a column on its own, or enable 
blob-view resolution.")
-                    if BlobDescriptor.is_blob_descriptor(raw):
-                        d = BlobDescriptor.deserialize(raw)
-                        ranges.append((d.uri, d.offset, d.length))
-                    else:  # blob stored inline: the bytes are the payload
-                        ranges.append(None)
-                        inline[i] = raw
-                i += 1
-        fetched = file_io.read_ranges_coalesced(ranges, parallelism)
-        for idx, raw in inline.items():
-            fetched[idx] = raw
-        bodies = {}
-        offset = 0
-        for col in blob_cols:
-            n = len(data[col])
-            bodies[col] = fetched[offset:offset + n]
-            offset += n
-        return bodies
+        return fetch_blob_bodies(file_io, data, blob_cols, parallelism)
 
     def _all_blob_columns(self) -> List[str]:
         return [
@@ -300,6 +337,9 @@ class _PreFilterQuery(ScanQuery):
     def stream_blobs(self, *args, **kwargs):
         raise TypeError("stream_blobs is only supported on scan(), not search 
queries.")
 
+    def to_ray(self, *args, **kwargs):
+        raise TypeError("to_ray is only supported on scan(), not search 
queries.")
+
 
 class VectorQuery(_PreFilterQuery):
     """Chainable query wrapper for vector global-index search."""
diff --git a/paimon-python/pypaimon/multimodal/table.py 
b/paimon-python/pypaimon/multimodal/table.py
index a00f8d1156..716d6ef621 100644
--- a/paimon-python/pypaimon/multimodal/table.py
+++ b/paimon-python/pypaimon/multimodal/table.py
@@ -157,6 +157,18 @@ class MultimodalTable:
     def merge(self, on):
         return _MergeBuilder(self, on)
 
+    def map_with_blobs(self, dataset, columns, fn, **kwargs):
+        from pypaimon.ray import map_with_blobs
+
+        return map_with_blobs(
+            dataset,
+            columns,
+            fn,
+            file_io=self.raw_table.file_io,
+            all_blob_columns=_blob_columns(self.raw_table),
+            **kwargs,
+        )
+
     def scan(
             self,
             *,
@@ -357,6 +369,13 @@ class _MergeBuilder:
         )
 
 
+def _blob_columns(table):
+    return tuple(
+        field.name for field in table.fields
+        if getattr(field.type, "type", None) == "BLOB"
+    )
+
+
 def _to_arrow_table(data, target_schema=None):
     if isinstance(data, pa.Table):
         table = data
diff --git a/paimon-python/pypaimon/ray/__init__.py 
b/paimon-python/pypaimon/ray/__init__.py
index f2daad3a69..52b4575b5b 100644
--- a/paimon-python/pypaimon/ray/__init__.py
+++ b/paimon-python/pypaimon/ray/__init__.py
@@ -15,7 +15,7 @@
 # specific language governing permissions and limitations
 # under the License.
 
-from pypaimon.ray.ray_paimon import read_paimon, write_paimon
+from pypaimon.ray.ray_paimon import map_with_blobs, read_paimon, write_paimon
 from pypaimon.ray.bucket_join import bucket_join
 from pypaimon.ray.data_evolution_merge_into import (
     WhenMatched,
@@ -31,6 +31,7 @@ from pypaimon.ray.update_by_row_id import update_by_row_id
 
 __all__ = [
     "read_paimon",
+    "map_with_blobs",
     "write_paimon",
     "bucket_join",
     "merge_into",
diff --git a/paimon-python/pypaimon/ray/ray_paimon.py 
b/paimon-python/pypaimon/ray/ray_paimon.py
index d0e7706c83..b375e3950f 100644
--- a/paimon-python/pypaimon/ray/ray_paimon.py
+++ b/paimon-python/pypaimon/ray/ray_paimon.py
@@ -27,7 +27,7 @@ Usage::
 """
 
 import importlib
-from typing import Any, Dict, List, Optional, TYPE_CHECKING
+from typing import Any, Callable, Dict, List, Optional, TYPE_CHECKING
 
 from pypaimon.common.predicate import Predicate
 
@@ -137,6 +137,148 @@ def read_paimon(
     return ds
 
 
+def map_with_blobs(
+    dataset: "ray.data.Dataset",
+    columns,
+    fn: Callable,
+    *,
+    file_io=None,
+    all_blob_columns=None,
+    parallelism: int = 64,
+    batch_size: Optional[int] = 1024,
+    fn_kwargs: Optional[Dict[str, Any]] = None,
+    ray_remote_args: Optional[Dict[str, Any]] = None,
+    **map_args,
+) -> "ray.data.Dataset":
+    """Fetch BLOB payloads in Ray batches and call ``fn``.
+
+    ``fn(scalar_batch, blobs, **fn_kwargs)`` receives a ``pyarrow.Table`` of
+    non-BLOB columns and a row-aligned ``dict`` of BLOB bytes. Return a small
+    Ray-compatible batch; for side-effect-only work, return an empty
+    ``pyarrow.Table`` instead of ``None``. Call this directly on
+    ``scan().to_ray()`` output, or pass ``file_io`` and ``all_blob_columns``.
+    Tune ``batch_size`` for BLOB size and worker memory.
+    """
+    _require_ray_data()
+
+    if not callable(fn):
+        raise ValueError("fn must be callable")
+    if isinstance(columns, str):
+        blob_cols = [columns]
+    else:
+        blob_cols = list(dict.fromkeys(columns))
+    if not blob_cols:
+        raise ValueError("columns must contain at least one BLOB column")
+    if parallelism < 1:
+        raise ValueError("parallelism must be at least 1, got 
{}".format(parallelism))
+    if batch_size is not None and batch_size < 1:
+        raise ValueError("batch_size must be at least 1, got 
{}".format(batch_size))
+
+    resolved_file_io = file_io
+    if resolved_file_io is None:
+        resolved_file_io = getattr(dataset, "_paimon_blob_file_io", None)
+    if resolved_file_io is None:
+        raise ValueError(
+            "map_with_blobs requires a FileIO. Use table.scan().to_ray() or "
+            "pass file_io= explicitly.")
+
+    batch_format = map_args.pop("batch_format", "pyarrow")
+    if batch_format != "pyarrow":
+        raise ValueError("map_with_blobs requires batch_format='pyarrow'")
+
+    kwargs = dict(map_args)
+    kwargs["batch_format"] = "pyarrow"
+    if batch_size is not None:
+        kwargs.setdefault("batch_size", batch_size)
+    if ray_remote_args is not None:
+        _set_map_batches_remote_args(dataset, kwargs, ray_remote_args)
+
+    all_blob_cols = all_blob_columns
+    if all_blob_cols is None:
+        all_blob_cols = getattr(dataset, "_paimon_blob_columns", None)
+    if all_blob_cols is None:
+        raise ValueError(
+            "map_with_blobs requires all_blob_columns when Dataset lacks "
+            "BLOB metadata.")
+
+    all_blob = set(all_blob_cols)
+    invalid = [name for name in blob_cols if name not in all_blob]
+    if invalid:
+        raise ValueError("Column {!r} is not a BLOB 
column.".format(invalid[0]))
+
+    return dataset.map_batches(
+        _map_blob_batch,
+        fn_kwargs={
+            "file_io": resolved_file_io,
+            "blob_cols": blob_cols,
+            "all_blob_cols": list(all_blob_cols),
+            "parallelism": parallelism,
+            "fn": fn,
+            "fn_kwargs": dict(fn_kwargs or {}),
+        },
+        **kwargs)
+
+
+def _set_map_batches_remote_args(dataset, kwargs, ray_remote_args):
+    import inspect
+
+    param = 
inspect.signature(dataset.map_batches).parameters.get("ray_remote_args")
+    if param is not None and param.kind != inspect.Parameter.VAR_KEYWORD:
+        kwargs["ray_remote_args"] = ray_remote_args
+    else:
+        kwargs.update(ray_remote_args)
+
+
+def _map_blob_batch(
+        batch, file_io, blob_cols, all_blob_cols, parallelism, fn, fn_kwargs):
+    from pypaimon.multimodal.blob_read import fetch_blob_bodies
+
+    missing = [name for name in blob_cols if name not in batch.schema.names]
+    if missing:
+        raise ValueError("BLOB column(s) not found in Ray Dataset: {}".format(
+            ", ".join(missing)))
+
+    all_blob = set(all_blob_cols)
+    scalar_cols = [name for name in batch.schema.names if name not in all_blob]
+    unknown = _unknown_blob_descriptor_columns(batch, scalar_cols)
+    if unknown:
+        raise ValueError(
+            "Column {!r} holds BLOB descriptors this table does not own "
+            "(likely from a joined BLOB table). Fetch it with its own "
+            "table.map_with_blobs() in a separate pass, or drop it before "
+            "mapping.".format(unknown[0]))
+
+    bodies = fetch_blob_bodies(
+        file_io, batch.select(blob_cols).to_pydict(), blob_cols, parallelism)
+    result = fn(batch.select(scalar_cols), bodies, **fn_kwargs)
+    if result is None:
+        raise ValueError(
+            "map_with_blobs UDF must return a Ray-compatible batch, such as a "
+            "pyarrow.Table. For side-effect-only processing, return an empty "
+            "pyarrow.Table instead of None.")
+    return result
+
+
+def _unknown_blob_descriptor_columns(batch, scalar_cols):
+    return [
+        name for name in scalar_cols
+        if _looks_like_blob_descriptor(batch.column(name))]
+
+
+def _looks_like_blob_descriptor(column):
+    import pyarrow as pa
+    from pypaimon.table.row.blob import BlobDescriptor
+
+    if not (pa.types.is_binary(column.type) or 
pa.types.is_large_binary(column.type)):
+        return False
+    chunks = getattr(column, "chunks", None) or [column]
+    for chunk in chunks:
+        for value in chunk:
+            if value.is_valid:
+                return BlobDescriptor.is_blob_descriptor(value.as_py())
+    return False
+
+
 def write_paimon(
     dataset: "ray.data.Dataset",
     table_identifier: str,
diff --git a/paimon-python/pypaimon/read/datasource/ray_datasource.py 
b/paimon-python/pypaimon/read/datasource/ray_datasource.py
index a5ac10bc10..7a2f726143 100644
--- a/paimon-python/pypaimon/read/datasource/ray_datasource.py
+++ b/paimon-python/pypaimon/read/datasource/ray_datasource.py
@@ -238,6 +238,7 @@ class RayDatasource(Datasource):
             metadata = BlockMetadata(**metadata_kwargs)
 
             read_fn = partial(get_read_task, chunk_splits)
+            read_fn.__name__ = "read_paimon_table"
             read_task_kwargs = {
                 'read_fn': read_fn,
                 'metadata': metadata,
diff --git a/paimon-python/pypaimon/tests/multimodal_table_test.py 
b/paimon-python/pypaimon/tests/multimodal_table_test.py
index d6ed03c969..9dfc0be22c 100644
--- a/paimon-python/pypaimon/tests/multimodal_table_test.py
+++ b/paimon-python/pypaimon/tests/multimodal_table_test.py
@@ -30,6 +30,11 @@ from pypaimon import Schema as PaimonSchema
 from pypaimon.globalindex.global_index_result import GlobalIndexResult
 from pypaimon.utils.range import Range
 
+try:
+    import ray
+except ImportError:
+    ray = None
+
 
 _PARQUET_OPTIONS = {
     "row-tracking.enabled": "true",
@@ -945,6 +950,8 @@ class MultimodalTableTest(unittest.TestCase):
             t.search([1.0, 0.0, 0.0], column="emb").read_blobs("img")
         with self.assertRaisesRegex(TypeError, "only supported on scan"):
             t.search([1.0, 0.0, 0.0], column="emb").stream_blobs("img")
+        with self.assertRaisesRegex(TypeError, "only supported on scan"):
+            t.search([1.0, 0.0, 0.0], column="emb").to_ray()
 
     def test_scan_stream_blobs(self):
         obs = self.conn.create_table(
@@ -984,6 +991,179 @@ class MultimodalTableTest(unittest.TestCase):
         self.assertEqual(
             [], list(obs.scan().where("clip = 'none'").stream_blobs("image")))
 
+    @unittest.skipIf(ray is None, "ray is not installed")
+    def test_scan_to_ray_map_with_blobs(self):
+        started_ray = False
+        if not ray.is_initialized():
+            ray.init(ignore_reinit_error=True, num_cpus=2)
+            started_ray = True
+        obs = self.conn.create_table(
+            "ray_obs",
+            schema=_schema({
+                "clip": pa.string(),
+                "idx": pa.int32(),
+                "image": pa.large_binary(),
+                "audio": pa.large_binary(),
+            }),
+            options=_PARQUET_OPTIONS,
+            partitioned=["clip"],
+        )
+        obs.add([
+            {"clip": "c1", "idx": 0, "image": b"img-0", "audio": b"aud-0"},
+            {"clip": "c1", "idx": 1, "image": b"img-1", "audio": b"aud-1"},
+            {"clip": "c2", "idx": 0, "image": b"img-x", "audio": b"aud-x"},
+        ])
+
+        def collect_batch(scalar, blobs, prefix):
+            assert isinstance(scalar, pa.Table)
+            assert ["idx"] == scalar.column_names
+            idxs = scalar.column("idx").to_pylist()
+            rows = []
+            for idx, image in zip(idxs, blobs["image"]):
+                rows.append({"idx": idx, "image": prefix + image})
+            return pa.Table.from_pylist(rows)
+
+        try:
+            from pypaimon.ray import map_with_blobs
+
+            ds = (
+                obs.scan()
+                .where("clip = 'c1'")
+                .select(["idx", "image", "audio"])
+                .to_ray(concurrency=1, override_num_blocks=1)
+            )
+            ds = ds.filter(lambda row: row["idx"] >= 0)
+
+            with self.assertRaisesRegex(ValueError, "not a BLOB column"):
+                obs.map_with_blobs(ds, ["idx"], collect_batch)
+            with self.assertRaisesRegex(ValueError, "FileIO"):
+                map_with_blobs(ds, ["image"], collect_batch)
+
+            from pypaimon.table.row.blob import BlobDescriptor
+
+            descriptor = BlobDescriptor("oss://bucket/blob", 0, 1).serialize()
+            foreign_ds = ray.data.from_arrow(pa.table({
+                "idx": [0],
+                "image": [descriptor],
+                "foreign_blob": [descriptor],
+            }))
+            with self.assertRaisesRegex(Exception, "does not own"):
+                map_with_blobs(
+                    foreign_ds,
+                    ["image"],
+                    collect_batch,
+                    file_io=obs.raw_table.file_io,
+                    all_blob_columns=["image"],
+                    batch_size=1,
+                ).take_all()
+
+            result = obs.map_with_blobs(
+                ds,
+                ["image"],
+                collect_batch,
+                parallelism=2,
+                batch_size=1,
+                fn_kwargs={"prefix": b"got-"},
+                ray_remote_args={"num_cpus": 1},
+            )
+            rows = sorted(result.to_pandas().to_dict("records"), key=lambda 
row: row["idx"])
+
+            self.assertEqual(
+                [
+                    {"idx": 0, "image": b"got-img-0"},
+                    {"idx": 1, "image": b"got-img-1"},
+                ],
+                rows,
+            )
+        finally:
+            if started_ray:
+                ray.shutdown()
+
+    @unittest.skipIf(ray is None, "ray is not installed")
+    def test_scan_to_ray_map_with_blobs_guards(self):
+        started_ray = False
+        if not ray.is_initialized():
+            ray.init(ignore_reinit_error=True, num_cpus=2)
+            started_ray = True
+        obs = self.conn.create_table(
+            "ray_obs_guards",
+            schema=_schema({
+                "clip": pa.string(),
+                "idx": pa.int32(),
+                "image": pa.large_binary(),
+            }),
+            options=_PARQUET_OPTIONS,
+            partitioned=["clip"],
+        )
+        obs.add([{"clip": "c1", "idx": 0, "image": b"img-0"}])
+
+        def return_none(scalar, blobs):
+            return None
+
+        try:
+            from pypaimon.ray import map_with_blobs
+
+            ds = (
+                obs.scan()
+                .where("clip = 'c1'")
+                .select(["idx", "image"])
+                .to_ray(concurrency=1, override_num_blocks=1)
+            )
+
+            with self.assertRaisesRegex(ValueError, "all_blob_columns"):
+                map_with_blobs(
+                    ray.data.from_arrow(pa.table({"image": [b"inline"]})),
+                    ["image"],
+                    lambda scalar, blobs: pa.table({"rows": 
[scalar.num_rows]}),
+                    file_io=obs.raw_table.file_io,
+                )
+
+            with self.assertRaisesRegex(Exception, "must return"):
+                obs.map_with_blobs(
+                    ds,
+                    ["image"],
+                    return_none,
+                    batch_size=1,
+                ).take_all()
+
+            empty_ds = (
+                obs.scan()
+                .where("clip = 'none'")
+                .select(["idx", "image"])
+                .to_ray(concurrency=1, override_num_blocks=1)
+            )
+            result = obs.map_with_blobs(
+                empty_ds,
+                ["image"],
+                lambda scalar, blobs: pa.table({"rows": [scalar.num_rows]}),
+                batch_size=1,
+            )
+            self.assertEqual([], result.take_all())
+        finally:
+            if started_ray:
+                ray.shutdown()
+
+    def test_scan_to_ray_nested_projection_output_names(self):
+        obs = self.conn.create_table(
+            "ray_nested_obs",
+            schema=_schema({
+                "id": pa.int32(),
+                "tag": pa.string(),
+                "payload": pa.struct([("a", pa.int64()), ("b", pa.string())]),
+                "image": pa.large_binary(),
+            }),
+            options=_PARQUET_OPTIONS,
+        )
+
+        _, _, visible_columns = (
+            obs.scan()
+            .where("tag = 'keep'")
+            .select(["id", "payload.a"])
+            ._blob_descriptor_query_read_builder()
+        )
+
+        self.assertEqual(["id", "payload_a"], visible_columns)
+
     def test_fetch_bodies_decodes_descriptor_inline_and_null(self):
         # Cells may be descriptor bytes (incl. -1 read-to-EOF), inline bytes, 
or null.
         from pypaimon.multimodal.query import ScanQuery

Reply via email to