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 08fbfd98c7 [python][ray] read_by_row_id: dynamic_options + return a
lazy Dataset (#8468)
08fbfd98c7 is described below
commit 08fbfd98c703759a150924c48e51948207fb503f
Author: XiaoHongbo <[email protected]>
AuthorDate: Mon Jul 6 16:03:06 2026 +0800
[python][ray] read_by_row_id: dynamic_options + return a lazy Dataset
(#8468)
Two follow-ups to #8465.
- `dynamic_options`: a dict applied via table.copy (as a normal read
does), so callers can override read options on a target passed by name —
mainly `{"blob-as-descriptor": "true"}` to return small BlobDescriptor
bytes instead of
materialising large blobs, resolved later with map_with_blobs.
Distributed path unchanged.
- `Lazy Dataset`: two upfront checks executed the source at call time
(so a bucket_join source ran twice). Now the schema is read with
fetch_if_missing=False and the empty-source precheck is dropped —
limit(1).count() runs only in the empty-target branch (like
update_by_row_id), schema preserved by unioning a typed-empty block.
---
docs/docs/pypaimon/ray-data.md | 16 ++-
paimon-python/pypaimon/ray/read_by_row_id.py | 68 +++++++++--
.../pypaimon/tests/ray_read_by_row_id_test.py | 127 +++++++++++++++++++++
3 files changed, 196 insertions(+), 15 deletions(-)
diff --git a/docs/docs/pypaimon/ray-data.md b/docs/docs/pypaimon/ray-data.md
index c45f88ec09..a9bd84d38d 100644
--- a/docs/docs/pypaimon/ray-data.md
+++ b/docs/docs/pypaimon/ray-data.md
@@ -571,9 +571,14 @@ ds = read_by_row_id(
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.
+ are resolved to their payloads, unless overridden via `dynamic_options`.
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.
+- `dynamic_options`: read options applied via `table.copy`, e.g.
+ `{"blob-as-descriptor": "true"}` to read blob columns as small
`BlobDescriptor` bytes
+ (resolved later with `map_with_blobs`), or `scan.snapshot-id` /
`scan.tag-name` to read a
+ specific snapshot. Options that flip table invariants
(`data-evolution.enabled`,
+ `row-tracking.enabled`, `deletion-vectors.enabled`) are rejected.
- `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.
@@ -584,9 +589,10 @@ ds = read_by_row_id(
- 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.
+- The row ids must exist in the resolved target snapshot (latest, or the one
selected via
+ `dynamic_options`); 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.
+- 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).
diff --git a/paimon-python/pypaimon/ray/read_by_row_id.py
b/paimon-python/pypaimon/ray/read_by_row_id.py
index b145823ba0..7c7a650803 100644
--- a/paimon-python/pypaimon/ray/read_by_row_id.py
+++ b/paimon-python/pypaimon/ray/read_by_row_id.py
@@ -48,6 +48,20 @@ def _empty_result(table: "FileStoreTable", read_cols:
List[str]):
return ray.data.from_arrow(_read_output_schema(table,
read_cols).empty_table())
+def _read_snapshot(table):
+ """The snapshot to route/read on: a time-travel dynamic option if set,
else the latest."""
+ from pypaimon.snapshot.time_travel_util import SCAN_KEYS, TimeTravelUtil
+
+ opts = table.options.options
+ if not any(opts.contains_key(k) for k in SCAN_KEYS):
+ return table.snapshot_manager().get_latest_snapshot()
+ snap = TimeTravelUtil.try_travel_to_snapshot(
+ opts, table.tag_manager(), table.snapshot_manager())
+ if snap is None:
+ raise ValueError("could not resolve the time-travel snapshot from
dynamic_options.")
+ return snap
+
+
def read_by_row_id(
target: str,
row_ids: Any,
@@ -55,6 +69,7 @@ def read_by_row_id(
*,
projection: List[str],
row_id_col: Optional[str] = None,
+ dynamic_options: Optional[Dict[str, str]] = None,
num_partitions: Optional[int] = None,
ray_remote_args: Optional[Dict[str, Any]] = None,
):
@@ -65,7 +80,11 @@ def read_by_row_id(
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.
+ ``projection`` lists top-level columns; blob columns resolve to payloads
by default.
+ ``dynamic_options`` overrides read options via ``table.copy``:
``{"blob-as-descriptor":
+ "true"}`` for descriptor bytes (resolve with ``map_with_blobs``), or
``scan.snapshot-id`` /
+ ``scan.tag-name`` to read that snapshot. Options flipping table invariants
+ (``data-evolution.enabled`` etc.) are rejected.
Requires ``ray >= 2.50`` and a target with ``data-evolution.enabled`` +
``row-tracking.enabled``.
@@ -78,6 +97,7 @@ def read_by_row_id(
Returns a ``ray.data.Dataset`` of ``(*projection, _ROW_ID)``.
"""
from pypaimon.catalog.catalog_factory import CatalogFactory
+ from pypaimon.snapshot.time_travel_util import SCAN_KEYS
from pypaimon.table.special_fields import SpecialFields
_require_ray_join()
@@ -98,6 +118,16 @@ def read_by_row_id(
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",
+ "deletion-vectors.enabled"} & set(dynamic_options))
+ if bad:
+ raise ValueError(f"dynamic_options cannot override table
invariants {bad}.")
+ # table.copy's _try_time_travel swallows the multi-key error, so
reject it here.
+ if len([k for k in SCAN_KEYS if k in dynamic_options]) > 1:
+ raise ValueError(f"dynamic_options may set at most one time-travel
key {SCAN_KEYS}.")
+ table = table.copy(dynamic_options)
rid = SpecialFields.ROW_ID.name
src_rid_col = row_id_col or rid
@@ -111,27 +141,44 @@ def read_by_row_id(
"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):
+ # Only check now if the schema is free; fetching it would execute a lazy
source.
+ known_schema = source_ds.schema(fetch_if_missing=False)
+ if known_schema is not None and src_rid_col not in set(known_schema.names):
raise ValueError(f"row_ids source is missing the {src_rid_col!r}
column.")
def _project_rid(batch: pa.Table) -> pa.Table:
+ if src_rid_col not in batch.column_names:
+ raise ValueError(f"row_ids source is missing the {src_rid_col!r}
column.")
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()
+ base = _read_snapshot(table)
+ if base is not None and dynamic_options and any(k in dynamic_options for k
in SCAN_KEYS):
+ # A pre-row-tracking snapshot has files without row ids; fail clearly
here rather
+ # than deep in the planner (the persisted-table check above cannot see
this).
+ from pypaimon.common.options.core_options import CoreOptions
+ from pypaimon.common.options.options import Options
+ base_schema = table.schema_manager.get_schema(base.schema_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 not source_empty:
+ # 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(
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)
+ # base captures the resolved snapshot; reduce any time-travel key to a
plain snapshot-id
+ # so the planner's own snapshot-id pin does not read as a second,
conflicting one.
+ from pypaimon.common.options.core_options import CoreOptions
+ present = [k for k in SCAN_KEYS if table.options.options.contains_key(k)]
+ if present:
+ overrides = {k: None for k in present}
+ overrides[CoreOptions.SCAN_SNAPSHOT_ID.key()] = str(base.id)
+ table = table.copy(overrides)
try:
result = distributed_read_by_row_id(
rid_ds, table, projection,
@@ -144,4 +191,5 @@ def read_by_row_id(
raise # _reraise_inner always raises
if result is None:
return _empty_result(table, read_cols)
- return result
+ # Lazy result; union a typed-empty block so an empty source still carries
the schema.
+ return result.union(_empty_result(table, read_cols))
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 18e67de3e9..2fa6cd00c9 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
@@ -164,6 +164,113 @@ class RayReadByRowIdTest(unittest.TestCase):
self.assertEqual(bytes(got[2]["payload"]), payloads[1])
self.assertEqual(bytes(got[4]["payload"]), payloads[3])
+ def test_blob_as_descriptor_via_dynamic_options(self):
+ from pypaimon.ray import map_with_blobs
+ from pypaimon.table.row.blob import BlobDescriptor
+ blob_schema = pa.schema([("id", pa.int32()), ("payload",
pa.large_binary())])
+ target = self._create(schema=blob_schema)
+ payloads = [bytes([i]) * (i + 3) 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=["payload"],
+ dynamic_options={"blob-as-descriptor": "true"})
+ rows = ds.take_all()
+
self.assertTrue(all(BlobDescriptor.is_blob_descriptor(bytes(r["payload"])) for
r in rows))
+
+ tbl = self.catalog.get_table(target)
+
+ def fn(scalar_batch, blobs):
+ return pa.table({"_ROW_ID":
scalar_batch.column("_ROW_ID").to_pylist(),
+ "n": [len(b) if b is not None else 0 for b in
blobs["payload"]]})
+
+ res = map_with_blobs(ds, ["payload"], fn, file_io=tbl.file_io,
+ all_blob_columns=["payload"], batch_size=1)
+ n = {r["_ROW_ID"]: r["n"] for r in res.take_all()}
+ self.assertEqual(n[rid[2]], len(payloads[1]))
+ self.assertEqual(n[rid[4]], len(payloads[3]))
+
+ def test_rejects_invariant_dynamic_options(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.assertRaisesRegex(ValueError, "invariant"):
+ read_by_row_id(target, src, self.catalog_options,
projection=["age"],
+ dynamic_options={"deletion-vectors.enabled":
"true"})
+
+ def test_time_travel_via_dynamic_options(self):
+ target = self._create()
+ self._write(target, pa.Table.from_pydict(
+ {"id": [1, 2], "name": ["a", "b"], "age": [1, 2]},
schema=self.pa_schema))
+ self._write(target, pa.Table.from_pydict( # snapshot 2 adds ids 3, 4
+ {"id": [3, 4], "name": ["c", "d"], "age": [3, 4]},
schema=self.pa_schema))
+ rid = self._rowid_by_id(target)
+ idcol = pa.schema([("_ROW_ID", pa.int64())])
+
+ ds = read_by_row_id(target, pa.table({"_ROW_ID": [rid[1]]},
schema=idcol),
+ self.catalog_options, projection=["id", "age"],
+ dynamic_options={"scan.snapshot-id": "1"})
+ got = self._rows_by_id(ds)
+ self.assertEqual(set(got), {1})
+
+ # id=3 exists only in snapshot 2; at snapshot 1 its row id is foreign
+ ds2 = read_by_row_id(target, pa.table({"_ROW_ID": [rid[3]]},
schema=idcol),
+ self.catalog_options, projection=["id", "age"],
+ dynamic_options={"scan.snapshot-id": "1"})
+ with self.assertRaisesRegex(Exception, "valid range"):
+ ds2.take_all()
+
+ def test_time_travel_via_tag_dynamic_options(self):
+ target = self._create()
+ self._write(target, pa.Table.from_pydict(
+ {"id": [1, 2], "name": ["a", "b"], "age": [1, 2]},
schema=self.pa_schema))
+ self.catalog.get_table(target).create_tag("v1", 1)
+ self._write(target, pa.Table.from_pydict(
+ {"id": [3, 4], "name": ["c", "d"], "age": [3, 4]},
schema=self.pa_schema))
+ rid = self._rowid_by_id(target)
+ idcol = pa.schema([("_ROW_ID", pa.int64())])
+
+ # tag v1 == snapshot 1: id=1 reads, id=3 (snapshot 2 only) is foreign
+ ds = read_by_row_id(target, pa.table({"_ROW_ID": [rid[1]]},
schema=idcol),
+ self.catalog_options, projection=["id", "age"],
+ dynamic_options={"scan.tag-name": "v1"})
+ self.assertEqual(set(self._rows_by_id(ds)), {1})
+ ds2 = read_by_row_id(target, pa.table({"_ROW_ID": [rid[3]]},
schema=idcol),
+ self.catalog_options, projection=["id", "age"],
+ dynamic_options={"scan.tag-name": "v1"})
+ with self.assertRaisesRegex(Exception, "valid range"):
+ ds2.take_all()
+
+ def test_rejects_multiple_time_travel_keys(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.assertRaisesRegex(ValueError, "at most one time-travel"):
+ read_by_row_id(target, src, self.catalog_options,
projection=["age"],
+ dynamic_options={"scan.snapshot-id": "1",
"scan.tag-name": "x"})
+
+ def test_time_travel_before_row_tracking_raises(self):
+ from pypaimon.schema.schema_change import SchemaChange
+ name = self._create(options={}) # plain table: no data-evolution /
row-tracking
+ self._write(name, pa.Table.from_pydict(
+ {"id": [1], "name": ["a"], "age": [1]}, schema=self.pa_schema))
+ self.catalog.alter_table(name, [
+ SchemaChange.set_option("row-tracking.enabled", "true"),
+ SchemaChange.set_option("data-evolution.enabled", "true")])
+ self._write(name, pa.Table.from_pydict(
+ {"id": [2], "name": ["b"], "age": [2]}, schema=self.pa_schema))
+ src = pa.table({"_ROW_ID": [0]}, schema=pa.schema([("_ROW_ID",
pa.int64())]))
+ # snapshot 1 predates row-tracking -> clear error, not a silent empty
read
+ with self.assertRaisesRegex(ValueError, "row-tracking|data-evolution"):
+ read_by_row_id(name, src, self.catalog_options, projection=["age"],
+ dynamic_options={"scan.snapshot-id": "1"})
+
def test_pins_base_snapshot(self):
import importlib
m = importlib.import_module("pypaimon.ray.read_by_row_id")
@@ -277,6 +384,26 @@ class RayReadByRowIdTest(unittest.TestCase):
with self.assertRaises(Exception):
ds.take_all()
+ def test_returns_lazy_without_executing_source(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)
+ marker = os.path.join(self.tempdir, f"exec_{uuid.uuid4().hex}")
+
+ def spy(batch):
+ open(marker, "a").close()
+ return batch
+
+ src = ray.data.from_arrow(
+ pa.table({"_ROW_ID": [rid[1]]}, schema=pa.schema([("_ROW_ID",
pa.int64())]))
+ ).map_batches(spy, batch_format="pyarrow")
+ ds = read_by_row_id(target, src, self.catalog_options,
projection=["id", "age"])
+ self.assertFalse(os.path.exists(marker), "source was executed at call
time")
+ rows = ds.take_all()
+ self.assertTrue(os.path.exists(marker))
+ self.assertEqual({r["id"] for r in rows}, {1})
+
def test_empty_source_non_empty_target_keeps_schema(self):
target = self._create()
self._write(target, pa.Table.from_pydict(