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 f33dee15 fix: make Python record batch reader cancellable (#885)
f33dee15 is described below

commit f33dee1568118897a6c2ee39223d15c7107128a1
Author: Jingsong Lee <[email protected]>
AuthorDate: Sun Sep 20 16:44:48 2026 +0800

    fix: make Python record batch reader cancellable (#885)
---
 .../python/python/pypaimon_rust/datafusion.pyi     |   3 +
 bindings/python/src/read.rs                        | 108 +++++++++++++++++++--
 bindings/python/tests/test_read.py                 |  13 +++
 3 files changed, 115 insertions(+), 9 deletions(-)

diff --git a/bindings/python/python/pypaimon_rust/datafusion.pyi 
b/bindings/python/python/pypaimon_rust/datafusion.pyi
index 8bf6b3cd..d179df9d 100644
--- a/bindings/python/python/pypaimon_rust/datafusion.pyi
+++ b/bindings/python/python/pypaimon_rust/datafusion.pyi
@@ -60,6 +60,9 @@ class RecordBatchReader:
     def __iter__(self) -> "RecordBatchReader": ...
     def __next__(self) -> pyarrow.RecordBatch: ...
     def read_next_batch(self) -> Optional[pyarrow.RecordBatch]: ...
+    def close(self) -> None:
+        """Stop an in-flight read and release the underlying native stream."""
+        ...
 
 class TableRead:
     def read_arrow(self, splits: Sequence[Split]) -> RecordBatchReader:
diff --git a/bindings/python/src/read.rs b/bindings/python/src/read.rs
index 42c89165..df706c0b 100644
--- a/bindings/python/src/read.rs
+++ b/bindings/python/src/read.rs
@@ -16,9 +16,11 @@
 // under the License.
 
 use std::collections::HashMap;
+use std::sync::atomic::{AtomicBool, Ordering};
 use std::sync::{Arc, Mutex};
 
 use arrow::pyarrow::ToPyArrow;
+use arrow::record_batch::RecordBatch;
 use futures::TryStreamExt;
 use paimon::spec::Predicate;
 use paimon::table::{ArrowRecordBatchStream, DataSplit, IncrementalScanMode, 
RowRange, Table};
@@ -26,6 +28,7 @@ use paimon_datafusion::runtime::runtime;
 use pyo3::exceptions::{PyRuntimeError, PyTypeError, PyValueError};
 use pyo3::prelude::*;
 use pyo3::types::{PyBytes, PyDict};
+use tokio::sync::Notify;
 
 use crate::error::to_py_err;
 use crate::predicate::dict_to_predicate;
@@ -438,7 +441,9 @@ impl PyTableRead {
         splits: &Bound<'_, PyAny>,
     ) -> PyResult<PyRecordBatchReader> {
         Ok(PyRecordBatchReader {
-            stream: Mutex::new(self.read_stream(py, splits)?),
+            stream: Mutex::new(Some(self.read_stream(py, splits)?)),
+            closed: AtomicBool::new(false),
+            close_notify: Notify::new(),
         })
     }
 
@@ -459,22 +464,56 @@ impl PyTableRead {
 
 #[pyclass(name = "RecordBatchReader", module = "pypaimon_rust.datafusion")]
 pub struct PyRecordBatchReader {
-    stream: Mutex<ArrowRecordBatchStream>,
+    stream: Mutex<Option<ArrowRecordBatchStream>>,
+    closed: AtomicBool,
+    close_notify: Notify,
 }
 
 impl PyRecordBatchReader {
+    fn next_record_batch(&self) -> PyResult<Option<RecordBatch>> {
+        let mut stream = self
+            .stream
+            .lock()
+            .map_err(|_| PyRuntimeError::new_err("native record batch reader 
lock poisoned"))?;
+        if self.closed.load(Ordering::Acquire) {
+            stream.take();
+            return Ok(None);
+        }
+        let result = match stream.as_mut() {
+            Some(stream) => runtime()
+                .block_on(async {
+                    tokio::select! {
+                        biased;
+                        _ = self.close_notify.notified() => Ok(None),
+                        batch = stream.try_next() => batch,
+                    }
+                })
+                .map_err(to_py_err),
+            None => Ok(None),
+        };
+        if self.closed.load(Ordering::Acquire) {
+            stream.take();
+        }
+        result
+    }
+
     fn next_batch(&self, py: Python<'_>) -> PyResult<Option<Py<PyAny>>> {
-        let batch = py.detach(|| {
-            let mut stream = self
-                .stream
-                .lock()
-                .map_err(|_| PyRuntimeError::new_err("native record batch 
reader lock poisoned"))?;
-            runtime().block_on(stream.try_next()).map_err(to_py_err)
-        })?;
+        let batch = py.detach(|| self.next_record_batch())?;
         batch
             .map(|batch| Ok(batch.to_pyarrow(py)?.unbind()))
             .transpose()
     }
+
+    fn close_reader(&self) {
+        self.closed.store(true, Ordering::Release);
+        // notify_one stores a permit when next_record_batch has not started
+        // polling yet, avoiding a lost wake-up between its closed check and
+        // the select.
+        self.close_notify.notify_one();
+        if let Ok(mut stream) = self.stream.try_lock() {
+            stream.take();
+        }
+    }
 }
 
 #[pymethods]
@@ -483,6 +522,11 @@ impl PyRecordBatchReader {
         self.next_batch(py)
     }
 
+    /// Stop an in-flight read and release the underlying stream. Idempotent.
+    fn close(&self) {
+        self.close_reader();
+    }
+
     fn __iter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> {
         slf
     }
@@ -586,3 +630,49 @@ impl PySplit {
         })
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use std::sync::{mpsc, Arc};
+    use std::task::Poll;
+    use std::time::Duration;
+
+    use super::*;
+
+    #[test]
+    fn record_batch_reader_close_interrupts_pending_next() {
+        let (polled_tx, polled_rx) = mpsc::channel();
+        let mut announced = false;
+        let stream: ArrowRecordBatchStream = 
Box::pin(futures::stream::poll_fn(move |_cx| {
+            if !announced {
+                announced = true;
+                polled_tx.send(()).unwrap();
+            }
+            Poll::<Option<paimon::Result<RecordBatch>>>::Pending
+        }));
+        let reader = Arc::new(PyRecordBatchReader {
+            stream: Mutex::new(Some(stream)),
+            closed: AtomicBool::new(false),
+            close_notify: Notify::new(),
+        });
+        let worker_reader = Arc::clone(&reader);
+        let (result_tx, result_rx) = mpsc::channel();
+        let worker = std::thread::spawn(move || {
+            result_tx
+                .send(matches!(worker_reader.next_record_batch(), Ok(None)))
+                .unwrap();
+        });
+
+        polled_rx
+            .recv_timeout(Duration::from_secs(5))
+            .expect("stream was not polled");
+        reader.close_reader();
+
+        assert!(result_rx
+            .recv_timeout(Duration::from_secs(5))
+            .expect("pending next was not interrupted"));
+        worker.join().unwrap();
+        assert!(matches!(reader.next_record_batch(), Ok(None)));
+        reader.close_reader();
+    }
+}
diff --git a/bindings/python/tests/test_read.py 
b/bindings/python/tests/test_read.py
index e772f73d..fa836077 100644
--- a/bindings/python/tests/test_read.py
+++ b/bindings/python/tests/test_read.py
@@ -617,6 +617,19 @@ def test_read_arrow_streams_expected_rows():
         assert reader.read_next_batch() is None
 
 
+def test_read_arrow_close_is_idempotent_and_stops_iteration():
+    with tempfile.TemporaryDirectory() as warehouse:
+        table = _make_table_with_data(warehouse)
+        builder = table.new_read_builder()
+        splits = builder.new_scan().plan().splits()
+        reader = builder.new_read().read_arrow(splits)
+
+        reader.close()
+        assert reader.read_next_batch() is None
+        assert list(reader) == []
+        reader.close()
+
+
 def test_read_empty_splits():
     with tempfile.TemporaryDirectory() as warehouse:
         table = _make_table_with_data(warehouse)

Reply via email to