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 b67e6e8d feat(python): plan chunk-shuffled reads with IndexedSplit 
(#890)
b67e6e8d is described below

commit b67e6e8d9c7ce221c951d4977def6a9aeae20b59
Author: Jingsong Lee <[email protected]>
AuthorDate: Mon Sep 21 09:29:13 2026 +0800

    feat(python): plan chunk-shuffled reads with IndexedSplit (#890)
---
 bindings/python/src/read.rs                      |   58 +-
 bindings/python/tests/test_read.py               |  133 ++-
 crates/paimon/src/table/chunk_shuffle.rs         | 1035 ++++++++++++++++++++++
 crates/paimon/src/table/data_evolution_reader.rs |  243 ++---
 crates/paimon/src/table/data_file_reader.rs      |   41 +-
 crates/paimon/src/table/incremental_scan.rs      |   23 +-
 crates/paimon/src/table/mod.rs                   |    1 +
 crates/paimon/src/table/source.rs                |   15 +-
 crates/paimon/src/table/table_scan.rs            |  227 ++++-
 9 files changed, 1659 insertions(+), 117 deletions(-)

diff --git a/bindings/python/src/read.rs b/bindings/python/src/read.rs
index 22af9ee6..c6605a2b 100644
--- a/bindings/python/src/read.rs
+++ b/bindings/python/src/read.rs
@@ -412,6 +412,8 @@ impl PyReadBuilder {
             incremental_range: None,
             row_position_slice: None,
             row_position_shard: None,
+            chunk_shuffle: None,
+            shard: None,
         }
     }
 
@@ -449,6 +451,14 @@ pub struct PyTableScan {
     incremental_range: Option<(i64, i64)>,
     row_position_slice: Option<(u64, u64)>,
     row_position_shard: Option<(u64, u64)>,
+    chunk_shuffle: Option<PyChunkShuffle>,
+    shard: Option<(usize, usize)>,
+}
+
+#[derive(Clone)]
+struct PyChunkShuffle {
+    seed: String,
+    chunk_size: u64,
 }
 
 impl PyTableScan {
@@ -464,6 +474,14 @@ impl PyTableScan {
                 .with_row_position_shard(index, count)
                 .map_err(to_py_err)?;
         }
+        if let Some(chunk_shuffle) = &self.chunk_shuffle {
+            scan = scan
+                .with_chunk_shuffle(&chunk_shuffle.seed, 
chunk_shuffle.chunk_size)
+                .map_err(to_py_err)?;
+        }
+        if let Some((index, count)) = self.shard {
+            scan = scan.with_shard(index, count).map_err(to_py_err)?;
+        }
         Ok(scan)
     }
 
@@ -485,6 +503,14 @@ impl PyTableScan {
                 .with_row_position_shard(index, count)
                 .map_err(to_py_err)?;
         }
+        if let Some(chunk_shuffle) = &self.chunk_shuffle {
+            scan = scan
+                .with_chunk_shuffle(&chunk_shuffle.seed, 
chunk_shuffle.chunk_size)
+                .map_err(to_py_err)?;
+        }
+        if let Some((index, count)) = self.shard {
+            scan = scan.with_shard(index, count).map_err(to_py_err)?;
+        }
         Ok(scan)
     }
 
@@ -533,6 +559,35 @@ impl PyTableScan {
         Ok(slf)
     }
 
