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 a2da430e feat(core): add stateful StreamTableScan for Native streaming 
reads (#1075)
a2da430e is described below

commit a2da430e89c10593d7ea4fcae8a39ce96916a43f
Author: Jingsong Lee <[email protected]>
AuthorDate: Thu Oct 8 19:19:10 2026 +0800

    feat(core): add stateful StreamTableScan for Native streaming reads (#1075)
---
 .../python/python/pypaimon_rust/datafusion.pyi     |  17 +
 bindings/python/src/context.rs                     |   1 +
 bindings/python/src/read.rs                        | 146 +++++-
 bindings/python/src/table.rs                       |   4 +
 bindings/python/tests/test_stream_table_scan.py    | 226 ++++++++
 crates/paimon/src/lib.rs                           |   3 +
 crates/paimon/src/table/consumer_manager.rs        |  60 ++-
 crates/paimon/src/table/data_file_reader.rs        |  46 +-
 crates/paimon/src/table/mod.rs                     |   7 +
 crates/paimon/src/table/read_builder.rs            |   5 +
 crates/paimon/src/table/snapshot_scan_tests.rs     | 566 +++++++++++++++++++++
 crates/paimon/src/table/stream_table_scan.rs       | 252 +++++++++
 crates/paimon/src/table/table_scan.rs              | 244 ++++++++-
 crates/paimon/tests/stream_table_scan_test.rs      | 466 +++++++++++++++++
 14 files changed, 2020 insertions(+), 23 deletions(-)

diff --git a/bindings/python/python/pypaimon_rust/datafusion.pyi 
b/bindings/python/python/pypaimon_rust/datafusion.pyi
index adfa1c75..ff4453ca 100644
--- a/bindings/python/python/pypaimon_rust/datafusion.pyi
+++ b/bindings/python/python/pypaimon_rust/datafusion.pyi
@@ -80,6 +80,19 @@ class TableScan:
         ...
     def plan(self) -> Plan: ...
 
+class StreamTableScan:
+    """Stateful continuous scan; checkpoint is the next snapshot to consume."""
+
+    def with_bucket_filter(self, filter: Optional[Callable[[int], bool]] = 
None) -> StreamTableScan: ...
+    def with_consumer_id(self, consumer_id: str) -> StreamTableScan: ...
+    def with_shard(self, index: int, count: int) -> StreamTableScan: ...
+    def plan(self) -> Optional[Plan]: ...
+    def checkpoint(self) -> Optional[int]: ...
+    def watermark(self) -> Optional[int]: ...
+    def restore(self, next_snapshot_id: Optional[int] = None) -> None: ...
+    def notify_checkpoint_complete(self, next_snapshot: Optional[int]) -> 
None: ...
+
+
 class RecordBatchReader:
     def __iter__(self) -> "RecordBatchReader": ...
     def __next__(self) -> pyarrow.RecordBatch: ...
@@ -130,6 +143,9 @@ class ReadBuilder:
         """Set Data Evolution row ranges. Empty selects no rows; format tables 
are unsupported."""
         ...
     def new_scan(self) -> TableScan: ...
+    def new_stream_scan(self) -> StreamTableScan:
+        """Create a stateful stream scan retaining filters, read type and 
ranges."""
+        ...
     def new_incremental_scan(
         self,
         start_snapshot_id: int,
@@ -196,6 +212,7 @@ class Table:
     def location(self) -> str: ...
     def schema(self) -> TableSchema: ...
     def new_read_builder(self, options: Optional[Dict[str, str]] = None) -> 
ReadBuilder: ...
+    def new_stream_scan(self) -> StreamTableScan: ...
     def new_batch_write_builder(self) -> "BatchWriteBuilder": ...
     def new_postpone_fixed_bucket_write_builder(self) -> 
"PostponeFixedBucketWriteBuilder": ...
     def new_stream_write_builder(self) -> "StreamWriteBuilder": ...
diff --git a/bindings/python/src/context.rs b/bindings/python/src/context.rs
index 3bb58b04..2bc67988 100644
--- a/bindings/python/src/context.rs
+++ b/bindings/python/src/context.rs
@@ -669,6 +669,7 @@ pub fn register_module(py: Python<'_>, m: &Bound<'_, 
PyModule>) -> PyResult<()>
     this.add_class::<crate::table::PyTable>()?;
     this.add_class::<crate::read::PyReadBuilder>()?;
     this.add_class::<crate::read::PyTableScan>()?;
+    this.add_class::<crate::read::PyStreamTableScan>()?;
     this.add_class::<crate::read::PyPlan>()?;
     this.add_class::<crate::read::PyTableRead>()?;
     this.add_class::<crate::read::PyRecordBatchReader>()?;
diff --git a/bindings/python/src/read.rs b/bindings/python/src/read.rs
index 02bd66d1..deb87eca 100644
--- a/bindings/python/src/read.rs
+++ b/bindings/python/src/read.rs
@@ -23,11 +23,14 @@ use arrow::pyarrow::ToPyArrow;
 use arrow::record_batch::RecordBatch;
 use futures::TryStreamExt;
 use paimon::spec::{is_variant_extraction_row, DataField, DataType, Predicate, 
RowType};
-use paimon::table::{ArrowRecordBatchStream, DataSplit, IncrementalScanMode, 
RowRange, Table};
+use paimon::table::{
+    ArrowRecordBatchStream, DataSplit, IncrementalScanMode, RowRange, 
StreamTableScan, Table,
+};
 use paimon_datafusion::runtime::runtime;
-use pyo3::exceptions::{PyRuntimeError, PyTypeError, PyValueError};
+use pyo3::exceptions::{PyBaseException, PyRuntimeError, PyTypeError, 
PyValueError};
 use pyo3::prelude::*;
 use pyo3::types::{PyBytes, PyDict};
+use pyo3::{PyTraverseError, PyVisit};
 use tokio::sync::Notify;
 
 use crate::error::to_py_err;
@@ -322,6 +325,18 @@ impl PyReadBuilder {
         }
     }
 
+    /// Create a stateful Java-compatible stream scan.
+    pub(crate) fn new_stream_scan(&self) -> PyResult<PyStreamTableScan> {
+        Ok(PyStreamTableScan {
+            scan: self
+                .new_scan()
+                .read_builder()?
+                .new_stream_scan()
+                .map_err(to_py_err)?,
+            bucket_filter: 
Arc::new(Mutex::new(PyBucketFilterState::default())),
+        })
+    }
+
     /// Plan physical changes in (start_snapshot_id, end_snapshot_id] as one
     /// ordinary split plan. Mode is `delta` by default; `changelog` reads
     /// changelog manifest files and `auto` follows the table's producer.
@@ -469,6 +484,133 @@ impl PyTableScan {
     }
 }
 
+/// A thin wrapper: planning, cursor and consumer progress belong to core.
+#[pyclass(name = "StreamTableScan", module = "pypaimon_rust.datafusion")]
+pub struct PyStreamTableScan {
+    scan: StreamTableScan,
+    bucket_filter: Arc<Mutex<PyBucketFilterState>>,
+}
+
+// The wrapper and core closure share one Python reference per object, so GC
+// can traverse every retained reference exactly once through the wrapper.
+#[derive(Default)]
+struct PyBucketFilterState {
+    filter: Option<Py<PyAny>>,
+    error: Option<Py<PyBaseException>>,
+}
+
+#[pymethods]
+impl PyStreamTableScan {
+    fn __traverse__(&self, visit: PyVisit<'_>) -> Result<(), PyTraverseError> {
+        let state = self.bucket_filter.lock().unwrap();
+        visit.call(&state.filter)?;
+        visit.call(&state.error)
+    }
+
+    fn __clear__(&mut self) {
+        let references = {
+            let mut state = self.bucket_filter.lock().unwrap();
+            (state.filter.take(), state.error.take())
+        };
+        // Drop Python references outside the lock: destructors may re-enter
+        // Python. The core closure sees an empty filter and retains no cycle.
+        drop(references);
+    }
+
+    #[pyo3(signature = (filter = None))]
+    fn with_bucket_filter(
+        mut slf: PyRefMut<'_, Self>,
+        filter: Option<Py<PyAny>>,
+    ) -> PyResult<PyRefMut<'_, Self>> {
+        if filter
+            .as_ref()
+            .is_some_and(|filter| !filter.bind(slf.py()).is_callable())
+        {
+            return Err(PyTypeError::new_err("bucket filter must be callable"));
+        }
+        let previous_filter =
+            std::mem::replace(&mut slf.bucket_filter.lock().unwrap().filter, 
filter);
+        drop(previous_filter);
+        let state = Arc::clone(&slf.bucket_filter);
+        slf.scan.with_bucket_filter(move |bucket| {
+            Python::attach(|py| {
+                // Release the lock before invoking Python: callbacks may run
+                // GC, which traverses this same state.
+                let filter = state
+                    .lock()
+                    .unwrap()
+                    .filter
+                    .as_ref()
+                    .map(|filter| filter.clone_ref(py));
+                let Some(filter) = filter else {
+                    return Ok(true);
+                };
+                filter
+                    .call1(py, (bucket,))
+                    .and_then(|result| result.is_truthy(py))
+                    .map_err(|error| {
+                        let error = error.into_value(py);
+                        let previous_error = 
state.lock().unwrap().error.replace(error);
+                        drop(previous_error);
+                        paimon::Error::UnexpectedError {
+                            message: "Python bucket filter failed".into(),
+                            source: None,
+                        }
+                    })
+            })
+        });
+        Ok(slf)
+    }
+
+    fn with_consumer_id(
+        mut slf: PyRefMut<'_, Self>,
+        consumer_id: String,
+    ) -> PyResult<PyRefMut<'_, Self>> {
+        slf.scan.with_consumer_id(consumer_id).map_err(to_py_err)?;
+        Ok(slf)
+    }
+
+    fn with_shard(
+        mut slf: PyRefMut<'_, Self>,
+        index: usize,
+        count: usize,
+    ) -> PyResult<PyRefMut<'_, Self>> {
+        slf.scan.with_shard(index, count).map_err(to_py_err)?;
+        Ok(slf)
+    }
+
+    fn checkpoint(&self) -> Option<i64> {
+        self.scan.checkpoint()
+    }
+
+    fn watermark(&self) -> Option<i64> {
+        self.scan.watermark()
+    }
+
+    #[pyo3(signature = (next_snapshot_id = None))]
+    fn restore(&mut self, next_snapshot_id: Option<i64>) -> PyResult<()> {
+        self.scan.restore(next_snapshot_id).map_err(to_py_err)
+    }
+
+    fn notify_checkpoint_complete(
+        &self,
+        py: Python<'_>,
+        next_snapshot: Option<i64>,
+    ) -> PyResult<()> {
+        py.detach(|| 
runtime().block_on(self.scan.notify_checkpoint_complete(next_snapshot)))
+            .map_err(to_py_err)
+    }
+
+    fn plan(&mut self, py: Python<'_>) -> PyResult<Option<PyPlan>> {
+        let plan = py.detach(|| runtime().block_on(self.scan.plan()));
+        let error = self.bucket_filter.lock().unwrap().error.take();
+        if let Some(error) = error {
+            return Err(PyErr::from_value(error.into_bound(py).into_any()));
+        }
+        plan.map(|plan| plan.map(PyPlan::from)).map_err(to_py_err)
+    }
+}
+
 #[pymethods]
 impl PyTableScan {
     /// Select a half-open range of append-table row positions.
diff --git a/bindings/python/src/table.rs b/bindings/python/src/table.rs
index ed19d070..7dbee4d3 100644
--- a/bindings/python/src/table.rs
+++ b/bindings/python/src/table.rs
@@ -175,6 +175,10 @@ impl PyTable {
         PyBatchWriteBuilder::new(Arc::clone(&self.inner))
     }
 
+    fn new_stream_scan(&self) -> PyResult<crate::read::PyStreamTableScan> {
+        PyReadBuilder::new(Arc::clone(&self.inner)).new_stream_scan()
+    }
+
     fn new_postpone_fixed_bucket_write_builder(
         &self,
     ) -> PyResult<PyPostponeFixedBucketWriteBuilder> {
diff --git a/bindings/python/tests/test_stream_table_scan.py 
b/bindings/python/tests/test_stream_table_scan.py
new file mode 100644
index 00000000..2e81b69a
--- /dev/null
+++ b/bindings/python/tests/test_stream_table_scan.py
@@ -0,0 +1,226 @@
+# 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.
+
+"""Stateful core streaming through the Python wrapper."""
+
+import gc
+import weakref
+
+import pyarrow as pa
+import pytest
+
+from pypaimon_rust.datafusion import PaimonCatalog, StreamTableScan, SQLContext
+
+
[email protected]
+def table(tmp_path):
+    options = {"warehouse": str(tmp_path)}
+    ctx = SQLContext()
+    ctx.register_catalog("paimon", options)
+    ctx.sql("CREATE SCHEMA paimon.snapdb")
+    ctx.sql("CREATE TABLE paimon.snapdb.t (id INT, value INT, PRIMARY KEY 
(id)) "
+            "WITH ('bucket'='4', 'changelog-producer'='input', "
+            "'source.split.target-size'='1 b', 
'source.split.open-file-cost'='1 b')")
+    table = PaimonCatalog(options).get_table("snapdb.t")
+    for value in (100, 200):
+        builder = table.new_batch_write_builder()
+        writer = builder.new_write()
+        writer.write_arrow(pa.record_batch({'id': pa.array(range(32), 
type=pa.int32()),
+                                     'value': pa.array(range(value, value + 
32), type=pa.int32())}))
+        builder.new_commit().commit(writer.prepare_commit())
+        writer.close()
+    return table
+
+
+def _rows(builder, plan):
+    return sorted((row['id'], row['value'])
+                  for batch in builder.new_read().read(plan.splits()) for row 
in batch.to_pylist())
+
+
[email protected]('follow_up', [False, True])
+def test_cursor_restore_and_initial_vs_follow_up(table, follow_up):
+    builder = table.new_read_builder()
+    scan = builder.new_stream_scan()
+    assert isinstance(scan, StreamTableScan)
+    assert scan.checkpoint() is None
+    if follow_up:
+        scan.restore(1)
+    plan = scan.plan()
+    expected_id = 1 if follow_up else 2
+    assert plan.snapshot_id() == expected_id
+    assert _rows(builder, plan) == [(i, (100 if follow_up else 200) + i) for i 
in range(32)]
+    assert all(split.is_streaming() == follow_up for split in plan.splits())
+    assert scan.checkpoint() == expected_id + 1
+    if follow_up:
+        assert scan.plan().snapshot_id() == 2
+    assert scan.plan() is None
+    assert scan.checkpoint() == 3
+    scan.restore(1)
+    assert scan.plan().snapshot_id() == 1
+    scan.restore(None)
+    assert scan.plan().snapshot_id() == 2
+
+
[email protected]('follow_up', [False, True])
+def test_bucket_callback_replacement_clearing_and_shard(table, follow_up):
+    builder = table.new_read_builder()
+    scan = builder.new_stream_scan().with_bucket_filter(lambda bucket: False)
+    scan.restore(2 if follow_up else None)
+    plan = scan.plan()
+    assert plan is None if follow_up else plan.splits() == []
+    scan.with_bucket_filter(lambda bucket: bucket % 2 == 0)
+    scan.restore(2 if follow_up else None)
+    selected_rows = _rows(builder, scan.plan())
+    assert 0 < len(selected_rows) < 32
+    shard = builder.new_stream_scan().with_shard(0, 2)
+    shard.restore(2 if follow_up else None)
+    assert selected_rows == _rows(builder, shard.plan())
+    scan.with_bucket_filter(None)
+    scan.restore(2 if follow_up else None)
+    assert len(_rows(builder, scan.plan())) == 32
+    union = []
+    for index in range(2):
+        shard = builder.new_stream_scan().with_shard(index, 2)
+        shard.restore(2 if follow_up else None)
+        union.extend(_rows(builder, shard.plan()))
+    assert sorted(union) == [(i, 200 + i) for i in range(32)]
+
+
[email protected]('bound_method', [False, True])
+def test_bucket_callback_owner_cycle_is_collected(table, bound_method):
+    class Owner:
+        def __init__(self):
+            self.scan = table.new_stream_scan()
+            self.scan.with_bucket_filter(self.select if bound_method else self)
+
+        def select(self, bucket):
+            return True
+
+        def __call__(self, bucket):
+            return self.select(bucket)
+
+    owner = Owner()
+    reference = weakref.ref(owner)
+    # A live owner must keep the callback and its scan usable during GC.
+    gc.collect()
+    assert owner.scan.plan().snapshot_id() == 2
+    del owner
+    gc.collect()
+    assert reference() is None
+
+
[email protected]('clear', [False, True])
+def test_bucket_callback_owner_is_released_on_replacement(table, clear):
+    class Owner:
+        def select(self, bucket):
+            gc.collect()
+            return True
+
+    owner = Owner()
+    reference = weakref.ref(owner)
+    scan = table.new_stream_scan().with_bucket_filter(owner.select)
+    del owner
+    gc.collect()
+    assert reference() is not None
+    assert scan.plan().snapshot_id() == 2
+    scan.with_bucket_filter(None if clear else lambda bucket: True)
+    gc.collect()
+    assert reference() is None
+    scan.restore(None)
+    assert scan.plan().snapshot_id() == 2
+
+
[email protected]('follow_up', [False, True])
+def test_callback_exception_preserves_identity_and_checkpoint(table, 
follow_up):
+    calls = []
+    failure = LookupError('bucket callback failed')
+
+    def select(bucket):
+        calls.append(bucket)
+        if len(calls) == 2:
+            raise failure
+        return True
+
+    scan = table.new_stream_scan().with_bucket_filter(select)
+    scan.restore(1 if follow_up else None)
+    with pytest.raises(LookupError) as caught:
+        scan.plan()
+    assert caught.value is failure
+    assert len(calls) == 2
+    assert scan.checkpoint() == (1 if follow_up else None)
+    assert scan.plan().snapshot_id() == (1 if follow_up else 2)
+
+
[email protected]('follow_up', [False, True])
+def test_projection_and_predicate_come_from_read_builder(table, follow_up):
+    builder = 
(table.new_read_builder().with_projection(['value']).with_filter({
+        'method': 'equal', 'field': 'id', 'literals': [2]}))
+    scan = builder.new_stream_scan()
+    scan.restore(1 if follow_up else None)
+    plan = scan.plan()
+    assert [row for batch in builder.new_read().read(plan.splits()) for row in 
batch.to_pylist()] == [
+        {'value': 102 if follow_up else 202}]
+
+
+def test_consumer_is_only_persisted_by_explicit_acknowledgement(table):
+    first = table.new_stream_scan().with_consumer_id('job')
+    first.restore(1)
+    assert first.plan().snapshot_id() == 1
+    # Planning alone does not persist a consumer.
+    unacknowledged = table.new_stream_scan().with_consumer_id('job')
+    assert unacknowledged.plan().snapshot_id() == 2
+    assert not unacknowledged.plan()
+    first.notify_checkpoint_complete(first.checkpoint())
+    resumed = table.new_stream_scan().with_consumer_id('job')
+    plan = resumed.plan()
+    assert plan.snapshot_id() == 2
+    assert all(split.is_streaming() for split in plan.splits())
+    assert resumed.checkpoint() == 3
+
+
+def test_missing_checkpoint_waits_then_rejects_out_of_range(table):
+    scan = table.new_read_builder().with_limit(0).new_stream_scan()
+    scan.restore(12345)
+    for _ in range(15):
+        assert scan.plan() is None
+        assert scan.checkpoint() == 12345
+    with pytest.raises(ValueError, match='outside the available snapshots'):
+        scan.plan()
+    assert scan.checkpoint() == 12345
+
+
+def test_invalid_configuration_keeps_the_scan_usable(table):
+    scan = table.new_stream_scan()
+    for value in [0, -1]:
+        with pytest.raises(ValueError, match='snapshot id'):
+            scan.restore(value)
+    for index, count in [(0, 0), (2, 2)]:
+        with pytest.raises(ValueError, match='shard'):
+            scan.with_shard(index, count)
+    with pytest.raises(TypeError, match='callable'):
+        scan.with_bucket_filter(42)
+    for value in ['', '.', '..', 'x/../../snapshot/snapshot-1', r'x\y']:
+        with pytest.raises(ValueError, match='consumer id'):
+            scan.with_consumer_id(value)
+    assert scan.plan().snapshot_id() == 2
+
+
+def test_snapshot_reader_is_an_internal_component():
+    from pypaimon_rust import datafusion
+    assert not hasattr(datafusion, 'SnapshotReader')
+    assert not hasattr(datafusion.Table, 'new_snapshot_reader')
+    assert not hasattr(datafusion.ReadBuilder, 'new_snapshot_reader')
diff --git a/crates/paimon/src/lib.rs b/crates/paimon/src/lib.rs
index 1d65128f..146c5e2b 100644
--- a/crates/paimon/src/lib.rs
+++ b/crates/paimon/src/lib.rs
@@ -62,3 +62,6 @@ pub use table::{
 pub use table::{
     HybridSearchBuilder, HybridSearchRanker, HybridSearchRoute, 
HybridSearchRouteKind,
 };
+
+#[cfg(test)]
+extern crate self as paimon;
diff --git a/crates/paimon/src/table/consumer_manager.rs 
b/crates/paimon/src/table/consumer_manager.rs
index 35cb055b..20e1a084 100644
--- a/crates/paimon/src/table/consumer_manager.rs
+++ b/crates/paimon/src/table/consumer_manager.rs
@@ -22,7 +22,7 @@ use std::time::Duration;
 
 use crate::io::FileIO;
 use futures::{stream, StreamExt, TryStreamExt};
-use serde::Deserialize;
+use serde::{Deserialize, Serialize};
 
 const CONSUMER_DIR: &str = "consumer";
 const CONSUMER_PREFIX: &str = "consumer-";
@@ -53,6 +53,19 @@ impl ConsumerManager {
         )
     }
 
+    pub(super) fn validate_consumer_id(consumer_id: &str) -> crate::Result<()> 
{
+        if consumer_id.is_empty()
+            || matches!(consumer_id, "." | "..")
+            || consumer_id.contains(['/', '\\'])
+        {
+            return Err(crate::Error::DataInvalid {
+                message: "consumer id must be a non-empty name without path 
separators".into(),
+                source: None,
+            });
+        }
+        Ok(())
+    }
+
     /// Read one consumer's next snapshot id.
     pub async fn get(&self, consumer_id: &str) -> crate::Result<Option<i64>> {
         let ids = HashSet::from([consumer_id]);
@@ -64,6 +77,27 @@ impl ConsumerManager {
             .map(|(_, next_snapshot)| next_snapshot))
     }
 
+    /// Persist an explicitly acknowledged next snapshot, like Java 
resetConsumer.
+    pub async fn reset(&self, consumer_id: &str, next_snapshot: i64) -> 
crate::Result<()> {
+        Self::validate_consumer_id(consumer_id)?;
+        if next_snapshot < 1 {
+            return Err(crate::Error::DataInvalid {
+                message: "consumer next snapshot must be positive".into(),
+                source: None,
+            });
+        }
+        let directory = format!("{}/{}", self.table_path, CONSUMER_DIR);
+        self.file_io.mkdirs(&directory).await?;
+        let path = format!("{directory}/{CONSUMER_PREFIX}{consumer_id}");
+        let bytes = serde_json::to_vec(&Consumer { next_snapshot 
}).map_err(|error| {
+            crate::Error::DataInvalid {
+                message: format!("consumer JSON invalid: {error}"),
+                source: Some(Box::new(error)),
+            }
+        })?;
+        self.file_io.new_output(&path)?.write(bytes.into()).await
+    }
+
     async fn read_path(&self, consumer_id: &str, path: &str) -> 
crate::Result<Option<i64>> {
         let input = self.file_io.new_input(path)?;
         for attempt in 0..READ_RETRIES {
@@ -143,7 +177,7 @@ impl ConsumerManager {
     }
 }
 
-#[derive(Deserialize)]
+#[derive(Deserialize, Serialize)]
 struct Consumer {
     #[serde(rename = "nextSnapshot")]
     next_snapshot: i64,
@@ -156,6 +190,28 @@ mod tests {
     use super::*;
     use crate::io::FileIOBuilder;
 
+    #[tokio::test]
+    async fn reset_is_explicit_branch_scoped_and_rejects_path_traversal() {
+        let file_io = FileIOBuilder::new("memory").build().unwrap();
+        let manager = ConsumerManager::new(file_io.clone(), 
"memory:/consumer-reset".into());
+        let branch = manager.with_branch("dev");
+        for invalid in ["", ".", "..", "x/../../snapshot/snapshot-1", "x\\y"] {
+            assert!(manager.reset(invalid, 2).await.is_err());
+        }
+        assert!(!file_io
+            .new_input("memory:/consumer-reset/snapshot/snapshot-1")
+            .unwrap()
+            .exists()
+            .await
+            .unwrap());
+        assert!(manager.reset("job", 0).await.is_err());
+        branch.reset("job", 2).await.unwrap();
+        assert_eq!(branch.get("job").await.unwrap(), Some(2));
+        assert_eq!(manager.get("job").await.unwrap(), None);
+        branch.reset("job", 3).await.unwrap();
+        assert_eq!(branch.get("job").await.unwrap(), Some(3));
+    }
+
     #[tokio::test]
     async fn retries_a_consumer_being_overwritten() {
         let file_io = FileIOBuilder::new("memory").build().unwrap();
diff --git a/crates/paimon/src/table/data_file_reader.rs 
b/crates/paimon/src/table/data_file_reader.rs
index d12e49df..4c32b659 100644
--- a/crates/paimon/src/table/data_file_reader.rs
+++ b/crates/paimon/src/table/data_file_reader.rs
@@ -1188,18 +1188,31 @@ fn read_data_fields(
         }
     }
     for expected in expected_fields {
-        if expected.id() == crate::spec::SEQUENCE_NUMBER_FIELD_ID
-            && expected.name() == crate::spec::SEQUENCE_NUMBER_FIELD_NAME
+        if is_physical_system_field(expected)
             && !read_fields.iter().any(|field| field.id() == expected.id())
         {
-            // Row tracking stores this physical system column outside the
-            // logical table schema, including alongside selected MAP keys.
+            // Row tracking and KV files store these columns outside the 
logical
+            // table schema. Keep them during MAP pruning/schema evolution too;
+            // an audit reader must receive the stored _VALUE_KIND.
             read_fields.push(expected.clone());
         }
     }
     Ok(read_fields)
 }
 
+fn is_physical_system_field(field: &DataField) -> bool {
+    matches!(
+        (field.id(), field.name()),
+        (
+            crate::spec::SEQUENCE_NUMBER_FIELD_ID,
+            crate::spec::SEQUENCE_NUMBER_FIELD_NAME
+        ) | (
+            crate::spec::VALUE_KIND_FIELD_ID,
+            crate::spec::VALUE_KIND_FIELD_NAME
+        )
+    )
+}
+
 fn prune_data_type(
     read_type: &DataType,
     data_type: &DataType,
@@ -1684,6 +1697,31 @@ mod row_tests {
         assert_eq!(read[1], sequence);
     }
 
+    #[test]
+    fn schema_pruning_keeps_value_kind_but_does_not_bind_an_alias() {
+        let value_kind = field(
+            crate::spec::VALUE_KIND_FIELD_ID,
+            crate::spec::VALUE_KIND_FIELD_NAME,
+            DataType::TinyInt(crate::spec::TinyIntType::with_nullable(false)),
+        );
+        let read = read_data_fields(&[], std::slice::from_ref(&value_kind), 
false).unwrap();
+        assert_eq!(read, vec![value_kind]);
+        for alias in [
+            field(
+                10,
+                crate::spec::VALUE_KIND_FIELD_NAME,
+                DataType::Int(IntType::new()),
+            ),
+            field(
+                crate::spec::VALUE_KIND_FIELD_ID,
+                "kind",
+                DataType::Int(IntType::new()),
+            ),
+        ] {
+            assert!(read_data_fields(&[], &[alias], 
false).unwrap().is_empty());
+        }
+    }
+
     #[test]
     fn read_data_fields_preserves_variant_extraction_row_type() {
         let configured = DataType::Row(RowType::new(vec![field(
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index 56c27d35..443f4848 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -141,6 +141,7 @@ mod sorted_global_index_build_builder;
 mod sorted_global_index_options;
 mod source;
 mod stats_filter;
+mod stream_table_scan;
 pub(crate) mod table_commit;
 mod table_merge_input;
 mod table_merge_into;
@@ -222,6 +223,7 @@ pub use sorted_global_index_build_builder::{
 pub use source::{
     merge_row_ranges, DataSplit, DataSplitBuilder, DeletionFile, 
PartitionBucket, Plan, RowRange,
 };
+pub use stream_table_scan::StreamTableScan;
 pub use table_commit::TableCommit;
 pub use table_merge_into::{
     MergeAssignment, MergeCondition, MergeSource, WhenMatched, WhenNotMatched,
@@ -524,6 +526,11 @@ impl Table {
         ReadBuilder::new(self)
     }
 
+    /// Create a stateful streaming scan. Configure filters through a read 
builder.
+    pub fn new_stream_scan(&self) -> Result<StreamTableScan> {
+        self.new_read_builder().new_stream_scan()
+    }
+
     /// Create a full-text search builder.
     ///
     /// Reference: 
[FullTextSearchBuilderImpl](https://github.com/apache/paimon/blob/master/paimon-core/src/main/java/org/apache/paimon/table/source/FullTextSearchBuilderImpl.java)
diff --git a/crates/paimon/src/table/read_builder.rs 
b/crates/paimon/src/table/read_builder.rs
index dfffa31d..ce971a17 100644
--- a/crates/paimon/src/table/read_builder.rs
+++ b/crates/paimon/src/table/read_builder.rs
@@ -298,6 +298,11 @@ impl<'a> ReadBuilder<'a> {
         }
     }
 
+    /// Create a stateful streaming scan using the configured filter and read 
type.
+    pub fn new_stream_scan(&self) -> Result<super::StreamTableScan> {
+        super::StreamTableScan::new(self.new_scan())
+    }
+
     /// Create a batch incremental scan over snapshot id range
     /// `(start_exclusive, end_inclusive]`.
     ///
diff --git a/crates/paimon/src/table/snapshot_scan_tests.rs 
b/crates/paimon/src/table/snapshot_scan_tests.rs
new file mode 100644
index 00000000..50910988
--- /dev/null
+++ b/crates/paimon/src/table/snapshot_scan_tests.rs
@@ -0,0 +1,566 @@
+// 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.
+
+#[path = "../../tests/common/mod.rs"]
+mod common;
+
+use super::SnapshotScanMode;
+use arrow_array::{Int32Array, RecordBatch};
+use common::incremental_helpers::{
+    make_batch, make_batch_with_kinds, memory_table, persist_table_schema, 
setup_dirs, write_batch,
+};
+use futures::TryStreamExt;
+use paimon::spec::{DataType, IntType, Schema, TableSchema};
+use paimon::table::{IncrementalScanMode, Plan, Table, TableScan};
+use std::collections::HashMap;
+
+async fn table(name: &str, engine: &str, producer: &str, buckets: i32) -> 
Table {
+    let mut schema = Schema::builder()
+        .column("id", DataType::Int(IntType::new()))
+        .column("value", DataType::Int(IntType::new()))
+        .option("changelog-producer", producer)
+        .option("source.split.target-size", "1 b")
+        .option("source.split.open-file-cost", "1 b");
+    match engine {
+        "append" => {
+            schema = schema.option("bucket", "-1");
+        }
+        "evolution" => {
+            schema = schema
+                .option("bucket", "-1")
+                .option("row-tracking.enabled", "true")
+                .option("data-evolution.enabled", "true");
+        }
+        _ => {
+            schema = schema
+                .primary_key(["id"])
+                .option("bucket", buckets.to_string())
+                .option("merge-engine", engine);
+        }
+    }
+    if engine == "aggregation" {
+        schema = schema.option("fields.value.aggregate-function", "sum");
+    }
+    let path = format!("memory:/snapshot_scan/{name}");
+    let (io, table) = memory_table(&path, TableSchema::new(0, 
&schema.build().unwrap()));
+    setup_dirs(&io, &path).await;
+    persist_table_schema(&io, &path, table.schema()).await;
+    table
+}
+
+// Snapshot loading is test fixture setup; the production planner receives an
+// already selected Snapshot from the batch/stream scanning policy.
+async fn snapshot_plan(
+    scan: TableScan<'_>,
+    snapshot_id: Option<i64>,
+    mode: SnapshotScanMode,
+) -> paimon::Result<Plan> {
+    scan.validate_snapshot_read()?;
+    let manager = scan.snapshot_table().snapshot_manager();
+    let snapshot = match snapshot_id {
+        Some(id) => Some(manager.get_snapshot(id).await?),
+        None => manager.get_latest_snapshot().await?,
+    };
+    match snapshot {
+        Some(snapshot) => scan.plan_selected_snapshot(snapshot, mode).await,
+        None => Ok(Plan::new(Vec::new())),
+    }
+}
+
+async fn rows(table: &Table, plan: &Plan) -> Vec<(i32, i32)> {
+    let batches: Vec<RecordBatch> = table
+        .new_read_builder()
+        .new_read()
+        .unwrap()
+        .to_arrow(plan.splits())
+        .unwrap()
+        .try_collect()
+        .await
+        .unwrap();
+    let mut rows = Vec::new();
+    for batch in batches {
+        let ids = batch
+            .column(0)
+            .as_any()
+            .downcast_ref::<Int32Array>()
+            .unwrap();
+        let values = batch
+            .column(1)
+            .as_any()
+            .downcast_ref::<Int32Array>()
+            .unwrap();
+        rows.extend((0..batch.num_rows()).map(|row| (ids.value(row), 
values.value(row))));
+    }
+    rows.sort_unstable();
+    rows
+}
+
+#[tokio::test]
+async fn 
all_pins_snapshot_and_keeps_batch_merge_semantics_including_level_zero() {
+    for engine in [
+        "deduplicate",
+        "first-row",
+        "partial-update",
+        "aggregation",
+        "append",
+        "evolution",
+    ] {
+        let table = table(engine, engine, "none", 1).await;
+        write_batch(&table, &make_batch(vec![1, 2], vec![10, 20])).await;
+        write_batch(&table, &make_batch(vec![1, 3], vec![11, 30])).await;
+        write_batch(&table, &make_batch(vec![4], vec![40])).await;
+        let plan = snapshot_plan(
+            table.new_read_builder().new_scan(),
+            Some(2),
+            SnapshotScanMode::All,
+        )
+        .await
+        .unwrap();
+        assert_eq!(plan.snapshot_id(), Some(2));
+        assert!(!plan.splits().is_empty());
+        assert!(plan.splits().iter().all(|split| !split.is_streaming()));
+        let expected = match engine {
+            "append" | "evolution" => vec![(1, 10), (1, 11), (2, 20), (3, 30)],
+            "first-row" => vec![(1, 10), (2, 20), (3, 30)],
+            "aggregation" => vec![(1, 21), (2, 20), (3, 30)],
+            _ => vec![(1, 11), (2, 20), (3, 30)],
+        };
+        assert_eq!(rows(&table, &plan).await, expected, "{engine}");
+        let current = snapshot_plan(
+            table.new_read_builder().new_scan(),
+            None,
+            SnapshotScanMode::All,
+        )
+        .await
+        .unwrap();
+        assert_eq!(current.snapshot_id(), Some(3));
+        assert!(rows(&table, &current).await.contains(&(4, 40)));
+    }
+}
+
+#[tokio::test]
+async fn explicit_snapshot_overrides_startup_options_without_mutating_table() {
+    let table = table("startup", "append", "none", 1).await;
+    write_batch(&table, &make_batch(vec![1], vec![10])).await;
+    write_batch(&table, &make_batch(vec![2], vec![20])).await;
+    let selected = table.copy_with_options(HashMap::from([
+        ("scan.snapshot-id".into(), "1".into()),
+        ("scan.mode".into(), "from-snapshot".into()),
+    ]));
+    let pinned = snapshot_plan(
+        selected.new_read_builder().new_scan(),
+        Some(2),
+        SnapshotScanMode::All,
+    )
+    .await
+    .unwrap();
+    assert_eq!(rows(&selected, &pinned).await, vec![(1, 10), (2, 20)]);
+    let batch = selected.new_read_builder().new_scan().plan().await.unwrap();
+    assert_eq!(batch.snapshot_id(), Some(1));
+    assert_eq!(rows(&selected, &batch).await, vec![(1, 10)]);
+}
+
+#[tokio::test]
+async fn changelog_reads_overwrite_while_batch_range_still_skips_it() {
+    let table = table("overwrite", "deduplicate", "input", 1).await;
+    write_batch(&table, &make_batch(vec![1], vec![10])).await;
+    let builder = table.new_write_builder().with_overwrite();
+    let mut writer = builder.new_write().unwrap();
+    writer
+        .write_arrow_batch(&make_batch(vec![2], vec![20]))
+        .await
+        .unwrap();
+    builder
+        .new_commit()
+        .overwrite(writer.prepare_commit().await.unwrap(), None)
+        .await
+        .unwrap();
+    writer.close().await;
+    // The local overwrite writer does not publish a changelog. Reuse a real
+    // physical changelog manifest to model a producer which supplies one;
+    // its rows deliberately differ from the overwrite's delta data.
+    let first = table.snapshot_manager().get_snapshot(1).await.unwrap();
+    let overwrite = table.snapshot_manager().get_snapshot(2).await.unwrap();
+    let mut json = serde_json::to_value(overwrite).unwrap();
+    json["changelogManifestList"] = 
first.changelog_manifest_list().unwrap().into();
+    table
+        .file_io()
+        .new_output(&table.snapshot_manager().snapshot_path(2))
+        .unwrap()
+        .write(serde_json::to_vec(&json).unwrap().into())
+        .await
+        .unwrap();
+    write_batch(&table, &make_batch(vec![3], vec![30])).await;
+    let scan = table.new_read_builder().new_scan();
+    let events = snapshot_plan(scan.clone(), Some(2), 
SnapshotScanMode::Changelog)
+        .await
+        .unwrap();
+    assert_eq!(events.snapshot_id(), Some(2));
+    assert_eq!(rows(&table, &events).await, vec![(1, 10)]);
+    assert!(events.splits().iter().all(|split| split.is_streaming()));
+    assert!(events
+        .splits()
+        .iter()
+        .flat_map(|split| split.data_files())
+        .all(|file| file.file_name.starts_with("changelog-")));
+    let range = table
+        .new_read_builder()
+        .new_incremental_scan(IncrementalScanMode::Changelog, 1, 2)
+        .plan_combined()
+        .await
+        .unwrap();
+    assert!(range.splits().is_empty());
+    assert_eq!(range.snapshot_id(), Some(2));
+    let full = snapshot_plan(scan, Some(2), SnapshotScanMode::All)
+        .await
+        .unwrap();
+    assert_eq!(rows(&table, &full).await, vec![(2, 20)]);
+}
+
+#[tokio::test]
+async fn changelog_keeps_all_row_kinds_and_arrival_order() {
+    let table = table("events", "deduplicate", "input", 1).await;
+    write_batch(
+        &table,
+        &make_batch_with_kinds(vec![1, 1, 1, 1], vec![10, 10, 11, 11], vec![0, 
1, 2, 3]),
+    )
+    .await;
+    let plan = snapshot_plan(
+        table.new_read_builder().new_scan(),
+        Some(1),
+        SnapshotScanMode::Changelog,
+    )
+    .await
+    .unwrap();
+    let builder = table.new_read_builder();
+    let batches: Vec<RecordBatch> = builder
+        .new_read()
+        .unwrap()
+        .to_arrow_with_row_kind(plan.splits())
+        .unwrap()
+        .try_collect()
+        .await
+        .unwrap();
+    let kinds: Vec<&str> = batches
+        .iter()
+        .flat_map(|batch| {
+            batch
+                .column(0)
+                .as_any()
+                .downcast_ref::<arrow_array::StringArray>()
+                .unwrap()
+                .iter()
+                .map(Option::unwrap)
+        })
+        .collect();
+    assert_eq!(kinds, vec!["+I", "-U", "+U", "-D"]);
+}
+
+#[tokio::test]
+async fn bucket_filter_precedes_packing_and_limit_for_every_mode() {
+    let table = table("buckets", "deduplicate", "input", 4).await;
+    write_batch(&table, &make_batch((0..32).collect(), 
(100..132).collect())).await;
+    for mode in [
+        SnapshotScanMode::All,
+        SnapshotScanMode::Delta,
+        SnapshotScanMode::Changelog,
+    ] {
+        let all = snapshot_plan(table.new_read_builder().new_scan(), None, 
mode)
+            .await
+            .unwrap();
+        let selected_bucket = all.splits().last().unwrap().bucket();
+        let expected_ids: std::collections::HashSet<i32> = rows(
+            &table,
+            &Plan::new(
+                all.splits()
+                    .iter()
+                    .filter(|split| split.bucket() == selected_bucket)
+                    .cloned()
+                    .collect(),
+            ),
+        )
+        .await
+        .into_iter()
+        .map(|(id, _)| id)
+        .collect();
+        assert!(!expected_ids.is_empty());
+        let mut builder = table.new_read_builder();
+        builder.with_limit(1);
+        let selected = snapshot_plan(
+            builder
+                .new_scan()
+                .with_snapshot_bucket_filter(move |bucket| Ok(bucket == 
selected_bucket)),
+            None,
+            mode,
+        )
+        .await
+        .unwrap();
+        assert_eq!(selected.snapshot_id(), Some(1));
+        assert!(!selected.splits().is_empty());
+        assert!(selected
+            .splits()
+            .iter()
+            .all(|split| split.bucket() == selected_bucket));
+        assert!(rows(&table, &selected)
+            .await
+            .iter()
+            .all(|(id, _)| expected_ids.contains(id)));
+        let empty = snapshot_plan(
+            table
+                .new_read_builder()
+                .new_scan()
+                .with_snapshot_bucket_filter(|_| Ok(false)),
+            None,
+            mode,
+        )
+        .await
+        .unwrap();
+        assert_eq!(empty.snapshot_id(), Some(1));
+        assert!(empty.splits().is_empty());
+    }
+}
+
+#[tokio::test]
+async fn filter_errors_fail_instead_of_publishing_a_partial_plan() {
+    let table = table("callback", "deduplicate", "input", 4).await;
+    write_batch(&table, &make_batch((0..32).collect(), 
(100..132).collect())).await;
+    for mode in [
+        SnapshotScanMode::All,
+        SnapshotScanMode::Delta,
+        SnapshotScanMode::Changelog,
+    ] {
+        let calls = 
std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
+        let counter = calls.clone();
+        let error = snapshot_plan(
+            table
+                .new_read_builder()
+                .new_scan()
+                .with_snapshot_bucket_filter(move |_| {
+                    if counter.fetch_add(1, 
std::sync::atomic::Ordering::SeqCst) == 0 {
+                        return Ok(true);
+                    }
+                    Err(paimon::Error::DataInvalid {
+                        message: "bucket selection failed".into(),
+                        source: None,
+                    })
+                }),
+            None,
+            mode,
+        )
+        .await
+        .unwrap_err();
+        assert!(error.to_string().contains("bucket selection failed"));
+        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 2);
+    }
+}
+
+#[tokio::test]
+async fn replacing_bucket_filter_and_composing_shard_preserves_versions() {
+    let table = table("shard", "deduplicate", "input", 4).await;
+    write_batch(&table, &make_batch((0..32).collect(), 
(100..132).collect())).await;
+    write_batch(&table, &make_batch((0..32).collect(), 
(200..232).collect())).await;
+    let mut union = Vec::new();
+    for index in 0..2 {
+        let plan = snapshot_plan(
+            table
+                .new_read_builder()
+                .new_scan()
+                .with_snapshot_bucket_filter(|_| Ok(false))
+                .with_snapshot_bucket_filter(|bucket| Ok(bucket >= 0))
+                .with_shard(index, 2)
+                .unwrap(),
+            None,
+            SnapshotScanMode::All,
+        )
+        .await
+        .unwrap();
+        assert!(plan
+            .splits()
+            .iter()
+            .all(|split| split.bucket() as usize % 2 == index));
+        union.extend(rows(&table, &plan).await);
+    }
+    union.sort_unstable();
+    assert_eq!(union, (0..32).map(|id| (id, 200 + id)).collect::<Vec<_>>());
+}
+
+#[tokio::test]
+async fn read_configuration_and_empty_snapshot_metadata_survive_modes() {
+    for engine in ["append", "evolution", "deduplicate"] {
+        let table = table(&format!("projection_{engine}"), engine, "none", 
1).await;
+        write_batch(&table, &make_batch(vec![1, 2], vec![10, 20])).await;
+        let mut builder = table.new_read_builder();
+        builder.with_projection(&["value"]).unwrap();
+        builder.with_filter(
+            paimon::spec::PredicateBuilder::new(table.schema().fields())
+                .greater_than("id", paimon::spec::Datum::Int(1))
+                .unwrap(),
+        );
+        for mode in [SnapshotScanMode::All, SnapshotScanMode::Delta] {
+            let plan = snapshot_plan(builder.new_scan(), None, 
mode).await.unwrap();
+            let batches: Vec<RecordBatch> = builder
+                .new_read()
+                .unwrap()
+                .to_arrow(plan.splits())
+                .unwrap()
+                .try_collect()
+                .await
+                .unwrap();
+            
assert_eq!(batches.iter().map(RecordBatch::num_rows).sum::<usize>(), 1);
+            assert_eq!(batches[0].schema().field(0).name(), "value");
+            assert_eq!(
+                batches[0]
+                    .column(0)
+                    .as_any()
+                    .downcast_ref::<Int32Array>()
+                    .unwrap()
+                    .value(0),
+                20
+            );
+        }
+        let empty = snapshot_plan(builder.new_scan(), None, 
SnapshotScanMode::Changelog)
+            .await
+            .unwrap();
+        assert!(empty.splits().is_empty());
+        assert_eq!(empty.snapshot_id(), Some(1));
+    }
+}
+
+#[tokio::test]
+async fn absent_and_missing_snapshots_are_distinct_even_with_zero_limit() {
+    let table = table("missing", "append", "none", 1).await;
+    let mut builder = table.new_read_builder();
+    builder.with_limit(0);
+    for mode in [
+        SnapshotScanMode::All,
+        SnapshotScanMode::Delta,
+        SnapshotScanMode::Changelog,
+    ] {
+        let scan = builder.new_scan();
+        let empty = snapshot_plan(scan.clone(), None, mode).await.unwrap();
+        assert!(empty.splits().is_empty());
+        assert_eq!(empty.snapshot_id(), None);
+        assert!(matches!(
+            snapshot_plan(scan, Some(1), mode).await,
+            Err(paimon::Error::SnapshotNotExist { snapshot_id: 1 })
+        ));
+    }
+    assert!(builder.new_scan().with_shard(0, 0).is_err());
+    assert!(builder.new_scan().with_shard(2, 2).is_err());
+}
+
+#[tokio::test]
+async fn all_dv_level_zero_versions_merge_before_value_predicates() {
+    for mor in ["true", "false"] {
+        let table = table(&format!("dv_{mor}"), "deduplicate", "none", 1)
+            .await
+            .copy_with_options(HashMap::from([
+                ("deletion-vectors.enabled".into(), "true".into()),
+                ("deletion-vectors.merge-on-read".into(), mor.into()),
+            ]));
+        write_batch(&table, &make_batch(vec![1, 2], vec![10, 20])).await;
+        write_batch(&table, &make_batch(vec![1, 3], vec![11, 30])).await;
+        let plan = snapshot_plan(
+            table.new_read_builder().new_scan(),
+            None,
+            SnapshotScanMode::All,
+        )
+        .await
+        .unwrap();
+        assert_eq!(rows(&table, &plan).await, vec![(1, 11), (2, 20), (3, 30)]);
+        let mut builder = table.new_read_builder();
+        builder.with_filter(
+            paimon::spec::PredicateBuilder::new(table.schema().fields())
+                .equal("value", paimon::spec::Datum::Int(10))
+                .unwrap(),
+        );
+        let plan = snapshot_plan(builder.new_scan(), None, 
SnapshotScanMode::All)
+            .await
+            .unwrap();
+        let batches: Vec<RecordBatch> = builder
+            .new_read()
+            .unwrap()
+            .to_arrow(plan.splits())
+            .unwrap()
+            .try_collect()
+            .await
+            .unwrap();
+        assert_eq!(batches.iter().map(RecordBatch::num_rows).sum::<usize>(), 
0);
+    }
+}
+
+#[tokio::test]
+async fn snapshot_planning_never_bypasses_query_authorization() {
+    let table = table("auth", "append", "none", 1)
+        .await
+        .copy_with_options(HashMap::from([(
+            "query-auth.enabled".into(),
+            "true".into(),
+        )]));
+    for mode in [
+        SnapshotScanMode::All,
+        SnapshotScanMode::Delta,
+        SnapshotScanMode::Changelog,
+    ] {
+        for snapshot_id in [None, Some(1)] {
+            let scan = table
+                .new_read_builder()
+                .new_scan()
+                .with_snapshot_bucket_filter(|_| Ok(false));
+            let error = snapshot_plan(scan, snapshot_id, 
mode).await.unwrap_err();
+            assert!(error.to_string().contains("query-auth.enabled"));
+            assert!(matches!(error, paimon::Error::Unsupported { .. }));
+        }
+    }
+}
+
+#[tokio::test]
+async fn postpone_pending_visibility_is_an_explicit_reader_selection() {
+    for producer in ["none", "input"] {
+        let table = table(&format!("pending_{producer}"), "deduplicate", 
producer, -2).await;
+        write_batch(&table, &make_batch(vec![1], vec![10])).await;
+        for mode in [
+            SnapshotScanMode::All,
+            SnapshotScanMode::Delta,
+            SnapshotScanMode::Changelog,
+        ] {
+            let plan = snapshot_plan(table.new_read_builder().new_scan(), 
None, mode)
+                .await
+                .unwrap();
+            let expected = if mode == SnapshotScanMode::Changelog {
+                vec![]
+            } else {
+                vec![(1, 10)]
+            };
+            assert_eq!(rows(&table, &plan).await, expected, "{producer} 
{mode:?}");
+            assert!(plan.splits().iter().all(|split| split.bucket() == -2));
+            let plan = snapshot_plan(
+                table
+                    .new_read_builder()
+                    .new_scan()
+                    .with_snapshot_bucket_filter(|_| panic!("unassigned bucket 
reached callback"))
+                    .only_read_snapshot_real_buckets(),
+                None,
+                mode,
+            )
+            .await
+            .unwrap();
+            assert!(plan.splits().is_empty());
+            assert_eq!(plan.snapshot_id(), Some(1));
+        }
+    }
+}
diff --git a/crates/paimon/src/table/stream_table_scan.rs 
b/crates/paimon/src/table/stream_table_scan.rs
new file mode 100644
index 00000000..fea63186
--- /dev/null
+++ b/crates/paimon/src/table/stream_table_scan.rs
@@ -0,0 +1,252 @@
+// 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.
+
+//! Stateful snapshot selection and progress for continuous table reads.
+
+use super::table_scan::SnapshotScanMode;
+use super::{Plan, TableScan};
+use crate::spec::{ChangelogProducer, CommitKind, CoreOptions, Snapshot};
+use crate::{Error, Result};
+
+/// Continuous table scan. `checkpoint` is the next snapshot to consume.
+///
+/// The first plan reads the latest full state. Follow-up plans read APPEND
+/// deltas when no changelog producer is configured, or physical changelogs
+/// otherwise. Empty follow-up plans and irrelevant commits are skipped, as in
+/// Java. `None` means there is no available next snapshot; clients can poll.
+/// Progress is persisted only by an explicit `notify_checkpoint_complete`.
+#[derive(Debug)]
+pub struct StreamTableScan {
+    scan: TableScan<'static>,
+    next_snapshot_id: Option<i64>,
+    watermark: Option<i64>,
+    consumer_id: Option<String>,
+    follow_up_mode: SnapshotScanMode,
+    consumer_restored: bool,
+    missing_snapshot_polls: usize,
+}
+
+impl StreamTableScan {
+    pub(super) fn new(scan: TableScan<'_>) -> Result<Self> {
+        let mut scan = scan.into_owned_paimon_scan()?;
+        let options = 
CoreOptions::new(scan.snapshot_table().schema().options());
+        let producer = options.try_changelog_producer()?;
+        let follow_up_mode = if producer == ChangelogProducer::None {
+            SnapshotScanMode::Delta
+        } else {
+            SnapshotScanMode::Changelog
+        };
+        if options.bucket() == -2 && producer != ChangelogProducer::None {
+            scan = scan.only_read_snapshot_real_buckets();
+        }
+        let consumer_id = scan
+            .snapshot_table()
+            .schema()
+            .options()
+            .get("consumer-id")
+            .cloned();
+        if let Some(id) = &consumer_id {
+            super::ConsumerManager::validate_consumer_id(id)?;
+        }
+        Ok(Self {
+            scan,
+            next_snapshot_id: None,
+            watermark: None,
+            consumer_id,
+            follow_up_mode,
+            consumer_restored: false,
+            missing_snapshot_polls: 0,
+        })
+    }
+
+    /// Set the consumer whose acknowledged checkpoint will be restored.
+    pub fn with_consumer_id(&mut self, consumer_id: impl Into<String>) -> 
Result<&mut Self> {
+        let consumer_id = consumer_id.into();
+        super::ConsumerManager::validate_consumer_id(&consumer_id)?;
+        self.consumer_id = Some(consumer_id);
+        Ok(self)
+    }
+
+    /// Select buckets before split packing. Replaces the previous filter.
+    pub fn with_bucket_filter(
+        &mut self,
+        filter: impl Fn(i32) -> Result<bool> + Send + Sync + 'static,
+    ) -> &mut Self {
+        self.scan = self.scan.clone().with_snapshot_bucket_filter(filter);
+        self
+    }
+
+    /// Distribute initial primary-key state by bucket so all key versions
+    /// merge together. Follow-up physical events use Java's shard rules.
+    pub fn with_shard(&mut self, index: usize, count: usize) -> Result<&mut 
Self> {
+        self.scan = self.scan.clone().with_shard(index, count)?;
+        Ok(self)
+    }
+
+    pub fn checkpoint(&self) -> Option<i64> {
+        self.next_snapshot_id
+    }
+
+    pub fn watermark(&self) -> Option<i64> {
+        self.watermark
+    }
+
+    /// Restore the next snapshot to consume. `None` resets to the configured 
starting position, including consumer progress.
+    pub fn restore(&mut self, next_snapshot_id: Option<i64>) -> Result<()> {
+        if next_snapshot_id.is_some_and(|id| id < 1) {
+            return Err(Error::DataInvalid {
+                message: "next snapshot id must be positive".into(),
+                source: None,
+            });
+        }
+        self.next_snapshot_id = next_snapshot_id;
+        self.consumer_restored = next_snapshot_id.is_some();
+        self.missing_snapshot_polls = 0;
+        Ok(())
+    }
+
+    pub async fn plan(&mut self) -> Result<Option<Plan>> {
+        // Validate even when the table is empty or no follow-up is available.
+        self.scan.validate_snapshot_read()?;
+        if self.next_snapshot_id.is_none() && !self.consumer_restored {
+            if let Some(consumer_id) = &self.consumer_id {
+                let next = self
+                    .scan
+                    .snapshot_table()
+                    .consumer_manager()
+                    .get(consumer_id)
+                    .await?;
+                if let Some(next) = next {
+                    self.restore(Some(next))?;
+                }
+            }
+            self.consumer_restored = true;
+        }
+        let manager = self.scan.snapshot_table().snapshot_manager();
+        if self.next_snapshot_id.is_none() {
+            let Some(snapshot) = manager.get_latest_snapshot().await? else {
+                return Ok(None);
+            };
+            let next = snapshot.id() + 1;
+            let watermark = snapshot.watermark();
+            let plan = self.plan_initial_snapshot(snapshot).await?;
+            self.next_snapshot_id = Some(next);
+            self.watermark = watermark;
+            return Ok(Some(plan));
+        }
+        let mode = self.follow_up_mode;
+        loop {
+            let id = self.next_snapshot_id.expect("initial phase completed");
+            let snapshot = match manager.get_snapshot(id).await {
+                Ok(snapshot) => snapshot,
+                Err(Error::SnapshotNotExist { .. }) => {
+                    // Like NextSnapshotFetcher, wait for publication but 
detect
+                    // expired checkpoints / recreated tables periodically.
+                    self.missing_snapshot_polls += 1;
+                    if self.missing_snapshot_polls.is_multiple_of(16) {
+                        let earliest = manager.earliest_snapshot_id().await?;
+                        let latest = manager.get_latest_snapshot_id().await?;
+                        if earliest.is_some_and(|first| first > id)
+                            || latest.is_some_and(|last| id > last + 1)
+                            || (latest.is_none() && id > 1)
+                        {
+                            return Err(Error::DataInvalid {
+                                message: format!("stream checkpoint {id} is 
outside the available snapshots {earliest:?}..{latest:?}"),
+                                source: None,
+                            });
+                        }
+                    }
+                    return Ok(None);
+                }
+                Err(error) => return Err(error),
+            };
+            self.missing_snapshot_polls = 0;
+            let should_scan = match mode {
+                SnapshotScanMode::Delta => snapshot.commit_kind() == 
&CommitKind::APPEND,
+                SnapshotScanMode::Changelog => 
snapshot.changelog_manifest_list().is_some(),
+                SnapshotScanMode::All => unreachable!("follow-up mode"),
+            };
+            if !should_scan {
+                self.next_snapshot_id = Some(id + 1);
+                continue;
+            }
+            let watermark = snapshot.watermark();
+            // Keep the failing snapshot retryable if planning or a callback 
fails.
+            let plan = self.scan.plan_selected_snapshot(snapshot, mode).await?;
+            self.next_snapshot_id = Some(id + 1);
+            self.watermark = watermark;
+            if !plan.splits().is_empty() {
+                return Ok(Some(plan));
+            }
+        }
+    }
+
+    async fn plan_initial_snapshot(&self, snapshot: Snapshot) -> Result<Plan> {
+        let options = self.scan.snapshot_table().schema().options();
+        let mut scan = self.scan.clone();
+        let parse = |key, default| -> Result<i32> {
+            options
+                .get(key)
+                .map(|value| {
+                    value.parse().map_err(|error| crate::Error::DataInvalid {
+                        message: format!("invalid {key}: {value}"),
+                        source: Some(Box::new(error)),
+                    })
+                })
+                .unwrap_or(Ok(default))
+        };
+        match self
+            .scan
+            .snapshot_table()
+            .schema()
+            .core_options()
+            .try_changelog_producer()?
+        {
+            ChangelogProducer::Lookup => scan = 
scan.with_snapshot_levels(1..=i32::MAX),
+            ChangelogProducer::FullCompaction => {
+                // Java numLevels defaults to 
incrementSafely(compactionTrigger).
+                let levels = if options.contains_key("num-levels") {
+                    parse("num-levels", 0)?
+                } else {
+                    parse("num-sorted-run.compaction-trigger", 
5)?.saturating_add(1)
+                };
+                let last = levels
+                    .checked_sub(1)
+                    .ok_or_else(|| crate::Error::DataInvalid {
+                        message: "num-levels is out of range".into(),
+                        source: None,
+                    })?;
+                scan = scan.with_snapshot_levels(last..=last);
+            }
+            ChangelogProducer::None | ChangelogProducer::Input => {}
+        }
+        scan.plan_selected_snapshot(snapshot, SnapshotScanMode::All)
+            .await
+    }
+
+    /// Acknowledge a checkpoint after the caller has processed its plan.
+    pub async fn notify_checkpoint_complete(&self, next_snapshot: Option<i64>) 
-> Result<()> {
+        if let (Some(consumer_id), Some(next)) = (&self.consumer_id, 
next_snapshot) {
+            self.scan
+                .snapshot_table()
+                .consumer_manager()
+                .reset(consumer_id, next)
+                .await?;
+        }
+        Ok(())
+    }
+}
diff --git a/crates/paimon/src/table/table_scan.rs 
b/crates/paimon/src/table/table_scan.rs
index e728c730..53f8bb63 100644
--- a/crates/paimon/src/table/table_scan.rs
+++ b/crates/paimon/src/table/table_scan.rs
@@ -1097,7 +1097,121 @@ enum TableScanKind<'a> {
     Format(FormatTableScan<'a>),
 }
 
+/// File set to plan from one selected snapshot. Streaming progress is managed
+/// separately by StreamTableScan.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(super) enum SnapshotScanMode {
+    All,
+    Delta,
+    Changelog,
+}
+
+#[derive(Clone)]
+struct BucketFilter(std::sync::Arc<dyn Fn(i32) -> crate::Result<bool> + Send + 
Sync>);
+
+impl std::fmt::Debug for BucketFilter {
+    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result 
{
+        formatter.write_str("BucketFilter")
+    }
+}
+
+impl BucketFilter {
+    fn test(&self, bucket: i32) -> crate::Result<bool> {
+        (self.0)(bucket)
+    }
+}
+
 impl<'a> TableScan<'a> {
+    pub(super) fn into_owned_paimon_scan(self) -> 
crate::Result<TableScan<'static>> {
+        let TableScanKind::Paimon(scan) = self.0 else {
+            return Err(crate::Error::Unsupported {
+                message: "StreamTableScan requires a Paimon table".into(),
+            });
+        };
+        Ok(TableScan(TableScanKind::Paimon(PaimonTableScan {
+            table: 
ScanTable::Owned(std::sync::Arc::new((*scan.table).clone())),
+            partition_filter: scan.partition_filter,
+            data_predicates: scan.data_predicates,
+            bucket_predicate: scan.bucket_predicate,
+            limit: scan.limit,
+            row_ranges: scan.row_ranges,
+            split_selection: scan.split_selection,
+            row_range_optimization_disabled: 
scan.row_range_optimization_disabled,
+            scan_all_files: scan.scan_all_files,
+            incremental_split_mode: scan.incremental_split_mode,
+            projected_read_field_ids: scan.projected_read_field_ids,
+        })))
+    }
+
+    pub(super) fn snapshot_table(&self) -> &Table {
+        let TableScanKind::Paimon(scan) = &self.0 else {
+            unreachable!("snapshot scans only contain Paimon tables")
+        };
+        &scan.table
+    }
+
+    pub(super) fn validate_snapshot_read(&self) -> crate::Result<()> {
+        let TableScanKind::Paimon(scan) = &self.0 else {
+            unreachable!("snapshot scans only contain Paimon tables")
+        };
+        scan.validate_read_options()
+    }
+
+    pub(super) fn with_snapshot_levels(mut self, levels: 
std::ops::RangeInclusive<i32>) -> Self {
+        if let TableScanKind::Paimon(scan) = &mut self.0 {
+            scan.split_selection
+                .get_or_insert_with(Default::default)
+                .snapshot_levels = Some(levels);
+        }
+        self
+    }
+
+    pub(super) fn only_read_snapshot_real_buckets(mut self) -> Self {
+        if let TableScanKind::Paimon(scan) = &mut self.0 {
+            scan.split_selection
+                .get_or_insert_with(Default::default)
+                .only_real_buckets = true;
+        }
+        self
+    }
+
+    pub(super) fn with_snapshot_bucket_filter(
+        mut self,
+        filter: impl Fn(i32) -> crate::Result<bool> + Send + Sync + 'static,
+    ) -> Self {
+        if let TableScanKind::Paimon(scan) = &mut self.0 {
+            let selection = 
scan.split_selection.get_or_insert_with(Default::default);
+            selection.bucket_filter = 
Some(BucketFilter(std::sync::Arc::new(filter)));
+        }
+        self
+    }
+
+    pub(super) async fn plan_selected_snapshot(
+        &self,
+        snapshot: crate::spec::Snapshot,
+        mode: SnapshotScanMode,
+    ) -> crate::Result<Plan> {
+        self.validate_snapshot_read()?;
+        let TableScanKind::Paimon(scan) = &self.0 else {
+            unreachable!("snapshot scans only contain Paimon tables")
+        };
+        // Snapshot planning preserves the selected file set. Scan policies
+        // explicitly decide whether unassigned buckets should be hidden.
+        let mut scan = scan.clone();
+        scan.scan_all_files = true;
+        scan.split_selection
+            .get_or_insert_with(Default::default)
+            .snapshot_mode = Some(mode);
+        match mode {
+            SnapshotScanMode::All => {
+                let fields = scan.projected_read_field_ids()?;
+                scan.plan_snapshot(snapshot, fields.as_ref(), None).await
+            }
+            SnapshotScanMode::Delta => 
scan.plan_snapshot_delta(&snapshot).await,
+            SnapshotScanMode::Changelog => 
scan.plan_snapshot_changelog(&snapshot, None).await,
+        }
+    }
+
     pub(crate) fn new(
         table: &'a Table,
         partition_filter: Option<PartitionFilter>,
@@ -1191,6 +1305,10 @@ impl<'a> TableScan<'a> {
                 scan.split_selection = Some(Box::new(ScanSplitSelection {
                     mode: Some(ScanSplitMode::ChunkShuffle(config)),
                     shard: scan.shard(),
+                    bucket_filter: scan.bucket_filter().cloned(),
+                    only_real_buckets: scan.only_real_buckets(),
+                    snapshot_mode: scan.snapshot_mode(),
+                    snapshot_levels: scan.snapshot_levels().cloned(),
                 }));
                 Ok(Self(TableScanKind::Paimon(scan)))
             }
@@ -1230,6 +1348,10 @@ impl<'a> TableScan<'a> {
                         scan.split_selection = 
Some(Box::new(ScanSplitSelection {
                             mode: None,
                             shard: Some((index, count)),
+                            bucket_filter: None,
+                            only_real_buckets: false,
+                            snapshot_mode: None,
+                            snapshot_levels: None,
                         }));
                     }
                 }
@@ -1288,6 +1410,10 @@ impl<'a> TableScan<'a> {
                 scan.split_selection = Some(Box::new(ScanSplitSelection {
                     mode: Some(ScanSplitMode::RowPosition(selection)),
                     shard: None,
+                    bucket_filter: scan.bucket_filter().cloned(),
+                    only_real_buckets: scan.only_real_buckets(),
+                    snapshot_mode: scan.snapshot_mode(),
+                    snapshot_levels: scan.snapshot_levels().cloned(),
                 }));
                 Ok(Self(TableScanKind::Paimon(scan)))
             }
@@ -1409,15 +1535,38 @@ enum ScanSplitMode {
     ChunkShuffle(ChunkShuffle),
 }
 
-#[derive(Debug, Clone)]
+#[derive(Debug, Clone, Default)]
 struct ScanSplitSelection {
     mode: Option<ScanSplitMode>,
     shard: Option<(usize, usize)>,
+    bucket_filter: Option<BucketFilter>,
+    only_real_buckets: bool,
+    snapshot_mode: Option<SnapshotScanMode>,
+    snapshot_levels: Option<std::ops::RangeInclusive<i32>>,
+}
+
+// A stream scan owns its table so clients can retain the scan across polls.
+// Batch scans keep borrowing their table and retain their existing lifetime.
+#[derive(Debug, Clone)]
+enum ScanTable<'a> {
+    Borrowed(&'a Table),
+    Owned(std::sync::Arc<Table>),
+}
+
+impl std::ops::Deref for ScanTable<'_> {
+    type Target = Table;
+
+    fn deref(&self) -> &Table {
+        match self {
+            Self::Borrowed(table) => table,
+            Self::Owned(table) => table,
+        }
+    }
 }
 
 #[derive(Debug, Clone)]
 struct PaimonTableScan<'a> {
-    table: &'a Table,
+    table: ScanTable<'a>,
     partition_filter: Option<PartitionFilter>,
     data_predicates: Vec<Predicate>,
     bucket_predicate: Option<Predicate>,
@@ -1441,6 +1590,30 @@ struct PaimonTableScan<'a> {
 }
 
 impl<'a> PaimonTableScan<'a> {
+    fn snapshot_levels(&self) -> Option<&std::ops::RangeInclusive<i32>> {
+        self.split_selection
+            .as_deref()
+            .and_then(|selection| selection.snapshot_levels.as_ref())
+    }
+
+    fn snapshot_mode(&self) -> Option<SnapshotScanMode> {
+        self.split_selection
+            .as_deref()
+            .and_then(|selection| selection.snapshot_mode)
+    }
+
+    fn only_real_buckets(&self) -> bool {
+        self.split_selection
+            .as_deref()
+            .is_some_and(|selection| selection.only_real_buckets)
+    }
+
+    fn bucket_filter(&self) -> Option<&BucketFilter> {
+        self.split_selection
+            .as_deref()
+            .and_then(|selection| selection.bucket_filter.as_ref())
+    }
+
     fn row_position_selection(&self) -> Option<RowPositionSelection> {
         match self
             .split_selection
@@ -1482,7 +1655,7 @@ impl<'a> PaimonTableScan<'a> {
         row_ranges: Option<Vec<RowRange>>,
     ) -> Self {
         Self {
-            table,
+            table: ScanTable::Borrowed(table),
             partition_filter,
             data_predicates,
             bucket_predicate,
@@ -1567,7 +1740,7 @@ impl<'a> PaimonTableScan<'a> {
             return Ok(plan.planned(grant));
         }
         let data_evolution_read_field_ids = scan.projected_read_field_ids()?;
-        let plan = match 
super::time_travel::resolve_snapshot(scan.table).await? {
+        let plan = match 
super::time_travel::resolve_snapshot(&scan.table).await? {
             Some(snapshot) => {
                 scan.plan_snapshot(snapshot, 
data_evolution_read_field_ids.as_ref(), None)
                     .await?
@@ -1605,7 +1778,7 @@ impl<'a> PaimonTableScan<'a> {
             return Ok((plan.planned(grant), trace));
         }
         let data_evolution_read_field_ids = scan.projected_read_field_ids()?;
-        let plan = match 
super::time_travel::resolve_snapshot(scan.table).await? {
+        let plan = match 
super::time_travel::resolve_snapshot(&scan.table).await? {
             Some(snapshot) => {
                 trace.snapshot_id = Some(snapshot.id());
                 let plan = scan
@@ -1697,7 +1870,7 @@ impl<'a> PaimonTableScan<'a> {
             return Ok(Plan::new(Vec::new()));
         };
         super::IncrementalScan::new(
-            self.table,
+            &self.table,
             TableScan(TableScanKind::Paimon(self.clone())),
             super::IncrementalScanMode::Auto,
             start,
@@ -1788,16 +1961,21 @@ impl<'a> PaimonTableScan<'a> {
     /// cannot be selected from packed splits: one split can contain files 
owned
     /// by several workers. Keep chunk-shuffle input complete until shuffling.
     fn retain_shard_entries(&self, entries: &mut Vec<ManifestEntry>) -> 
crate::Result<()> {
+        self.retain_bucket_entries(entries)?;
         let Some((index, count)) = self.shard().filter(|_| 
self.chunk_shuffle().is_none()) else {
             return Ok(());
         };
         let options = self.table.schema().core_options();
         let has_primary_keys = !self.table.schema().primary_keys().is_empty();
-        // Match SplitGenerator.alwaysRawConvertible, independent of whether a
-        // particular split is raw-convertible or the scan includes level zero.
+        let initial_state = self.snapshot_mode() == 
Some(SnapshotScanMode::All);
+        // Initial stream state merges overlapping key versions, including
+        // L0 files. Keep each bucket intact until that merge has completed.
+        // Follow-up physical events and ordinary raw scans retain Java's
+        // SplitGenerator.alwaysRawConvertible file-name distribution.
         let by_file_name = if has_primary_keys {
-            options.deletion_vectors_enabled()
-                || options.merge_engine()? == 
crate::spec::MergeEngine::FirstRow
+            !initial_state
+                && (options.deletion_vectors_enabled()
+                    || options.merge_engine()? == 
crate::spec::MergeEngine::FirstRow)
         } else {
             !options.data_evolution_enabled()
         };
@@ -1814,12 +1992,43 @@ impl<'a> PaimonTableScan<'a> {
                 // also handles Integer.MIN_VALUE without overflow.
                 ((i128::from(hash) % count as i128).unsigned_abs() as usize) 
== index
             } else {
-                i128::from(entry.bucket()) % count as i128 == index as i128
+                let bucket = i128::from(entry.bucket());
+                // Initial state includes postpone buckets. Assign them to
+                // one worker as well, rather than dropping a negative 
remainder.
+                let bucket = if initial_state { bucket.abs() } else { bucket };
+                bucket % count as i128 == index as i128
             }
         });
         Ok(())
     }
 
+    fn retain_bucket_entries(&self, entries: &mut Vec<ManifestEntry>) -> 
crate::Result<()> {
+        let filter = self.bucket_filter();
+        let only_real = self.only_real_buckets();
+        let levels = self.snapshot_levels();
+        if filter.is_none() && !only_real && levels.is_none() {
+            return Ok(());
+        }
+        let mut selected = Vec::with_capacity(entries.len());
+        for entry in std::mem::take(entries) {
+            if levels.is_some_and(|levels| 
!levels.contains(&entry.file().level)) {
+                continue;
+            }
+            if only_real && entry.bucket() < 0 {
+                continue;
+            }
+            if filter
+                .map(|filter| filter.test(entry.bucket()))
+                .transpose()?
+                .unwrap_or(true)
+            {
+                selected.push(entry);
+            }
+        }
+        *entries = selected;
+        Ok(())
+    }
+
     fn projected_read_field_ids(&self) -> crate::Result<Option<HashSet<i32>>> {
         Ok(self.projected_read_field_ids.clone())
     }
@@ -2116,7 +2325,8 @@ impl<'a> PaimonTableScan<'a> {
 
     fn requires_key_merge(&self) -> bool {
         let options = self.table.schema().core_options();
-        self.is_streaming()
+        self.snapshot_mode() == Some(SnapshotScanMode::All)
+            || self.is_streaming()
             || match options.merge_engine() {
                 Ok(crate::spec::MergeEngine::FirstRow) => 
!self.skip_level_zero(),
                 _ => {
@@ -2477,7 +2687,7 @@ impl<'a> PaimonTableScan<'a> {
                 for entry in entries {
                     if entry.file().schema_id == current_schema_id
                         || data_file_matches_predicates_for_table(
-                            self.table,
+                            &self.table,
                             entry.file(),
                             &stats_pruning_predicates,
                             &key_stats_predicates,
@@ -2614,7 +2824,7 @@ impl<'a> PaimonTableScan<'a> {
                     let mut groups = Vec::with_capacity(before);
                     for group in row_id_groups {
                         if data_evolution_group_matches_predicates_for_table(
-                            self.table,
+                            &self.table,
                             &group,
                             &self.data_predicates,
                             &mut stats_schema_cache,
@@ -2823,7 +3033,7 @@ impl<'a> PaimonTableScan<'a> {
             splits
         };
         let splits = if let Some(config) = self.chunk_shuffle() {
-            chunk_shuffle_splits(self.table, splits, config, 
self.shard()).await?
+            chunk_shuffle_splits(&self.table, splits, config, 
self.shard()).await?
         } else {
             splits
         };
@@ -6903,3 +7113,7 @@ mod tests {
         assert_eq!(empty.snapshot_id(), Some(1));
     }
 }
+
+#[cfg(test)]
+#[path = "snapshot_scan_tests.rs"]
+mod snapshot_tests;
diff --git a/crates/paimon/tests/stream_table_scan_test.rs 
b/crates/paimon/tests/stream_table_scan_test.rs
new file mode 100644
index 00000000..ab6a7575
--- /dev/null
+++ b/crates/paimon/tests/stream_table_scan_test.rs
@@ -0,0 +1,466 @@
+// 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.
+
+mod common;
+
+use common::incremental_helpers::{
+    make_batch, memory_table, persist_table_schema, setup_dirs, write_batch,
+};
+use futures::TryStreamExt;
+use paimon::spec::{DataType, IntType, Schema, TableSchema};
+use paimon::table::{Plan, Table};
+use std::collections::HashMap;
+use std::sync::{
+    atomic::{AtomicBool, Ordering},
+    Arc,
+};
+
+async fn table(name: &str, producer: &str) -> Table {
+    let schema = Schema::builder()
+        .column("id", DataType::Int(IntType::new()))
+        .column("value", DataType::Int(IntType::new()))
+        .primary_key(["id"])
+        .option("bucket", "1")
+        .option("changelog-producer", producer)
+        .build()
+        .unwrap();
+    let (file_io, table) = memory_table(name, TableSchema::new(0, &schema));
+    setup_dirs(&file_io, name).await;
+    persist_table_schema(&file_io, name, table.schema()).await;
+    table
+}
+
+async fn values(table: &Table, plan: Plan) -> Vec<(i32, i32)> {
+    use arrow_array::Int32Array;
+    let batches: Vec<_> = table
+        .new_read_builder()
+        .new_read()
+        .unwrap()
+        .to_arrow_with_row_kind(plan.splits())
+        .unwrap()
+        .try_collect()
+        .await
+        .unwrap();
+    let mut values = Vec::new();
+    for batch in batches {
+        let ids = batch
+            .column(1)
+            .as_any()
+            .downcast_ref::<Int32Array>()
+            .unwrap();
+        let vals = batch
+            .column(2)
+            .as_any()
+            .downcast_ref::<Int32Array>()
+            .unwrap();
+        values.extend((0..batch.num_rows()).map(|row| (ids.value(row), 
vals.value(row))));
+    }
+    values.sort_unstable();
+    values
+}
+
+async fn patch_snapshot(table: &Table, id: i64, key: &str, value: 
serde_json::Value) {
+    let path = table.snapshot_manager().snapshot_path(id);
+    let bytes = table
+        .file_io()
+        .new_input(&path)
+        .unwrap()
+        .read()
+        .await
+        .unwrap();
+    let mut snapshot: serde_json::Value = 
serde_json::from_slice(&bytes).unwrap();
+    snapshot[key] = value;
+    table
+        .file_io()
+        .new_output(&path)
+        .unwrap()
+        .write(serde_json::to_vec(&snapshot).unwrap().into())
+        .await
+        .unwrap();
+}
+
+#[tokio::test]
+async fn empty_poll_then_full_initial_and_follow_up() {
+    let table = table("memory:/stream-phases", "none").await;
+    let mut scan = table.new_stream_scan().unwrap();
+    assert!(scan.plan().await.unwrap().is_none());
+    assert_eq!(scan.checkpoint(), None);
+    write_batch(&table, &make_batch(vec![1], vec![10])).await;
+    patch_snapshot(&table, 1, "watermark", 42.into()).await;
+    let full = scan.plan().await.unwrap().unwrap();
+    assert!(!full.splits()[0].is_streaming());
+    assert_eq!(values(&table, full).await, vec![(1, 10)]);
+    assert_eq!(scan.checkpoint(), Some(2));
+    assert_eq!(scan.watermark(), Some(42));
+    assert!(scan.plan().await.unwrap().is_none());
+    write_batch(&table, &make_batch(vec![1], vec![11])).await;
+    let next = scan.plan().await.unwrap().unwrap();
+    assert!(next.splits()[0].is_streaming());
+    assert_eq!(values(&table, next).await, vec![(1, 11)]);
+    assert_eq!(scan.checkpoint(), Some(3));
+    scan.restore(Some(1)).unwrap();
+    assert_eq!(scan.plan().await.unwrap().unwrap().snapshot_id(), Some(1));
+}
+
+#[tokio::test]
+async fn stream_scan_owns_table_and_read_configuration() {
+    let table = table("memory:/stream-owned", "input").await;
+    write_batch(&table, &make_batch(vec![1], vec![10])).await;
+    let mut scan = {
+        let copy = table.clone();
+        let builder = copy.new_read_builder();
+        builder.new_stream_scan().unwrap()
+    };
+    assert_eq!(scan.plan().await.unwrap().unwrap().snapshot_id(), Some(1));
+    scan.restore(Some(1)).unwrap();
+    assert_eq!(
+        values(&table, scan.plan().await.unwrap().unwrap()).await,
+        vec![(1, 10)]
+    );
+}
+
+#[tokio::test]
+async fn 
initial_shards_merge_all_key_versions_and_follow_up_shards_keep_events() {
+    for (engine, dv, bucket, producer, expected) in [
+        ("first-row", false, "1", "none", 10),
+        ("first-row", false, "-2", "none", 10),
+        ("deduplicate", true, "1", "none", 13),
+        ("deduplicate", true, "-2", "none", 13),
+        ("deduplicate", true, "1", "input", 13),
+    ] {
+        for count in [2, 3] {
+            let table = table(
+                
&format!("memory:/stream-shards-{engine}-{producer}-{bucket}-{count}"),
+                producer,
+            )
+            .await
+            .copy_with_options(HashMap::from([
+                ("merge-engine".into(), engine.into()),
+                ("deletion-vectors.enabled".into(), dv.to_string()),
+                ("bucket".into(), bucket.into()),
+            ]));
+            for version in 0..4 {
+                let builder = table.new_write_builder();
+                let mut writer = builder.new_write().unwrap();
+                writer
+                    .write_arrow_batch(&make_batch(vec![1], vec![10 + 
version]))
+                    .await
+                    .unwrap();
+                let mut messages = writer.prepare_commit().await.unwrap();
+                // Deterministic names distribute consecutive versions to
+                // different file-name shards, independent of writer UUIDs.
+                for message in &mut messages {
+                    let bucket_path = format!(
+                        "{}/{}",
+                        table.location(),
+                        paimon::spec::bucket_dir_name(message.bucket)
+                    );
+                    for (prefix, files) in [
+                        ("data", &mut message.new_files),
+                        ("changelog", &mut message.new_changelog_files),
+                    ] {
+                        for (index, file) in files.iter_mut().enumerate() {
+                            let old_path = file.data_file_path(&bucket_path);
+                            let bytes = table
+                                .file_io()
+                                .new_input(&old_path)
+                                .unwrap()
+                                .read()
+                                .await
+                                .unwrap();
+                            file.file_name = 
format!("{prefix}-{version}-{index}.parquet");
+                            table
+                                .file_io()
+                                .new_output(&file.data_file_path(&bucket_path))
+                                .unwrap()
+                                .write(bytes)
+                                .await
+                                .unwrap();
+                        }
+                    }
+                }
+                builder.new_commit().commit(messages).await.unwrap();
+            }
+            let full = table
+                .new_stream_scan()
+                .unwrap()
+                .plan()
+                .await
+                .unwrap()
+                .unwrap();
+            let expected_rows = values(&table, full).await;
+            assert_eq!(expected_rows, vec![(1, expected)]);
+            let mut initial_union = Vec::new();
+            let mut event_union = Vec::new();
+            for index in 0..count {
+                let mut scan = table.new_stream_scan().unwrap();
+                scan.with_shard(index, count).unwrap();
+                let initial = scan.plan().await.unwrap().unwrap();
+                assert_eq!(initial.snapshot_id(), Some(4));
+                initial_union.extend(values(&table, initial).await);
+                scan.restore(Some(1)).unwrap();
+                let mut events = Vec::new();
+                while let Some(plan) = scan.plan().await.unwrap() {
+                    assert!(plan.splits().iter().all(|split| 
split.is_streaming()));
+                    events.extend(values(&table, plan).await);
+                }
+                if count == 2 {
+                    assert!(!events.is_empty(), "physical events should use 
both shards");
+                }
+                assert_eq!(scan.checkpoint(), Some(5));
+                event_union.extend(events);
+            }
+            initial_union.sort_unstable();
+            assert_eq!(
+                initial_union, expected_rows,
+                "{engine}, {producer}, bucket={bucket}, count={count}"
+            );
+            event_union.sort_unstable();
+            assert_eq!(
+                event_union,
+                (10..14).map(|value| (1, value)).collect::<Vec<_>>()
+            );
+        }
+    }
+}
+
+#[tokio::test]
+async fn delta_follow_up_skips_overwrite_and_compact_commits() {
+    let table = table("memory:/stream-delta-selection", "none").await;
+    for id in 1..=4 {
+        write_batch(&table, &make_batch(vec![id], vec![id * 10])).await;
+    }
+    patch_snapshot(&table, 2, "commitKind", "OVERWRITE".into()).await;
+    patch_snapshot(&table, 3, "commitKind", "COMPACT".into()).await;
+    let mut scan = table.new_stream_scan().unwrap();
+    scan.restore(Some(2)).unwrap();
+    let plan = scan.plan().await.unwrap().unwrap();
+    assert_eq!(plan.snapshot_id(), Some(4));
+    assert_eq!(values(&table, plan).await, vec![(4, 40)]);
+    assert_eq!(scan.checkpoint(), Some(5));
+}
+
+#[tokio::test]
+async fn changelog_follow_up_consumes_overwrite_physical_events() {
+    let table = table("memory:/stream-changelog-selection", "input").await;
+    write_batch(&table, &make_batch(vec![1], vec![10])).await;
+    write_batch(&table, &make_batch(vec![2], vec![20])).await;
+    let first = table.snapshot_manager().get_snapshot(1).await.unwrap();
+    patch_snapshot(&table, 2, "commitKind", "OVERWRITE".into()).await;
+    patch_snapshot(
+        &table,
+        2,
+        "changelogManifestList",
+        first.changelog_manifest_list().unwrap().into(),
+    )
+    .await;
+    let mut scan = table.new_stream_scan().unwrap();
+    scan.restore(Some(2)).unwrap();
+    let plan = scan.plan().await.unwrap().unwrap();
+    assert_eq!(plan.snapshot_id(), Some(2));
+    assert_eq!(values(&table, plan).await, vec![(1, 10)]);
+}
+
+#[tokio::test]
+async fn empty_follow_up_is_skipped_and_initial_empty_plan_is_returned() {
+    let table = table("memory:/stream-empty-selection", "none").await;
+    write_batch(&table, &make_batch(vec![1], vec![10])).await;
+    write_batch(&table, &make_batch(vec![2], vec![20])).await;
+    let mut scan = table.new_stream_scan().unwrap();
+    scan.with_bucket_filter(|_| Ok(false));
+    let initial = scan.plan().await.unwrap().unwrap();
+    assert!(initial.splits().is_empty());
+    assert_eq!(initial.snapshot_id(), Some(2));
+    scan.restore(Some(1)).unwrap();
+    assert!(scan.plan().await.unwrap().is_none());
+    assert_eq!(scan.checkpoint(), Some(3));
+}
+
+#[tokio::test]
+async fn callback_failure_keeps_the_selected_snapshot_retryable() {
+    for initial in [false, true] {
+        let table = table(&format!("memory:/stream-retry-{initial}"), 
"none").await;
+        write_batch(&table, &make_batch(vec![1], vec![10])).await;
+        let fail = Arc::new(AtomicBool::new(true));
+        let flag = Arc::clone(&fail);
+        let mut scan = table.new_stream_scan().unwrap();
+        scan.with_bucket_filter(move |_| {
+            if flag.swap(false, Ordering::SeqCst) {
+                return Err(paimon::Error::Unsupported {
+                    message: "retry this frame".into(),
+                });
+            }
+            Ok(true)
+        });
+        let checkpoint = if initial { None } else { Some(1) };
+        scan.restore(checkpoint).unwrap();
+        assert!(scan.plan().await.is_err());
+        assert_eq!(scan.checkpoint(), checkpoint);
+        assert_eq!(scan.plan().await.unwrap().unwrap().snapshot_id(), Some(1));
+    }
+}
+
+#[tokio::test]
+async fn large_gap_preserves_every_changelog_frame() {
+    let table = table("memory:/stream-long-gap", "input").await;
+    for value in 1..=24 {
+        write_batch(&table, &make_batch(vec![1], vec![value])).await;
+    }
+    let mut scan = table.new_stream_scan().unwrap();
+    scan.restore(Some(1)).unwrap();
+    for id in 1..=24 {
+        let plan = scan.plan().await.unwrap().unwrap();
+        assert_eq!(plan.snapshot_id(), Some(id));
+        assert_eq!(values(&table, plan).await, vec![(1, id as i32)]);
+    }
+    assert!(scan.plan().await.unwrap().is_none());
+    assert_eq!(scan.checkpoint(), Some(25));
+}
+
+#[tokio::test]
+async fn consumer_acknowledgement_and_explicit_restore() {
+    let table = table("memory:/stream-consumer", "none").await;
+    for id in 1..=2 {
+        write_batch(&table, &make_batch(vec![id], vec![id * 10])).await;
+    }
+    let mut scan = table.new_stream_scan().unwrap();
+    scan.with_consumer_id("job")
+        .unwrap()
+        .restore(Some(1))
+        .unwrap();
+    scan.plan().await.unwrap().unwrap();
+    assert_eq!(table.consumer_manager().get("job").await.unwrap(), None);
+    scan.notify_checkpoint_complete(scan.checkpoint())
+        .await
+        .unwrap();
+    assert_eq!(table.consumer_manager().get("job").await.unwrap(), Some(2));
+    let mut resumed = table.new_stream_scan().unwrap();
+    resumed.with_consumer_id("job").unwrap();
+    let plan = resumed.plan().await.unwrap().unwrap();
+    assert_eq!(plan.snapshot_id(), Some(2));
+    assert!(plan.splits()[0].is_streaming());
+    resumed.restore(Some(1)).unwrap();
+    assert_eq!(
+        resumed.plan().await.unwrap().unwrap().snapshot_id(),
+        Some(1)
+    );
+}
+
+#[tokio::test]
+async fn missing_checkpoints_wait_without_skipping_a_hole() {
+    let table = table("memory:/stream-missing", "none").await;
+    write_batch(&table, &make_batch(vec![1], vec![10])).await;
+    let mut scan = table.new_stream_scan().unwrap();
+    scan.restore(Some(100)).unwrap();
+    for _ in 0..15 {
+        assert!(scan.plan().await.unwrap().is_none());
+    }
+    assert!(scan.plan().await.is_err());
+    assert_eq!(scan.checkpoint(), Some(100));
+    scan.restore(Some(2)).unwrap();
+    for _ in 0..16 {
+        assert!(scan.plan().await.unwrap().is_none());
+    }
+    assert_eq!(scan.checkpoint(), Some(2));
+}
+
+#[tokio::test]
+async fn query_auth_is_checked_before_empty_polling() {
+    let table = table("memory:/stream-query-auth", "none").await;
+    let table = table.copy_with_options(HashMap::from([(
+        "query-auth.enabled".into(),
+        "true".into(),
+    )]));
+    let mut scan = table.new_stream_scan().unwrap();
+    assert!(scan.plan().await.is_err());
+    assert_eq!(scan.checkpoint(), None);
+}
+
+#[tokio::test]
+async fn producer_configuration_uses_typed_case_insensitive_parsing() {
+    for producer in ["none", "NONE", "input", "INPUT"] {
+        let table = table(&format!("memory:/stream-producer-{producer}"), 
producer).await;
+        write_batch(&table, &make_batch(vec![1], vec![10])).await;
+        let mut scan = table.new_stream_scan().unwrap();
+        scan.restore(Some(1)).unwrap();
+        assert_eq!(
+            values(&table, scan.plan().await.unwrap().unwrap()).await,
+            vec![(1, 10)]
+        );
+    }
+    let table = table("memory:/stream-invalid-producer", "invalid").await;
+    assert!(table.new_stream_scan().is_err());
+}
+
+#[tokio::test]
+async fn initial_lookup_and_full_compaction_select_java_levels_only_once() {
+    use paimon::spec::{Manifest, ManifestEntry, ManifestList};
+    let table = table("memory:/stream-initial-levels", "input").await;
+    for (id, level) in [0, 1, 5].into_iter().enumerate() {
+        write_batch(&table, &make_batch(vec![id as i32], vec![id as i32 * 
10])).await;
+        let snapshot = table
+            .snapshot_manager()
+            .get_snapshot(id as i64 + 1)
+            .await
+            .unwrap();
+        let list_path = format!(
+            "{}/manifest/{}",
+            table.location(),
+            snapshot.delta_manifest_list()
+        );
+        for meta in ManifestList::read(table.file_io(), &list_path)
+            .await
+            .unwrap()
+        {
+            let path = format!("{}/manifest/{}", table.location(), 
meta.file_name());
+            let entries = Manifest::read(table.file_io(), 
&path).await.unwrap();
+            let entries: Vec<ManifestEntry> = entries
+                .into_iter()
+                .map(|entry| {
+                    let mut value = serde_json::to_value(entry).unwrap();
+                    value["_FILE"]["_LEVEL"] = level.into();
+                    serde_json::from_value(value).unwrap()
+                })
+                .collect();
+            Manifest::write(table.file_io(), &path, &entries)
+                .await
+                .unwrap();
+        }
+    }
+    for (producer, levels, expected) in [
+        ("lookup", None, vec![(1, 10), (2, 20)]),
+        ("full-compaction", None, vec![(2, 20)]),
+        ("full-compaction", Some("2"), vec![(1, 10)]),
+    ] {
+        let mut options = HashMap::from([("changelog-producer".into(), 
producer.into())]);
+        if let Some(levels) = levels {
+            options.insert("num-levels".into(), levels.into());
+        }
+        let copy = table.copy_with_options(options);
+        let mut scan = copy.new_stream_scan().unwrap();
+        assert_eq!(
+            values(&copy, scan.plan().await.unwrap().unwrap()).await,
+            expected
+        );
+        // The initial level restriction must not hide L0 changelog events.
+        scan.restore(Some(1)).unwrap();
+        assert_eq!(
+            values(&copy, scan.plan().await.unwrap().unwrap()).await,
+            vec![(0, 0)]
+        );
+    }
+}

Reply via email to