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 e602ec58 [core] Support copying stream scan state in Python bindings 
(#1187)
e602ec58 is described below

commit e602ec58358684a9d4dafedebd83fa743a38719d
Author: Jingsong Lee <[email protected]>
AuthorDate: Wed Oct 21 16:22:14 2026 +0800

    [core] Support copying stream scan state in Python bindings (#1187)
---
 .github/workflows/ci.yml                           |   6 +-
 .../python/python/pypaimon_rust/datafusion.pyi     |   3 +
 bindings/python/src/read.rs                        | 149 +++++++++++++++++----
 bindings/python/tests/test_read.py                 |  18 ++-
 bindings/python/tests/test_stream_table_scan.py    |  84 ++++++++++++
 crates/paimon/src/table/read_builder.rs            |   8 +-
 crates/paimon/src/table/stream_table_scan.rs       |   4 +-
 crates/paimon/tests/scan_planning_parity_test.rs   |  49 +++++++
 crates/paimon/tests/stream_table_scan_test.rs      |  67 +++++++++
 9 files changed, 348 insertions(+), 40 deletions(-)

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d568322f..3b75498a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -187,6 +187,11 @@ jobs:
           - ubuntu-latest
           - macos-latest
           - windows-latest
+    env:
+      # Core and REST-server tests share a runner. Keep backtrace line numbers
+      # without full debug symbols or one-off incremental build caches.
+      CARGO_PROFILE_TEST_DEBUG: line-tables-only
+      CARGO_INCREMENTAL: "0"
     steps:
       - uses: actions/checkout@v7
 
@@ -196,7 +201,6 @@ jobs:
         run: |
           # Full-feature test binaries can exhaust the runner's paging file.
           "CARGO_BUILD_JOBS=2" >> $env:GITHUB_ENV
-          "CARGO_PROFILE_TEST_DEBUG=line-tables-only" >> $env:GITHUB_ENV
 
       - name: Test
         run: cargo test --locked -p paimon --all-targets --features 
fulltext,vortex
diff --git a/bindings/python/python/pypaimon_rust/datafusion.pyi 
b/bindings/python/python/pypaimon_rust/datafusion.pyi
index c5b66362..4d4dcefb 100644
--- a/bindings/python/python/pypaimon_rust/datafusion.pyi
+++ b/bindings/python/python/pypaimon_rust/datafusion.pyi
@@ -186,6 +186,9 @@ class TableScan:
 class StreamTableScan:
     """Stateful continuous scan; checkpoint is the next snapshot to consume."""
 
+    def __copy__(self) -> StreamTableScan:
+        """Copy scan configuration and progress without persisting a 
consumer."""
+        ...
     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: ...
diff --git a/bindings/python/src/read.rs b/bindings/python/src/read.rs
index ed7759f3..30ffdf52 100644
--- a/bindings/python/src/read.rs
+++ b/bindings/python/src/read.rs
@@ -545,6 +545,43 @@ struct PyBucketFilterState {
     error: Option<Py<PyBaseException>>,
 }
 
+impl PyStreamTableScan {
+    fn install_bucket_filter(&mut self) {
+        if self.bucket_filter.lock().unwrap().filter.is_none() {
+            self.scan.with_bucket_filter(|_| Ok(true));
+            return;
+        }
+        let state = Arc::clone(&self.bucket_filter);
+        self.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,
+                        }
+                    })
+            })
+        });
+    }
+}
+
 #[pymethods]
 impl PyStreamTableScan {
     fn __traverse__(&self, visit: PyVisit<'_>) -> Result<(), PyTraverseError> {
@@ -563,6 +600,28 @@ impl PyStreamTableScan {
         drop(references);
     }
 
+    /// Copy scan progress with independently owned Python callback references.
+    fn __copy__(&self, py: Python<'_>) -> Self {
+        let filter = self
+            .bucket_filter
+            .lock()
+            .unwrap()
+            .filter
+            .as_ref()
+            .map(|filter| filter.clone_ref(py));
+        let mut copy = Self {
+            scan: self.scan.clone(),
+            bucket_filter: Arc::new(Mutex::new(PyBucketFilterState {
+                filter,
+                error: None,
+            })),
+        };
+        // Each wrapper owns one Python reference and its core callback 
captures
+        // that wrapper's state. Sharing the Arc would duplicate GC traversal.
+        copy.install_bucket_filter();
+        copy
+    }
+
     #[pyo3(signature = (filter = None))]
     fn with_bucket_filter(
         mut slf: PyRefMut<'_, Self>,
@@ -577,34 +636,7 @@ impl PyStreamTableScan {
         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,
-                        }
-                    })
-            })
-        });
+        slf.install_bucket_filter();
         Ok(slf)
     }
 