+    /// Deterministically shuffle fixed-live-row chunks. `seed` is a decimal
+    /// Python integer string so arbitrarily large seeds retain Python's
+    /// `random.Random` semantics.
+    fn with_chunk_shuffle(
+        mut slf: PyRefMut<'_, Self>,
+        seed: String,
+        chunk_size: u64,
+    ) -> PyResult<PyRefMut<'_, Self>> {
+        // Validate every combination immediately, not only when plan() runs.
+        slf.core_scan()?
+            .with_chunk_shuffle(&seed, chunk_size)
+            .map_err(to_py_err)?;
+        slf.chunk_shuffle = Some(PyChunkShuffle { seed, chunk_size });
+        Ok(slf)
+    }
+
+    /// Select one balanced worker shard for a distributed scan.
+    fn with_shard(
+        mut slf: PyRefMut<'_, Self>,
+        index: usize,
+        count: usize,
+    ) -> PyResult<PyRefMut<'_, Self>> {
+        slf.core_scan()?
+            .with_shard(index, count)
+            .map_err(to_py_err)?;
+        slf.shard = Some((index, count));
+        Ok(slf)
+    }
+
     fn plan(&self, py: Python<'_>) -> PyResult<PyPlan> {
         py.detach(|| {
             runtime().block_on(async {
@@ -756,7 +811,8 @@ impl PySplit {
 
 #[pymethods]
 impl PySplit {
-    /// Physical row count: sum of data-file row counts (not a logical result 
count).
+    /// Selected row count for IndexedSplit-compatible row ranges, otherwise
+    /// the sum of physical data-file row counts.
     fn row_count(&self) -> i64 {
         self.inner.row_count()
     }
diff --git a/bindings/python/tests/test_read.py 
b/bindings/python/tests/test_read.py
index c072f32e..559b4f82 100644
--- a/bindings/python/tests/test_read.py
+++ b/bindings/python/tests/test_read.py
@@ -79,6 +79,100 @@ def test_with_blob_parallelism():
             table.new_read_builder().with_blob_parallelism(0)
 
 
+def test_chunk_shuffle_takes_seed_and_chunk_size_before_optional_shard():
+    with tempfile.TemporaryDirectory() as warehouse:
+        table = _make_table_with_data(warehouse)
+        builder = table.new_read_builder().with_projection(["id"])
+
+        scan = builder.new_scan()
+        assert scan.with_chunk_shuffle(str(2 ** 70), 2) is scan
+        plan = scan.plan()
+        for split in plan.splits():
+            restored = Split.deserialize(split.serialize())
+            assert restored.row_count() == split.row_count()
+        chunks = [
+            pa.Table.from_batches(builder.new_read().read([split]))
+            .column("id").to_pylist()
+            for split in plan.splits()
+        ]
+        assert sorted(value for chunk in chunks for value in chunk) == [1, 2, 
3]
+        assert all(0 < len(chunk) <= 2 for chunk in chunks)
+
+        sharded = []
+        for index in range(2):
+            shard = (
+                builder.new_scan()
+                .with_chunk_shuffle(str(2 ** 70), 2)
+                .with_shard(index, 2)
+                .plan()
+            )
+            sharded.extend(
+                pa.Table.from_batches(builder.new_read().read([split]))
+                .column("id").to_pylist()
+                for split in shard.splits()
+            )
+        assert sharded == chunks
+
+        # Shard is scan-level state, so it may be configured before shuffle.
+        before_shuffle = (
+            builder.new_scan()
+            .with_shard(0, 2)
+            .with_chunk_shuffle(str(2 ** 70), 2)
+            .plan()
+        )
+        before_rows = [
+            pa.Table.from_batches(builder.new_read().read([split]))
+            .column("id").to_pylist()
+            for split in before_shuffle.splits()
+        ]
+        after_rows = [
+            pa.Table.from_batches(builder.new_read().read([split]))
+            .column("id").to_pylist()
+            for split in (
+                builder.new_scan()
+                .with_chunk_shuffle(str(2 ** 70), 2)
+                .with_shard(0, 2)
+                .plan()
+                .splits()
+            )
+        ]
+        assert before_rows == after_rows
+
+        with pytest.raises(ValueError, match="count must be positive"):
+            builder.new_scan().with_shard(0, 0)
+        with pytest.raises(RuntimeError, match="requires chunk_shuffle"):
+            builder.new_scan().with_shard(0, 2).plan()
+
+
+def test_chunk_shuffle_reads_split_local_ranges_across_files():
+    with tempfile.TemporaryDirectory() as warehouse:
+        ctx = SQLContext()
+        ctx.register_catalog("paimon", {"warehouse": warehouse})
+        ctx.sql("CREATE SCHEMA paimon.rdb")
+        ctx.sql("CREATE TABLE paimon.rdb.t (id INT)")
+        ctx.sql("INSERT INTO paimon.rdb.t VALUES (1), (2)")
+        ctx.sql("INSERT INTO paimon.rdb.t VALUES (3), (4)")
+        table = PaimonCatalog({"warehouse": warehouse}).get_table("rdb.t")
+        builder = table.new_read_builder().with_projection(["id"])
+        splits = (
+            builder.new_scan()
+            .with_chunk_shuffle("7", 3)
+            .plan()
+            .splits()
+        )
+
+        chunks = []
+        for split in splits:
+            restored = Split.deserialize(split.serialize())
+            chunks.append(
+                pa.Table.from_batches(builder.new_read().read([restored]))
+                .column("id").to_pylist()
+            )
+
+        assert sorted(len(chunk) for chunk in chunks) == [1, 3]
+        assert sorted(value for chunk in chunks for value in chunk) == [1, 2, 
3, 4]
+
+
 def test_with_row_ranges():
     with tempfile.TemporaryDirectory() as warehouse:
         ctx = SQLContext()
@@ -109,6 +203,43 @@ def test_with_row_ranges():
             table.new_read_builder().with_row_ranges([(2, 1)])
 
 
+def test_row_tracking_append_row_ranges_keep_global_row_ids():
+    with tempfile.TemporaryDirectory() as warehouse:
+        ctx = SQLContext()
+        ctx.register_catalog("paimon", {"warehouse": warehouse})
+        ctx.sql("CREATE SCHEMA paimon.rdb")
+        ctx.sql("""CREATE TABLE paimon.rdb.tracked (id INT, pt STRING)
+            PARTITIONED BY (pt) WITH ('row-tracking.enabled' = 'true')""")
+        ctx.sql("""INSERT INTO paimon.rdb.tracked VALUES
+            (1, 'a'), (2, 'a'), (3, 'a')""")
+        ctx.sql("""INSERT INTO paimon.rdb.tracked VALUES
+            (4, 'b'), (5, 'b'), (6, 'b')""")
+        table = PaimonCatalog({"warehouse": 
warehouse}).get_table("rdb.tracked")
+        builder = table.new_read_builder().with_row_ranges([(3, 4)])
+
+        plan = builder.new_scan().plan()
+        rows = pa.Table.from_batches(builder.new_read().read(plan.splits()))
+
+        assert rows.column("id").to_pylist() == [4, 5]
+
+        chunk_builder = table.new_read_builder().with_projection(["id"])
+        chunks = (
+            chunk_builder.new_scan()
+            .with_chunk_shuffle("7", 2)
+            .plan()
+            .splits()
+        )
+        chunk_rows = [
+            pa.Table.from_batches(chunk_builder.new_read().read([split]))
+            .column("id").to_pylist()
+            for split in chunks
+        ]
+        assert all(0 < len(values) <= 2 for values in chunk_rows)
+        assert sorted(value for values in chunk_rows for value in values) == [
+            1, 2, 3, 4, 5, 6,
+        ]
+
+
 def test_format_table_rejects_row_ranges():
     with tempfile.TemporaryDirectory() as warehouse:
         ctx = SQLContext()
@@ -205,7 +336,7 @@ def 
test_indexed_split_wire_roundtrip_preserves_row_ranges():
         restored = Split.deserialize(split.serialize())
         rows = pa.Table.from_batches(builder.new_read().read([restored]))
 
-        assert restored.row_count() == 3
+        assert restored.row_count() == 1
         assert rows.column("id").to_pylist() == [2]
 
 
diff --git a/crates/paimon/src/table/chunk_shuffle.rs 
b/crates/paimon/src/table/chunk_shuffle.rs
new file mode 100644
index 00000000..d426f649
--- /dev/null
+++ b/crates/paimon/src/table/chunk_shuffle.rs
@@ -0,0 +1,1035 @@
+// 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.
+
+//! Deterministic, fixed-row chunk planning for native Python reads.
+//!
+//! The shuffle intentionally matches `random.Random(seed).shuffle` in CPython.
+//! PyPaimon exposed that ordering before native planning existed, so using a
+//! different Rust RNG would silently assign different chunks to workers.
+
+use std::cmp::Ordering;
+use std::collections::{BTreeMap, HashMap};
+
+use crate::deletion_vector::{DeletionVector, DeletionVectorFactory};
+use crate::spec::{BinaryRow, DataField, DataFileMeta, Datum};
+use crate::table::source::{data_evolution_anchor_file, 
is_data_evolution_normal_file};
+use crate::table::stats_filter::group_by_overlapping_row_id;
+use crate::table::{merge_row_ranges, DataSplit, DataSplitBuilder, 
DeletionFile, RowRange, Table};
+
+/// Native chunk-shuffle configuration. The seed is stored as the unsigned
+/// little-endian 32-bit words consumed by CPython's MT19937 initializer.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) struct ChunkShuffle {
+    seed_words: Vec<u32>,
+    chunk_size: i64,
+}
+
+impl ChunkShuffle {
+    /// Build from a Python integer's decimal spelling. Negative integers use
+    /// their absolute value, matching `random.Random`.
+    pub(crate) fn from_decimal_seed(seed: &str, chunk_size: u64) -> 
crate::Result<Self> {
+        let chunk_size = i64::try_from(chunk_size).map_err(|_| 
crate::Error::DataInvalid {
+            message: format!("chunk_shuffle chunk_size {chunk_size} exceeds 
i64::MAX"),
+            source: None,
+        })?;
+        if chunk_size == 0 {
+            return Err(crate::Error::DataInvalid {
+                message: "chunk_shuffle chunk_size must be 
positive".to_string(),
+                source: None,
+            });
+        }
+        Ok(Self {
+            seed_words: decimal_seed_words(seed)?,
+            chunk_size,
+        })
+    }
+}
+
+#[derive(Debug, Clone)]
+struct InputFile {
+    file: DataFileMeta,
+    deletion_file: Option<DeletionFile>,
+}
+
+#[derive(Debug)]
+struct InputGroup {
+    partition: BinaryRow,
+    bucket: i32,
+    bucket_path: String,
+    total_buckets: i32,
+    snapshot_id: i64,
+    is_streaming: bool,
+    files: Vec<InputFile>,
+}
+
+#[derive(Debug)]
+struct AppendSegment {
+    input: InputFile,
+    ranges: Vec<RowRange>,
+}
+
+#[derive(Debug)]
+struct EvolutionSegment {
+    files: Vec<InputFile>,
+    ranges: Vec<RowRange>,
+}
+
+/// Repack planned files into shuffled, fixed-live-row chunks. Normal scan
+/// planning runs first, so partition/stats/projection pruning and 
deletion-file
+/// resolution remain centralized in `TableScan`.
+pub(crate) async fn chunk_shuffle_splits(
+    table: &Table,
+    splits: Vec<DataSplit>,
+    config: &ChunkShuffle,
+    shard: Option<(usize, usize)>,
+) -> crate::Result<Vec<DataSplit>> {
+    if !table.schema().primary_keys().is_empty() {
+        return Err(crate::Error::Unsupported {
+            message: "chunk_shuffle only supports append tables".to_string(),
+        });
+    }
+    if splits.iter().any(|split| split.row_ranges().is_some()) {
+        return Err(crate::Error::Unsupported {
+            message: "chunk_shuffle cannot combine with row-range 
selection".to_string(),
+        });
+    }
+
+    let partition_fields = partition_fields(table)?;
+    let mut groups = flatten_groups(splits)?;
+    groups.sort_by(|left, right| {
+        compare_partitions(&left.partition, &right.partition, 
&partition_fields)
+            .unwrap_or_else(|_| {
+                left.partition
+                    .to_serialized_bytes()
+                    .cmp(&right.partition.to_serialized_bytes())
+            })
+            .then_with(|| left.bucket.cmp(&right.bucket))
+    });
+
+    let data_evolution = 
table.schema().core_options().data_evolution_enabled();
+    let mut chunks = Vec::new();
+    for mut group in groups {
+        if data_evolution {
+            group.files.sort_by(|left, right| {
+                left.file
+                    .first_row_id
+                    .cmp(&right.file.first_row_id)
+                    .then_with(|| {
+                        is_data_evolution_normal_file(&right.file)
+                            .cmp(&is_data_evolution_normal_file(&left.file))
+                    })
+                    .then_with(|| 
left.file.file_name.cmp(&right.file.file_name))
+            });
+            chunks.extend(evolution_chunks(table, group, 
config.chunk_size).await?);
+        } else {
+            group
+                .files
+                .sort_by(|left, right| 
left.file.file_name.cmp(&right.file.file_name));
+            chunks.extend(append_chunks(table, group, 
config.chunk_size).await?);
+        }
+    }
+
+    PythonRandom::new(&config.seed_words).shuffle(&mut chunks);
+    if let Some((index, count)) = shard {
+        let (start, end) = shard_range(chunks.len(), index, count);
+        chunks = chunks.drain(start..end).collect();
+    }
+    Ok(chunks)
+}
+
+fn partition_fields(table: &Table) -> crate::Result<Vec<DataField>> {
+    let fields = table.schema().fields();
+    table
+        .schema()
+        .partition_keys()
+        .iter()
+        .map(|name| {
+            fields
+                .iter()
+                .find(|field| field.name() == name)
+                .cloned()
+                .ok_or_else(|| crate::Error::DataInvalid {
+                    message: format!("partition field '{name}' does not 
exist"),
+                    source: None,
+                })
+        })
+        .collect()
+}
+
+fn compare_partitions(
+    left: &BinaryRow,
+    right: &BinaryRow,
+    fields: &[DataField],
+) -> crate::Result<Ordering> {
+    for (index, field) in fields.iter().enumerate() {
+        let left = left.get_datum(index, field.data_type())?;
+        let right = right.get_datum(index, field.data_type())?;
+        // Python's key is `(value is None, value)`: non-null sorts first.
+        let ordering = match (left, right) {
+            (None, None) => Ordering::Equal,
+            (None, Some(_)) => Ordering::Greater,
+            (Some(_), None) => Ordering::Less,
+            (Some(left), Some(right)) => compare_datums(&left, &right),
+        };
+        if ordering != Ordering::Equal {
+            return Ok(ordering);
+        }
+    }
+    Ok(Ordering::Equal)
+}
+
+fn compare_datums(left: &Datum, right: &Datum) -> Ordering {
+    left.partial_cmp(right).unwrap_or_else(|| {
+        // NaN has no total order. Python's stable sort leaves incomparable
+        // values in input order; the binary representation is a deterministic
+        // fallback when manifest concurrency changed that input order.
+        left.to_string().cmp(&right.to_string())
+    })
+}
+
+fn flatten_groups(splits: Vec<DataSplit>) -> crate::Result<Vec<InputGroup>> {
+    let mut grouped: BTreeMap<(Vec<u8>, i32), InputGroup> = BTreeMap::new();
+    for split in splits {
+        let deletion_files = split.data_deletion_files();
+        let key = (split.partition().to_serialized_bytes(), split.bucket());
+        let group = grouped.entry(key).or_insert_with(|| InputGroup {
+            partition: split.partition().clone(),
+            bucket: split.bucket(),
+            bucket_path: split.bucket_path().to_string(),
+            total_buckets: split.total_buckets(),
+            snapshot_id: split.snapshot_id(),
+            is_streaming: split.is_streaming(),
+            files: Vec::new(),
+        });
+        if group.bucket_path != split.bucket_path()
+            || group.total_buckets != split.total_buckets()
+            || group.snapshot_id != split.snapshot_id()
+            || group.is_streaming != split.is_streaming()
+        {
+            return Err(crate::Error::DataInvalid {
+                message: "inconsistent split metadata within a partition 
bucket".to_string(),
+                source: None,
+            });
+        }
+        for (index, file) in split.data_files().iter().cloned().enumerate() {
+            group.files.push(InputFile {
+                file,
+                deletion_file: deletion_files
+                    .and_then(|files| files.get(index))
+                    .cloned()
+                    .flatten(),
+            });
+        }
+    }
+    Ok(grouped.into_values().collect())
+}
+
+async fn append_chunks(
+    table: &Table,
+    mut group: InputGroup,
+    chunk_size: i64,
+) -> crate::Result<Vec<DataSplit>> {
+    let row_tracking = table.schema().core_options().row_tracking_enabled();
+    let mut chunks: Vec<Vec<AppendSegment>> = Vec::new();
+    let mut current = Vec::new();
+    let mut current_rows = 0;
+
+    let inputs = std::mem::take(&mut group.files);
+    for input in inputs {
+        let mut slicer = match live_row_slicer(table, &input).await? {
+            Some(slicer) => slicer,
+            None => continue,
+        };
+        loop {
+            if current_rows == chunk_size {
+                chunks.push(std::mem::take(&mut current));
+                current_rows = 0;
+            }
+            let Some(slice) = slicer.take(chunk_size - current_rows)? else {
+                break;
+            };
+            current_rows += slice.live_rows;
+            current.push(AppendSegment {
+                input: input.clone(),
+                ranges: slice.ranges,
+            });
+        }
+    }
+    if !current.is_empty() {
+        chunks.push(current);
+    }
+
+    chunks
+        .into_iter()
+        .map(|segments| build_append_split(&group, segments, row_tracking))
+        .collect()
+}
+
+fn build_append_split(
+    group: &InputGroup,
+    segments: Vec<AppendSegment>,
+    row_tracking: bool,
+) -> crate::Result<DataSplit> {
+    let mut files = Vec::with_capacity(segments.len());
+    let mut deletion_files = Vec::with_capacity(segments.len());
+    let mut ranges = Vec::new();
+    let mut split_offset = 0;
+    for segment in segments {
+        let range_base = if row_tracking {
+            segment
+                .input
+                .file
+                .first_row_id
+                .ok_or_else(|| crate::Error::DataInvalid {
+                    message: format!(
+                        "Row-tracked file '{}' is missing first_row_id",
+                        segment.input.file.file_name
+                    ),
+                    source: None,
+                })?
+        } else {
+            split_offset
+        };
+        ranges.extend(
+            segment
+                .ranges
+                .into_iter()
+                .map(|range| RowRange::new(range_base + range.from(), 
range_base + range.to())),
+        );
+        split_offset += segment.input.file.row_count;
+        files.push(segment.input.file);
+        deletion_files.push(segment.input.deletion_file);
+    }
+
+    let mut builder = base_builder(group, files, 
true).with_row_ranges(merge_row_ranges(ranges));
+    if deletion_files.iter().any(Option::is_some) {
+        builder = builder.with_data_deletion_files(deletion_files);
+    }
+    builder.build()
+}
+
+async fn evolution_chunks(
+    table: &Table,
+    group: InputGroup,
+    chunk_size: i64,
+) -> crate::Result<Vec<DataSplit>> {
+    if group
+        .files
+        .iter()
+        .any(|input| input.file.first_row_id.is_none())
+    {
+        return Err(crate::Error::DataInvalid {
+            message: "chunk_shuffle for data evolution requires first_row_id 
on every file"
+                .to_string(),
+            source: None,
+        });
+    }
+    let deletion_by_name: HashMap<_, _> = group
+        .files
+        .iter()
+        .map(|input| (input.file.file_name.clone(), 
input.deletion_file.clone()))
+        .collect();
+    let files = group.files.iter().map(|input| input.file.clone()).collect();
+    let aligned_groups = group_by_overlapping_row_id(files);
+    let mut chunks: Vec<Vec<EvolutionSegment>> = Vec::new();
+    let mut current = Vec::new();
+    let mut current_rows = 0;
+
+    for files in aligned_groups {
+        let from = files
+            .iter()
+            .filter_map(|file| file.first_row_id)
+            .min()
+            .ok_or_else(|| crate::Error::DataInvalid {
+                message: "data evolution chunk group has no 
first_row_id".to_string(),
+                source: None,
+            })?;
+        let to = files
+            .iter()
+            .filter_map(DataFileMeta::row_id_range)
+            .map(|(_, to)| to)
+            .max()
+            .ok_or_else(|| crate::Error::DataInvalid {
+                message: "data evolution chunk group has no row 
range".to_string(),
+                source: None,
+            })?;
+        let anchor = data_evolution_anchor_file(&files)?;
+        let anchor_deletion = 
deletion_by_name.get(&anchor.file_name).cloned().flatten();
+        let (first_row_id, physical_count) = if anchor_deletion.is_some() {
+            let (anchor_from, anchor_to) = anchor.row_id_range().unwrap();
+            if anchor_from > from || anchor_to < to {
+                return Err(crate::Error::DataInvalid {
+                    message: format!(
+                        "data evolution anchor range [{anchor_from}, 
{anchor_to}] does not contain group [{from}, {to}]"
+                    ),
+                    source: None,
+                });
+            }
+            (anchor_from, anchor.row_count)
+        } else {
+            (from, to - from + 1)
+        };
+        let anchor_input = InputFile {
+            file: anchor.clone(),
+            deletion_file: anchor_deletion.clone(),
+        };
+        let slicer = if anchor_deletion.is_some() {
+            live_row_slicer(table, &anchor_input).await?
+        } else {
+            LiveRowSlicer::new(physical_count, Vec::new())?
+        };
+        let mut slicer = match slicer {
+            Some(slicer) => slicer,
+            None => continue,
+        };
+        if slicer.physical_count != physical_count {
+            return Err(crate::Error::DataInvalid {
+                message: format!(
+                    "data evolution anchor row count {} does not match group 
row count {physical_count}",
+                    slicer.physical_count
+                ),
+                source: None,
+            });
+        }
+        let inputs: Vec<_> = files
+            .into_iter()
+            .map(|file| InputFile {
+                deletion_file: 
deletion_by_name.get(&file.file_name).cloned().flatten(),
+                file,
+            })
+            .collect();
+        loop {
+            if current_rows == chunk_size {
+                chunks.push(std::mem::take(&mut current));
+                current_rows = 0;
+            }
+            let Some(slice) = slicer.take(chunk_size - current_rows)? else {
+                break;
+            };
+            current_rows += slice.live_rows;
+            current.push(EvolutionSegment {
+                files: inputs.clone(),
+                ranges: slice
+                    .ranges
+                    .into_iter()
+                    .map(|range| {
+                        RowRange::new(first_row_id + range.from(), 
first_row_id + range.to())
+                    })
+                    .collect(),
+            });
+        }
+    }
+    if !current.is_empty() {
+        chunks.push(current);
+    }
+
+    chunks
+        .into_iter()
+        .map(|segments| build_evolution_split(&group, segments))
+        .collect()
+}
+
+fn build_evolution_split(
+    group: &InputGroup,
+    segments: Vec<EvolutionSegment>,
+) -> crate::Result<DataSplit> {
+    let mut files = Vec::new();
+    let mut deletion_files = Vec::new();
+    let mut ranges = Vec::new();
+    for segment in segments {
+        ranges.extend(segment.ranges);
+        for input in segment.files {
+            files.push(input.file);
+            deletion_files.push(input.deletion_file);
+        }
+    }
+    let mut builder = base_builder(group, files, 
false).with_row_ranges(merge_row_ranges(ranges));
+    if deletion_files.iter().any(Option::is_some) {
+        builder = builder.with_data_deletion_files(deletion_files);
+    }
+    builder.build()
+}
+
+fn base_builder(group: &InputGroup, files: Vec<DataFileMeta>, raw: bool) -> 
DataSplitBuilder {
+    DataSplitBuilder::new()
+        .with_snapshot(group.snapshot_id)
+        .with_partition(group.partition.clone())
+        .with_bucket(group.bucket)
+        .with_bucket_path(group.bucket_path.clone())
+        .with_total_buckets(group.total_buckets)
+        .with_data_files(files)
+        .with_raw_convertible(raw)
+        .with_streaming(group.is_streaming)
+}
+
+#[derive(Debug)]
+struct PhysicalSlice {
+    ranges: Vec<RowRange>,
+    live_rows: i64,
+}
+
+#[derive(Debug)]
+struct LiveRowSlicer {
+    physical_count: i64,
+    deleted: Vec<i64>,
+    deleted_index: usize,
+    position: i64,
+}
+
+impl LiveRowSlicer {
+    fn new(physical_count: i64, deleted: Vec<i64>) -> 
crate::Result<Option<Self>> {
+        if physical_count < 0 {
+            return Err(crate::Error::DataInvalid {
+                message: format!("negative physical row count 
{physical_count}"),
+                source: None,
+            });
+        }
+        if deleted
+            .iter()
+            .any(|position| *position < 0 || *position >= physical_count)
+        {
+            return Err(crate::Error::DataInvalid {
+                message: "deletion vector position is outside the data 
file".to_string(),
+                source: None,
+            });
+        }
+        if deleted.windows(2).any(|pair| pair[0] >= pair[1]) {
+            return Err(crate::Error::DataInvalid {
+                message: "deletion vector positions must be strictly 
increasing".to_string(),
+                source: None,
+            });
+        }
+        if deleted.len() as i64 == physical_count {
+            return Ok(None);
+        }
+        Ok(Some(Self {
+            physical_count,
+            deleted,
+            deleted_index: 0,
+            position: 0,
+        }))
+    }
+
+    fn take(&mut self, expected_live_rows: i64) -> 
crate::Result<Option<PhysicalSlice>> {
+        if expected_live_rows <= 0 {
+            return Err(crate::Error::DataInvalid {
+                message: "chunk slice must request a positive number of live 
rows".to_string(),
+                source: None,
+            });
+        }
+        if self.position >= self.physical_count {
+            return Ok(None);
+        }
+        let mut live_rows = 0;
+        let mut ranges = Vec::new();
+        while self.position < self.physical_count {
+            let next_deleted = self.deleted.get(self.deleted_index).copied();
+            if let Some(deleted) = next_deleted {
+                let live_run = deleted - self.position;
+                let take = (expected_live_rows - live_rows).min(live_run);
+                if take > 0 {
+                    ranges.push(RowRange::new(self.position, self.position + 
take - 1));
+                    self.position += take;
+                    live_rows += take;
+                }
+            } else {
+                let take =
+                    (expected_live_rows - live_rows).min(self.physical_count - 
self.position);
+                if take > 0 {
+                    ranges.push(RowRange::new(self.position, self.position + 
take - 1));
+                    self.position += take;
+                    live_rows += take;
+                }
+            }
+            if live_rows == expected_live_rows {
+                self.skip_deleted_at_cursor();
+                return Ok(Some(PhysicalSlice { ranges, live_rows }));
+            }
+            self.skip_deleted_at_cursor();
+        }
+        if live_rows == 0 {
+            Ok(None)
+        } else {
+            Ok(Some(PhysicalSlice { ranges, live_rows }))
+        }
+    }
+
+    fn skip_deleted_at_cursor(&mut self) {
+        while self.deleted.get(self.deleted_index) == Some(&self.position) {
+            self.position += 1;
+            self.deleted_index += 1;
+        }
+    }
+}
+
+async fn live_row_slicer(table: &Table, input: &InputFile) -> 
crate::Result<Option<LiveRowSlicer>> {
+    let Some(deletion_file) = &input.deletion_file else {
+        return LiveRowSlicer::new(input.file.row_count, Vec::new());
+    };
+    if deletion_file.cardinality() == Some(0) {
+        return LiveRowSlicer::new(input.file.row_count, Vec::new());
+    }
+    if let Some(cardinality) = deletion_file.cardinality() {
+        if cardinality < 0 || cardinality > input.file.row_count {
+            return Err(crate::Error::DataInvalid {
+                message: format!(
+                    "deletion vector cardinality {cardinality} is outside [0, 
{}]",
+                    input.file.row_count
+                ),
+                source: None,
+            });
+        }
+    }
+    let vector = DeletionVectorFactory::read(table.file_io(), 
deletion_file).await?;
+    validate_cardinality(deletion_file, &vector)?;
+    let deleted = vector.iter().map(|position| position as i64).collect();
+    LiveRowSlicer::new(input.file.row_count, deleted)
+}
+
+fn validate_cardinality(file: &DeletionFile, vector: &DeletionVector) -> 
crate::Result<()> {
+    if let Some(expected) = file.cardinality() {
+        if expected as u64 != vector.cardinality() {
+            return Err(crate::Error::DataInvalid {
+                message: format!(
+                    "deletion vector cardinality mismatch: metadata 
{expected}, bitmap {}",
+                    vector.cardinality()
+                ),
+                source: None,
+            });
+        }
+    }
+    Ok(())
+}
+
+fn shard_range(total: usize, index: usize, count: usize) -> (usize, usize) {
+    let base = total / count;
+    let remainder = total % count;
+    let start = index * base + index.min(remainder);
+    (start, start + base + usize::from(index < remainder))
+}
+
+fn decimal_seed_words(seed: &str) -> crate::Result<Vec<u32>> {
+    let digits = seed
+        .strip_prefix('-')
+        .or_else(|| seed.strip_prefix('+'))
+        .unwrap_or(seed);
+    if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
+        return Err(crate::Error::DataInvalid {
+            message: format!("invalid integer chunk_shuffle seed '{seed}'"),
+            source: None,
+        });
+    }
+    let mut words = vec![0u32];
+    for digit in digits.bytes().map(|byte| u64::from(byte - b'0')) {
+        let mut carry = digit;
+        for word in &mut words {
+            let value = u64::from(*word) * 10 + carry;
+            *word = value as u32;
+            carry = value >> 32;
+        }
+        if carry != 0 {
+            words.push(carry as u32);
+        }
+    }
+    while words.len() > 1 && words.last() == Some(&0) {
+        words.pop();
+    }
+    Ok(words)
+}
+
+/// MT19937 plus CPython's integer-seed and `_randbelow_with_getrandbits`
+/// conventions. Only up to 64 random bits are needed because slice lengths are
+/// Rust `usize` values.
+struct PythonRandom {
+    state: [u32; 624],
+    index: usize,
+}
+
+impl PythonRandom {
+    fn new(seed_words: &[u32]) -> Self {
+        let mut random = Self {
+            state: [0; 624],
+            index: 624,
+        };
+        random.init_genrand(19_650_218);
+        let mut i = 1usize;
+        let mut j = 0usize;
+        for _ in 0..624usize.max(seed_words.len()) {
+            random.state[i] = (random.state[i]
+                ^ (random.state[i - 1] ^ (random.state[i - 1] >> 
30)).wrapping_mul(1_664_525))
+            .wrapping_add(seed_words[j])
+            .wrapping_add(j as u32);
+            i += 1;
+            j += 1;
+            if i >= 624 {
+                random.state[0] = random.state[623];
+                i = 1;
+            }
+            if j >= seed_words.len() {
+                j = 0;
+            }
+        }
+        for _ in 0..623 {
+            random.state[i] = (random.state[i]
+                ^ (random.state[i - 1] ^ (random.state[i - 1] >> 
30)).wrapping_mul(1_566_083_941))
+            .wrapping_sub(i as u32);
+            i += 1;
+            if i >= 624 {
+                random.state[0] = random.state[623];
+                i = 1;
+            }
+        }
+        random.state[0] = 0x8000_0000;
+        random
+    }
+
+    fn init_genrand(&mut self, seed: u32) {
+        self.state[0] = seed;
+        for i in 1..624 {
+            self.state[i] = 1_812_433_253u32
+                .wrapping_mul(self.state[i - 1] ^ (self.state[i - 1] >> 30))
+                .wrapping_add(i as u32);
+        }
+    }
+
+    fn gen_u32(&mut self) -> u32 {
+        if self.index >= 624 {
+            for i in 0..624 {
+                let y = (self.state[i] & 0x8000_0000) | (self.state[(i + 1) % 
624] & 0x7fff_ffff);
+                self.state[i] = self.state[(i + 397) % 624]
+                    ^ (y >> 1)
+                    ^ if y & 1 == 0 { 0 } else { 0x9908_b0df };
+            }
+            self.index = 0;
+        }
+        let mut y = self.state[self.index];
+        self.index += 1;
+        y ^= y >> 11;
+        y ^= (y << 7) & 0x9d2c_5680;
+        y ^= (y << 15) & 0xefc6_0000;
+        y ^ (y >> 18)
+    }
+
+    fn getrandbits(&mut self, bits: u32) -> u64 {
+        if bits <= 32 {
+            return u64::from(self.gen_u32() >> (32 - bits));
+        }
+        let low = u64::from(self.gen_u32());
+        let high_bits = bits - 32;
+        let high = u64::from(self.gen_u32() >> (32 - high_bits));
+        low | (high << 32)
+    }
+
+    fn randbelow(&mut self, n: usize) -> usize {
+        debug_assert!(n > 0);
+        let bits = usize::BITS - n.leading_zeros();
+        loop {
+            let value = self.getrandbits(bits) as usize;
+            if value < n {
+                return value;
+            }
+        }
+    }
+
+    fn shuffle<T>(&mut self, values: &mut [T]) {
+        for index in (1..values.len()).rev() {
+            let other = self.randbelow(index + 1);
+            values.swap(index, other);
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::catalog::Identifier;
+    use crate::io::FileIOBuilder;
+    use crate::spec::{stats::BinaryTableStats, DataType, IntType, Schema, 
TableSchema};
+
+    fn test_table(data_evolution: bool) -> Table {
+        let mut schema = Schema::builder().column("id", 
DataType::Int(IntType::new()));
+        if data_evolution {
+            schema = schema
+                .option("data-evolution.enabled", "true")
+                .option("row-tracking.enabled", "true");
+        }
+        Table::new(
+            FileIOBuilder::new("memory").build().unwrap(),
+            Identifier::new("default", "chunk_test"),
+            "memory:/chunk-test".to_string(),
+            TableSchema::new(0, &schema.build().unwrap()),
+            None,
+        )
+    }
+
+    fn row_tracking_table() -> Table {
+        let schema = Schema::builder()
+            .column("id", DataType::Int(IntType::new()))
+            .option("row-tracking.enabled", "true")
+            .build()
+            .unwrap();
+        Table::new(
+            FileIOBuilder::new("memory").build().unwrap(),
+            Identifier::new("default", "chunk_test"),
+            "memory:/chunk-test".to_string(),
+            TableSchema::new(0, &schema),
+            None,
+        )
+    }
+
+    fn file(name: &str, row_count: i64, first_row_id: Option<i64>) -> 
DataFileMeta {
+        DataFileMeta {
+            file_name: name.to_string(),
+            file_size: 100,
+            row_count,
+            min_key: Vec::new(),
+            max_key: Vec::new(),
+            key_stats: BinaryTableStats::new(Vec::new(), Vec::new(), 
Vec::new()),
+            value_stats: BinaryTableStats::new(Vec::new(), Vec::new(), 
Vec::new()),
+            min_sequence_number: 0,
+            max_sequence_number: 0,
+            schema_id: 0,
+            level: 0,
+            extra_files: Vec::new(),
+            creation_time: None,
+            delete_row_count: None,
+            embedded_index: None,
+            first_row_id,
+            write_cols: None,
+            external_path: None,
+            file_source: None,
+            value_stats_cols: None,
+            column_max_sequence_numbers: None,
+        }
+    }
+
+    fn split(files: Vec<DataFileMeta>, raw: bool) -> DataSplit {
+        DataSplitBuilder::new()
+            .with_snapshot(1)
+            .with_partition(BinaryRow::new(0))
+            .with_bucket(0)
+            .with_bucket_path("memory:/chunk-test/bucket-0".to_string())
+            .with_total_buckets(1)
+            .with_data_files(files)
+            .with_raw_convertible(raw)
+            .build()
+            .unwrap()
+    }
+
+    #[test]
+    fn python_shuffle_matches_cpython_for_signed_and_large_integer_seeds() {
+        for (seed, expected) in [
+            ("0", vec![7, 8, 1, 5, 3, 4, 2, 0, 9, 6]),
+            ("42", vec![7, 3, 2, 8, 5, 6, 9, 4, 0, 1]),
+            ("-11", vec![2, 6, 0, 1, 5, 4, 3, 9, 8, 7]),
+            ("1180591620717411303424", vec![4, 9, 5, 2, 1, 8, 0, 7, 6, 3]),
+        ] {
+            let words = decimal_seed_words(seed).unwrap();
+            let mut actual: Vec<_> = (0..10).collect();
+            PythonRandom::new(&words).shuffle(&mut actual);
+            assert_eq!(actual, expected, "seed={seed}");
+        }
+    }
+
+    #[test]
+    fn live_row_slicer_returns_only_visible_physical_ranges() {
+        let mut slicer = LiveRowSlicer::new(12, vec![0, 3, 4, 8, 11])
+            .unwrap()
+            .unwrap();
+        let first = slicer.take(3).unwrap().unwrap();
+        assert_eq!(
+            (first.ranges, first.live_rows),
+            (vec![RowRange::new(1, 2), RowRange::new(5, 5)], 3)
+        );
+        let second = slicer.take(3).unwrap().unwrap();
+        assert_eq!(
+            (second.ranges, second.live_rows),
+            (vec![RowRange::new(6, 7), RowRange::new(9, 9)], 3)
+        );
+        let last = slicer.take(3).unwrap().unwrap();
+        assert_eq!(
+            (last.ranges, last.live_rows),
+            (vec![RowRange::new(10, 10)], 1)
+        );
+        assert!(slicer.take(1).unwrap().is_none());
+
+        let mut alternating = LiveRowSlicer::new(8, vec![1, 3, 5, 
7]).unwrap().unwrap();
+        assert_eq!(
+            alternating.take(3).unwrap().unwrap().ranges,
+            vec![
+                RowRange::new(0, 0),
+                RowRange::new(2, 2),
+                RowRange::new(4, 4)
+            ]
+        );
+        assert_eq!(
+            alternating.take(3).unwrap().unwrap().ranges,
+            vec![RowRange::new(6, 6)]
+        );
+    }
+
+    #[test]
+    fn shard_ranges_are_balanced_disjoint_and_cover_all_chunks() {
+        let ranges: Vec<_> = (0..5).map(|index| shard_range(12, index, 
5)).collect();
+        assert_eq!(ranges, vec![(0, 3), (3, 6), (6, 8), (8, 10), (10, 12)]);
+    }
+
+    #[tokio::test]
+    async fn append_chunks_cover_every_file_position_once_and_shard_by_chunk() 
{
+        let table = test_table(false);
+        // File-name order, not the input split order, defines chunk positions.
+        let input = split(
+            vec![file("b.parquet", 4, None), file("a.parquet", 5, None)],
+            true,
+        );
+        let config = ChunkShuffle::from_decimal_seed("42", 3).unwrap();
+        let chunks = chunk_shuffle_splits(&table, vec![input.clone()], 
&config, None)
+            .await
+            .unwrap();
+        assert_eq!(chunks.len(), 3);
+        assert!(chunks
+            .iter()
+            .all(|chunk| chunk.row_count() == 3 && chunk.merged_row_count() == 
Some(3)));
+
+        let mut covered = Vec::new();
+        for chunk in &chunks {
+            let mut split_offset = 0;
+            for file in chunk.data_files() {
+                for range in chunk.row_ranges().unwrap() {
+                    let from = range.from().max(split_offset);
+                    let to = range.to().min(split_offset + file.row_count - 1);
+                    if from <= to {
+                        covered.push((
+                            file.file_name.clone(),
+                            from - split_offset,
+                            to - split_offset,
+                        ));
+                    }
+                }
+                split_offset += file.row_count;
+            }
+            let serialized = chunk.serialize_split_v1().unwrap();
+            assert_eq!(
+                DataSplit::deserialize_split_v1(&serialized)
+                    .unwrap()
+                    .row_ranges(),
+                chunk.row_ranges()
+            );
+        }
+        covered.sort();
+        assert_eq!(
+            covered,
+            vec![
+                ("a.parquet".to_string(), 0, 2),
+                ("a.parquet".to_string(), 3, 4),
+                ("b.parquet".to_string(), 0, 0),
+                ("b.parquet".to_string(), 1, 3),
+            ]
+        );
+
+        let left = chunk_shuffle_splits(&table, vec![input.clone()], &config, 
Some((0, 2)))
+            .await
+            .unwrap();
+        let right = chunk_shuffle_splits(&table, vec![input], &config, 
Some((1, 2)))
+            .await
+            .unwrap();
+        assert_eq!([left, right].concat(), chunks);
+    }
+
+    #[tokio::test]
+    async fn row_tracking_append_chunks_keep_global_row_ids() {
+        let table = row_tracking_table();
+        let input = split(
+            vec![
+                file("a.parquet", 2, Some(100)),
+                file("b.parquet", 2, Some(200)),
+            ],
+            true,
+        );
+        let chunks = chunk_shuffle_splits(
+            &table,
+            vec![input],
+            &ChunkShuffle::from_decimal_seed("0", 3).unwrap(),
+            None,
+        )
+        .await
+        .unwrap();
+
+        assert_eq!(chunks.len(), 2);
+        assert_eq!(chunks.iter().map(DataSplit::row_count).sum::<i64>(), 4);
+        let mut ranges: Vec<_> = chunks
+            .iter()
+            .flat_map(|chunk| chunk.row_ranges().unwrap())
+            .map(|range| (range.from(), range.to()))
+            .collect();
+        ranges.sort();
+        assert_eq!(ranges, vec![(100, 101), (200, 200), (201, 201)]);
+
+        let missing_row_id = split(vec![file("legacy.parquet", 2, None)], 
true);
+        let error = chunk_shuffle_splits(
+            &table,
+            vec![missing_row_id],
+            &ChunkShuffle::from_decimal_seed("0", 3).unwrap(),
+            None,
+        )
+        .await
+        .unwrap_err();
+        assert!(error.to_string().contains("missing first_row_id"));
+    }
+
+    #[tokio::test]
+    async fn data_evolution_chunks_keep_aligned_files_and_global_ranges() {
+        let table = test_table(true);
+        let input = split(
+            vec![
+                file("base.parquet", 5, Some(10)),
+                file("payload.blob", 5, Some(10)),
+                file("next.parquet", 4, Some(20)),
+            ],
+            false,
+        );
+        let chunks = chunk_shuffle_splits(
+            &table,
+            vec![input],
+            &ChunkShuffle::from_decimal_seed("0", 3).unwrap(),
+            None,
+        )
+        .await
+        .unwrap();
+        assert_eq!(chunks.len(), 3);
+        assert!(chunks
+            .iter()
+            .all(|chunk| chunk.row_count() == 3 && chunk.merged_row_count() == 
Some(3)));
+
+        let mut ranges: Vec<_> = chunks
+            .iter()
+            .flat_map(|chunk| chunk.row_ranges().unwrap())
+            .map(|range| (range.from(), range.to()))
+            .collect();
+        ranges.sort();
+        assert_eq!(ranges, vec![(10, 12), (13, 14), (20, 20), (21, 23)]);
+        for chunk in chunks {
+            for range in chunk.row_ranges().unwrap() {
+                if range.from() < 20 {
+                    assert!(chunk
+                        .data_files()
+                        .iter()
+                        .any(|file| file.file_name == "payload.blob"));
+                }
+            }
+        }
+    }
+}
diff --git a/crates/paimon/src/table/data_evolution_reader.rs 
b/crates/paimon/src/table/data_evolution_reader.rs
index ddd80975..8bfe68f5 100644
--- a/crates/paimon/src/table/data_evolution_reader.rs
+++ b/crates/paimon/src/table/data_evolution_reader.rs
@@ -34,6 +34,7 @@ use crate::spec::{
 use crate::table::dedicated_format_file_writer::is_blob_file_name;
 use crate::table::schema_manager::SchemaManager;
 use crate::table::source::any_range_overlaps_file;
+use crate::table::stats_filter::group_by_overlapping_row_id;
 use crate::table::{ArrowRecordBatchStream, RESTEnv, RowRange};
 use crate::{DataSplit, Error};
 use arrow_array::{Array, Int64Array, LargeBinaryArray, RecordBatch};
@@ -85,6 +86,20 @@ fn is_raw_convertible(files: &[DataFileMeta]) -> bool {
     true
 }
 
+/// Split a reader input into independently readable row-id groups.
+///
+/// Native chunk shuffle can deliberately combine several disjoint groups in
+/// one split. If any group needs column merging, checking only the whole split
+/// is insufficient: all normal files would then be sent to one merge group,
+/// which requires a single shared row-id range.
+fn reader_file_groups(files: &[DataFileMeta]) -> Vec<Vec<DataFileMeta>> {
+    if is_raw_convertible(files) {
+        vec![files.to_vec()]
+    } else {
+        group_by_overlapping_row_id(files.to_vec())
+    }
+}
+
 /// Reads data files in data evolution mode, merging columns from files
 /// that share the same logical row range.
 pub(crate) struct DataEvolutionReader {
@@ -291,61 +306,133 @@ impl DataEvolutionReader {
 
             for split in splits {
                 let row_ranges = split.row_ranges().map(|r| r.to_vec());
+                // A chunk may span several disjoint row-id groups while one
+                // group still needs column-wise merging. Process each aligned
+                // group independently; treating the whole chunk as one merge
+                // group would require unrelated normal files to share a range.
+                let file_groups = reader_file_groups(split.data_files());
+
+                for files in file_groups {
+                    if is_raw_convertible(&files) {
+                        for file_meta in files {
+                            let deletion_vector = read_file_deletion_vector(
+                                &self.file_io,
+                                &split,
+                                &file_meta,
+                            )
+                            .await?;
+                            let data_fields = raw_file_physical_fields(
+                                &self.schema_manager,
+                                self.table_schema_id,
+                                &self.table_fields,
+                                &file_meta,
+                            )
+                            .await?;
+
+                            let has_row_id = file_meta.first_row_id.is_some();
+                            let effective_row_ranges = if has_row_id { 
row_ranges.clone() } else { None };
 
-                if is_raw_convertible(split.data_files()) {
-                    for file_meta in split.data_files().to_vec() {
-                        let deletion_vector = read_file_deletion_vector(
+                            let selected_row_ids = if 
self.row_id_index.is_some() && has_row_id {
+                                selected_absolute_row_ranges_for_file(
+                                    file_meta.first_row_id.unwrap(),
+                                    file_meta.row_count,
+                                    effective_row_ranges.as_deref(),
+                                    deletion_vector.as_deref(),
+                                )?
+                                .map(|ranges| {
+                                    expand_selected_row_ids(
+                                        file_meta.first_row_id.unwrap(),
+                                        file_meta.row_count,
+                                        &ranges,
+                                    )
+                                })
+                            } else {
+                                None
+                            };
+                            let file_base_row_id = 
file_meta.first_row_id.unwrap_or(0);
+                            let mut row_id_cursor = file_base_row_id;
+                            let mut row_id_offset: usize = 0;
+
+                            let mut stream = 
raw_file_reader.read_single_file_stream(
+                                &split,
+                                file_meta,
+                                data_fields,
+                                deletion_vector,
+                                effective_row_ranges,
+                            )?;
+                            while let Some(batch) = stream.next().await {
+                                let batch = batch?;
+                                let num_rows = batch.num_rows();
+                                let batch = if let Some(idx) = 
self.row_id_index {
+                                    if !has_row_id {
+                                        append_null_row_id_column(batch, idx, 
&self.wide_output_schema)?
+                                    } else if let Some(ref ids) = 
selected_row_ids {
+                                        attach_row_id(batch, idx, ids, &mut 
row_id_offset, &self.wide_output_schema)?
+                                    } else {
+                                        let row_ids: Vec<i64> = 
(row_id_cursor..row_id_cursor + num_rows as i64).collect();
+                                        row_id_cursor += num_rows as i64;
+                                        let array: Arc<dyn arrow_array::Array> 
= Arc::new(Int64Array::from(row_ids));
+                                        insert_column_at(batch, array, idx, 
&self.wide_output_schema)?
+                                    }
+                                } else {
+                                    batch
+                                };
+                                yield self.finish_wide_batch(
+                                    batch,
+                                    blob_view_lookup.as_ref(),
+                                    &descriptor_fields,
+                                    filter_before_blob_resolution,
+                                ).await?;
+                            }
+                        }
+                    } else {
+                        let prepared_group = PreparedMergeGroup::new(&files)?;
+                        let anchor_deletion_vector = 
read_anchor_deletion_vector(
                             &self.file_io,
                             &split,
-                            &file_meta,
-                        )
-                        .await?;
-                        let data_fields = raw_file_physical_fields(
-                            &self.schema_manager,
-                            self.table_schema_id,
-                            &self.table_fields,
-                            &file_meta,
+                            &prepared_group.files,
                         )
                         .await?;
+                        let effective_row_ranges = row_ranges.clone();
+                        let selected_ranges = 
selected_absolute_row_ranges_for_file(
+                            prepared_group.first_row_id,
+                            prepared_group.logical_row_count,
+                            effective_row_ranges.as_deref(),
+                            anchor_deletion_vector
+                                .as_ref()
+                                .map(|ctx| ctx.deletion_vector.as_ref()),
+                        )?;
+                        let expected_output_rows = match 
selected_ranges.as_ref() {
+                            Some(ranges) => ranges.iter().map(|r| r.count() as 
usize).sum(),
+                            None => prepared_group.logical_row_count as usize,
+                        };
 
-                        let has_row_id = file_meta.first_row_id.is_some();
-                        let effective_row_ranges = if has_row_id { 
row_ranges.clone() } else { None };
-
-                        let selected_row_ids = if self.row_id_index.is_some() 
&& has_row_id {
-                            selected_absolute_row_ranges_for_file(
-                                file_meta.first_row_id.unwrap(),
-                                file_meta.row_count,
-                                effective_row_ranges.as_deref(),
-                                deletion_vector.as_deref(),
-                            )?
-                            .map(|ranges| {
+                        let selected_row_ids = if self.row_id_index.is_some() {
+                            selected_ranges.as_ref().map(|ranges| {
                                 expand_selected_row_ids(
-                                    file_meta.first_row_id.unwrap(),
-                                    file_meta.row_count,
-                                    &ranges,
+                                    prepared_group.first_row_id,
+                                    prepared_group.logical_row_count,
+                                    ranges,
                                 )
                             })
                         } else {
                             None
                         };
-                        let file_base_row_id = 
file_meta.first_row_id.unwrap_or(0);
-                        let mut row_id_cursor = file_base_row_id;
+                        let mut row_id_cursor = prepared_group.first_row_id;
                         let mut row_id_offset: usize = 0;
 
-                        let mut stream = 
raw_file_reader.read_single_file_stream(
+                        let mut merge_stream = self.merge_files_by_columns(
                             &split,
-                            file_meta,
-                            data_fields,
-                            deletion_vector,
+                            &prepared_group,
                             effective_row_ranges,
+                            expected_output_rows,
+                            anchor_deletion_vector,
                         )?;
-                        while let Some(batch) = stream.next().await {
+                        while let Some(batch) = merge_stream.next().await {
                             let batch = batch?;
                             let num_rows = batch.num_rows();
                             let batch = if let Some(idx) = self.row_id_index {
-                                if !has_row_id {
-                                    append_null_row_id_column(batch, idx, 
&self.wide_output_schema)?
-                                } else if let Some(ref ids) = selected_row_ids 
{
+                                if let Some(ref ids) = selected_row_ids {
                                     attach_row_id(batch, idx, ids, &mut 
row_id_offset, &self.wide_output_schema)?
                                 } else {
                                     let row_ids: Vec<i64> = 
(row_id_cursor..row_id_cursor + num_rows as i64).collect();
@@ -364,71 +451,6 @@ impl DataEvolutionReader {
                             ).await?;
                         }
                     }
-                } else {
-                    let prepared_group = 
PreparedMergeGroup::new(split.data_files())?;
-                    let anchor_deletion_vector = read_anchor_deletion_vector(
-                        &self.file_io,
-                        &split,
-                        &prepared_group.files,
-                    )
-                    .await?;
-                    let effective_row_ranges = row_ranges.clone();
-                    let selected_ranges = 
selected_absolute_row_ranges_for_file(
-                        prepared_group.first_row_id,
-                        prepared_group.logical_row_count,
-                        effective_row_ranges.as_deref(),
-                        anchor_deletion_vector
-                            .as_ref()
-                            .map(|ctx| ctx.deletion_vector.as_ref()),
-                    )?;
-                    let expected_output_rows = match selected_ranges.as_ref() {
-                        Some(ranges) => ranges.iter().map(|r| r.count() as 
usize).sum(),
-                        None => prepared_group.logical_row_count as usize,
-                    };
-
-                    let selected_row_ids = if self.row_id_index.is_some() {
-                        selected_ranges.as_ref().map(|ranges| {
-                            expand_selected_row_ids(
-                                prepared_group.first_row_id,
-                                prepared_group.logical_row_count,
-                                ranges,
-                            )
-                        })
-                    } else {
-                        None
-                    };
-                    let mut row_id_cursor = prepared_group.first_row_id;
-                    let mut row_id_offset: usize = 0;
-
-                    let mut merge_stream = self.merge_files_by_columns(
-                        &split,
-                        &prepared_group,
-                        effective_row_ranges,
-                        expected_output_rows,
-                        anchor_deletion_vector,
-                    )?;
-                    while let Some(batch) = merge_stream.next().await {
-                        let batch = batch?;
-                        let num_rows = batch.num_rows();
-                        let batch = if let Some(idx) = self.row_id_index {
-                            if let Some(ref ids) = selected_row_ids {
-                                attach_row_id(batch, idx, ids, &mut 
row_id_offset, &self.wide_output_schema)?
-                            } else {
-                                let row_ids: Vec<i64> = 
(row_id_cursor..row_id_cursor + num_rows as i64).collect();
-                                row_id_cursor += num_rows as i64;
-                                let array: Arc<dyn arrow_array::Array> = 
Arc::new(Int64Array::from(row_ids));
-                                insert_column_at(batch, array, idx, 
&self.wide_output_schema)?
-                            }
-                        } else {
-                            batch
-                        };
-                        yield self.finish_wide_batch(
-                            batch,
-                            blob_view_lookup.as_ref(),
-                            &descriptor_fields,
-                            filter_before_blob_resolution,
-                        ).await?;
-                    }
                 }
             }
         }
@@ -3055,6 +3077,27 @@ mod tests {
         assert!(!is_raw_convertible(&files));
     }
 
+    #[test]
+    fn test_reader_file_groups_separates_disjoint_merge_groups() {
+        let files = vec![
+            data_file("base-0.parquet", 0, 10, 1, Some(vec!["id"])),
+            data_file("partial-0.parquet", 0, 10, 2, Some(vec!["value"])),
+            data_file("base-10.parquet", 10, 10, 1, Some(vec!["id"])),
+            data_file("partial-10.parquet", 10, 10, 2, Some(vec!["value"])),
+        ];
+
+        assert!(!is_raw_convertible(&files));
+        let groups = reader_file_groups(&files);
+        assert_eq!(groups.len(), 2);
+        assert_eq!(groups[0].len(), 2);
+        assert_eq!(groups[1].len(), 2);
+        assert_eq!(PreparedMergeGroup::new(&groups[0]).unwrap().first_row_id, 
0);
+        assert_eq!(
+            PreparedMergeGroup::new(&groups[1]).unwrap().first_row_id,
+            10
+        );
+    }
+
     #[test]
     fn test_prepared_merge_group_rejects_vector_only_split() {
         // No normal anchor file -> DataInvalid.
diff --git a/crates/paimon/src/table/data_file_reader.rs 
b/crates/paimon/src/table/data_file_reader.rs
index 0b3cb03e..cf429efc 100644
--- a/crates/paimon/src/table/data_file_reader.rs
+++ b/crates/paimon/src/table/data_file_reader.rs
@@ -277,6 +277,10 @@ impl DataFileReader {
             for split in splits {
                 // Create DV factory for this split only.
                 let dv_factory = reader.build_split_dv_factory(&split).await?;
+                let core_options = 
crate::spec::CoreOptions::new(&reader.table_options);
+                let ranges_use_row_ids = core_options.row_tracking_enabled()
+                    || core_options.data_evolution_enabled();
+                let mut split_file_offset = 0;
 
                 for file_meta in split.data_files().to_vec() {
                     let dv = DataFileReader::deletion_vector_for_file(
@@ -310,13 +314,25 @@ impl DataFileReader {
                         FileIndexResult::Remain
                     };
 
+                    let range_base = if ranges_use_row_ids {
+                        file_meta.first_row_id.ok_or_else(|| 
crate::Error::DataInvalid {
+                            message: format!(
+                                "Row-tracked file '{}' is missing 
first_row_id",
+                                file_meta.file_name
+                            ),
+                            source: None,
+                        })?
+                    } else {
+                        split_file_offset
+                    };
                     let split_ranges = split.row_ranges().map(|ranges| {
                         to_local_row_ranges(
                             ranges,
-                            file_meta.first_row_id.unwrap_or(0),
+                            range_base,
                             file_meta.row_count,
                         )
                     });
+                    split_file_offset += file_meta.row_count;
                     let selected_ranges = match file_index_result {
                         FileIndexResult::Remain => split_ranges,
                         FileIndexResult::Skip => Some(Vec::new()),
@@ -1024,7 +1040,10 @@ fn is_row_file(file_meta: &DataFileMeta) -> bool {
             .is_some_and(|path| path.to_ascii_lowercase().ends_with(".row"))
 }
 
-/// Convert absolute RowRanges to normalized file-local 0-based ranges.
+/// Convert ranges from their read-path coordinate system to file-local ranges.
+/// `first_row_id` is the coordinate base selected by the table's read path:
+/// stable row ID for row-tracked tables, or cumulative split-local physical
+/// offset for raw tables without row tracking.
 fn to_local_row_ranges(
     row_ranges: &[RowRange],
     first_row_id: i64,
@@ -1691,6 +1710,24 @@ mod tests {
     use roaring::RoaringBitmap;
     use std::io;
 
+    #[test]
+    fn split_local_ranges_map_across_file_boundaries() {
+        let ranges = [RowRange::new(3, 4), RowRange::new(5, 7)];
+        assert_eq!(
+            to_local_row_ranges(&ranges, 0, 5),
+            vec![RowRange::new(3, 4)]
+        );
+        assert_eq!(
+            to_local_row_ranges(&ranges, 5, 4),
+            vec![RowRange::new(0, 2)]
+        );
+
+        assert_eq!(
+            to_local_row_ranges(&[RowRange::new(103, 104)], 100, 6),
+            vec![RowRange::new(3, 4)]
+        );
+    }
+
     #[test]
     fn test_data_file_read_timing_aggregates_file_waits() {
         let timing = DataFileReadTiming::default();
diff --git a/crates/paimon/src/table/incremental_scan.rs 
b/crates/paimon/src/table/incremental_scan.rs
index c4e64040..19696462 100644
--- a/crates/paimon/src/table/incremental_scan.rs
+++ b/crates/paimon/src/table/incremental_scan.rs
@@ -256,11 +256,30 @@ impl<'a> IncrementalScan<'a> {
         Ok(self)
     }
 
+    /// Repack the combined APPEND-delta batch into deterministic chunks.
+    pub fn with_chunk_shuffle(
+        mut self,
+        seed: impl ToString,
+        chunk_size: u64,
+    ) -> crate::Result<Self> {
+        self.scan = self.scan.with_chunk_shuffle(seed, chunk_size)?;
+        Ok(self)
+    }
+
+    /// Select one balanced worker shard for a distributed scan.
+    pub fn with_shard(mut self, index: usize, count: usize) -> 
crate::Result<Self> {
+        self.scan = self.scan.with_shard(index, count)?;
+        Ok(self)
+    }
+
     pub async fn plan(&self) -> crate::Result<IncrementalPlan> {
         
crate::spec::CoreOptions::new(self.table.schema().options()).ensure_read_authorized()?;
-        if self.scan.has_row_position_selection() {
+        if self.scan.has_row_position_selection()
+            || self.scan.has_chunk_shuffle()
+            || self.scan.has_shard()
+        {
             return Err(crate::Error::Unsupported {
-                message: "Incremental row-position selection requires combined 
delta planning"
+                message: "Incremental row-position selection, chunk_shuffle 
and sharding require combined delta planning"
                     .into(),
             });
         }
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index 407f9e0b..74581c01 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -33,6 +33,7 @@ mod bucket_assigner_dynamic;
 mod bucket_assigner_fixed;
 mod bucket_filter;
 mod bucket_function;
+mod chunk_shuffle;
 mod commit_message;
 mod consumer_manager;
 pub(crate) mod cow_writer;
diff --git a/crates/paimon/src/table/source.rs 
b/crates/paimon/src/table/source.rs
index 4499fd01..e4c30866 100644
--- a/crates/paimon/src/table/source.rs
+++ b/crates/paimon/src/table/source.rs
@@ -45,7 +45,9 @@ pub(crate) fn data_evolution_anchor_file(files: 
&[DataFileMeta]) -> crate::Resul
 }
 // ======================= RowRange ===============================
 
-/// An inclusive row ID range `[from, to]` for filtering reads in data 
evolution mode.
+/// An inclusive row range `[from, to]` in the coordinate system of the read
+/// path: stable row IDs for row-tracked tables, or physical positions for raw
+/// tables without row tracking.
 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
 pub struct RowRange {
     from: i64,
@@ -495,6 +497,9 @@ pub struct DataSplit {
     /// Deletion file for each data file, same order as `data_files`.
     /// `None` at index `i` means no deletion file for `data_files[i]` 
(matches Java getDeletionFiles() / List<DeletionFile> with null elements).
     data_deletion_files: Option<Arc<[Option<DeletionFile>]>>,
+    /// IndexedSplit-compatible ranges. Row-tracked tables interpret these as
+    /// stable row IDs; raw tables without row tracking interpret them as
+    /// split-local physical positions over `data_files` in list order.
     row_ranges: Option<Arc<[RowRange]>>,
     /// Whether the split can be read raw, without the merge reader: its
     /// physical rows are exactly its logical rows (modulo deletion files).
@@ -587,6 +592,9 @@ impl DataSplit {
     /// nothing, so the result is a lower bound, not a total. Ask
     /// [`Self::row_counts_known`] before presenting it as one.
     pub fn row_count(&self) -> i64 {
+        if let Some(ranges) = &self.row_ranges {
+            return ranges.iter().map(RowRange::count).sum();
+        }
         self.data_files
             .iter()
             .filter(|f| f.row_count_known())
@@ -619,6 +627,9 @@ impl DataSplit {
     ///
     /// Reference: 
[DataSplit.mergedRowCount()](https://github.com/apache/paimon/blob/release-1.3/paimon-core/src/main/java/org/apache/paimon/table/source/DataSplit.java#L133)
     pub fn merged_row_count(&self) -> Option<i64> {
+        if let Some(ranges) = &self.row_ranges {
+            return Some(ranges.iter().map(RowRange::count).sum());
+        }
         if !self.row_counts_known() {
             return None;
         }
@@ -2243,6 +2254,8 @@ mod tests {
             .with_row_ranges(vec![RowRange::new(1, 4), RowRange::new(11, 13)])
             .build()
             .unwrap();
+        assert_eq!(split.row_count(), 7);
+        assert_eq!(split.merged_row_count(), Some(7));
         assert_eq!(split.serialize_split_v1().unwrap(), expected);
     }
 
diff --git a/crates/paimon/src/table/table_scan.rs 
b/crates/paimon/src/table/table_scan.rs
index bfc9f2b1..46864b8e 100644
--- a/crates/paimon/src/table/table_scan.rs
+++ b/crates/paimon/src/table/table_scan.rs
@@ -21,6 +21,7 @@
 //! and 
[FullStartingScanner](https://github.com/apache/paimon/blob/release-1.3/paimon-python/pypaimon/read/scanner/full_starting_scanner.py).
 
 use super::bucket_filter::compute_target_buckets;
+use super::chunk_shuffle::{chunk_shuffle_splits, ChunkShuffle};
 use super::format_table_scan::FormatTableScan;
 use super::global_index_scanner::RowRangeIndex;
 use super::global_index_types::normalize_queryable_global_index_type;
@@ -1136,20 +1137,125 @@ impl<'a> TableScan<'a> {
         self.with_row_position_selection(RowPositionSelection::shard(index, 
count)?)
     }
 
+    /// Repack an append scan into deterministic fixed-live-row chunks.
+    ///
+    /// `seed` must render as a decimal integer. Accepting any `ToString` seed
+    /// keeps ordinary Rust integer calls ergonomic while allowing language
+    /// bindings to preserve arbitrary-precision integer seeds.
+    pub fn with_chunk_shuffle(self, seed: impl ToString, chunk_size: u64) -> 
crate::Result<Self> {
+        let config = ChunkShuffle::from_decimal_seed(&seed.to_string(), 
chunk_size)?;
+        match self.0 {
+            TableScanKind::Paimon(mut scan) => {
+                if !scan.table.schema().primary_keys().is_empty() {
+                    return Err(crate::Error::Unsupported {
+                        message: "chunk_shuffle only supports append 
tables".to_string(),
+                    });
+                }
+                if scan.limit.is_some() {
+                    return Err(crate::Error::Unsupported {
+                        message: "chunk_shuffle cannot combine with 
limit".to_string(),
+                    });
+                }
+                if scan.row_ranges.is_some() || 
scan.row_position_selection().is_some() {
+                    return Err(crate::Error::Unsupported {
+                        message:
+                            "chunk_shuffle cannot combine with row ranges or 
positional selection"
+                                .to_string(),
+                    });
+                }
+                if !scan.data_predicates.is_empty() {
+                    return Err(crate::Error::Unsupported {
+                        message: "chunk_shuffle only supports partition 
predicates".to_string(),
+                    });
+                }
+                scan.split_selection = Some(Box::new(ScanSplitSelection {
+                    mode: Some(ScanSplitMode::ChunkShuffle(config)),
+                    shard: scan.shard(),
+                }));
+                Ok(Self(TableScanKind::Paimon(scan)))
+            }
+            TableScanKind::Format(_) => Err(crate::Error::Unsupported {
+                message: "format tables do not support 
chunk_shuffle".to_string(),
+            }),
+        }
+    }
+
+    /// Select one balanced worker shard for a distributed scan.
+    ///
+    /// Sharding is scan-level state, independent of the selected planning
+    /// strategy, so callers may configure it before or after chunk shuffling.
+    pub fn with_shard(mut self, index: usize, count: usize) -> 
crate::Result<Self> {
+        if count == 0 || index >= count {
+            return Err(crate::Error::DataInvalid {
+                message: "shard count must be positive and index less than 
count".to_string(),
+                source: None,
+            });
+        }
+        match &mut self.0 {
+            TableScanKind::Paimon(scan) => {
+                if scan.row_position_selection().is_some() {
+                    return Err(crate::Error::DataInvalid {
+                        message:
+                            "with_shard and row-position selection cannot be 
used simultaneously"
+                                .to_string(),
+                        source: None,
+                    });
+                }
+                match scan.split_selection.as_deref_mut() {
+                    Some(selection) => selection.shard = Some((index, count)),
+                    None => {
+                        scan.split_selection = 
Some(Box::new(ScanSplitSelection {
+                            mode: None,
+                            shard: Some((index, count)),
+                        }));
+                    }
+                }
+                Ok(self)
+            }
+            TableScanKind::Format(_) => Err(crate::Error::Unsupported {
+                message: "format tables do not support sharding".to_string(),
+            }),
+        }
+    }
+
     pub(crate) fn has_row_position_selection(&self) -> bool {
         match &self.0 {
-            TableScanKind::Paimon(scan) => 
scan.row_position_selection.is_some(),
+            TableScanKind::Paimon(scan) => 
scan.row_position_selection().is_some(),
             TableScanKind::Format(_) => false,
         }
     }
 
+    pub(crate) fn has_chunk_shuffle(&self) -> bool {
+        matches!(&self.0, TableScanKind::Paimon(scan) if 
scan.chunk_shuffle().is_some())
+    }
+
+    pub(crate) fn has_shard(&self) -> bool {
+        matches!(&self.0, TableScanKind::Paimon(scan) if 
scan.shard().is_some())
+    }
+
     fn with_row_position_selection(self, selection: RowPositionSelection) -> 
crate::Result<Self> {
         match self.0 {
             TableScanKind::Paimon(mut scan)
                 if scan.table.schema().core_options().data_evolution_enabled() 
=>
             {
+                if scan.chunk_shuffle().is_some() {
+                    return Err(crate::Error::DataInvalid {
+                        message:
+                            "row-position selection and chunk_shuffle cannot 
be used simultaneously"
+                                .into(),
+                        source: None,
+                    });
+                }
+                if scan.shard().is_some() {
+                    return Err(crate::Error::DataInvalid {
+                        message:
+                            "row-position selection and with_shard cannot be 
used simultaneously"
+                                .into(),
+                        source: None,
+                    });
+                }
                 if scan
-                    .row_position_selection
+                    .row_position_selection()
                     .is_some_and(|previous| previous.is_slice() != 
selection.is_slice())
                 {
                     return Err(crate::Error::DataInvalid {
@@ -1158,7 +1264,10 @@ impl<'a> TableScan<'a> {
                         source: None,
                     });
                 }
-                scan.row_position_selection = Some(selection);
+                scan.split_selection = Some(Box::new(ScanSplitSelection {
+                    mode: Some(ScanSplitMode::RowPosition(selection)),
+                    shard: None,
+                }));
                 Ok(Self(TableScanKind::Paimon(scan)))
             }
             _ => Err(crate::Error::Unsupported {
@@ -1264,6 +1373,18 @@ impl<'a> TableScan<'a> {
 /// Paimon table scan: resolves snapshots, reads manifests, and builds data 
splits.
 ///
 /// Reference: 
[pypaimon.read.table_scan.TableScan](https://github.com/apache/paimon/blob/master/paimon-python/pypaimon/read/table_scan.py)
+#[derive(Debug, Clone)]
+enum ScanSplitMode {
+    RowPosition(RowPositionSelection),
+    ChunkShuffle(ChunkShuffle),
+}
+
+#[derive(Debug, Clone)]
+struct ScanSplitSelection {
+    mode: Option<ScanSplitMode>,
+    shard: Option<(usize, usize)>,
+}
+
 #[derive(Debug, Clone)]
 struct PaimonTableScan<'a> {
     table: &'a Table,
@@ -1274,7 +1395,10 @@ struct PaimonTableScan<'a> {
     /// When set, the scan will try to return only enough splits to satisfy 
the limit.
     limit: Option<usize>,
     row_ranges: Option<Vec<RowRange>>,
-    row_position_selection: Option<RowPositionSelection>,
+    /// Mutually exclusive row-position or fixed-row chunk transformation.
+    /// Boxed because scans normally use neither, and keeping the cold payload
+    /// out of the scan preserves the compact `TableScanKind` representation.
+    split_selection: Option<Box<ScanSplitSelection>>,
     /// Diff compares complete logical states, so it must not accept physical
     /// row-range pruning from an explicit range or a global-index lookup.
     row_range_optimization_disabled: bool,
@@ -1287,6 +1411,34 @@ struct PaimonTableScan<'a> {
 }
 
 impl<'a> PaimonTableScan<'a> {
+    fn row_position_selection(&self) -> Option<RowPositionSelection> {
+        match self
+            .split_selection
+            .as_deref()
+            .and_then(|selection| selection.mode.as_ref())
+        {
+            Some(ScanSplitMode::RowPosition(selection)) => Some(*selection),
+            _ => None,
+        }
+    }
+
+    fn chunk_shuffle(&self) -> Option<&ChunkShuffle> {
+        match self
+            .split_selection
+            .as_deref()
+            .and_then(|selection| selection.mode.as_ref())
+        {
+            Some(ScanSplitMode::ChunkShuffle(config)) => Some(config),
+            _ => None,
+        }
+    }
+
+    fn shard(&self) -> Option<(usize, usize)> {
+        self.split_selection
+            .as_deref()
+            .and_then(|selection| selection.shard)
+    }
+
     fn is_streaming(&self) -> bool {
         self.incremental_split_mode.is_some()
     }
@@ -1306,7 +1458,7 @@ impl<'a> PaimonTableScan<'a> {
             bucket_predicate,
             limit,
             row_ranges,
-            row_position_selection: None,
+            split_selection: None,
             row_range_optimization_disabled: false,
             scan_all_files: false,
             incremental_split_mode: None,
@@ -1335,7 +1487,7 @@ impl<'a> PaimonTableScan<'a> {
 
     fn without_row_range_optimization(mut self) -> Self {
         self.row_ranges = None;
-        self.row_position_selection = None;
+        self.split_selection = None;
         self.row_range_optimization_disabled = true;
         self
     }
@@ -1368,6 +1520,7 @@ impl<'a> PaimonTableScan<'a> {
     /// `scan.snapshot-id` / `scan.tag-name` handling.
     pub async fn plan(&self) -> crate::Result<Plan> {
         self.ensure_query_auth_allowed()?;
+        self.validate_shard_strategy()?;
         let data_evolution_read_field_ids = self.projected_read_field_ids()?;
         let snapshot = match 
super::time_travel::resolve_snapshot(self.table).await? {
             Some(snapshot) => snapshot,
@@ -1380,6 +1533,7 @@ impl<'a> PaimonTableScan<'a> {
     /// Plan the full scan and return metadata-pruning trace counters.
     pub async fn plan_with_trace(&self) -> crate::Result<(Plan, ScanTrace)> {
         self.ensure_query_auth_allowed()?;
+        self.validate_shard_strategy()?;
         let mut trace = ScanTrace {
             limit: self.limit,
             ..Default::default()
@@ -1408,6 +1562,15 @@ impl<'a> PaimonTableScan<'a> {
         
CoreOptions::new(self.table.schema().options()).ensure_read_authorized()
     }
 
+    fn validate_shard_strategy(&self) -> crate::Result<()> {
+        if self.shard().is_some() && self.chunk_shuffle().is_none() {
+            return Err(crate::Error::Unsupported {
+                message: "with_shard currently requires 
chunk_shuffle".to_string(),
+            });
+        }
+        Ok(())
+    }
+
     fn projected_read_field_ids(&self) -> crate::Result<Option<HashSet<i32>>> {
         Ok(self.projected_read_field_ids.clone())
     }
@@ -1764,6 +1927,7 @@ impl<'a> PaimonTableScan<'a> {
         end_snapshot: &Snapshot,
     ) -> crate::Result<Plan> {
         self.ensure_query_auth_allowed()?;
+        self.validate_shard_strategy()?;
         let data_evolution_read_field_ids = self.projected_read_field_ids()?;
         let mut scan = self.clone();
         scan.incremental_split_mode = Some(IncrementalSplitMode::Batch);
@@ -1826,7 +1990,7 @@ impl<'a> PaimonTableScan<'a> {
         // Positional scans count all candidate row IDs. Pruning a preceding
         // manifest or file here would renumber the surviving rows; intersect
         // explicit/global-index ranges after assigning positions instead.
-        let row_range_index = if data_evolution_enabled && 
self.row_position_selection.is_none() {
+        let row_range_index = if data_evolution_enabled && 
self.row_position_selection().is_none() {
             manifest_row_ranges.clone().map(RowRangeIndex::create)
         } else {
             None
@@ -2121,7 +2285,7 @@ impl<'a> PaimonTableScan<'a> {
         // Positional scans count all candidate row IDs. Pruning a preceding
         // manifest or file here would renumber the surviving rows; intersect
         // explicit/global-index ranges after assigning positions instead.
-        let row_range_index = if data_evolution_enabled && 
self.row_position_selection.is_none() {
+        let row_range_index = if data_evolution_enabled && 
self.row_position_selection().is_none() {
             manifest_row_ranges.clone().map(RowRangeIndex::create)
         } else {
             None
@@ -2177,7 +2341,7 @@ impl<'a> PaimonTableScan<'a> {
         // Assign row positions using the full candidate file ranges, before
         // group stats, projection or DVs change visible rows. Intersect 
explicit
         // and index-selected ranges only after assigning the positional range.
-        let effective_row_ranges = if let Some(selection) = 
self.row_position_selection {
+        let effective_row_ranges = if let Some(selection) = 
self.row_position_selection() {
             Some(selection.select(&entries, effective_row_ranges.as_deref())?)
         } else {
             effective_row_ranges
@@ -2395,7 +2559,7 @@ impl<'a> PaimonTableScan<'a> {
                     row_id_groups
                 };
 
-                if self.row_position_selection.is_some() {
+                if self.row_position_selection().is_some() {
                     // Positional scans promise row-id order across groups. A
                     // projected group can become a singleton, so moving all
                     // multi-file groups first would change which rows a limit
@@ -2557,6 +2721,11 @@ impl<'a> PaimonTableScan<'a> {
                 let split_candidates_built = splits.len();
                 (splits, split_candidates_built, false)
             };
+        let splits = if let Some(config) = self.chunk_shuffle() {
+            chunk_shuffle_splits(self.table, splits, config, 
self.shard()).await?
+        } else {
+            splits
+        };
         let splits_before_limit = split_candidates_built;
         if let Some(trace) = trace {
             let final_files = splits.iter().map(|split| 
split.data_files().len()).sum();
@@ -3680,6 +3849,44 @@ mod tests {
             .unwrap()
             .with_row_position_shard(1, 2)
             .is_ok());
+        assert!(reader
+            .new_scan()
+            .with_row_position_shard(0, 1)
+            .unwrap()
+            .with_chunk_shuffle(0, 1)
+            .is_err());
+        assert!(reader
+            .new_scan()
+            .with_row_position_shard(0, 1)
+            .unwrap()
+            .with_shard(0, 1)
+            .is_err());
+        assert!(reader
+            .new_scan()
+            .with_shard(0, 1)
+            .unwrap()
+            .with_row_position_shard(0, 1)
+            .is_err());
+        assert!(reader
+            .new_scan()
+            .with_chunk_shuffle(0, 1)
+            .unwrap()
+            .with_row_position_shard(0, 1)
+            .is_err());
+        assert!(reader
+            .new_scan()
+            .with_shard(0, 1)
+            .unwrap()
+            .with_chunk_shuffle(0, 1)
+            .is_ok());
+        assert!(reader
+            .new_scan()
+            .with_chunk_shuffle(0, 1)
+            .unwrap()
+            .with_shard(0, 1)
+            .is_ok());
+        assert!(reader.new_scan().with_shard(0, 0).is_err());
+        assert!(reader.new_scan().with_shard(1, 1).is_err());
     }
 
     #[tokio::test]

Reply via email to