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 aebc9846fe [python][ray] Add row-id range processing API (#9112)
aebc9846fe is described below

commit aebc9846fe0d574b48cbb62ec3161a5d47dfca72
Author: Jingsong Lee <[email protected]>
AuthorDate: Sat Aug 8 19:09:32 2026 +0800

    [python][ray] Add row-id range processing API (#9112)
---
 docs/docs/pypaimon/ray-data.md                     | 113 +++++++++++++
 paimon-python/pypaimon/ray/__init__.py             |   2 +
 .../pypaimon/ray/process_row_id_ranges.py          | 101 +++++++++++
 .../tests/ray_process_row_id_ranges_test.py        | 188 +++++++++++++++++++++
 paimon-python/pypaimon/tests/vector_table_test.py  |  95 +++++++++++
 .../pypaimon/write/table_update_by_row_id.py       |  13 +-
 6 files changed, 510 insertions(+), 2 deletions(-)

diff --git a/docs/docs/pypaimon/ray-data.md b/docs/docs/pypaimon/ray-data.md
index 082abfe3ef..3f90068336 100644
--- a/docs/docs/pypaimon/ray-data.md
+++ b/docs/docs/pypaimon/ray-data.md
@@ -637,3 +637,116 @@ ds = read_by_row_id(
 - For a non-empty target, the `row_ids` source is consumed lazily by the 
downstream
   action, not read here. A lazy source missing `row_id_col` raises when the 
read runs
   (a materialized source raises up front).
+
+## Process Row Id Ranges
+
+`process_row_id_ranges` plans the latest snapshot into logical file groups and
+calls a user-supplied processor synchronously for each target-sized batch. The
+processor receives a `List[Range]` and owns the read, distributed computation,
+commit, and retry policy. Base files and overlapping data-evolution, BLOB, or
+VECTOR files remain in one indivisible group.
+
+`rows_per_commit` is therefore a target rather than a hard limit: the function
+never splits a file group, so a batch can contain more rows. The range plan is
+captured once at the start of a run, callbacks execute in row-id order, and an
+exception stops later callbacks.
+
+### Resumable embedding backfill from a BLOB column
+
+The following pattern reads an `image` BLOB, computes a nullable `embedding`
+VECTOR with Ray, and commits about one million row ids at a time. It pushes
+`embedding IS NULL` into each range scan, so a completed row is filtered before
+its image payload is materialized. Rerun the whole function after a failure;
+already committed ranges are skipped automatically.
+
+```python
+import pyarrow as pa
+
+from my_embedding_model import load_model
+from pypaimon import CatalogFactory
+from pypaimon.ray import process_row_id_ranges, update_by_row_id
+
+TARGET = "database_name.images"
+CATALOG_OPTIONS = {"warehouse": "/path/to/warehouse"}
+EMBEDDING_DIM = 768
+
+
+class EmbedImages:
+    def __init__(self):
+        # Constructed once in every Ray actor, not once per Arrow batch.
+        self.model = load_model()
+
+    def __call__(self, batch: pa.Table) -> pa.Table:
+        vectors = self.model.encode(batch["image"].to_pylist())
+        return pa.table({
+            "_ROW_ID": batch["_ROW_ID"],
+            "embedding": pa.array(
+                vectors.tolist(),
+                type=pa.list_(pa.float32(), EMBEDDING_DIM),
+            ),
+        })
+
+
+def process_ranges(ranges):
+    # Resolve a fresh table for every batch so this scan sees embeddings
+    # committed by earlier callbacks. Force BLOB payloads rather than 
descriptors.
+    table = (
+        CatalogFactory.create(CATALOG_OPTIONS)
+        .get_table(TARGET)
+        .copy({"blob-as-descriptor": "false"})
+    )
+    read_builder = table.new_read_builder().with_projection(
+        ["image", "embedding", "_ROW_ID"]
+    )
+    read_builder.with_filter(
+        read_builder.new_predicate_builder().is_null("embedding")
+    )
+    splits = (
+        read_builder.new_scan()
+        .with_row_ranges(ranges)
+        .plan()
+        .splits()
+    )
+    pending = read_builder.new_read().to_ray(
+        splits,
+        concurrency=64,
+        ray_remote_args={"num_cpus": 1},
+    )
+    if pending.limit(1).count() == 0:
+        return
+
+    updates = pending.map_batches(
+        EmbedImages,
+        batch_format="pyarrow",
+        batch_size=128,
+        concurrency=8,       # required for a callable-class Ray actor pool
+        num_gpus=1,
+    )
+
+    # update_by_row_id executes the Ray pipeline and makes one Paimon commit.
+    # It is valid for VECTOR/ARRAY embedding columns; BLOB columns themselves
+    # cannot be updated through update_by_row_id.
+    update_by_row_id(
+        target=TARGET,
+        source=updates,
+        catalog_options=CATALOG_OPTIONS,
+        update_cols=["embedding"],
+        num_partitions=128,
+    )
+
+
+process_row_id_ranges(
+    TARGET,
+    CATALOG_OPTIONS,
+    rows_per_commit=1_000_000,
+    processor=process_ranges,
+)
+```
+
+The target must enable `row-tracking.enabled` and
+`data-evolution.enabled`; `embedding` must be nullable and the table must not
+enable deletion vectors. If the source BLOB or embedding model can change,
+use an additional source/model-version column instead of treating every
+non-null embedding as permanently complete. `process_row_id_ranges` does not
+retry a failed processor itself—the resumability in this example comes from
+rerunning it and selecting only rows whose embedding is still null.
diff --git a/paimon-python/pypaimon/ray/__init__.py 
b/paimon-python/pypaimon/ray/__init__.py
index 03de068249..63141ecd41 100644
--- a/paimon-python/pypaimon/ray/__init__.py
+++ b/paimon-python/pypaimon/ray/__init__.py
@@ -30,6 +30,7 @@ from pypaimon.ray.data_evolution_merge_transform import (
 )
 from pypaimon.ray.update_by_row_id import update_by_row_id
 from pypaimon.ray.read_by_row_id import read_by_row_id
+from pypaimon.ray.process_row_id_ranges import process_row_id_ranges
 
 __all__ = [
     "read_paimon",
@@ -40,6 +41,7 @@ __all__ = [
     "merge_into",
     "update_by_row_id",
     "read_by_row_id",
+    "process_row_id_ranges",
     "WhenMatched",
     "WhenNotMatched",
     "source_col",
diff --git a/paimon-python/pypaimon/ray/process_row_id_ranges.py 
b/paimon-python/pypaimon/ray/process_row_id_ranges.py
new file mode 100644
index 0000000000..4af31d5ef0
--- /dev/null
+++ b/paimon-python/pypaimon/ray/process_row_id_ranges.py
@@ -0,0 +1,101 @@
+# 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.
+
+"""Sequential processing of row-id ranges at file-group boundaries."""
+
+from typing import Callable, Dict, List
+
+from pypaimon.utils.range import Range
+from pypaimon.utils.range_helper import RangeHelper
+
+__all__ = ["process_row_id_ranges"]
+
+
+def process_row_id_ranges(
+    target: str,
+    catalog_options: Dict[str, str],
+    *,
+    rows_per_commit: int,
+    processor: Callable[[List[Range]], None]
+) -> None:
+    """Process the target's row-id file groups in sequential batches.
+
+    The latest snapshot is planned once before processing starts. Files with
+    overlapping row-id ranges (for example, a base file and its data-evolution
+    files) form one indivisible file group. Adjacent groups are accumulated
+    until their row count reaches ``rows_per_commit``, then ``processor`` is
+    called synchronously with their inclusive :class:`Range` objects.
+
+    A batch may exceed ``rows_per_commit`` because file groups are never split.
+    The processor owns reading, distributed execution, committing, retries, and
+    cleanup. Its exception is propagated immediately and later batches are not
+    processed.
+    """
+    _validate_arguments(rows_per_commit, processor)
+
+    from pypaimon.catalog.catalog_factory import CatalogFactory
+
+    table = CatalogFactory.create(catalog_options).get_table(target)
+    if not table.options.row_tracking_enabled():
+        raise ValueError(
+            "process_row_id_ranges requires 'row-tracking.enabled'='true' "
+            "on '{}'.".format(target)
+        )
+
+    pending_ranges = []
+    pending_rows = 0
+    for row_id_range in _file_group_ranges(table):
+        pending_ranges.append(row_id_range)
+        pending_rows += row_id_range.count()
+        if pending_rows >= rows_per_commit:
+            processor(pending_ranges)
+            pending_ranges = []
+            pending_rows = 0
+
+    if pending_ranges:
+        processor(pending_ranges)
+
+
+def _validate_arguments(rows_per_commit, processor) -> None:
+    if (
+        isinstance(rows_per_commit, bool)
+        or not isinstance(rows_per_commit, int)
+        or rows_per_commit <= 0
+    ):
+        raise ValueError("rows_per_commit must be a positive integer.")
+    if not callable(processor):
+        raise ValueError("processor must be callable.")
+
+
+def _file_group_ranges(table) -> List[Range]:
+    plan = table.new_read_builder().new_scan().plan_for_write()
+    files = [data_file for split in plan.splits() for data_file in split.files]
+    file_groups = RangeHelper(
+        lambda data_file: data_file.non_null_row_id_range()
+    ).merge_overlapping_ranges(files)
+
+    ranges = []
+    for file_group in file_groups:
+        group_ranges = [
+            data_file.non_null_row_id_range() for data_file in file_group
+        ]
+        ranges.append(
+            Range(
+                min(row_range.from_ for row_range in group_ranges),
+                max(row_range.to for row_range in group_ranges),
+            )
+        )
+    return sorted(ranges, key=lambda row_range: row_range.from_)
diff --git a/paimon-python/pypaimon/tests/ray_process_row_id_ranges_test.py 
b/paimon-python/pypaimon/tests/ray_process_row_id_ranges_test.py
new file mode 100644
index 0000000000..1978e733ec
--- /dev/null
+++ b/paimon-python/pypaimon/tests/ray_process_row_id_ranges_test.py
@@ -0,0 +1,188 @@
+# 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 uuid
+from unittest import mock
+
+import pyarrow as pa
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.ray import process_row_id_ranges
+from pypaimon.utils.range import Range
+
+
+class ProcessRowIdRangesTest(unittest.TestCase):
+
+    schema = pa.schema([
+        ("id", pa.int32()),
+        ("name", pa.string()),
+    ])
+
+    @classmethod
+    def setUpClass(cls):
+        cls.tempdir = tempfile.mkdtemp()
+        cls.catalog_options = {
+            "warehouse": os.path.join(cls.tempdir, "warehouse")
+        }
+        cls.catalog = CatalogFactory.create(cls.catalog_options)
+        cls.catalog.create_database("default", True)
+
+    @classmethod
+    def tearDownClass(cls):
+        shutil.rmtree(cls.tempdir, ignore_errors=True)
+
+    def _create(self, options=None):
+        target = "default.ranges_{}".format(uuid.uuid4().hex[:8])
+        self.catalog.create_table(
+            target,
+            Schema.from_pyarrow_schema(self.schema, options=options or {}),
+            False,
+        )
+        return target
+
+    def _write(self, target, row_count):
+        table = self.catalog.get_table(target)
+        builder = table.new_batch_write_builder()
+        writer = builder.new_write()
+        writer.write_arrow(pa.Table.from_pydict({
+            "id": list(range(row_count)),
+            "name": ["n{}".format(i) for i in range(row_count)],
+        }, schema=self.schema))
+        commit = builder.new_commit()
+        commit.commit(writer.prepare_commit())
+        writer.close()
+        commit.close()
+
+    def test_processes_file_groups_in_target_sized_sequential_batches(self):
+        target = self._create({
+            "row-tracking.enabled": "true",
+            "data-evolution.enabled": "true",
+            "target-file-row-num": "3",
+        })
+        self._write(target, 10)
+
+        # Add an overlapping data-evolution file. It belongs to the first
+        # logical file group and must not be counted as another range.
+        table = self.catalog.get_table(target)
+        builder = table.new_batch_write_builder()
+        messages = (
+            builder.new_update()
+            .with_update_type(["name"])
+            .update_by_arrow_with_row_id(pa.table({
+                "_ROW_ID": pa.array([1], type=pa.int64()),
+                "name": pa.array(["updated"], type=pa.string()),
+            }))
+        )
+        commit = builder.new_commit()
+        commit.commit(messages)
+        commit.close()
+
+        batches = []
+        process_row_id_ranges(
+            target,
+            self.catalog_options,
+            rows_per_commit=7,
+            processor=batches.append,
+        )
+
+        self.assertEqual([
+            [Range(0, 2), Range(3, 5), Range(6, 8)],
+            [Range(9, 9)],
+        ], batches)
+
+    def test_processor_failure_stops_later_batches(self):
+        target = self._create({
+            "row-tracking.enabled": "true",
+            "data-evolution.enabled": "true",
+            "target-file-row-num": "2",
+        })
+        self._write(target, 6)
+        calls = []
+
+        def processor(ranges):
+            calls.append(ranges)
+            if len(calls) == 2:
+                raise RuntimeError("processor failed")
+
+        with self.assertRaisesRegex(RuntimeError, "processor failed"):
+            process_row_id_ranges(
+                target,
+                self.catalog_options,
+                rows_per_commit=2,
+                processor=processor,
+            )
+
+        self.assertEqual([[Range(0, 1)], [Range(2, 3)]], calls)
+
+    def test_empty_table_does_not_call_processor(self):
+        target = self._create({"row-tracking.enabled": "true"})
+        processor = mock.Mock()
+
+        process_row_id_ranges(
+            target,
+            self.catalog_options,
+            rows_per_commit=10,
+            processor=processor,
+        )
+
+        processor.assert_not_called()
+
+    def test_requires_row_tracking(self):
+        target = self._create()
+        with self.assertRaisesRegex(ValueError, "row-tracking.enabled"):
+            process_row_id_ranges(
+                target,
+                self.catalog_options,
+                rows_per_commit=10,
+                processor=lambda ranges: None,
+            )
+
+    def test_validates_arguments_before_loading_table(self):
+        invalid_values = [True, False, 0, -1, 1.5, "1", None]
+        for value in invalid_values:
+            with self.subTest(rows_per_commit=value), mock.patch(
+                "pypaimon.catalog.catalog_factory.CatalogFactory.create"
+            ) as create:
+                with self.assertRaisesRegex(ValueError, "positive integer"):
+                    process_row_id_ranges(
+                        "default.missing",
+                        {},
+                        rows_per_commit=value,
+                        processor=lambda ranges: None,
+                    )
+                create.assert_not_called()
+
+        with mock.patch(
+            "pypaimon.catalog.catalog_factory.CatalogFactory.create"
+        ) as create:
+            with self.assertRaisesRegex(
+                ValueError, "processor must be callable"
+            ):
+                process_row_id_ranges(
+                    "default.missing",
+                    {},
+                    rows_per_commit=1,
+                    processor=None,
+                )
+            create.assert_not_called()
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/paimon-python/pypaimon/tests/vector_table_test.py 
b/paimon-python/pypaimon/tests/vector_table_test.py
index 0f96455bbd..6a4515475e 100644
--- a/paimon-python/pypaimon/tests/vector_table_test.py
+++ b/paimon-python/pypaimon/tests/vector_table_test.py
@@ -394,6 +394,101 @@ class VectorTableWriteReadTest(unittest.TestCase):
         result = rb.new_read().to_arrow(splits).sort_by('id').to_pydict()
         self.assertEqual(result['name'], ['updated', 'updated'])
 
+    def test_backfill_vector_column_added_after_existing_rows(self):
+        from pypaimon.schema.data_types import AtomicType, VectorType
+        from pypaimon.schema.schema_change import SchemaChange
+        from pypaimon.utils.range import Range
+
+        table_name = 'test_db.vector_backfill_added_column'
+        base_schema = pa.schema([
+            ('id', pa.int64()),
+            ('image', pa.large_binary()),
+        ])
+        opts = {
+            'row-tracking.enabled': 'true',
+            'data-evolution.enabled': 'true',
+            'vector.file.format': 'parquet',
+        }
+        self.catalog.create_table(
+            table_name,
+            Schema.from_pyarrow_schema(base_schema, options=opts),
+            False,
+        )
+        table = self.catalog.get_table(table_name)
+        wb = table.new_batch_write_builder()
+        writer = wb.new_write()
+        writer.write_arrow(pa.table({
+            'id': pa.array([1, 2, 3], type=pa.int64()),
+            'image': pa.array([b'a', b'b', b'c'], type=pa.large_binary()),
+        }))
+        commit = wb.new_commit()
+        commit.commit(writer.prepare_commit())
+        writer.close()
+        commit.close()
+
+        self.catalog.alter_table(
+            table_name,
+            [SchemaChange.add_column(
+                'embedding', VectorType(True, AtomicType('FLOAT'), 2))],
+            False,
+        )
+        table = self.catalog.get_table(table_name)
+        read_builder = table.new_read_builder().with_projection(
+            ['image', 'embedding', '_ROW_ID'])
+        read_builder.with_filter(
+            read_builder.new_predicate_builder().is_null('embedding'))
+        splits = (
+            read_builder.new_scan()
+            .with_row_ranges([Range(0, 2)])
+            .plan()
+            .splits()
+        )
+        pending = read_builder.new_read().to_arrow(splits).sort_by('_ROW_ID')
+        self.assertEqual([0, 1, 2], pending['_ROW_ID'].to_pylist())
+        self.assertEqual([b'a', b'b', b'c'], pending['image'].to_pylist())
+
+        updates = pa.table({
+            '_ROW_ID': pending['_ROW_ID'],
+            'embedding': pa.array(
+                [[1.0, 0.0], [0.5, 0.5], [0.0, 1.0]],
+                type=pa.list_(pa.float32(), 2),
+            ),
+        })
+        wb = table.new_batch_write_builder()
+        messages = (
+            wb.new_update()
+            .with_update_type(['embedding'])
+            .update_by_arrow_with_row_id(updates)
+        )
+        commit = wb.new_commit()
+        commit.commit(messages)
+        commit.close()
+
+        table = self.catalog.get_table(table_name)
+        read_builder = table.new_read_builder().with_projection(
+            ['id', 'embedding'])
+        result = read_builder.new_read().to_arrow(
+            read_builder.new_scan().plan().splits()).sort_by('id')
+        self.assertEqual(
+            [[1.0, 0.0], [0.5, 0.5], [0.0, 1.0]],
+            result['embedding'].to_pylist(),
+        )
+
+        retry_builder = table.new_read_builder().with_projection(
+            ['embedding', '_ROW_ID'])
+        retry_builder.with_filter(
+            retry_builder.new_predicate_builder().is_null('embedding'))
+        retry_splits = (
+            retry_builder.new_scan()
+            .with_row_ranges([Range(0, 2)])
+            .plan()
+            .splits()
+        )
+        self.assertEqual(
+            0,
+            retry_builder.new_read().to_arrow(retry_splits).num_rows,
+        )
+
     def 
test_vector_table_partial_update_non_vector_column_with_rolling_files(self):
         from pypaimon.snapshot.snapshot import BATCH_COMMIT_IDENTIFIER
         from pypaimon.write.table_update_by_row_id import TableUpdateByRowId
diff --git a/paimon-python/pypaimon/write/table_update_by_row_id.py 
b/paimon-python/pypaimon/write/table_update_by_row_id.py
index b0caa6ad03..c74fbc54fa 100644
--- a/paimon-python/pypaimon/write/table_update_by_row_id.py
+++ b/paimon-python/pypaimon/write/table_update_by_row_id.py
@@ -410,8 +410,17 @@ class TableUpdateByRowId:
             bucket=owning_split.bucket,
             raw_convertible=True,
         )
-        table_read = TableRead(self.table, predicate=None, 
read_type=read_fields)
-        return table_read.to_arrow([origin_split])
+        # Keep _ROW_ID as a row-count anchor. If every requested column was
+        # added after the original file was written, reading only those
+        # missing columns can otherwise produce a zero-row table instead of
+        # one null value per original row.
+        table_read = TableRead(
+            self.table,
+            predicate=None,
+            read_type=read_fields + [SpecialFields.ROW_ID],
+        )
+        original = table_read.to_arrow([origin_split])
+        return original.select([field.name for field in read_fields])
 
     def _merge_update_with_original(
             self,

Reply via email to