@@ -1210,6 +1242,67 @@ mod tests {
 
     use super::*;
 
+    #[test]
+    fn stream_scan_copy_keeps_callback_ownership_and_progress_independent() {
+        Python::attach(|py| {
+            for bound_method in [false, true] {
+                let schema = Schema::builder()
+                    .column("id", DataType::Int(IntType::new()))
+                    .build()
+                    .unwrap();
+                let table = Arc::new(Table::new(
+                    FileIOBuilder::new("memory").build().unwrap(),
+                    Identifier::new("default", "stream_copy"),
+                    "memory:/stream-copy".into(),
+                    TableSchema::new(0, &schema),
+                    None,
+                ));
+                let scan =
+                    Py::new(py, 
PyReadBuilder::new(table).new_stream_scan().unwrap()).unwrap();
+                let globals = PyDict::new(py);
+                globals.set_item("scan", scan).unwrap();
+                globals.set_item("bound_method", bound_method).unwrap();
+                py.run(
+                    c"
+import copy
+import gc
+import weakref
+
+class Owner:
+    def __init__(self):
+        self.scan = scan.with_bucket_filter(self.select if bound_method else 
self)
+        self.scan.restore(1)
+        self.copied = copy.copy(self.scan)
+
+    def select(self, bucket):
+        return True
+
+    def __call__(self, bucket):
+        return self.select(bucket)
+
+owner = Owner()
+assert owner.scan is not owner.copied
+owner.copied.restore(2)
+assert owner.scan.checkpoint() == 1
+assert owner.copied.checkpoint() == 2
+owner.scan.restore(3)
+assert owner.copied.checkpoint() == 2
+reference = weakref.ref(owner)
+del scan
+gc.collect()
+assert reference() is owner
+del owner
+gc.collect()
+assert reference() is None
+",
+                    Some(&globals),
+                    None,
+                )
+                .unwrap();
+            }
+        });
+    }
+
     #[test]
     fn table_read_execution_keywords_validate_before_empty_splits() {
         Python::attach(|py| {
diff --git a/bindings/python/tests/test_read.py 
b/bindings/python/tests/test_read.py
index 4b56326e..316b6434 100644
--- a/bindings/python/tests/test_read.py
+++ b/bindings/python/tests/test_read.py
@@ -1880,20 +1880,26 @@ def 
test_append_row_position_slices_and_shards_are_native_readable(row_tracking)
         assert shards == list(range(9))
 
 
-def test_append_row_positions_follow_stats_pruned_file_order_before_limit():
[email protected]("row_tracking", [False, True])
[email protected]("limit,expected", [(None, [4, 5, 6, 7]), (2, [4, 5])])
+def 
test_append_row_positions_follow_stats_pruned_file_order_before_limit(row_tracking,
 limit, expected):
     with tempfile.TemporaryDirectory() as warehouse:
-        table = _make_append_position_table(warehouse)
+        table = _make_append_position_table(warehouse, row_tracking)
         builder = (
             table.new_read_builder()
             .with_projection(["id"])
             .with_filter({"method": "greaterOrEqual", "field": "id", 
"literals": [3]})
-            .with_limit(2)
         )
+        if limit is not None:
+            builder.with_limit(limit)
         plan = builder.new_scan().with_row_position_slice(1, 5).plan()
+        # Stats remove the first file before positions are assigned. LIMIT
+        # must not truncate the candidate files before applying the slice.
+        full_selection = 
table.new_read_builder().with_projection(["id"]).new_read().read(plan.splits())
+        assert pa.Table.from_batches(full_selection).column("id").to_pylist() 
== [4, 5, 6, 7]
         actual = pa.Table.from_batches(builder.new_read().read(plan.splits()))
-        # with_limit is a planning hint in the Rust API. The complete selected
-        # range must survive planning; PyPaimon enforces the final two-row 
limit.
-        assert actual.column("id").to_pylist() == [4, 5, 6, 7]
+        # The configured reader enforces the final LIMIT after selection.
+        assert actual.column("id").to_pylist() == expected
 
 
 def test_append_incremental_row_positions_use_combined_delta_order():
diff --git a/bindings/python/tests/test_stream_table_scan.py 
b/bindings/python/tests/test_stream_table_scan.py
index 07948428..15ca6624 100644
--- a/bindings/python/tests/test_stream_table_scan.py
+++ b/bindings/python/tests/test_stream_table_scan.py
@@ -17,6 +17,7 @@
 
 """Stateful core streaming through the Python wrapper."""
 
+import copy
 import gc
 import weakref
 
@@ -228,3 +229,86 @@ def test_snapshot_reader_is_an_internal_component():
     assert not hasattr(datafusion, 'SnapshotReader')
     assert not hasattr(datafusion.Table, 'new_snapshot_reader')
     assert not hasattr(datafusion.ReadBuilder, 'new_snapshot_reader')
+
+
[email protected]('follow_up', [False, True])
+def test_copy_has_independent_progress_and_bucket_callback(table, follow_up):
+    builder = table.new_read_builder()
+    original = builder.new_stream_scan().with_bucket_filter(lambda bucket: 
bucket % 2 == 0)
+    original.restore(1 if follow_up else None)
+    copied = copy.copy(original)
+    assert copied is not original
+    assert copied.checkpoint() == original.checkpoint()
+    copied.with_bucket_filter(lambda bucket: bucket % 2 == 1)
+    first = original.plan()
+    assert copied.checkpoint() == (1 if follow_up else None)
+    second = copied.plan()
+    expected = 100 if follow_up else 200
+    assert sorted(_rows(builder, first) + _rows(builder, second)) == [
+        (i, expected + i) for i in range(32)]
+    original.restore(1)
+    assert copied.checkpoint() == (2 if follow_up else 3)
+    assert original.checkpoint() == 1
+
+
[email protected]('bound_method', [False, True])
+def test_copy_owns_independent_callback_references_and_collects_cycles(table, 
bound_method):
+    class Owner:
+        def __init__(self):
+            self.scan = table.new_stream_scan().with_bucket_filter(self.select 
if bound_method else self)
+            self.copied = copy.copy(self.scan)
+
+        def select(self, bucket):
+            gc.collect()
+            return True
+
+        def __call__(self, bucket):
+            return self.select(bucket)
+
+    owner = Owner()
+    reference = weakref.ref(owner)
+    gc.collect()
+    assert owner.scan.plan().snapshot_id() == 2
+    assert owner.copied.plan().snapshot_id() == 2
+    del owner
+    gc.collect()
+    assert reference() is None
+
+
+def test_replacing_original_callback_does_not_clear_the_copy(table):
+    class Owner:
+        def select(self, bucket):
+            return True
+
+    owner = Owner()
+    reference = weakref.ref(owner)
+    original = table.new_stream_scan().with_bucket_filter(owner.select)
+    copied = copy.copy(original)
+    del owner
+    original.with_bucket_filter(None)
+    gc.collect()
+    assert reference() is not None
+    assert copied.plan().snapshot_id() == 2
+    copied.with_bucket_filter(None)
+    gc.collect()
+    assert reference() is None
+
+
+def 
test_copy_preserves_callback_error_identity_without_sharing_configuration(table):
+    failure = LookupError('copied bucket callback failure')
+
+    def select(bucket):
+        raise failure
+
+    original = table.new_stream_scan().with_bucket_filter(select)
+    copied = copy.copy(original)
+    with pytest.raises(LookupError) as caught:
+        copied.plan()
+    assert caught.value is failure
+    assert copied.checkpoint() is None
+    copied.with_bucket_filter(None)
+    assert copied.plan().snapshot_id() == 2
+    with pytest.raises(LookupError) as caught:
+        original.plan()
+    assert caught.value is failure
+    assert original.checkpoint() is None
diff --git a/crates/paimon/src/table/read_builder.rs 
b/crates/paimon/src/table/read_builder.rs
index 275203cf..590ebe2b 100644
--- a/crates/paimon/src/table/read_builder.rs
+++ b/crates/paimon/src/table/read_builder.rs
@@ -538,14 +538,14 @@ impl<'a> PaimonReadBuilder<'a> {
         }
     }
 
-    /// Push a row-limit hint down to scan planning.
+    /// Configure a row limit for scan planning and reads.
     ///
     /// This allows paimon-core scan planning to generate fewer splits when the
     /// current scan state keeps split-level `merged_row_count()` conservative.
     ///
-    /// Data-evolution [`TableRead`] enforces the limit before BLOB resolution.
-    /// Other read paths still treat it only as a planning hint, so callers or
-    /// query engines must enforce the final LIMIT themselves.
+    /// A [`TableRead`] created by this builder also enforces the final limit
+    /// across splits after filtering and row selection. Data-evolution reads
+    /// enforce it before BLOB resolution.
     pub fn with_limit(&mut self, limit: usize) -> &mut Self {
         self.limit = Some(limit);
         self
diff --git a/crates/paimon/src/table/stream_table_scan.rs 
b/crates/paimon/src/table/stream_table_scan.rs
index 2052d3ba..88b52f3a 100644
--- a/crates/paimon/src/table/stream_table_scan.rs
+++ b/crates/paimon/src/table/stream_table_scan.rs
@@ -29,7 +29,9 @@ use crate::{Error, Result};
 /// 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)]
+/// Clones retain the current progress and read configuration; subsequent
+/// planning, restoration and configuration changes are independent.
+#[derive(Debug, Clone)]
 pub struct StreamTableScan {
     scan: TableScan<'static>,
     next_snapshot_id: Option<i64>,
diff --git a/crates/paimon/tests/scan_planning_parity_test.rs 
b/crates/paimon/tests/scan_planning_parity_test.rs
index c5fb07d7..1411a850 100644
--- a/crates/paimon/tests/scan_planning_parity_test.rs
+++ b/crates/paimon/tests/scan_planning_parity_test.rs
@@ -167,6 +167,55 @@ async fn read_column(builder: &ReadBuilder<'_>, plan: 
&Plan, column: usize) -> V
         .collect()
 }
 
+#[tokio::test]
+async fn append_stats_pruning_then_positions_then_reader_limit() {
+    for row_tracking in [false, true] {
+        let schema = Schema::builder()
+            .column("id", DataType::Int(IntType::new()))
+            .column("value", DataType::Int(IntType::new()))
+            .option("row-tracking.enabled", row_tracking.to_string())
+            .option("source.split.target-size", "1b")
+            .build()
+            .unwrap();
+        let path = "memory:/planning_parity/append_position_limit";
+        let (io, table) = memory_table(path, TableSchema::new(0, &schema));
+        setup_dirs(&io, path).await;
+        persist_table_schema(&io, path, table.schema()).await;
+        for start in [0, 3, 6] {
+            append_ids(&table, start, start + 3).await;
+        }
+        let predicate = PredicateBuilder::new(table.schema().fields())
+            .greater_or_equal("id", Datum::Int(3))
+            .unwrap();
+        for limit in [None, Some(2)] {
+            let mut builder = table.new_read_builder();
+            builder.with_filter(predicate.clone());
+            if let Some(limit) = limit {
+                builder.with_limit(limit);
+            }
+            let plan = builder
+                .new_scan()
+                .with_row_position_slice(1, 5)
+                .unwrap()
+                .plan()
+                .await
+                .unwrap();
+            assert_eq!(plan.snapshot_id(), Some(3));
+            // An unlimited reader proves that scan planning kept the complete
+            // slice after stats pruned the first three-row file.
+            assert_eq!(read_ids(&table, &plan).await, vec![4, 5, 6, 7]);
+            assert_eq!(
+                read_column(&builder, &plan, 0).await,
+                if limit.is_some() {
+                    vec![4, 5]
+                } else {
+                    vec![4, 5, 6, 7]
+                }
+            );
+        }
+    }
+}
+
 async fn delete_ids(table: &Table, ids: &[i64]) {
     let builder = table.new_write_builder();
     let mut delete = builder.new_delete().unwrap();
diff --git a/crates/paimon/tests/stream_table_scan_test.rs 
b/crates/paimon/tests/stream_table_scan_test.rs
index ab6a7575..ea853eee 100644
--- a/crates/paimon/tests/stream_table_scan_test.rs
+++ b/crates/paimon/tests/stream_table_scan_test.rs
@@ -133,6 +133,73 @@ async fn stream_scan_owns_table_and_read_configuration() {
     );
 }
 
+#[tokio::test]
+async fn cloned_scan_keeps_independent_progress_watermarks_and_configuration() 
{
+    let table = table("memory:/stream-cloned-state", "none").await;
+    write_batch(&table, &make_batch(vec![1], vec![10])).await;
+    patch_snapshot(&table, 1, "watermark", 100.into()).await;
+    write_batch(&table, &make_batch(vec![1], vec![20])).await;
+    patch_snapshot(&table, 2, "watermark", 200.into()).await;
+    let mut original = table.new_stream_scan().unwrap();
+    original.with_consumer_id("original").unwrap();
+    original.restore(Some(1)).unwrap();
+    assert_eq!(
+        values(&table, original.plan().await.unwrap().unwrap()).await,
+        vec![(1, 10)]
+    );
+    let mut copy = original.clone();
+    assert_eq!(copy.checkpoint(), Some(2));
+    assert_eq!(copy.watermark(), Some(100));
+
+    copy.with_consumer_id("copy").unwrap();
+    copy.with_bucket_filter(|_| Ok(false));
+    assert!(copy.plan().await.unwrap().is_none());
+    assert_eq!(copy.checkpoint(), Some(3));
+    assert_eq!(copy.watermark(), Some(200));
+    assert_eq!(original.checkpoint(), Some(2));
+    assert_eq!(original.watermark(), Some(100));
+    assert_eq!(
+        values(&table, original.plan().await.unwrap().unwrap()).await,
+        vec![(1, 20)]
+    );
+
+    original.restore(Some(1)).unwrap();
+    assert_eq!(copy.checkpoint(), Some(3));
+    assert_eq!(copy.watermark(), Some(200));
+    copy.with_bucket_filter(|_| Ok(true));
+    copy.restore(Some(1)).unwrap();
+    assert_eq!(
+        values(&table, copy.plan().await.unwrap().unwrap()).await,
+        vec![(1, 10)]
+    );
+    assert_eq!(copy.watermark(), Some(100));
+    assert_eq!(original.checkpoint(), Some(1));
+    assert_eq!(original.watermark(), Some(200));
+
+    original.with_bucket_filter(|_| Ok(false));
+    assert!(original.plan().await.unwrap().is_none());
+    assert_eq!(original.checkpoint(), Some(3));
+    assert_eq!(copy.checkpoint(), Some(2));
+    original
+        .notify_checkpoint_complete(original.checkpoint())
+        .await
+        .unwrap();
+    copy.notify_checkpoint_complete(copy.checkpoint())
+        .await
+        .unwrap();
+    let consumers = table.consumer_manager();
+    assert_eq!(consumers.get("original").await.unwrap(), Some(3));
+    assert_eq!(consumers.get("copy").await.unwrap(), Some(2));
+
+    // Resetting one copy restores its own acknowledged consumer checkpoint.
+    copy.restore(None).unwrap();
+    assert_eq!(copy.plan().await.unwrap().unwrap().snapshot_id(), Some(2));
+    assert_eq!(copy.checkpoint(), Some(3));
+    assert_eq!(copy.watermark(), Some(200));
+    assert_eq!(original.checkpoint(), Some(3));
+    assert_eq!(consumers.get("original").await.unwrap(), Some(3));
+}
+
 #[tokio::test]
 async fn 
initial_shards_merge_all_key_versions_and_follow_up_shards_keep_events() {
     for (engine, dv, bucket, producer, expected) in [

Reply via email to