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 dd2bc70cca [python] Preserve retained snapshots in Ray row ID reads 
(#10042)
dd2bc70cca is described below

commit dd2bc70cca87f6f808b815040e07154ada282a06
Author: chaoyang <[email protected]>
AuthorDate: Mon Sep 21 15:04:58 2026 +0800

    [python] Preserve retained snapshots in Ray row ID reads (#10042)
---
 docs/docs/pypaimon/ray-row-ids.md                  |  5 ++
 .../pypaimon/ray/data_evolution_merge_join.py      | 17 ++---
 paimon-python/pypaimon/ray/read_by_row_id.py       | 16 ++---
 .../pypaimon/tests/ray_read_by_row_id_test.py      | 76 +++++++++++++++++++++-
 4 files changed, 92 insertions(+), 22 deletions(-)

diff --git a/docs/docs/pypaimon/ray-row-ids.md 
b/docs/docs/pypaimon/ray-row-ids.md
index 4f65b68f01..ec8eed7269 100644
--- a/docs/docs/pypaimon/ray-row-ids.md
+++ b/docs/docs/pypaimon/ray-row-ids.md
@@ -143,6 +143,11 @@ ds = read_by_row_id(
   ranges raise an error; matching numeric IDs from another table cannot be
   distinguished. Persist the source table and snapshot or tag with row-ID work
   lists, and select that version when reading them.
+- A tag selected through `scan.tag-name` or `scan.version` remains readable 
after
+  its main snapshot metadata expires, while the tag retains the snapshot's 
files.
+  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`.
 - For a non-empty target, the `row_ids` source is consumed lazily by the 
downstream
diff --git a/paimon-python/pypaimon/ray/data_evolution_merge_join.py 
b/paimon-python/pypaimon/ray/data_evolution_merge_join.py
index 5cb42fbf49..835c9a981b 100644
--- a/paimon-python/pypaimon/ray/data_evolution_merge_join.py
+++ b/paimon-python/pypaimon/ray/data_evolution_merge_join.py
@@ -904,22 +904,21 @@ def distributed_read_by_row_id(
     *,
     num_partitions: Optional[int],
     ray_remote_args: Optional[Dict[str, Any]] = None,
-    base_snapshot_id: Optional[int] = None,
     estimated_size_bytes: Optional[int] = None,
     estimated_num_rows: Optional[int] = None,
     data_context=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``.
+    resolved). ``table`` carries the resolved read snapshot. 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
@@ -935,13 +934,9 @@ def distributed_read_by_row_id(
     # 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
-    )
+    # The caller pinned the resolved snapshot, including any retained tag 
metadata.
     planner = TableUpdateByRowId(
-        scan_table,
+        table,
         "_read_by_row_id_planner_" + uuid.uuid4().hex[:8],
         BATCH_COMMIT_IDENTIFIER,
     )
@@ -995,7 +990,7 @@ def distributed_read_by_row_id(
             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_table = table  # read at the same pinned snapshot the planner 
routed on
     captured_read_cols = read_cols
     captured_empty = empty_out
 
diff --git a/paimon-python/pypaimon/ray/read_by_row_id.py 
b/paimon-python/pypaimon/ray/read_by_row_id.py
index d9358f3723..234b6edbe1 100644
--- a/paimon-python/pypaimon/ray/read_by_row_id.py
+++ b/paimon-python/pypaimon/ray/read_by_row_id.py
@@ -169,6 +169,10 @@ def read_by_row_id(
         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 table.table_schema.id != base_schema.id:
+            raise ValueError(
+                "The time-travel schema changed while resolving the read 
snapshot; "
+                "retry 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.")
@@ -179,20 +183,14 @@ def read_by_row_id(
             raise ValueError(
                 f"target '{target}' has no rows; every _ROW_ID in the source 
is foreign.")
         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)
+    # Carry the resolved metadata into planning and workers: a tag can retain 
its
+    # snapshot after the main snapshot file expires, or move before lazy 
execution.
+    table = table._copy_with_snapshot(base)
     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,
             estimated_size_bytes=estimated_size_bytes,
             estimated_num_rows=estimated_num_rows,
             data_context=data_context,
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 d14392634b..be214002b3 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
@@ -255,6 +255,78 @@ class RayReadByRowIdTest(unittest.TestCase):
             read_by_row_id(target, src, self.catalog_options, 
projection=["age"],
                            dynamic_options={"scan.snapshot-id": "1", 
"scan.tag-name": "x"})
 
+    def test_retained_tag_after_snapshot_expiry(self):
+        from pypaimon.schema.schema_change import SchemaChange
+
+        target = self._create()
+        self._write(target, pa.Table.from_pydict(
+            {"id": [1, 2], "name": ["a", "b"], "age": [10, 20]}, 
schema=self.pa_schema))
+        table = self.catalog.get_table(target)
+        table.create_tag("training", 1)
+        rid = self._rowid_by_id(target)
+        self._write(target, pa.Table.from_pydict(
+            {"id": [3], "name": ["c"], "age": [30]}, schema=self.pa_schema))
+        self.catalog.alter_table(target, [SchemaChange.rename_column("age", 
"years")])
+        table.file_io.delete(table.snapshot_manager().get_snapshot_path(1))
+
+        for selector in ("scan.tag-name", "scan.version"):
+            with self.subTest(selector=selector):
+                ds = read_by_row_id(
+                    target, pa.table({"_ROW_ID": [rid[2], rid[1], rid[1]]}),
+                    self.catalog_options, projection=["id", "age"],
+                    dynamic_options={selector: "training", 
"scan.native-plan.enabled": "true"})
+                self.assertEqual(self._rows_by_id(ds), {
+                    1: {"id": 1, "age": 10, "_ROW_ID": rid[1]},
+                    2: {"id": 2, "age": 20, "_ROW_ID": rid[2]},
+                })
+
+    def test_rejects_tag_schema_change_during_snapshot_resolution(self):
+        import importlib
+        from pypaimon.schema.schema_change import SchemaChange
+
+        module = importlib.import_module("pypaimon.ray.read_by_row_id")
+        target = self._create()
+        self._write(target, pa.Table.from_pydict(
+            {"id": [1], "name": ["a"], "age": [10]}, schema=self.pa_schema))
+        table = self.catalog.get_table(target)
+        table.create_tag("training", 1)
+        self.catalog.alter_table(target, [SchemaChange.rename_column("age", 
"years")])
+        schema = pa.schema([("id", pa.int32()), ("name", pa.string()), 
("years", pa.int32())])
+        self._write(target, pa.table({"id": [2], "name": ["b"], "years": 
[20]}, schema=schema))
+        resolve = module._read_snapshot
+
+        def resolve_after_tag_moves(read_table):
+            table.replace_tag("training")
+            return resolve(read_table)
+
+        with mock.patch.object(module, "_read_snapshot", 
resolve_after_tag_moves):
+            with self.assertRaisesRegex(ValueError, "schema changed.*retry"):
+                read_by_row_id(
+                    target, pa.table({"_ROW_ID": [0]}), self.catalog_options,
+                    projection=["age"], dynamic_options={"scan.tag-name": 
"training"})
+
+    def test_lazy_tag_read_keeps_resolved_snapshot(self):
+        target = self._create()
+        self._write(target, pa.Table.from_pydict(
+            {"id": [1], "name": ["a"], "age": [10]}, schema=self.pa_schema))
+        table = self.catalog.get_table(target)
+        table.create_tag("training", 1)
+        rid = self._rowid_by_id(target)[1]
+        ds = read_by_row_id(
+            target, pa.table({"_ROW_ID": [rid]}), self.catalog_options,
+            projection=["id", "age"], dynamic_options={"scan.tag-name": 
"training"})
+
+        from pypaimon.multimodal.table import MultimodalTable
+        MultimodalTable(self.catalog, target, table).update("id = 1", {"age": 
99})
+        table.replace_tag("training")
+        table.file_io.delete(table.snapshot_manager().get_snapshot_path(1))
+
+        self.assertEqual(ds.take_all(), [{"id": 1, "age": 10, "_ROW_ID": rid}])
+        latest = read_by_row_id(
+            target, pa.table({"_ROW_ID": [rid]}), self.catalog_options,
+            projection=["age"], dynamic_options={"scan.tag-name": "training"})
+        self.assertEqual(latest.take_all(), [{"age": 99, "_ROW_ID": rid}])
+
     def test_row_tracking_cannot_be_enabled_after_data(self):
         # row-tracking.enabled / data-evolution.enabled are immutable once the
         # table has snapshots (Java parity), so a "snapshot predates
@@ -283,10 +355,10 @@ class RayReadByRowIdTest(unittest.TestCase):
         captured = {}
 
         def fake_read(rid_ds, table, projection, *, num_partitions,
-                      ray_remote_args=None, base_snapshot_id=None,
+                      ray_remote_args=None,
                       estimated_size_bytes=None, estimated_num_rows=None,
                       data_context=None):
-            captured["base_snapshot_id"] = base_snapshot_id
+            captured["base_snapshot_id"] = table._read_snapshot.id
             captured["num_partitions"] = num_partitions
             captured["estimated_size_bytes"] = estimated_size_bytes
             captured["estimated_num_rows"] = estimated_num_rows

Reply via email to