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 3d63e41188 [python] Support deletion vectors in Ray row-id reads and 
updates (#10427)
3d63e41188 is described below

commit 3d63e41188f1cbb6521db9375b2b23270250ffa5
Author: chaoyang <[email protected]>
AuthorDate: Thu Oct 8 15:21:32 2026 +0800

    [python] Support deletion vectors in Ray row-id reads and updates (#10427)
---
 docs/docs/pypaimon/ray-row-ids.md                  | 16 ++++---
 .../pypaimon/ray/data_evolution_merge_join.py      | 33 +++++++++++---
 paimon-python/pypaimon/ray/read_by_row_id.py       | 10 ++---
 paimon-python/pypaimon/ray/update_by_row_id.py     | 11 +----
 .../pypaimon/tests/ray_read_by_row_id_test.py      | 31 ++++++++++---
 .../pypaimon/tests/ray_update_by_row_id_test.py    | 51 ++++++++++++++++++----
 6 files changed, 108 insertions(+), 44 deletions(-)

diff --git a/docs/docs/pypaimon/ray-row-ids.md 
b/docs/docs/pypaimon/ray-row-ids.md
index ec8eed7269..7a004e1e99 100644
--- a/docs/docs/pypaimon/ray-row-ids.md
+++ b/docs/docs/pypaimon/ray-row-ids.md
@@ -25,7 +25,7 @@ under the License.
 
 # Ray Row IDs and Backfills
 
-Read selected rows and write derived columns using target-table row IDs. These 
APIs require Ray 2.50 or newer and data-evolution tables with row tracking. The 
read/update paths on this page reject deletion-vectors-enabled tables; see 
[Multimodal Reads](./multimodal-reading) for high-level BLOB processing.
+Read selected rows and write derived columns using target-table row IDs. These 
APIs require Ray 2.50 or newer and data-evolution tables with row tracking. 
Row-ID reads and updates support deletion vectors; see [Multimodal 
Reads](./multimodal-reading) for high-level BLOB processing.
 
 Install a Ray version that supports these APIs in both the driver and workers:
 
@@ -88,8 +88,10 @@ print(metrics)   # {"num_updated": 50}
 - Multiple source rows mapping to the same `_ROW_ID` is rejected — deduplicate 
first.
 - Blob columns cannot be updated through this path.
 - Partition columns cannot be updated (in-place rewrite can't move a row 
across partitions).
-- Deletion-vectors-enabled tables are not supported yet: a DV-deleted row 
still lives
-  in its data file, so it can't be told apart from a live row without reading 
the target.
+- Updates validate the requested IDs against deletion vectors from the planning
+  snapshot and reject deleted IDs before writing that file group. Physical row
+  positions are preserved during partial-column rewrites. A concurrent delete
+  remains effective even if its row was live when this update was planned.
 
 ## Read By Row Id
 
@@ -148,8 +150,8 @@ ds = read_by_row_id(
   Each call captures the resolved snapshot for planning and worker reads; 
changing
   the tag before a lazy Dataset executes does not switch that Dataset to 
another
   snapshot.
-- Deletion-vectors-enabled tables are not supported yet, for the same reason as
-  `update_by_row_id`.
+- Deleted row IDs are omitted, using the deletion vectors of the resolved
+  snapshot. Reading an earlier retained snapshot can still return those rows.
 - 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).
@@ -262,8 +264,8 @@ process_row_id_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,
+`data-evolution.enabled`; `embedding` must be nullable. With deletion vectors,
+a row deleted between reading and update planning causes the update to fail. 
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
diff --git a/paimon-python/pypaimon/ray/data_evolution_merge_join.py 
b/paimon-python/pypaimon/ray/data_evolution_merge_join.py
index 3a261259a7..c745db9e8e 100644
--- a/paimon-python/pypaimon/ray/data_evolution_merge_join.py
+++ b/paimon-python/pypaimon/ray/data_evolution_merge_join.py
@@ -831,6 +831,9 @@ def distributed_update_apply(
             )
 
         for_update = group.drop_columns([frid_col])
+        info = ray.get(precomputed_info_ref)
+        _validate_live_row_ids(
+            captured_table, info, int(group[frid_col][0].as_py()), 
for_update[row_id_name].to_pylist())
         row_ids = (
             for_update.column(row_id_name).to_pylist()
             if collect_row_ids else []
@@ -908,7 +911,6 @@ def distributed_read_by_row_id(
     import ray
 
     from pypaimon.globalindex.indexed_split import IndexedSplit
-    from pypaimon.read.split import DataSplit
     from pypaimon.table.special_fields import SpecialFields
     from pypaimon.utils.range import Range
 
@@ -983,12 +985,8 @@ def distributed_read_by_row_id(
         frid = int(group.column(frid_col)[0].as_py())
         info = ray.get(precomputed_info_ref)
         owning_split, target_files = info.first_row_id_index[frid]
-        origin_split = DataSplit(
-            files=target_files,
-            partition=owning_split.partition,
-            bucket=owning_split.bucket,
-            raw_convertible=True,
-        )
+        target_names = {file.file_name for file in target_files}
+        origin_split = owning_split.filter_file(lambda file: file.file_name in 
target_names)
         # Only matched rows (deduped, contiguous ids -> ranges); blob gets 
row-index pushdown.
         wanted = set(group.column(row_id_name).to_pylist())
         indexed = IndexedSplit(origin_split, Range.to_ranges(list(wanted)))
@@ -1275,3 +1273,24 @@ def distributed_write_collect_msgs(
         write_kwargs["concurrency"] = concurrency
     insert_ds.write_datasink(sink, **write_kwargs)
     return sink.collected
+
+
+def _validate_live_row_ids(table, info, first_row_id, row_ids):
+    """Reject deleted updates using only the routed snapshot's DV files.
+
+    The writer must still merge physical rows, including tombstones, to 
preserve
+    row-id offsets. Filtering the writer's original data would shift those 
offsets.
+    """
+    from pypaimon.deletionvectors.deletion_vector import DeletionVector
+
+    split, files = info.first_row_id_index[first_row_id]
+    if split.data_deletion_files is None:
+        return
+    target_names = {file.file_name for file in files}
+    for file, deletion in zip(split.files, split.data_deletion_files):
+        if deletion is None or file.file_name not in target_names:
+            continue
+        vector = DeletionVector.read(table.file_io, deletion)
+        for row_id in row_ids:
+            if file.row_id_range().contains(row_id) and 
vector.is_deleted(row_id - file.first_row_id):
+                raise ValueError("Cannot update deleted _ROW_ID 
{}.".format(row_id))
diff --git a/paimon-python/pypaimon/ray/read_by_row_id.py 
b/paimon-python/pypaimon/ray/read_by_row_id.py
index 234b6edbe1..4e9011bb26 100644
--- a/paimon-python/pypaimon/ray/read_by_row_id.py
+++ b/paimon-python/pypaimon/ray/read_by_row_id.py
@@ -114,11 +114,7 @@ def read_by_row_id(
     if not table.options.row_tracking_enabled():
         raise ValueError(
             f"read_by_row_id requires 'row-tracking.enabled'='true' on 
'{target}'.")
-    if table.options.deletion_vectors_enabled():
-        # A DV-deleted row still lives in its file, so slicing would surface 
it.
-        raise ValueError(
-            f"read_by_row_id does not support deletion-vectors-enabled tables 
yet: "
-            f"'{target}'.")
+
     if dynamic_options:
         # Flipping these would bypass the checks above.
         bad = sorted({"data-evolution.enabled", "row-tracking.enabled",
@@ -176,8 +172,8 @@ def read_by_row_id(
         if not 
CoreOptions(Options(base_schema.options)).row_tracking_enabled():
             raise ValueError(
                 f"the resolved snapshot ({base.id}) predates row-tracking; 
read_by_row_id needs it.")
-    # No DV (rejected above) -> total_record_count is the live row count; 0 = 
empty.
-    if base is None or base.total_record_count == 0:
+    if base is None or (base.total_record_count == 0
+                        and not table.options.deletion_vectors_enabled()):
         # Force an action on the source only in this degenerate branch (like 
update_by_row_id).
         if rid_ds.limit(1).count() > 0:
             raise ValueError(
diff --git a/paimon-python/pypaimon/ray/update_by_row_id.py 
b/paimon-python/pypaimon/ray/update_by_row_id.py
index 58679a8a98..a3f7f30a34 100644
--- a/paimon-python/pypaimon/ray/update_by_row_id.py
+++ b/paimon-python/pypaimon/ray/update_by_row_id.py
@@ -85,12 +85,6 @@ def update_by_row_id(
     if not table.options.row_tracking_enabled():
         raise ValueError(
             f"update_by_row_id requires 'row-tracking.enabled'='true' on 
'{target}'.")
-    if table.options.deletion_vectors_enabled():
-        # A DV-deleted row still lives in its data file, so row-id ranges 
can't tell it
-        # apart without reading the target; refuse rather than update a 
deleted row.
-        raise ValueError(
-            f"update_by_row_id does not support deletion-vectors-enabled 
tables yet: "
-            f"'{target}'.")
 
     rid = SpecialFields.ROW_ID.name
     blob_cols = _blob_col_names(table)
@@ -139,9 +133,8 @@ def update_by_row_id(
     update_ds = source_ds.map_batches(_project_cast, batch_format="pyarrow")
 
     base = table.snapshot_manager().get_latest_snapshot()
-    # Without deletion vectors (rejected above), total_record_count is the 
live row
-    # count, so 0 means the target is empty (never written, or emptied by 
overwrite).
-    if base is None or base.total_record_count == 0:
+    if base is None or (base.total_record_count == 0
+                        and not table.options.deletion_vectors_enabled()):
         # Every source row id is foreign; don't silently no-op non-empty input.
         if update_ds.limit(1).count() > 0:
             raise ValueError(
diff --git a/paimon-python/pypaimon/tests/ray_read_by_row_id_test.py 
b/paimon-python/pypaimon/tests/ray_read_by_row_id_test.py
index be214002b3..9257c8fd4d 100644
--- a/paimon-python/pypaimon/tests/ray_read_by_row_id_test.py
+++ b/paimon-python/pypaimon/tests/ray_read_by_row_id_test.py
@@ -418,14 +418,33 @@ class RayReadByRowIdTest(unittest.TestCase):
         with self.assertRaises(ValueError):
             read_by_row_id(target, src, self.catalog_options, 
projection=["age"])
 
-    def test_rejects_deletion_vectors_table(self):
+    def test_reads_deletion_vectors_and_pins_snapshot(self):
         opts = dict(self.de_options, **{"deletion-vectors.enabled": "true"})
         target = self._create(options=opts)
-        self._write(target, pa.Table.from_pydict(
-            {"id": [1], "name": ["a"], "age": [1]}, schema=self.pa_schema))
-        src = pa.table({"_ROW_ID": [0]}, schema=pa.schema([("_ROW_ID", 
pa.int64())]))
-        with self.assertRaises(ValueError):
-            read_by_row_id(target, src, self.catalog_options, 
projection=["age"])
+        for ids in ([1, 2, 3], [4, 5, 6]):
+            self._write(target, pa.Table.from_pydict(
+                {"id": ids, "name": ["a"] * 3, "age": ids}, 
schema=self.pa_schema))
+        table = self.catalog.get_table(target)
+        src = pa.table({"_ROW_ID": [0, 1, 2, 3, 4, 5, 1]})
+        before = read_by_row_id(target, src, self.catalog_options, 
projection=["id"])
+        wb = table.new_batch_write_builder()
+        commit = wb.new_commit()
+        try:
+            commit.commit(wb.new_update().delete_by_row_id([0, 2, 4]))
+            after = read_by_row_id(target, src, self.catalog_options, 
projection=["id"])
+            self.assertEqual(set(self._rows_by_id(before)), {1, 2, 3, 4, 5, 6})
+            self.assertEqual(set(self._rows_by_id(after)), {2, 4, 6})
+            deleted = read_by_row_id(
+                target, pa.table({"_ROW_ID": [0, 2, 4]}), 
self.catalog_options, projection=["id"])
+            self.assertEqual(deleted.take_all(), [])
+            self.assertEqual(deleted.schema().names, ["id", "_ROW_ID"])
+            commit.close()
+            commit = wb.new_commit()
+            commit.commit(wb.new_update().delete_by_row_id([1, 3, 5]))
+            self.assertEqual(read_by_row_id(
+                target, src, self.catalog_options, 
projection=["id"]).take_all(), [])
+        finally:
+            commit.close()
 
     def test_rejects_missing_row_id_column(self):
         target = self._create()
diff --git a/paimon-python/pypaimon/tests/ray_update_by_row_id_test.py 
b/paimon-python/pypaimon/tests/ray_update_by_row_id_test.py
index a0072770ea..9fb5d3257f 100644
--- a/paimon-python/pypaimon/tests/ray_update_by_row_id_test.py
+++ b/paimon-python/pypaimon/tests/ray_update_by_row_id_test.py
@@ -492,17 +492,52 @@ class RayUpdateByRowIdTest(unittest.TestCase):
         with self.assertRaises(ValueError):
             update_by_row_id(name, src, self.catalog_options, 
update_cols=["name"])
 
-    def test_rejects_deletion_vectors_table(self):
-        # A DV-deleted row still lives in its file, so update_by_row_id can't 
tell it is
-        # gone without reading the target; DV tables are refused for now.
+    def test_updates_live_rows_with_deletion_vectors(self):
         opts = dict(self.de_options, **{"deletion-vectors.enabled": "true"})
         target = self._create(options=opts)
         self._write(target, pa.Table.from_pydict(
-            {"id": [1], "name": ["a"], "age": [1]}, schema=self.pa_schema))
-        src = pa.table({"_ROW_ID": [0], "age": [9]},
-                       schema=pa.schema([("_ROW_ID", pa.int64()), ("age", 
pa.int32())]))
-        with self.assertRaises(ValueError):
-            update_by_row_id(target, src, self.catalog_options, 
update_cols=["age"])
+            {"id": [1, 2, 3, 4], "name": ["a"] * 4, "age": [10, 20, 30, 40]},
+            schema=self.pa_schema))
+        table = self.catalog.get_table(target)
+        wb = table.new_batch_write_builder()
+        commit = wb.new_commit()
+        try:
+            commit.commit(wb.new_update().delete_by_row_id([0, 2]))
+        finally:
+            commit.close()
+        src = pa.table({"_ROW_ID": [1, 3], "age": [99, 88]})
+        self.assertEqual(update_by_row_id(
+            target, src, self.catalog_options, update_cols=["age"]), 
{"num_updated": 2})
+        rb = table.new_read_builder().with_projection(["id", "age", "_ROW_ID"])
+        rows = 
rb.new_read().to_arrow(rb.new_scan().plan().splits()).to_pylist()
+        self.assertEqual(sorted(rows, key=lambda r: r["id"]), [
+            {"id": 2, "age": 99, "_ROW_ID": 1}, {"id": 4, "age": 88, 
"_ROW_ID": 3}])
+        # Deleting after workers have prepared their column files must not
+        # resurrect the row when the update is committed.
+        import importlib
+        module = importlib.import_module("pypaimon.ray.update_by_row_id")
+        original_commit = module._commit_update_messages
+
+        def delete_then_commit(target_table, messages):
+            builder = target_table.new_batch_write_builder()
+            concurrent = builder.new_commit()
+            try:
+                concurrent.commit(builder.new_update().delete_by_row_id([3]))
+            finally:
+                concurrent.close()
+            original_commit(target_table, messages)
+
+        with mock.patch.object(module, "_commit_update_messages", 
delete_then_commit):
+            update_by_row_id(target, pa.table({"_ROW_ID": [3], "age": [77]}),
+                             self.catalog_options, update_cols=["age"])
+        rb = table.new_read_builder().with_projection(["id", "age", "_ROW_ID"])
+        
self.assertEqual(rb.new_read().to_arrow(rb.new_scan().plan().splits()).to_pylist(),
+                         [{"id": 2, "age": 99, "_ROW_ID": 1}])
+        snapshot = table.snapshot_manager().get_latest_snapshot().id
+        with self.assertRaisesRegex(ValueError, "deleted _ROW_ID"):
+            update_by_row_id(target, pa.table({"_ROW_ID": [0, 1], "age": [9, 
9]}),
+                             self.catalog_options, update_cols=["age"])
+        self.assertEqual(table.snapshot_manager().get_latest_snapshot().id, 
snapshot)
 
     def test_rejects_blob_column_update(self):
         blob_schema = pa.schema([("id", pa.int32()), ("payload", 
pa.large_binary())])

Reply via email to