This is an automated email from the ASF dual-hosted git repository.

JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git


The following commit(s) were added to refs/heads/main by this push:
     new af72cf06 Support captured row ID routing and read selection (#1209)
af72cf06 is described below

commit af72cf06451f3716e9a73e033cc271517871e64c
Author: Jingsong Lee <[email protected]>
AuthorDate: Fri Oct 23 20:35:20 2026 +0800

    Support captured row ID routing and read selection (#1209)
---
 bindings/python/README.md                          |  23 ++
 .../python/python/pypaimon_rust/datafusion.pyi     |  18 ++
 bindings/python/src/read.rs                        | 136 +++++++++++
 crates/paimon/src/table/table_update_by_row_id.rs  | 100 +++++++-
 crates/paimon/src/table/table_update_plan_tests.rs | 256 ++++++++++++++++++++-
 5 files changed, 528 insertions(+), 5 deletions(-)

diff --git a/bindings/python/README.md b/bindings/python/README.md
index fc9cc1d2..d793d2d3 100644
--- a/bindings/python/README.md
+++ b/bindings/python/README.md
@@ -204,6 +204,29 @@ Combined incremental scans expose
 the same API for their selected Delta or Changelog window. Snapshot metadata is
 available from `plan.snapshot_id()`.
 
+### Captured row-ID routing
+
+`RowIdPlan` provides pure routing for distributed Data Evolution operations:
+
+```python
+from pypaimon_rust.datafusion import RowIdPlan
+
+routing = RowIdPlan(table.new_read_builder().new_scan().plan_all())
+first_row_ids = routing.route(row_ids)  # row_ids is an Arrow Int64Array
+delete_groups = routing.route_by_bucket(row_ids)
+selected = routing.select(row_ids)
+```
+
+Routing preserves input order and duplicates, and rejects null or foreign IDs.
+Read/update groups use their anchor's first row ID. Delete groups share one
+routing ID per partition and bucket so each worker can publish a complete
+deletion-vector index. These IDs are local to the captured plan.
+
+`select` retains complete normal and dedicated file groups, deletion metadata,
+authorization and the captured snapshot, and adds sorted, deduplicated row 
ranges
+for reading. It does not check row liveness or authorize file access; readers
+and mutation workers retain those checks. The selected plan is only for 
reading.
+
 ### Shard column updates
 
 The batch and stream update objects expose
diff --git a/bindings/python/python/pypaimon_rust/datafusion.pyi 
b/bindings/python/python/pypaimon_rust/datafusion.pyi
index 073f583d..92964a99 100644
--- a/bindings/python/python/pypaimon_rust/datafusion.pyi
+++ b/bindings/python/python/pypaimon_rust/datafusion.pyi
@@ -78,6 +78,24 @@ class RowIdPlan:
         """Inclusive row-ID bounds and all physical files of each logical 
group."""
         ...
     def plan(self) -> Plan: ...
+    def route(self, row_ids: pyarrow.Int64Array) -> pyarrow.Int64Array:
+        """Return each row's owning file-group first row ID, preserving input 
order."""
+        ...
+    def route_by_bucket(self, row_ids: pyarrow.Int64Array) -> 
pyarrow.Int64Array:
+        """Return one opaque routing ID per captured partition/bucket.
+
+        IDs are stable within this plan. Multiple file groups in the same
+        partition/bucket share an ID so one worker can publish their DV index.
+        """
+        ...
+    def select(self, row_ids: pyarrow.Int64Array) -> Plan:
+        """Build a read-only plan with sorted, deduplicated row selections.
+
+        Complete file groups, deletion metadata, grants, paths and the captured
+        snapshot are retained. Readers authorize execution independently. This
+        selected plan must not be used as a mutation plan.
+        """
+        ...
 
 class MergeAssignment:
     """An immutable source reference, target reference, literal or callback."""
diff --git a/bindings/python/src/read.rs b/bindings/python/src/read.rs
index 3545ff39..4d9b9c32 100644
--- a/bindings/python/src/read.rs
+++ b/bindings/python/src/read.rs
@@ -20,6 +20,7 @@ use std::collections::HashMap;
 use std::sync::atomic::{AtomicBool, Ordering};
 use std::sync::{Arc, Mutex};
 
+use arrow::array::{Array, Int64Array};
 use arrow::datatypes::SchemaRef;
 use arrow::pyarrow::ToPyArrow;
 use arrow::record_batch::RecordBatch;
@@ -1243,6 +1244,16 @@ pub struct PyRowIdPlan {
     inner: paimon::table::RowIdPlan,
 }
 
+impl PyRowIdPlan {
+    fn row_ids(value: &Bound<'_, PyAny>) -> PyResult<Int64Array> {
+        crate::arrow_input::array_from_pyarrow(value)?
+            .as_any()
+            .downcast_ref::<Int64Array>()
+            .cloned()
+            .ok_or_else(|| PyTypeError::new_err("Row-ID routing requires an 
Int64Array"))
+    }
+}
+
 #[pymethods]
 impl PyRowIdPlan {
     #[new]
@@ -1275,6 +1286,32 @@ impl PyRowIdPlan {
     fn plan(&self) -> PyPlan {
         self.inner.plan().clone().into()
     }
+
+    /// Return the owning file group's first row ID in input order.
+    fn route(&self, py: Python<'_>, row_ids: &Bound<'_, PyAny>) -> 
PyResult<Py<PyAny>> {
+        let row_ids = Self::row_ids(row_ids)?;
+        let groups = py
+            .detach(|| self.inner.route(&row_ids))
+            .map_err(to_py_err)?;
+        groups.to_data().to_pyarrow(py).map(Bound::unbind)
+    }
+
+    /// Return one routing ID per captured partition/bucket, preserving order.
+    fn route_by_bucket(&self, py: Python<'_>, row_ids: &Bound<'_, PyAny>) -> 
PyResult<Py<PyAny>> {
+        let row_ids = Self::row_ids(row_ids)?;
+        let groups = py
+            .detach(|| self.inner.route_by_bucket(&row_ids))
+            .map_err(to_py_err)?;
+        groups.to_data().to_pyarrow(py).map(Bound::unbind)
+    }
+
+    /// Select rows for reading while retaining complete groups and their 
grants.
+    fn select(&self, py: Python<'_>, row_ids: &Bound<'_, PyAny>) -> 
PyResult<PyPlan> {
+        let row_ids = Self::row_ids(row_ids)?;
+        py.detach(|| self.inner.select(&row_ids))
+            .map(Into::into)
+            .map_err(to_py_err)
+    }
 }
 
 #[pyclass(name = "Split", module = "pypaimon_rust.datafusion")]
@@ -1363,6 +1400,105 @@ mod tests {
 
     use super::*;
 
+    #[test]
+    fn row_id_plan_binding_routes_arrow_and_keeps_read_selection_separate() {
+        Python::attach(|py| {
+            let schema = Schema::builder()
+                .column("value", DataType::Int(IntType::new()))
+                .option("bucket", "-1")
+                .option("data-evolution.enabled", "true")
+                .option("row-tracking.enabled", "true")
+                .option("deletion-vectors.enabled", "true")
+                .build()
+                .unwrap();
+            let table = Table::new(
+                FileIOBuilder::new("memory").build().unwrap(),
+                Identifier::new("default", "row_id_binding"),
+                "memory:/row-id-binding".into(),
+                TableSchema::new(0, &schema),
+                None,
+            );
+            let plan = py.detach(|| {
+                runtime().block_on(async {
+                    for values in [vec![10, 11], vec![12]] {
+                        let builder = table.new_write_builder();
+                        let mut writer = builder.new_write().unwrap();
+                        let batch = RecordBatch::try_from_iter(vec![(
+                            "value",
+                            Arc::new(arrow::array::Int32Array::from(values))
+                                as arrow::array::ArrayRef,
+                        )])
+                        .unwrap();
+                        writer.write_arrow_batch(&batch).await.unwrap();
+                        builder
+                            .new_commit()
+                            .commit(writer.prepare_commit().await.unwrap())
+                            .await
+                            .unwrap();
+                    }
+                    table
+                        .new_read_builder()
+                        .new_scan()
+                        .plan_all()
+                        .await
+                        .unwrap()
+                })
+            });
+            let locals = PyDict::new(py);
+            locals.set_item("plan", PyPlan::from(plan)).unwrap();
+            locals
+                .set_item("RowIdPlan", py.get_type::<PyRowIdPlan>())
+                .unwrap();
+            locals.set_item("Plan", py.get_type::<PyPlan>()).unwrap();
+            py.run(
+                pyo3::ffi::c_str!(
+                    r#"
+import pyarrow as pa
+
+routing = RowIdPlan(plan)
+assert routing.snapshot_id() == 2
+ids = pa.array([999, 2, 0, 1, 0, 999], type=pa.int64()).slice(1, 4)
+assert routing.route(ids).to_pylist() == [2, 0, 0, 0]
+assert routing.route_by_bucket(ids).to_pylist() == [0, 0, 0, 0]
+selected = routing.select(ids)
+assert selected.snapshot_id() == 2 and len(selected) == 2
+assert sum(split.row_count() for split in selected.splits()) == 3
+assert routing.route(pa.array([2], type=pa.int64())).to_pylist() == [2]
+try:
+    RowIdPlan(selected)
+except ValueError as error:
+    assert 'without row selection' in str(error)
+else:
+    raise AssertionError('A read selection must not become a mutation plan')
+
+for operation in [routing.route, routing.route_by_bucket, routing.select]:
+    for invalid, error_type in [(pa.array([None], type=pa.int64()), 
ValueError),
+                                (pa.array([-1], type=pa.int64()), ValueError),
+                                (pa.array([3], type=pa.int64()), ValueError),
+                                (pa.array([0], type=pa.int32()), TypeError)]:
+        try:
+            operation(invalid)
+        except error_type:
+            pass
+        else:
+            raise AssertionError('Invalid row IDs were accepted')
+
+empty_ids = pa.array([], type=pa.int64())
+empty = RowIdPlan(Plan(7, []))
+assert empty.route(empty_ids).to_pylist() == []
+assert empty.route_by_bucket(empty_ids).to_pylist() == []
+assert empty.select(empty_ids).snapshot_id() == 7
+assert len(empty.select(empty_ids)) == 0
+assert routing.plan().snapshot_id() == 2
+"#
+                ),
+                Some(&locals),
+                None,
+            )
+            .unwrap();
+        });
+    }
+
     #[test]
     fn 
file_planning_binding_keeps_complete_entries_and_independent_query_filters() {
         Python::attach(|py| {
diff --git a/crates/paimon/src/table/table_update_by_row_id.rs 
b/crates/paimon/src/table/table_update_by_row_id.rs
index acf5eba5..630da1d9 100644
--- a/crates/paimon/src/table/table_update_by_row_id.rs
+++ b/crates/paimon/src/table/table_update_by_row_id.rs
@@ -18,9 +18,9 @@
 //! Row-ID updates sharing one target snapshot and file index.
 
 use super::data_evolution_writer::{group_by_normal_row_id, RowIdFileIndex};
-use crate::table::{CommitMessage, DataEvolutionWriter, DataSplit, Plan, Table};
-use arrow_array::RecordBatch;
-use std::collections::{HashMap, HashSet};
+use crate::table::{CommitMessage, DataEvolutionWriter, DataSplit, Plan, 
RowRange, Table};
+use arrow_array::{Array, Int64Array, RecordBatch};
+use std::collections::{BTreeMap, HashMap, HashSet};
 
 fn invalid(message: impl Into<String>) -> crate::Error {
     crate::Error::DataInvalid {
@@ -37,6 +37,7 @@ fn invalid(message: impl Into<String>) -> crate::Error {
 pub struct RowIdPlan {
     plan: Plan,
     groups: Vec<(i64, i64, DataSplit)>,
+    bucket_first_row_ids: Vec<i64>,
 }
 
 impl RowIdPlan {
@@ -106,7 +107,22 @@ impl RowIdPlan {
         if groups.windows(2).any(|pair| pair[0].1 >= pair[1].0) {
             return Err(invalid("A row-ID plan contains overlapping file 
groups"));
         }
-        Ok(Self { plan, groups })
+        let mut buckets = HashMap::new();
+        let bucket_first_row_ids = groups
+            .iter()
+            .map(|(first, _, split)| {
+                // Groups are sorted, so the first anchor encountered is a
+                // deterministic representative for its partition and bucket.
+                *buckets
+                    .entry((split.partition().to_serialized_bytes(), 
split.bucket()))
+                    .or_insert(*first)
+            })
+            .collect();
+        Ok(Self {
+            plan,
+            groups,
+            bucket_first_row_ids,
+        })
     }
 
     pub fn snapshot_id(&self) -> Option<i64> {
@@ -120,6 +136,82 @@ impl RowIdPlan {
     pub fn plan(&self) -> &Plan {
         &self.plan
     }
+
+    /// Route each row ID to its normal-file group's first row ID.
+    ///
+    /// Output retains input order and duplicates. NULLs, negative IDs and IDs
+    /// outside the captured groups are rejected. This is metadata routing:
+    /// deletion vectors and row existence are checked by the eventual reader
+    /// or mutation, which must authorize independently.
+    pub fn route(&self, row_ids: &Int64Array) -> crate::Result<Int64Array> {
+        let groups = self.group_indices(row_ids)?;
+        Ok(Int64Array::from_iter_values(
+            groups.into_iter().map(|index| self.groups[index].0),
+        ))
+    }
+
+    /// Route each row ID to a stable representative of its partition/bucket.
+    ///
+    /// The representative is the smallest anchor first row ID in that bucket
+    /// across this captured plan. Deletion-vector mutations must assign all
+    /// rows for one partition/bucket to the same worker, including rows from
+    /// different normal-file groups sharing that bucket's index file.
+    pub fn route_by_bucket(&self, row_ids: &Int64Array) -> 
crate::Result<Int64Array> {
+        let groups = self.group_indices(row_ids)?;
+        Ok(Int64Array::from_iter_values(
+            groups
+                .into_iter()
+                .map(|index| self.bucket_first_row_ids[index]),
+        ))
+    }
+
+    /// Select row IDs for reading from the captured snapshot.
+    ///
+    /// Keep every file, deletion vector, exact path and authorization grant in
+    /// each selected logical group. Only the read's absolute row-ID ranges are
+    /// narrowed; input duplicates and order do not duplicate or reorder rows.
+    /// The returned plan is a read selection, not a complete mutation plan.
+    pub fn select(&self, row_ids: &Int64Array) -> crate::Result<Plan> {
+        let groups = self.group_indices(row_ids)?;
+        let mut selections: BTreeMap<usize, Vec<RowRange>> = BTreeMap::new();
+        for (index, row_id) in groups.into_iter().zip(row_ids.values()) {
+            selections
+                .entry(index)
+                .or_default()
+                .push(RowRange::new(*row_id, *row_id));
+        }
+        let splits = selections
+            .into_iter()
+            .map(|(index, ranges)| {
+                self.groups[index]
+                    .2
+                    .clone()
+                    
.with_selected_row_ranges(super::source::merge_row_ranges(ranges))
+            })
+            .collect();
+        self.plan.clone().map_splits(|_| Ok(splits))
+    }
+
+    fn group_indices(&self, row_ids: &Int64Array) -> crate::Result<Vec<usize>> 
{
+        if row_ids.null_count() != 0 {
+            return Err(invalid("_ROW_ID must not be null"));
+        }
+        row_ids
+            .values()
+            .iter()
+            .map(|row_id| {
+                let position = self.groups.partition_point(|(first, _, _)| 
first <= row_id);
+                position
+                    .checked_sub(1)
+                    .filter(|index| *row_id >= 0 && *row_id <= 
self.groups[*index].1)
+                    .ok_or_else(|| {
+                        invalid(format!(
+                            "_ROW_ID {row_id} does not belong to any valid 
range in the captured plan"
+                        ))
+                    })
+            })
+            .collect()
+    }
 }
 
 /// Update columns selected per input table, as in PyPaimon.
diff --git a/crates/paimon/src/table/table_update_plan_tests.rs 
b/crates/paimon/src/table/table_update_plan_tests.rs
index b4fb6ddd..a86c4fd8 100644
--- a/crates/paimon/src/table/table_update_plan_tests.rs
+++ b/crates/paimon/src/table/table_update_plan_tests.rs
@@ -19,7 +19,9 @@ use super::*;
 use crate::catalog::Identifier;
 use crate::io::FileIOBuilder;
 use crate::spec::{DataType, IntType, Schema, TableSchema};
-use crate::table::{DataSplitBuilder, Plan, RowRange};
+use crate::table::{
+    DataSplit, DataSplitBuilder, DeletionFile, Plan, RowIdPlan, RowRange, 
ScanStats,
+};
 use arrow_array::{ArrayRef, Int32Array, Int64Array};
 use std::sync::Arc;
 
@@ -306,6 +308,258 @@ async fn 
row_id_plan_routes_complete_groups_and_rejects_ambiguous_ranges() {
     assert_eq!(routing.plan().splits(), plan.splits());
 }
 
+fn routing_split(
+    source: &DataSplit,
+    first: i64,
+    count: i64,
+    partition: i32,
+    bucket: i32,
+) -> DataSplit {
+    let mut file = source.data_files()[0].clone();
+    file.file_name = format!("anchor-{first}.parquet");
+    file.first_row_id = Some(first);
+    file.row_count = count;
+    file.write_cols = None;
+    let mut partition_row = crate::spec::BinaryRowBuilder::new(1);
+    partition_row.write_int(0, partition);
+    DataSplitBuilder::new()
+        .with_snapshot(source.snapshot_id())
+        .with_partition(partition_row.build())
+        .with_bucket(bucket)
+        
.with_bucket_path(format!("memory:/partition-{partition}/bucket-{bucket}"))
+        .with_data_files(vec![file])
+        .build()
+        .unwrap()
+}
+
+#[tokio::test]
+async fn row_id_plan_routes_sliced_ids_by_anchor_and_partition_bucket() {
+    let table = table("row_id_arrow_routing").await;
+    let plan = table
+        .new_read_builder()
+        .new_scan()
+        .plan_all()
+        .await
+        .unwrap();
+    let source = &plan.splits()[0];
+    let splits = vec![
+        routing_split(source, 30, 5, 1, 1),
+        routing_split(source, i64::MAX - 1, 2, 1, 0),
+        routing_split(source, 5, 4, 1, 0),
+        routing_split(source, 20, 2, 2, 0),
+        routing_split(source, 0, 3, 1, 0),
+    ];
+    let routing = RowIdPlan::from_plan(Plan::from_splits(Some(2), 
splits)).unwrap();
+    // Slices, repeated IDs and arbitrary order must retain their coordinates.
+    // The same bucket number in another partition must remain independent.
+    let ids =
+        Int64Array::from(vec![-99, i64::MAX, 8, 20, 2, 30, 5, i64::MAX - 1, 8, 
-99]).slice(1, 8);
+    assert_eq!(
+        routing.route(&ids).unwrap().values(),
+        &[i64::MAX - 1, 5, 20, 0, 30, 5, i64::MAX - 1, 5]
+    );
+    assert_eq!(
+        routing.route_by_bucket(&ids).unwrap().values(),
+        &[0, 0, 20, 0, 30, 0, 0, 0]
+    );
+    // Bucket representatives come from the complete plan, not only the IDs
+    // present in this input batch or the order of the input splits.
+    assert_eq!(
+        routing
+            .route_by_bucket(&Int64Array::from(vec![7]))
+            .unwrap()
+            .values(),
+        &[0]
+    );
+    let mut reversed = routing.plan().splits().to_vec();
+    reversed.reverse();
+    let reordered = RowIdPlan::from_plan(Plan::from_splits(Some(2), 
reversed)).unwrap();
+    assert_eq!(routing.route(&ids).unwrap(), reordered.route(&ids).unwrap());
+    assert_eq!(
+        routing.route_by_bucket(&ids).unwrap(),
+        reordered.route_by_bucket(&ids).unwrap()
+    );
+
+    for invalid_id in [-1, 3, 4, 9, 19, 22, 35] {
+        let ids = Int64Array::from(vec![0, invalid_id]);
+        for error in [
+            routing.route(&ids).unwrap_err(),
+            routing.route_by_bucket(&ids).unwrap_err(),
+            routing.select(&ids).unwrap_err(),
+        ] {
+            assert!(error.to_string().contains(&format!(
+                "_ROW_ID {invalid_id} does not belong to any valid range"
+            )));
+        }
+    }
+    let null_ids = Int64Array::from(vec![Some(0), None]);
+    for error in [
+        routing.route(&null_ids).unwrap_err(),
+        routing.route_by_bucket(&null_ids).unwrap_err(),
+        routing.select(&null_ids).unwrap_err(),
+    ] {
+        assert!(error.to_string().contains("_ROW_ID must not be null"));
+    }
+    // A failed routing request never changes the captured metadata.
+    assert_eq!(routing.route(&ids).unwrap().len(), ids.len());
+}
+
+#[test]
+fn row_id_plan_empty_inputs_keep_the_captured_snapshot() {
+    for snapshot_id in [None, Some(7)] {
+        let routing = RowIdPlan::from_plan(Plan::from_splits(snapshot_id, 
Vec::new())).unwrap();
+        let empty = Int64Array::from(Vec::<i64>::new());
+        assert!(routing.route(&empty).unwrap().is_empty());
+        assert!(routing.route_by_bucket(&empty).unwrap().is_empty());
+        let selected = routing.select(&empty).unwrap();
+        assert_eq!(selected.snapshot_id(), snapshot_id);
+        assert!(selected.splits().is_empty());
+        assert!(routing.route(&Int64Array::from(vec![0])).is_err());
+        assert!(routing.route_by_bucket(&Int64Array::from(vec![0])).is_err());
+        assert!(routing.select(&Int64Array::from(vec![0])).is_err());
+        assert!(routing
+            .select(&Int64Array::from(vec![None::<i64>]))
+            .is_err());
+    }
+}
+
+#[tokio::test]
+async fn 
row_id_plan_read_selection_retains_complete_groups_paths_deletions_and_grants() 
{
+    let table = table("row_id_read_selection").await;
+    let initial = table
+        .new_read_builder()
+        .new_scan()
+        .plan_all()
+        .await
+        .unwrap();
+    let source = &initial.splits()[0];
+    let mut first = routing_split(source, 10, 6, 1, 0);
+    let mut anchor = first.data_files()[0].clone();
+    anchor.external_path = Some("s3://external/anchor-10.parquet".into());
+    let mut partial = anchor.clone();
+    partial.file_name = "partial-column.parquet".into();
+    partial.external_path = 
Some("s3://external/partial-column.parquet".into());
+    partial.max_sequence_number += 1;
+    partial.write_cols = Some(vec!["value".into()]);
+    let mut blob = partial.clone();
+    blob.file_name = "partial-payload.blob".into();
+    blob.external_path = Some("s3://external/partial-payload.blob".into());
+    blob.first_row_id = Some(11);
+    blob.row_count = 2;
+    let deletions = vec![
+        None,
+        Some(DeletionFile::new(
+            "s3://external/deletions".into(),
+            5,
+            20,
+            Some(1),
+        )),
+        None,
+    ];
+    first = DataSplitBuilder::new()
+        .with_snapshot(2)
+        .with_partition(first.partition().clone())
+        .with_bucket(first.bucket())
+        .with_bucket_path(first.bucket_path().to_string())
+        .with_data_files(vec![blob, anchor, partial])
+        .with_data_deletion_files(deletions)
+        .with_raw_convertible(false)
+        .build()
+        .unwrap();
+    let grant = Arc::new(
+        crate::table::query_auth::QueryAuthGrant::parse(
+            &crate::api::AuthTableQueryResponse::default(),
+            17,
+            None,
+            table.schema().fields(),
+        )
+        .unwrap(),
+    );
+    let stats = ScanStats {
+        manifest_entries_read: 123,
+        split_candidates_built: 3,
+        ..Default::default()
+    };
+    let plan = Plan::from_splits(
+        Some(2),
+        vec![
+            routing_split(source, 60, 2, 1, 0),
+            first,
+            routing_split(source, 30, 5, 1, 0),
+        ],
+    )
+    .planned(Some(grant.clone()))
+    .with_scan_stats(stats);
+    let routing = RowIdPlan::from_plan(plan).unwrap();
+    let ids = Int64Array::from(vec![999, 15, 10, 12, 12, 30, 13, 34, 11, 
999]).slice(1, 8);
+    let selected = routing.select(&ids).unwrap();
+    assert_eq!(selected.snapshot_id(), Some(2));
+    assert_eq!(selected.splits().len(), 2);
+    assert_eq!(selected.stats().manifest_entries_read, 123);
+    assert_eq!(selected.stats().split_candidates_built, 3);
+    assert_eq!(selected.stats().final_splits, 2);
+    assert_eq!(selected.stats().final_files, 4);
+    assert_eq!(
+        selected.splits()[0].row_ranges(),
+        Some([RowRange::new(10, 13), RowRange::new(15, 15)].as_slice())
+    );
+    assert_eq!(
+        selected.splits()[1].row_ranges(),
+        Some([RowRange::new(30, 30), RowRange::new(34, 34)].as_slice())
+    );
+    for (selected, (_, _, original)) in 
selected.splits().iter().zip(routing.groups()) {
+        assert_eq!(selected.data_files(), original.data_files());
+        assert_eq!(
+            selected.data_deletion_files(),
+            original.data_deletion_files()
+        );
+        assert_eq!(selected.partition(), original.partition());
+        assert_eq!(selected.bucket(), original.bucket());
+        assert_eq!(selected.bucket_path(), original.bucket_path());
+        assert_eq!(selected.raw_convertible(), original.raw_convertible());
+        assert!(selected.query_auth_required());
+        assert!(Arc::ptr_eq(selected.query_auth_grant().unwrap(), &grant));
+        assert_eq!(
+            selected.query_auth_response(),
+            original.query_auth_response()
+        );
+
+        // Wire transport preserves the read selection and auth requirement,
+        // but cannot turn serialized policy metadata back into a live grant.
+        let restored =
+            
DataSplit::deserialize_split_v1(&selected.serialize_split_v1().unwrap()).unwrap();
+        assert_eq!(restored.row_ranges(), selected.row_ranges());
+        assert_eq!(restored.data_files(), selected.data_files());
+        assert_eq!(
+            restored.data_deletion_files(),
+            selected.data_deletion_files()
+        );
+        assert!(restored.query_auth_required());
+        assert!(restored.query_auth_grant().is_none());
+    }
+    assert_eq!(routing.plan().splits().len(), 3);
+    assert!(routing
+        .groups()
+        .iter()
+        .all(|(_, _, split)| split.row_ranges().is_none()));
+    // A read selection is intentionally not accepted as a mutation baseline.
+    assert!(RowIdPlan::from_plan(selected.clone()).is_err());
+    assert!(table
+        .new_write_builder()
+        .new_update()
+        .unwrap()
+        .with_plan(selected)
+        .is_err());
+
+    let empty = routing
+        .select(&Int64Array::from(Vec::<i64>::new()))
+        .unwrap();
+    assert_eq!(empty.snapshot_id(), Some(2));
+    assert!(empty.splits().is_empty());
+    assert_eq!(empty.stats().manifest_entries_read, 123);
+    assert_eq!(empty.stats().final_files, 0);
+}
+
 #[tokio::test]
 async fn row_id_plan_keeps_full_partial_columns_and_dedicated_subranges() {
     let table = table("planned_row_id_partial_ranges").await;

Reply via email to