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 a9a3d9277a [python][ray] Add distributed read_by_row_id for
data-evolution tables (#8465)
a9a3d9277a is described below
commit a9a3d9277a2574a3faa6d8cfb5d9d2d189f06d9d
Author: XiaoHongbo <[email protected]>
AuthorDate: Sun Jul 5 16:08:05 2026 +0800
[python][ray] Add distributed read_by_row_id for data-evolution tables
(#8465)
Add Ray support for distributed `read_by_row_id` on data-evolution
tables.
This is the read-side counterpart of `update_by_row_id`. It allows a Ray
Dataset carrying target `_ROW_ID`s, for example produced by
`bucket_join`, to read projected columns from the target table without
scanning or joining the whole target table.
This is useful for workflows such as:
1. match input keys to target `_ROW_ID`s with `bucket_join`
2. read only matched rows / blob columns by row id
3. transform or run inference in Ray
4. write results back with `update_by_row_id`
---
docs/docs/pypaimon/ray-data.md | 48 +++
paimon-python/pypaimon/ray/__init__.py | 2 +
.../pypaimon/ray/data_evolution_merge_join.py | 134 +++++++++
paimon-python/pypaimon/ray/read_by_row_id.py | 147 ++++++++++
.../pypaimon/tests/ray_read_by_row_id_test.py | 325 +++++++++++++++++++++
5 files changed, 656 insertions(+)
diff --git a/docs/docs/pypaimon/ray-data.md b/docs/docs/pypaimon/ray-data.md
index fc1e6cc9a9..c45f88ec09 100644
--- a/docs/docs/pypaimon/ray-data.md
+++ b/docs/docs/pypaimon/ray-data.md
@@ -542,3 +542,51 @@ print(metrics) # {"num_updated": 50}
- 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.
+
+## Read By Row Id
+
+`read_by_row_id` is the read-side mirror of `update_by_row_id`: it reads
columns
+(including blob) of a **data-evolution** table for a set of `_ROW_ID`s, without
+scanning or joining the whole target. Each row id is routed to the data file
that
+owns it and only those files — and only the matched rows — are read. It pairs
with
+`bucket_join` (which produces the row ids) and feeds `update_by_row_id`: match
by
+key → read the matched rows → transform → write back by row id. Requires
+`ray >= 2.50` and a target with `data-evolution.enabled` and
`row-tracking.enabled`.
+
+```python
+from pypaimon.ray import read_by_row_id
+
+ds = read_by_row_id(
+ target="database_name.table_name",
+ row_ids=locator_ds, # ray.data.Dataset / pa.Table / pandas,
carrying the row ids
+ catalog_options={"warehouse": "/path/to/warehouse"},
+ projection=["image", "feature"], # columns to read; may include blob
columns
+ row_id_col="row_id", # source column holding the row ids (default
"_ROW_ID")
+)
+# ds: ray.data.Dataset of (image, feature, _ROW_ID) for the matched rows
+```
+
+**Parameters:**
+- `row_ids`: a `ray.data.Dataset`, `pyarrow.Table`, or `pandas.DataFrame`
carrying the
+ target row ids in column `row_id_col`; other columns are ignored. A
table-name source
+ is not accepted (a table's system `_ROW_ID` is its own and cannot address
the target).
+- `projection`: top-level columns to read (nested paths are not supported).
Blob columns
+ are resolved to their payloads. Must be non-empty.
+- `row_id_col`: the source column holding the row ids (default `_ROW_ID`); set
e.g.
+ `row_id_col="row_id"` to consume a `bucket_join` locator directly.
+- `num_partitions`: parallelism for grouping the row ids by target file;
defaults to
+ `max(1, cluster_cpus * 2)`.
+- `ray_remote_args`: Ray remote options applied to the read tasks.
+
+**Returns:** a `ray.data.Dataset` of `(*projection, _ROW_ID)`.
+
+**Notes:**
+- Lookup/set semantics, like SQL `... WHERE _ROW_ID IN (...)`: one row per
**distinct**
+ matched row id (duplicates deduplicated), input order not preserved (rows
come out
+ grouped by owning file). An empty source yields an empty but correctly-typed
Dataset.
+- The row ids must exist in the target's current snapshot; a foreign `_ROW_ID`
raises.
+- Deletion-vectors-enabled tables are not supported yet, for the same reason as
+ `update_by_row_id`.
+- Prefer a materialized `row_ids` source (a `bucket_join` result already is
one): the
+ emptiness check reads one block up front, which would otherwise re-run a
lazy source's
+ first block.
diff --git a/paimon-python/pypaimon/ray/__init__.py
b/paimon-python/pypaimon/ray/__init__.py
index 52b4575b5b..bc91c8da45 100644
--- a/paimon-python/pypaimon/ray/__init__.py
+++ b/paimon-python/pypaimon/ray/__init__.py
@@ -28,6 +28,7 @@ from pypaimon.ray.data_evolution_merge_transform import (
lit,
)
from pypaimon.ray.update_by_row_id import update_by_row_id
+from pypaimon.ray.read_by_row_id import read_by_row_id
__all__ = [
"read_paimon",
@@ -36,6 +37,7 @@ __all__ = [
"bucket_join",
"merge_into",
"update_by_row_id",
+ "read_by_row_id",
"WhenMatched",
"WhenNotMatched",
"source_col",
diff --git a/paimon-python/pypaimon/ray/data_evolution_merge_join.py
b/paimon-python/pypaimon/ray/data_evolution_merge_join.py
index 03fc068ea2..5535b7ec1b 100644
--- a/paimon-python/pypaimon/ray/data_evolution_merge_join.py
+++ b/paimon-python/pypaimon/ray/data_evolution_merge_join.py
@@ -584,6 +584,140 @@ def distributed_update_apply(
return all_msgs, num_updated, action_row_ids
+def _read_output_schema(table, read_cols: Sequence[str]) -> "pa.Schema":
+ """Result schema: each projected column's type plus int64 ``_ROW_ID``, in
+ ``read_cols`` order. Shared by the empty-result paths so they can't
drift."""
+ from pypaimon.schema.data_types import PyarrowFieldParser
+ from pypaimon.table.special_fields import SpecialFields
+
+ rid = SpecialFields.ROW_ID.name
+ full = PyarrowFieldParser.from_paimon_schema(table.table_schema.fields)
+ # Keep each field's nullability so an empty result matches a non-empty
read.
+ return pa.schema([
+ pa.field(rid, pa.int64(), nullable=False) if col == rid else
full.field(col)
+ for col in read_cols
+ ])
+
+
+def distributed_read_by_row_id(
+ row_ids_ds,
+ table,
+ projection: Sequence[str],
+ *,
+ num_partitions: int,
+ ray_remote_args: Optional[Dict[str, Any]] = None,
+ base_snapshot_id: Optional[int] = None,
+):
+ """Read ``projection`` for the ``_ROW_ID``s in ``row_ids_ds``, routing
each to its
+ owning file and reading only the matched rows via ``IndexedSplit`` slicing
(blob
+ resolved). Returns a ``ray.data.Dataset`` of ``(*projection, _ROW_ID)``,
or ``None``
+ if the target is empty. Read-side mirror of ``distributed_update_apply``.
+ """
+ import numpy as np
+ import uuid
+
+ import ray
+
+ from pypaimon.common.options.core_options import CoreOptions
+ from pypaimon.globalindex.indexed_split import IndexedSplit
+ from pypaimon.read.split import DataSplit
+ from pypaimon.snapshot.snapshot import BATCH_COMMIT_IDENTIFIER
+ from pypaimon.table.special_fields import SpecialFields
+ from pypaimon.utils.range import Range
+ from pypaimon.write.table_update_by_row_id import TableUpdateByRowId
+
+ row_id_name = SpecialFields.ROW_ID.name
+ read_cols = list(projection)
+ if row_id_name not in read_cols:
+ read_cols.append(row_id_name)
+
+ # Typed empty block so all output blocks share one schema.
+ empty_out = _read_output_schema(table, read_cols).empty_table()
+
+ # Read-only planner (only scans the manifest); pinned to the base snapshot
for stable routing.
+ scan_table = (
+ table.copy({CoreOptions.SCAN_SNAPSHOT_ID.key(): str(base_snapshot_id)})
+ if base_snapshot_id is not None else table
+ )
+ planner = TableUpdateByRowId(
+ scan_table,
+ "_read_by_row_id_planner_" + uuid.uuid4().hex[:8],
+ BATCH_COMMIT_IDENTIFIER,
+ )
+ sorted_first_row_ids = list(planner.first_row_ids)
+ if not sorted_first_row_ids:
+ return None
+
+ precomputed_info_ref = ray.put(planner._snapshot_files_info())
+ frid_col = "_FIRST_ROW_ID"
+ sorted_arr = np.asarray(sorted_first_row_ids, dtype=np.int64)
+ valid_ranges = planner.valid_row_id_ranges
+ range_starts = np.asarray([r.from_ for r in valid_ranges], dtype=np.int64)
+ range_ends = np.asarray([r.to for r in valid_ranges], dtype=np.int64)
+
+ def _assign_frid(batch: pa.Table) -> pa.Table:
+ if batch.num_rows == 0:
+ return batch.append_column(frid_col, pa.array([], type=pa.int64()))
+ rid_col = batch.column(row_id_name)
+ if rid_col.null_count:
+ raise ValueError(
+ "_ROW_ID is null; the planner snapshot is stale or the row ids
"
+ "come from a different table."
+ )
+ rids = rid_col.to_numpy(zero_copy_only=False)
+ # Foreign-id check: valid_ranges are sorted+merged, so one
searchsorted finds
+ # the candidate range (O(rows log ranges), like
distributed_delete_apply).
+ ridx = np.searchsorted(range_starts, rids, side="right") - 1
+ safe = np.clip(ridx, 0, len(range_starts) - 1)
+ in_range = (
+ (ridx >= 0)
+ & (rids >= range_starts[safe])
+ & (rids <= range_ends[safe])
+ )
+ if not in_range.all():
+ bad = rids[~in_range][0]
+ raise ValueError(
+ f"_ROW_ID {bad} does not belong to any valid range "
+ f"{[f'[{r.from_}, {r.to}]' for r in valid_ranges]}; the
planner "
+ f"snapshot is stale or the row ids come from a different
table."
+ )
+ idx = np.searchsorted(sorted_arr, rids, side="right") - 1
+ return batch.append_column(
+ frid_col, pa.array(sorted_arr[idx], type=pa.int64())
+ )
+
+ captured_table = scan_table # read at the same pinned snapshot the
planner routed on
+ captured_read_cols = read_cols
+ captured_empty = empty_out
+
+ def _read_group(group: pa.Table) -> pa.Table:
+ if group.num_rows == 0:
+ return captured_empty
+ 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,
+ )
+ # 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)))
+ read = captured_table.new_read_builder().with_projection(
+ captured_read_cols
+ ).new_read()
+ return read.to_arrow([indexed])
+
+ map_kwargs = _map_kwargs(ray_remote_args)
+ with_frid = row_ids_ds.map_batches(_assign_frid, **map_kwargs)
+ group_partitions = max(1, min(len(sorted_first_row_ids), num_partitions))
+ return with_frid.groupby(frid_col,
num_partitions=group_partitions).map_groups(
+ _read_group, **map_kwargs
+ )
+
+
def distributed_delete_apply(
delete_ds,
table,
diff --git a/paimon-python/pypaimon/ray/read_by_row_id.py
b/paimon-python/pypaimon/ray/read_by_row_id.py
new file mode 100644
index 0000000000..b145823ba0
--- /dev/null
+++ b/paimon-python/pypaimon/ray/read_by_row_id.py
@@ -0,0 +1,147 @@
+# 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.
+
+"""Distributed row-id read on Ray for data-evolution tables.
+
+The read-side mirror of ``update_by_row_id``: read columns (including blob)
for a set
+of ``_ROW_ID``s by routing each to its owning data file -- no full-target
read, no
+shuffle join. Pairs with ``bucket_join``, which produces the row ids.
+"""
+
+from typing import Any, Dict, List, Optional
+
+import pyarrow as pa
+
+from pypaimon.ray.data_evolution_merge_into import (
+ _normalize_source,
+ _reraise_inner,
+ _require_ray_join,
+ _resolve_num_partitions,
+)
+from pypaimon.ray.data_evolution_merge_join import (
+ _read_output_schema,
+ distributed_read_by_row_id,
+)
+
+__all__ = ["read_by_row_id"]
+
+
+def _empty_result(table: "FileStoreTable", read_cols: List[str]):
+ """An empty ``ray.data.Dataset`` with the projected read schema (empty
source
+ or target). Uses the same schema builder as the read path so they can't
drift."""
+ import ray
+
+ return ray.data.from_arrow(_read_output_schema(table,
read_cols).empty_table())
+
+
+def read_by_row_id(
+ target: str,
+ row_ids: Any,
+ catalog_options: Dict[str, str],
+ *,
+ projection: List[str],
+ row_id_col: Optional[str] = None,
+ num_partitions: Optional[int] = None,
+ ray_remote_args: Optional[Dict[str, Any]] = None,
+):
+ """Read ``projection`` columns of a data-evolution table by ``_ROW_ID``.
+
+ ``row_ids`` (a ``ray.data.Dataset`` / ``pyarrow.Table`` /
``pandas.DataFrame``)
+ must carry the target row ids in column ``row_id_col`` (default
``_ROW_ID``; set
+ e.g. ``row_id_col="row_id"`` for a ``bucket_join`` locator). Each row id
is routed
+ to the data file owning it and only those files -- and only the matched
rows --
+ are read, so the target is never fully scanned and there is no join
against it.
+ ``projection`` lists top-level columns; blob columns are resolved to their
payloads.
+ Requires ``ray >= 2.50`` and a target with ``data-evolution.enabled`` +
+ ``row-tracking.enabled``.
+
+ Lookup/set semantics, like SQL ``... WHERE _ROW_ID IN (...)``: the result
has one
+ row per *distinct* matched row id -- duplicate row ids are deduplicated,
source
+ columns other than ``row_id_col`` are dropped, and the input row order is
not
+ preserved (rows come out grouped by owning file). An empty source yields
an empty
+ but correctly-typed Dataset.
+
+ Returns a ``ray.data.Dataset`` of ``(*projection, _ROW_ID)``.
+ """
+ from pypaimon.catalog.catalog_factory import CatalogFactory
+ from pypaimon.table.special_fields import SpecialFields
+
+ _require_ray_join()
+ if not projection:
+ raise ValueError("projection must be non-empty.")
+ projection = list(dict.fromkeys(projection))
+ num_partitions = _resolve_num_partitions(num_partitions)
+
+ table = CatalogFactory.create(catalog_options).get_table(target)
+ if not table.options.data_evolution_enabled():
+ raise ValueError(
+ f"read_by_row_id requires 'data-evolution.enabled'='true' on
'{target}'.")
+ 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}'.")
+
+ rid = SpecialFields.ROW_ID.name
+ src_rid_col = row_id_col or rid
+ for col in projection:
+ if col != rid and col not in table.field_names:
+ raise ValueError(f"projection column {col!r} is not in target
'{target}'.")
+
+ if isinstance(row_ids, str):
+ # A source table's _ROW_ID is its own, not the target's; require
in-memory ids.
+ raise ValueError(
+ "read_by_row_id does not accept a table-name source; pass a
ray.data."
+ "Dataset / pyarrow.Table / pandas.DataFrame carrying the target
row ids.")
+ source_ds = _normalize_source(row_ids, catalog_options)
+ if src_rid_col not in set(source_ds.schema().names):
+ raise ValueError(f"row_ids source is missing the {src_rid_col!r}
column.")
+
+ def _project_rid(batch: pa.Table) -> pa.Table:
+ return pa.table({rid: batch.column(src_rid_col).cast(pa.int64())})
+
+ rid_ds = source_ds.map_batches(_project_rid, batch_format="pyarrow")
+ read_cols = list(projection) + ([rid] if rid not in projection else [])
+
+ # Empty source -> typed empty Dataset (a zero-row groupby has no schema).
+ source_empty = rid_ds.limit(1).count() == 0
+
+ base = table.snapshot_manager().get_latest_snapshot()
+ # 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 not source_empty:
+ raise ValueError(
+ f"target '{target}' has no rows; every _ROW_ID in the source
is foreign.")
+ return _empty_result(table, read_cols)
+ if source_empty:
+ return _empty_result(table, read_cols)
+ try:
+ result = distributed_read_by_row_id(
+ rid_ds, table, projection,
+ num_partitions=num_partitions,
+ ray_remote_args=ray_remote_args,
+ base_snapshot_id=base.id,
+ )
+ except Exception as e:
+ _reraise_inner(e)
+ raise # _reraise_inner always raises
+ if result is None:
+ return _empty_result(table, read_cols)
+ return result
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
new file mode 100644
index 0000000000..18e67de3e9
--- /dev/null
+++ b/paimon-python/pypaimon/tests/ray_read_by_row_id_test.py
@@ -0,0 +1,325 @@
+# 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
+import pytest
+
+pypaimon = pytest.importorskip("pypaimon")
+ray = pytest.importorskip("ray")
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.ray import read_by_row_id
+
+
+class RayReadByRowIdTest(unittest.TestCase):
+ """Distributed row-id read: read only the files owning the given row ids
(and
+ only the matched rows), without reading or joining the whole target. The
+ read-side mirror of update_by_row_id."""
+
+ pa_schema = pa.schema([
+ ("id", pa.int32()),
+ ("name", pa.string()),
+ ("age", pa.int32()),
+ ])
+ de_options = {"row-tracking.enabled": "true", "data-evolution.enabled":
"true"}
+
+ @classmethod
+ def setUpClass(cls):
+ cls.tempdir = tempfile.mkdtemp()
+ cls.catalog_options = {"warehouse": os.path.join(cls.tempdir, "wh")}
+ cls.catalog = CatalogFactory.create(cls.catalog_options)
+ cls.catalog.create_database("default", True)
+ if not ray.is_initialized():
+ ray.init(ignore_reinit_error=True, num_cpus=2)
+
+ @classmethod
+ def tearDownClass(cls):
+ try:
+ if ray.is_initialized():
+ ray.shutdown()
+ except Exception:
+ pass
+ shutil.rmtree(cls.tempdir, ignore_errors=True)
+
+ def _create(self, options=None, schema=None):
+ name = f"default.r_{uuid.uuid4().hex[:8]}"
+ opts = self.de_options if options is None else options
+ self.catalog.create_table(
+ name, Schema.from_pyarrow_schema(schema or self.pa_schema,
options=opts), False)
+ return name
+
+ def _write(self, target, data):
+ t = self.catalog.get_table(target)
+ wb = t.new_batch_write_builder()
+ w = wb.new_write()
+ w.write_arrow(data)
+ wb.new_commit().commit(w.prepare_commit())
+ w.close()
+
+ def _read(self, target, projection=None):
+ t = self.catalog.get_table(target)
+ rb = t.new_read_builder()
+ if projection is not None:
+ rb = rb.with_projection(projection)
+ return rb.new_read().to_arrow(rb.new_scan().plan().splits())
+
+ def _rowid_by_id(self, target):
+ tab = self._read(target, ["_ROW_ID", "id"])
+ return dict(zip(tab.column("id").to_pylist(),
tab.column("_ROW_ID").to_pylist()))
+
+ def _rows_by_id(self, ds):
+ return {r["id"]: r for r in ds.take_all()}
+
+ def test_read_by_row_id_basic(self):
+ target = self._create()
+ self._write(target, pa.Table.from_pydict(
+ {"id": list(range(1, 7)), "name": [f"n{i}" for i in range(1, 7)],
+ "age": [i * 10 for i in range(1, 7)]}, schema=self.pa_schema))
+ rid = self._rowid_by_id(target)
+
+ want = [2, 5]
+ src = pa.table({"_ROW_ID": [rid[i] for i in want]},
+ schema=pa.schema([("_ROW_ID", pa.int64())]))
+ ds = read_by_row_id(target, ray.data.from_arrow(src),
self.catalog_options,
+ projection=["id", "name", "age"])
+ got = self._rows_by_id(ds)
+ self.assertEqual(set(got), set(want))
+ self.assertEqual(got[2]["age"], 20)
+ self.assertEqual(got[5]["name"], "n5")
+ self.assertEqual(got[2]["_ROW_ID"], rid[2])
+
+ def test_reads_correct_row_across_files(self):
+ target = self._create()
+ for chunk in ([10, 11, 12], [20, 21], [30, 31, 32, 33]):
+ self._write(target, pa.Table.from_pydict(
+ {"id": chunk, "name": ["x"] * len(chunk), "age": [c for c in
chunk]},
+ schema=self.pa_schema))
+ rid = self._rowid_by_id(target)
+ src = pa.table({"_ROW_ID": [rid[21]]}, schema=pa.schema([("_ROW_ID",
pa.int64())]))
+ ds = read_by_row_id(target, ray.data.from_arrow(src),
self.catalog_options,
+ projection=["id", "age"])
+ rows = ds.take_all()
+ self.assertEqual(len(rows), 1)
+ self.assertEqual(rows[0]["id"], 21)
+ self.assertEqual(rows[0]["age"], 21)
+
+ def test_reads_across_evolution_split_files(self):
+ # update_by_row_id writes a column delta (splits the row's range); the
read must merge original + delta.
+ from pypaimon.ray import update_by_row_id
+ target = self._create()
+ self._write(target, pa.Table.from_pydict(
+ {"id": [1, 2, 3], "name": ["a", "b", "c"], "age": [10, 20, 30]},
+ schema=self.pa_schema))
+ rid = self._rowid_by_id(target)
+ update_by_row_id(
+ target,
+ pa.table({"_ROW_ID": [rid[2]], "age": [999]},
+ schema=pa.schema([("_ROW_ID", pa.int64()), ("age",
pa.int32())])),
+ self.catalog_options, update_cols=["age"])
+ ds = read_by_row_id(
+ target,
+ pa.table({"_ROW_ID": [rid[2]]}, schema=pa.schema([("_ROW_ID",
pa.int64())])),
+ self.catalog_options, projection=["id", "name", "age"])
+ rows = ds.take_all()
+ self.assertEqual(len(rows), 1)
+ self.assertEqual(rows[0]["id"], 2)
+ self.assertEqual(rows[0]["name"], "b")
+ self.assertEqual(rows[0]["age"], 999)
+
+ def test_reads_blob_column(self):
+ blob_schema = pa.schema([("id", pa.int32()), ("payload",
pa.large_binary())])
+ target = self._create(schema=blob_schema)
+ payloads = [bytes([i]) * (i + 1) for i in range(1, 5)]
+ self._write(target, pa.Table.from_pydict(
+ {"id": [1, 2, 3, 4], "payload": pa.array(payloads,
pa.large_binary())},
+ schema=blob_schema))
+ rid = self._rowid_by_id(target)
+ src = pa.table({"_ROW_ID": [rid[2], rid[4]]},
+ schema=pa.schema([("_ROW_ID", pa.int64())]))
+ ds = read_by_row_id(target, ray.data.from_arrow(src),
self.catalog_options,
+ projection=["id", "payload"])
+ got = self._rows_by_id(ds)
+ self.assertEqual(set(got), {2, 4})
+ self.assertEqual(bytes(got[2]["payload"]), payloads[1])
+ self.assertEqual(bytes(got[4]["payload"]), payloads[3])
+
+ def test_pins_base_snapshot(self):
+ import importlib
+ m = importlib.import_module("pypaimon.ray.read_by_row_id")
+ target = self._create()
+ self._write(target, pa.Table.from_pydict(
+ {"id": [1, 2], "name": ["a", "b"], "age": [1, 2]},
schema=self.pa_schema))
+ expected_sid = self.catalog.get_table(
+ target).snapshot_manager().get_latest_snapshot().id
+ rid = self._rowid_by_id(target)
+ src = pa.table({"_ROW_ID": [rid[1]]}, schema=pa.schema([("_ROW_ID",
pa.int64())]))
+
+ captured = {}
+
+ def fake_read(rid_ds, table, projection, *, num_partitions,
+ ray_remote_args=None, base_snapshot_id=None):
+ captured["base_snapshot_id"] = base_snapshot_id
+ return ray.data.from_arrow(pa.table({"_ROW_ID": pa.array([],
pa.int64())}))
+
+ with mock.patch.object(m, "distributed_read_by_row_id", fake_read):
+ read_by_row_id(target, src, self.catalog_options,
projection=["age"])
+ self.assertEqual(captured["base_snapshot_id"], expected_sid)
+
+ def test_accepts_pyarrow_and_pandas_source(self):
+ target = self._create()
+ self._write(target, pa.Table.from_pydict(
+ {"id": [1, 2, 3], "name": ["a", "b", "c"], "age": [1, 2, 3]},
+ schema=self.pa_schema))
+ rid = self._rowid_by_id(target)
+ ds = read_by_row_id(
+ target, pa.table({"_ROW_ID": [rid[2]]},
schema=pa.schema([("_ROW_ID", pa.int64())])),
+ self.catalog_options, projection=["name"])
+ self.assertEqual([r["name"] for r in ds.take_all()], ["b"])
+ import pandas as pd
+ ds = read_by_row_id(
+ target, pd.DataFrame({"_ROW_ID": pd.array([rid[3]],
dtype="int64")}),
+ self.catalog_options, projection=["name"])
+ self.assertEqual([r["name"] for r in ds.take_all()], ["c"])
+
+ def test_ignores_extra_source_columns_and_dedups(self):
+ target = self._create()
+ self._write(target, pa.Table.from_pydict(
+ {"id": [1, 2], "name": ["a", "b"], "age": [1, 2]},
schema=self.pa_schema))
+ rid = self._rowid_by_id(target)
+ src = pa.table({"_ROW_ID": [rid[2], rid[2]], "junk": ["x", "y"]},
+ schema=pa.schema([("_ROW_ID", pa.int64()), ("junk",
pa.string())]))
+ ds = read_by_row_id(target, ray.data.from_arrow(src),
self.catalog_options,
+ projection=["id", "name"])
+ rows = ds.take_all()
+ self.assertEqual(len(rows), 1)
+ self.assertEqual(rows[0]["name"], "b")
+
+ def test_rejects_table_name_source(self):
+ target = self._create()
+ self._write(target, pa.Table.from_pydict(
+ {"id": [1], "name": ["a"], "age": [1]}, schema=self.pa_schema))
+ with self.assertRaises(ValueError):
+ read_by_row_id(target, "default.some_source", self.catalog_options,
+ projection=["age"])
+
+ def test_rejects_non_data_evolution_table(self):
+ target = self._create(options={})
+ 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"])
+
+ def test_rejects_deletion_vectors_table(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"])
+
+ def test_rejects_missing_row_id_column(self):
+ target = self._create()
+ self._write(target, pa.Table.from_pydict(
+ {"id": [1], "name": ["a"], "age": [1]}, schema=self.pa_schema))
+ src = pa.table({"id": [1]}, schema=pa.schema([("id", pa.int32())]))
+ with self.assertRaises(ValueError):
+ read_by_row_id(target, src, self.catalog_options,
projection=["age"])
+
+ def test_rejects_unknown_and_empty_projection(self):
+ target = self._create()
+ 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=["nope"])
+ with self.assertRaises(ValueError):
+ read_by_row_id(target, src, self.catalog_options, projection=[])
+
+ def test_empty_target(self):
+ src = pa.table({"_ROW_ID": [0]}, schema=pa.schema([("_ROW_ID",
pa.int64())]))
+ empty_src = pa.table({"_ROW_ID": pa.array([], pa.int64())})
+
+ target = self._create()
+ with self.assertRaises(ValueError):
+ read_by_row_id(target, src, self.catalog_options,
projection=["age"])
+ ds = read_by_row_id(target, empty_src, self.catalog_options,
projection=["age"])
+ self.assertEqual(ds.count(), 0)
+
+ def test_foreign_row_id_raises(self):
+ target = self._create()
+ self._write(target, pa.Table.from_pydict(
+ {"id": [1, 2], "name": ["a", "b"], "age": [1, 2]},
schema=self.pa_schema))
+ src = pa.table({"_ROW_ID": [10_000]}, schema=pa.schema([("_ROW_ID",
pa.int64())]))
+ ds = read_by_row_id(target, src, self.catalog_options,
projection=["age"])
+ with self.assertRaises(Exception):
+ ds.take_all()
+
+ def test_empty_source_non_empty_target_keeps_schema(self):
+ target = self._create()
+ self._write(target, pa.Table.from_pydict(
+ {"id": [1, 2], "name": ["a", "b"], "age": [1, 2]},
schema=self.pa_schema))
+ empty_src = pa.table({"_ROW_ID": pa.array([], pa.int64())})
+ ds = read_by_row_id(target, empty_src, self.catalog_options,
projection=["id", "age"])
+ self.assertEqual(ds.count(), 0)
+ self.assertIsNotNone(ds.schema())
+ self.assertEqual(set(ds.schema().names), {"id", "age", "_ROW_ID"})
+
+ def test_empty_result_schema_matches_nonempty_read(self):
+ target = self._create()
+ self._write(target, pa.Table.from_pydict(
+ {"id": [1, 2], "name": ["a", "b"], "age": [1, 2]},
schema=self.pa_schema))
+ rid = self._rowid_by_id(target)
+ proj = ["id", "age"]
+ nonempty = read_by_row_id(
+ target, pa.table({"_ROW_ID": [rid[1]]},
schema=pa.schema([("_ROW_ID", pa.int64())])),
+ self.catalog_options, projection=proj)
+ real_schema = None
+ for b in nonempty.iter_batches(batch_format="pyarrow"):
+ real_schema = b.schema
+ break
+ empty = read_by_row_id(
+ target, pa.table({"_ROW_ID": pa.array([], pa.int64())}),
+ self.catalog_options, projection=proj)
+ self.assertTrue(real_schema.equals(empty.schema().base_schema))
+ self.assertFalse(empty.schema().base_schema.field("_ROW_ID").nullable)
+
+ def test_custom_row_id_col(self):
+ target = self._create()
+ self._write(target, pa.Table.from_pydict(
+ {"id": [1, 2, 3], "name": ["a", "b", "c"], "age": [1, 2, 3]},
+ schema=self.pa_schema))
+ rid = self._rowid_by_id(target)
+ src = pa.table({"row_id": [rid[2]], "url": ["u2"]},
+ schema=pa.schema([("row_id", pa.int64()), ("url",
pa.string())]))
+ ds = read_by_row_id(target, src, self.catalog_options,
+ projection=["name"], row_id_col="row_id")
+ rows = ds.take_all()
+ self.assertEqual([r["name"] for r in rows], ["b"])
+ self.assertEqual(rows[0]["_ROW_ID"], rid[2])
+
+
+if __name__ == "__main__":
+ unittest.main()