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

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


The following commit(s) were added to refs/heads/main by this push:
     new 350d9362 feat: Add RecordBatchLogReader for bounded log reading (#446)
350d9362 is described below

commit 350d9362249914daa9e60f2ff57012702b408d20
Author: Kaiqi Dong <[email protected]>
AuthorDate: Thu May 14 11:11:14 2026 +0200

    feat: Add RecordBatchLogReader for bounded log reading (#446)
    
    * Add RecordBatchLogReader for bounded log reading
    
    * address comments
    
    * update doc
    
    * update doc and inline comments
    
    * rebase and follow up after rebase of new changes, and fix a corner issue
    
    * feedback
    
    * run tests in thread to avoid asyncio event loop starvation
    
    * address feedback
---
 bindings/python/example/example.py              |  10 +-
 bindings/python/fluss/__init__.pyi              |  24 +
 bindings/python/src/table.rs                    | 293 +++++-----
 bindings/python/test/test_log_table.py          | 124 +++++
 crates/fluss/src/client/table/mod.rs            |   2 +
 crates/fluss/src/client/table/reader.rs         | 701 ++++++++++++++++++++++++
 crates/fluss/src/client/table/scanner.rs        | 138 ++++-
 website/docs/user-guide/python/api-reference.md |   3 +
 website/docs/user-guide/rust/api-reference.md   |  44 ++
 9 files changed, 1169 insertions(+), 170 deletions(-)

diff --git a/bindings/python/example/example.py 
b/bindings/python/example/example.py
index 0149996c..23ccc6d1 100644
--- a/bindings/python/example/example.py
+++ b/bindings/python/example/example.py
@@ -294,8 +294,14 @@ async def main():
         except Exception as e:
             print(f"Could not convert to Pandas: {e}")
 
-        # TODO: support to_arrow_batch_reader()
-        # which is reserved for streaming use cases
+        # to_arrow_batch_reader() — returns a lazy PyArrow RecordBatchReader
+        batch_scanner_reader = await 
table.new_scan().create_record_batch_log_scanner()
+        batch_scanner_reader.subscribe_buckets(
+            {i: fluss.EARLIEST_OFFSET for i in range(num_buckets)}
+        )
+        arrow_reader = batch_scanner_reader.to_arrow_batch_reader()
+        reader_table = pa.Table.from_batches(list(arrow_reader), 
schema=arrow_reader.schema)
+        print(f"\nVia to_arrow_batch_reader(): {reader_table.num_rows} rows")
 
         # TODO: support to_duckdb()
 
diff --git a/bindings/python/fluss/__init__.pyi 
b/bindings/python/fluss/__init__.pyi
index 18095c01..b5bfdfab 100644
--- a/bindings/python/fluss/__init__.pyi
+++ b/bindings/python/fluss/__init__.pyi
@@ -898,6 +898,26 @@ class LogScanner:
             or timeout expires.
         """
         ...
+    def to_arrow_batch_reader(self) -> pa.RecordBatchReader:
+        """Create a lazy Arrow RecordBatchReader that reads until latest 
offsets.
+
+        Returns a ``pyarrow.RecordBatchReader`` that lazily polls batches one 
at
+        a time (streaming). Prefer this when you want to process batches 
without
+        holding the full result in memory at once.
+
+        Do not call ``poll_arrow`` / ``poll_record_batch`` on this scanner 
while
+        iterating the reader; they share the same underlying scanner state.
+        Overlapping calls are not supported. Use one active
+        polling/consumption path at a time.
+
+        Requires a batch-based scanner (created with 
``new_scan().create_record_batch_log_scanner()``).
+        You must call ``subscribe()``, ``subscribe_buckets()``, 
``subscribe_partition()``,
+        or ``subscribe_partition_buckets()`` first.
+
+        Returns:
+            ``pyarrow.RecordBatchReader`` yielding ``RecordBatch`` objects.
+        """
+        ...
     async def to_pandas(self) -> pd.DataFrame:
         """Convert all data to Pandas DataFrame.
 
@@ -910,6 +930,10 @@ class LogScanner:
     async def to_arrow(self) -> pa.Table:
         """Convert all data to Arrow Table.
 
+        Batches are collected in Rust then combined into one table (no 
per-batch
+        Python iteration). Do not interleave with ``poll_arrow`` / 
``poll_record_batch``
+        for the same subscription session; overlapping use is not supported.
+
         Requires a batch-based scanner (created with 
new_scan().create_record_batch_log_scanner()).
         Reads from currently subscribed buckets until reaching their latest 
offsets.
 
diff --git a/bindings/python/src/table.rs b/bindings/python/src/table.rs
index 9ee84d76..4133bed4 100644
--- a/bindings/python/src/table.rs
+++ b/bindings/python/src/table.rs
@@ -18,10 +18,10 @@
 use crate::TOKIO_RUNTIME;
 use crate::*;
 use arrow::array::RecordBatch as ArrowRecordBatch;
+use arrow::record_batch::RecordBatchReader as _;
 use arrow_pyarrow::{FromPyArrow, ToPyArrow};
 use arrow_schema::SchemaRef;
 use fluss::record::to_arrow_schema;
-use fluss::rpc::message::OffsetSpec;
 use indexmap::IndexMap;
 use pyo3::IntoPyObjectExt;
 use pyo3::exceptions::{PyIndexError, PyRuntimeError, PyTypeError};
@@ -2014,6 +2014,38 @@ fn get_type_name(value: &Bound<PyAny>) -> String {
         .unwrap_or_else(|_| "unknown".to_string())
 }
 
+/// Thin Python iterator over [`fcore::client::SyncRecordBatchLogReader`].
+/// Used internally as the backing iterator for
+/// ``pa.RecordBatchReader.from_batches()``; not registered on the module.
+#[pyclass]
+struct PyRecordBatchLogReader {
+    sync_reader: fcore::client::SyncRecordBatchLogReader,
+}
+
+#[pymethods]
+impl PyRecordBatchLogReader {
+    fn __iter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> {
+        slf
+    }
+
+    fn __next__(&mut self, py: Python) -> PyResult<Option<Py<PyAny>>> {
+        let result = py.detach(|| self.sync_reader.next().transpose());
+
+        match result {
+            Ok(Some(batch)) => {
+                let py_batch = batch
+                    .to_pyarrow(py)
+                    .map_err(|e| FlussError::new_err(format!("Failed to 
convert batch: {e}")))?;
+                Ok(Some(py_batch.unbind()))
+            }
+            Ok(None) => Ok(None),
+            Err(arrow_err) => Err(FlussError::new_err(format!(
+                "Error reading batch: {arrow_err}"
+            ))),
+        }
+    }
+}
+
 /// Wraps the two scanner variants so we never have an impossible state
 /// (both None or both Some).
 enum ScannerKind {
@@ -2066,8 +2098,6 @@ pub struct LogScanner {
     projected_schema: SchemaRef,
     /// The projected row type to use for record-based scanning
     projected_row_type: Arc<fcore::metadata::RowType>,
-    /// Cache for partition_id -> partition_name mapping (avoids repeated 
list_partition_infos calls)
-    partition_name_cache: Arc<std::sync::RwLock<Option<HashMap<i64, String>>>>,
 }
 
 #[pymethods]
@@ -2307,11 +2337,75 @@ impl LogScanner {
         })
     }
 
+    /// Create a lazy Arrow RecordBatchReader that reads until latest offsets.
+    ///
+    /// This is a **blocking / synchronous** API: construction queries the
+    /// server for latest offsets (via ``block_on``), and each
+    /// ``RecordBatchReader.__next__()`` call blocks the calling thread until
+    /// the next batch is available. It is suitable for Arrow interop
+    /// (feeding into DuckDB, Polars, etc.) but should not be used
+    /// from ``asyncio`` coroutines -- see issue #545 for a planned
+    /// asyncio-native streaming alternative.
+    /// TODO(#545): Add asyncio-native streaming counterpart.
+    ///
+    /// Returns a PyArrow RecordBatchReader that lazily polls batches one at a
+    /// time. This is more memory-efficient than ``to_arrow()`` which loads all
+    /// data into a single table.
+    ///
+    /// **Concurrency:** While this reader is alive, ``subscribe*`` and
+    /// ``unsubscribe*`` calls on the scanner are rejected with an error.
+    /// You should also avoid calling ``poll_arrow`` / ``poll_record_batch``
+    /// on the same scanner — these are not blocked by the guard, but they
+    /// share the underlying fetch buffer with the reader and would
+    /// interleave batches between both consumers. Drop the reader before
+    /// resuming any of these operations.
+    ///
+    /// You must call subscribe(), subscribe_buckets(), subscribe_partition(),
+    /// or subscribe_partition_buckets() first.
+    ///
+    /// Returns:
+    ///     ``pyarrow.RecordBatchReader`` yielding ``RecordBatch`` objects
+    fn to_arrow_batch_reader(&self, py: Python) -> PyResult<Py<PyAny>> {
+        let scanner = self.kind.as_batch()?;
+
+        let sync_reader = py
+            .detach(|| {
+                TOKIO_RUNTIME.block_on(async {
+                    let reader = 
fcore::client::RecordBatchLogReader::new_until_latest(
+                        scanner.new_shared_handle(),
+                        &self.admin,
+                    )
+                    .await?;
+                    Ok::<_, fcore::error::Error>(
+                        
reader.to_record_batch_reader(TOKIO_RUNTIME.handle().clone()),
+                    )
+                })
+            })
+            .map_err(|e| FlussError::from_core_error(&e))?;
+
+        let py_schema = sync_reader
+            .schema()
+            .to_pyarrow(py)
+            .map_err(|e| FlussError::new_err(format!("Failed to convert 
schema: {e}")))?;
+
+        let py_iter = Py::new(py, PyRecordBatchLogReader { sync_reader })?;
+
+        let pyarrow = py.import("pyarrow")?;
+        let batch_reader = pyarrow
+            .getattr("RecordBatchReader")?
+            .call_method1("from_batches", (py_schema, py_iter))?;
+
+        Ok(batch_reader.into())
+    }
+
     /// Convert all data to Arrow Table.
     ///
     /// Reads from currently subscribed buckets until reaching their latest 
offsets.
     /// Works for both partitioned and non-partitioned tables.
     ///
+    /// Materializes batches in Rust 
(``RecordBatchLogReader::collect_all_batches``)
+    /// then builds one PyArrow table, avoiding per-batch Python iteration.
+    ///
     /// You must call subscribe(), subscribe_buckets(), subscribe_partition(), 
or subscribe_partition_buckets() first.
     ///
     /// Returns:
@@ -2319,29 +2413,29 @@ impl LogScanner {
     fn to_arrow<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
         let kind = Arc::clone(&self.kind);
         let admin = Arc::clone(&self.admin);
-        let table_info = self.table_info.clone();
         let projected_schema = self.projected_schema.clone();
-        let partition_name_cache = Arc::clone(&self.partition_name_cache);
 
         future_into_py(py, async move {
             let scanner = kind.as_batch()?;
-            let subscribed = scanner.get_subscribed_buckets();
-            if subscribed.is_empty() {
-                return Err(FlussError::new_err(
-                    "No buckets subscribed. Call subscribe(), 
subscribe_buckets(), subscribe_partition(), or subscribe_partition_buckets() 
first.",
-                ));
-            }
 
-            let all_batches = Self::collect_all_batches(
-                scanner,
+            let mut reader = 
fcore::client::RecordBatchLogReader::new_until_latest(
+                scanner.new_shared_handle(),
                 &admin,
-                &table_info,
-                &subscribed,
-                &partition_name_cache,
             )
-            .await?;
+            .await
+            .map_err(|e| FlussError::from_core_error(&e))?;
+
+            let scan_batches = reader
+                .collect_all_batches()
+                .await
+                .map_err(|e| FlussError::from_core_error(&e))?;
 
-            Python::attach(|py| Self::batches_to_arrow_table(py, all_batches, 
&projected_schema))
+            let batches: Vec<Arc<ArrowRecordBatch>> = scan_batches
+                .into_iter()
+                .map(|sb| Arc::new(sb.into_batch()))
+                .collect();
+
+            Python::attach(|py| Self::batches_to_arrow_table(py, batches, 
&projected_schema))
         })
     }
 
@@ -2357,30 +2451,30 @@ impl LogScanner {
     fn to_pandas<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
         let kind = Arc::clone(&self.kind);
         let admin = Arc::clone(&self.admin);
-        let table_info = self.table_info.clone();
         let projected_schema = self.projected_schema.clone();
-        let partition_name_cache = Arc::clone(&self.partition_name_cache);
 
         future_into_py(py, async move {
             let scanner = kind.as_batch()?;
-            let subscribed = scanner.get_subscribed_buckets();
-            if subscribed.is_empty() {
-                return Err(FlussError::new_err(
-                    "No buckets subscribed. Call subscribe(), 
subscribe_buckets(), subscribe_partition(), or subscribe_partition_buckets() 
first.",
-                ));
-            }
 
-            let all_batches = Self::collect_all_batches(
-                scanner,
+            let mut reader = 
fcore::client::RecordBatchLogReader::new_until_latest(
+                scanner.new_shared_handle(),
                 &admin,
-                &table_info,
-                &subscribed,
-                &partition_name_cache,
             )
-            .await?;
+            .await
+            .map_err(|e| FlussError::from_core_error(&e))?;
+
+            let scan_batches = reader
+                .collect_all_batches()
+                .await
+                .map_err(|e| FlussError::from_core_error(&e))?;
+
+            let batches: Vec<Arc<ArrowRecordBatch>> = scan_batches
+                .into_iter()
+                .map(|sb| Arc::new(sb.into_batch()))
+                .collect();
 
             Python::attach(|py| {
-                let arrow_table = Self::batches_to_arrow_table(py, 
all_batches, &projected_schema)?;
+                let arrow_table = Self::batches_to_arrow_table(py, batches, 
&projected_schema)?;
                 arrow_table.call_method0(py, "to_pandas")
             })
         })
@@ -2442,7 +2536,6 @@ impl LogScanner {
             table_info,
             projected_schema,
             projected_row_type,
-            partition_name_cache: Arc::new(std::sync::RwLock::new(None)),
         }
     }
 
@@ -2466,138 +2559,6 @@ impl LogScanner {
             Utils::combine_batches_to_table(py, batches)
         }
     }
-
-    /// Query stopping offsets and poll until all subscribed buckets are fully 
read.
-    /// Returns collected Arrow record batches.
-    async fn collect_all_batches(
-        scanner: &fcore::client::RecordBatchLogScanner,
-        admin: &fcore::client::FlussAdmin,
-        table_info: &fcore::metadata::TableInfo,
-        subscribed: &[(fcore::metadata::TableBucket, i64)],
-        partition_name_cache: &std::sync::RwLock<Option<HashMap<i64, String>>>,
-    ) -> PyResult<Vec<Arc<ArrowRecordBatch>>> {
-        let is_partitioned = scanner.is_partitioned();
-        let table_path = &table_info.table_path;
-        let table_id = table_info.table_id;
-
-        // 1. Query latest offsets
-        let mut stopping_offsets: HashMap<fcore::metadata::TableBucket, i64> = 
if !is_partitioned {
-            let bucket_ids: Vec<i32> = subscribed.iter().map(|(tb, _)| 
tb.bucket_id()).collect();
-            let offsets = admin
-                .list_offsets(table_path, &bucket_ids, OffsetSpec::Latest)
-                .await
-                .map_err(|e| FlussError::from_core_error(&e))?;
-            offsets
-                .into_iter()
-                .filter(|(_, offset)| *offset > 0)
-                .map(|(bucket_id, offset)| {
-                    (
-                        fcore::metadata::TableBucket::new(table_id, bucket_id),
-                        offset,
-                    )
-                })
-                .collect()
-        } else {
-            let cached = partition_name_cache.read().unwrap().clone();
-            let partition_id_to_name = match cached {
-                Some(map) => map,
-                None => {
-                    let infos = admin
-                        .list_partition_infos(table_path)
-                        .await
-                        .map_err(|e| FlussError::from_core_error(&e))?;
-                    let map: HashMap<i64, String> = infos
-                        .into_iter()
-                        .map(|info| (info.get_partition_id(), 
info.get_partition_name()))
-                        .collect();
-                    *partition_name_cache.write().unwrap() = Some(map.clone());
-                    map
-                }
-            };
-
-            let mut by_partition: HashMap<i64, Vec<i32>> = HashMap::new();
-            for (tb, _) in subscribed {
-                if let Some(partition_id) = tb.partition_id() {
-                    by_partition
-                        .entry(partition_id)
-                        .or_default()
-                        .push(tb.bucket_id());
-                }
-            }
-
-            let mut result = HashMap::new();
-            for (partition_id, bucket_ids) in by_partition {
-                let partition_name = 
partition_id_to_name.get(&partition_id).ok_or_else(|| {
-                    FlussError::new_err(format!("Unknown partition_id: 
{partition_id}"))
-                })?;
-                let offsets = admin
-                    .list_partition_offsets(
-                        table_path,
-                        partition_name,
-                        &bucket_ids,
-                        OffsetSpec::Latest,
-                    )
-                    .await
-                    .map_err(|e| FlussError::from_core_error(&e))?;
-                for (bucket_id, offset) in offsets {
-                    if offset > 0 {
-                        let tb = 
fcore::metadata::TableBucket::new_with_partition(
-                            table_id,
-                            Some(partition_id),
-                            bucket_id,
-                        );
-                        result.insert(tb, offset);
-                    }
-                }
-            }
-            result
-        };
-
-        // 2. Poll until all buckets reach their stopping offsets
-        let mut all_batches = Vec::new();
-        while !stopping_offsets.is_empty() {
-            let scan_batches = scanner
-                .poll(Duration::from_millis(500))
-                .await
-                .map_err(|e| FlussError::from_core_error(&e))?;
-
-            if scan_batches.is_empty() {
-                continue;
-            }
-
-            for scan_batch in scan_batches {
-                let table_bucket = scan_batch.bucket().clone();
-                let Some(&stop_at) = stopping_offsets.get(&table_bucket) else {
-                    continue;
-                };
-
-                let base_offset = scan_batch.base_offset();
-                let last_offset = scan_batch.last_offset();
-
-                if base_offset >= stop_at {
-                    stopping_offsets.remove(&table_bucket);
-                    continue;
-                }
-
-                let batch = if last_offset >= stop_at {
-                    let num_to_keep = (stop_at - base_offset) as usize;
-                    let b = scan_batch.into_batch();
-                    let limit = num_to_keep.min(b.num_rows());
-                    b.slice(0, limit)
-                } else {
-                    scan_batch.into_batch()
-                };
-
-                all_batches.push(Arc::new(batch));
-
-                if last_offset >= stop_at - 1 {
-                    stopping_offsets.remove(&table_bucket);
-                }
-            }
-        }
-
-        Ok(all_batches)
-    }
 }
 
 #[cfg(test)]
diff --git a/bindings/python/test/test_log_table.py 
b/bindings/python/test/test_log_table.py
index 2f560bcf..50b9078b 100644
--- a/bindings/python/test/test_log_table.py
+++ b/bindings/python/test/test_log_table.py
@@ -388,6 +388,130 @@ async def test_to_arrow_and_to_pandas(connection, admin):
     await admin.drop_table(table_path, ignore_if_not_exists=False)
 
 
+async def test_to_arrow_batch_reader(connection, admin):
+    """Test to_arrow_batch_reader() returns a lazy PyArrow 
RecordBatchReader."""
+    table_path = fluss.TablePath("fluss", "py_test_to_arrow_batch_reader")
+    await admin.drop_table(table_path, ignore_if_not_exists=True)
+
+    schema = fluss.Schema(
+        pa.schema([pa.field("id", pa.int32()), pa.field("name", pa.string())])
+    )
+    table_descriptor = fluss.TableDescriptor(schema)
+    await admin.create_table(table_path, table_descriptor, 
ignore_if_exists=False)
+
+    table = await connection.get_table(table_path)
+    writer = table.new_append().create_writer()
+
+    pa_schema = pa.schema([pa.field("id", pa.int32()), pa.field("name", 
pa.string())])
+    writer.write_arrow_batch(
+        pa.RecordBatch.from_arrays(
+            [pa.array([10, 20, 30], type=pa.int32()), pa.array(["x", "y", 
"z"])],
+            schema=pa_schema,
+        )
+    )
+    await writer.flush()
+
+    num_buckets = (await admin.get_table_info(table_path)).num_buckets
+
+    scanner = await table.new_scan().create_record_batch_log_scanner()
+    scanner.subscribe_buckets({i: fluss.EARLIEST_OFFSET for i in 
range(num_buckets)})
+
+    # to_arrow_batch_reader() is a blocking/sync API; run in a thread to
+    # avoid starving the asyncio event loop (see docstring warning).
+    def _read_all():
+        reader = scanner.to_arrow_batch_reader()
+        assert isinstance(reader, pa.RecordBatchReader)
+        assert reader.schema == pa_schema
+
+        batches = list(reader)
+        total_rows = sum(b.num_rows for b in batches)
+        assert total_rows == 3
+
+        result_table = pa.Table.from_batches(batches, schema=pa_schema)
+        assert result_table.column("id").to_pylist() == [10, 20, 30]
+        assert result_table.column("name").to_pylist() == ["x", "y", "z"]
+
+    await asyncio.to_thread(_read_all)
+
+    await admin.drop_table(table_path, ignore_if_not_exists=False)
+
+
+async def test_to_arrow_batch_reader_drop_and_guard(connection, admin):
+    """Test reader-active guard and Drop cleanup on mid-iteration drop."""
+    table_path = fluss.TablePath("fluss", "py_test_batch_reader_drop_guard")
+    await admin.drop_table(table_path, ignore_if_not_exists=True)
+
+    schema = fluss.Schema(
+        pa.schema([pa.field("id", pa.int32()), pa.field("name", pa.string())])
+    )
+    table_descriptor = fluss.TableDescriptor(schema)
+    await admin.create_table(table_path, table_descriptor, 
ignore_if_exists=False)
+
+    table = await connection.get_table(table_path)
+    writer = table.new_append().create_writer()
+
+    pa_schema = pa.schema([pa.field("id", pa.int32()), pa.field("name", 
pa.string())])
+    # Write multiple separate flushes so the server stores multiple log
+    # batches per bucket. This makes it likely that the reader's first poll
+    # only drains a subset, leaving real work for the Drop cleanup loop.
+    num_flushes = 10
+    rows_per_flush = 200
+    total_rows = num_flushes * rows_per_flush
+    for f in range(num_flushes):
+        start = f * rows_per_flush
+        writer.write_arrow_batch(
+            pa.RecordBatch.from_arrays(
+                [
+                    pa.array(
+                        list(range(start, start + rows_per_flush)), 
type=pa.int32()
+                    ),
+                    pa.array(
+                        [f"row_{i}" for i in range(start, start + 
rows_per_flush)]
+                    ),
+                ],
+                schema=pa_schema,
+            )
+        )
+        await writer.flush()
+
+    num_buckets = (await admin.get_table_info(table_path)).num_buckets
+
+    scanner = await table.new_scan().create_record_batch_log_scanner()
+    scanner.subscribe_buckets({i: fluss.EARLIEST_OFFSET for i in 
range(num_buckets)})
+
+    # to_arrow_batch_reader() is a blocking/sync API; run all blocking
+    # interactions in a thread to avoid starving the asyncio event loop.
+    def _test_guard_and_drop():
+        # --- Guard blocks subscribe / unsubscribe while reader is active ---
+        reader = scanner.to_arrow_batch_reader()
+        with pytest.raises(fluss.FlussError, match="RecordBatchLogReader is 
active"):
+            scanner.subscribe_buckets({0: fluss.EARLIEST_OFFSET})
+        with pytest.raises(fluss.FlussError, match="RecordBatchLogReader is 
active"):
+            scanner.unsubscribe(0)
+
+        # --- Drop mid-iteration: read one batch, then discard ---
+        first_batch = next(reader)
+        assert first_batch.num_rows > 0
+        del reader
+
+        # --- Drop unsubscribed leftover buckets: creating a reader without
+        #     re-subscribing must fail with "No buckets subscribed" ---
+        with pytest.raises(fluss.FlussError, match="No buckets subscribed"):
+            scanner.to_arrow_batch_reader()
+
+        # --- Guard cleared after drop: scanner is reusable from a fresh 
subscribe ---
+        scanner.subscribe_buckets(
+            {i: fluss.EARLIEST_OFFSET for i in range(num_buckets)}
+        )
+        reader2 = scanner.to_arrow_batch_reader()
+        batches = list(reader2)
+        assert sum(b.num_rows for b in batches) == total_rows
+
+    await asyncio.to_thread(_test_guard_and_drop)
+
+    await admin.drop_table(table_path, ignore_if_not_exists=False)
+
+
 async def test_partitioned_table_append_scan(connection, admin, 
wait_for_table_ready):
     """Test append and scan on a partitioned log table."""
     table_path = fluss.TablePath("fluss", "py_test_partitioned_log_append")
diff --git a/crates/fluss/src/client/table/mod.rs 
b/crates/fluss/src/client/table/mod.rs
index ba1edd2f..e116bbb4 100644
--- a/crates/fluss/src/client/table/mod.rs
+++ b/crates/fluss/src/client/table/mod.rs
@@ -29,12 +29,14 @@ mod lookup;
 
 mod log_fetch_buffer;
 mod partition_getter;
+mod reader;
 mod remote_log;
 mod scanner;
 mod upsert;
 
 pub use append::{AppendWriter, TableAppend};
 pub use lookup::{LookupResult, Lookuper, PrefixKeyLookuper, TableLookup, 
TablePrefixLookup};
+pub use reader::{RecordBatchLogReader, SyncRecordBatchLogReader};
 pub use remote_log::{
     DEFAULT_REMOTE_FILE_DOWNLOAD_THREAD_NUM, 
DEFAULT_SCANNER_REMOTE_LOG_PREFETCH_NUM,
 };
diff --git a/crates/fluss/src/client/table/reader.rs 
b/crates/fluss/src/client/table/reader.rs
new file mode 100644
index 00000000..0a08803d
--- /dev/null
+++ b/crates/fluss/src/client/table/reader.rs
@@ -0,0 +1,701 @@
+// 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.
+
+//! Bounded log reader that polls until stopping offsets, then terminates.
+//!
+//! Unlike [`RecordBatchLogScanner`] which is unbounded (continuous streaming),
+//! [`RecordBatchLogReader`] reads log data up to a finite set of stopping
+//! offsets and then signals completion. This enables "snapshot-style" reads
+//! from a streaming log: capture the latest offsets, then consume all data
+//! up to those offsets.
+//!
+//! The reader **takes ownership** of the scanner (move, not clone). Once the
+//! scanner is moved into a reader, the compiler prevents concurrent polls.
+//!
+//! The reader also provides a synchronous 
[`arrow::record_batch::RecordBatchReader`]
+//! adapter via [`RecordBatchLogReader::to_record_batch_reader`] for Arrow
+//! ecosystem interop and FFI consumers (Python, C++).
+
+use crate::client::admin::FlussAdmin;
+use crate::client::table::RecordBatchLogScanner;
+use crate::error::{Error, Result};
+use crate::metadata::TableBucket;
+use crate::record::ScanBatch;
+use crate::rpc::message::OffsetSpec;
+use arrow::record_batch::RecordBatch;
+use arrow_schema::SchemaRef;
+use log::warn;
+use std::collections::{HashMap, VecDeque};
+use std::time::Duration;
+
+const DEFAULT_POLL_TIMEOUT: Duration = Duration::from_millis(500);
+
+/// Bounded log reader that consumes log data up to specified stopping offsets.
+///
+/// This type wraps a [`RecordBatchLogScanner`] and adds stopping semantics:
+/// it polls batches from the scanner, filters/slices them against per-bucket
+/// stopping offsets, and signals completion when all buckets are caught up.
+///
+/// The reader takes **ownership** of the scanner. Once moved in, no other code
+/// can poll the same scanner concurrently.
+///
+/// # Construction
+///
+/// Use [`RecordBatchLogReader::new_until_latest`] for the common case of
+/// reading all currently-available data, or 
[`RecordBatchLogReader::new_until_offsets`]
+/// for custom stopping offsets.
+///
+/// # Async iteration
+///
+/// Call [`next_batch`](RecordBatchLogReader::next_batch) repeatedly to get
+/// [`ScanBatch`]es lazily, one at a time. Returns `None` when all buckets
+/// have reached their stopping offsets.
+///
+/// # Sync adapter
+///
+/// Call 
[`to_record_batch_reader`](RecordBatchLogReader::to_record_batch_reader)
+/// to get a synchronous [`arrow::record_batch::RecordBatchReader`] suitable
+/// for Arrow FFI consumers.
+pub struct RecordBatchLogReader {
+    scanner: RecordBatchLogScanner,
+    stopping_offsets: HashMap<TableBucket, i64>,
+    buffer: VecDeque<ScanBatch>,
+    schema: SchemaRef,
+}
+
+impl RecordBatchLogReader {
+    /// Create a reader that reads until the latest offsets at the time of 
creation.
+    ///
+    /// Queries the server for the current latest offset of each subscribed
+    /// bucket, then reads until those offsets are reached. Buckets whose
+    /// subscribed offset already meets or exceeds the latest offset are
+    /// excluded (nothing to read).
+    ///
+    /// Partition metadata is fetched once during construction; no caching
+    /// is needed since each reader is typically short-lived.
+    pub async fn new_until_latest(
+        scanner: RecordBatchLogScanner,
+        admin: &FlussAdmin,
+    ) -> Result<Self> {
+        // Acquire the guard first so no concurrent unsubscribe can mutate
+        // state between reading subscriptions and using them.
+        scanner.try_set_reader_active()?;
+
+        let subscribed = scanner.get_subscribed_buckets();
+        if subscribed.is_empty() {
+            scanner.clear_reader_active();
+            return Err(Error::IllegalArgument {
+                message: "No buckets subscribed. Call subscribe() before 
creating a reader."
+                    .to_string(),
+            });
+        }
+
+        let stopping_offsets = match query_latest_offsets(admin, &scanner, 
&subscribed).await {
+            Ok(o) => o,
+            Err(e) => {
+                scanner.clear_reader_active();
+                return Err(e);
+            }
+        };
+        let schema = scanner.schema();
+
+        Ok(Self {
+            scanner,
+            stopping_offsets,
+            buffer: VecDeque::new(),
+            schema,
+        })
+    }
+
+    /// Create a reader with explicit stopping offsets per bucket.
+    ///
+    /// # NOTE: Every key in `stopping_offsets` **must** correspond to a 
bucket that is
+    /// currently subscribed on the `scanner`. If a stopping offset refers to a
+    /// bucket that will never appear in polled batches, the reader will loop
+    /// indefinitely waiting for data that never arrives.
+    ///
+    /// Use [`new_until_latest`](Self::new_until_latest) for the common case;
+    /// it queries the server and builds a validated stopping-offset map
+    /// automatically.
+    pub fn new_until_offsets(
+        scanner: RecordBatchLogScanner,
+        stopping_offsets: HashMap<TableBucket, i64>,
+    ) -> Result<Self> {
+        scanner.try_set_reader_active()?;
+        let schema = scanner.schema();
+        Ok(Self {
+            scanner,
+            stopping_offsets,
+            buffer: VecDeque::new(),
+            schema,
+        })
+    }
+
+    /// Returns the Arrow schema for batches produced by this reader.
+    pub fn schema(&self) -> SchemaRef {
+        self.schema.clone()
+    }
+
+    /// Drain all remaining batches until stopping offsets are satisfied.
+    ///
+    /// This is a convenience for callers (e.g. bindings building a single 
Arrow
+    /// table) that want to materialize the full result in Rust without 
per-batch
+    /// iteration.
+    pub async fn collect_all_batches(&mut self) -> Result<Vec<ScanBatch>> {
+        let mut out = Vec::new();
+        while let Some(b) = self.next_batch().await? {
+            out.push(b);
+        }
+        Ok(out)
+    }
+
+    /// Fetch the next [`ScanBatch`], or `None` if all buckets are caught up.
+    ///
+    /// Each call may internally poll multiple batches from the scanner,
+    /// buffer them, and return one at a time. Batches that cross a stopping
+    /// offset boundary are sliced to exclude records at or beyond the stop 
point.
+    ///
+    /// Completed buckets are unsubscribed from the scanner to avoid wasting
+    /// network traffic on data the reader will discard.
+    pub async fn next_batch(&mut self) -> Result<Option<ScanBatch>> {
+        loop {
+            if let Some(batch) = self.buffer.pop_front() {
+                return Ok(Some(batch));
+            }
+
+            if self.stopping_offsets.is_empty() {
+                return Ok(None);
+            }
+
+            let scan_batches = self.scanner.poll(DEFAULT_POLL_TIMEOUT).await?;
+
+            if scan_batches.is_empty() {
+                continue;
+            }
+
+            let completed =
+                filter_batches(scan_batches, &mut self.stopping_offsets, &mut 
self.buffer);
+
+            // Use the `_sync` unsubscribe variants here: the active-reader
+            // guard rejects calls to the async `unsubscribe*` methods, but
+            // the reader is allowed to clean up its own completed buckets.
+            // The sync variants do the same map removal without the guard
+            // check, and the partitioned/non-partitioned mismatch they
+            // silently ignore is unreachable since the reader inherits the
+            // scanner's partition mode.
+            for tb in completed {
+                if let Some(partition_id) = tb.partition_id() {
+                    self.scanner
+                        .unsubscribe_partition_sync(partition_id, 
tb.bucket_id());
+                } else {
+                    self.scanner.unsubscribe_sync(tb.bucket_id());
+                }
+            }
+        }
+    }
+
+    /// Convert this async reader into a synchronous 
[`arrow::record_batch::RecordBatchReader`].
+    ///
+    /// The returned adapter calls [`tokio::runtime::Handle::block_on`] on each
+    /// iterator step. **Do not** call this from inside a Tokio worker thread
+    /// while the same runtime is driving async work (nested `block_on` can
+    /// panic or deadlock). Prefer 
[`next_batch`](RecordBatchLogReader::next_batch)
+    /// in async Rust code. This is intended for sync/FFI boundaries (C++, some
+    /// Python call paths).
+    pub fn to_record_batch_reader(
+        self,
+        handle: tokio::runtime::Handle,
+    ) -> SyncRecordBatchLogReader {
+        SyncRecordBatchLogReader {
+            reader: self,
+            handle,
+        }
+    }
+}
+
+/// Best-effort cleanup when the reader is dropped before all buckets reach
+/// their stopping offsets (early `break`, an exception in the consumer, etc.).
+///
+/// Why this matters even though we own the scanner:
+///
+/// In pure Rust, dropping the reader drops the owned `RecordBatchLogScanner`,
+/// which decrements the `Arc<LogScannerInner>` to zero and frees the inner
+/// state. Subscriptions die with it, so this `Drop` is a no-op in that path.
+///
+/// In the binding layer (Python today, C++/Elixir later), the binding holds
+/// its own `Arc<LogScannerInner>` and uses
+/// [`RecordBatchLogScanner::new_shared_handle`] to obtain a second handle for
+/// the reader. When the reader is dropped mid-iteration the inner state stays
+/// alive — and any buckets the reader hadn't yet completed remain in
+/// `LogScannerStatus.bucket_status_map`. The user's next operations on the
+/// original `LogScanner` would then see "ghost" subscriptions (extra buckets
+/// being polled, stale offsets, etc.).
+///
+/// The `next_batch` loop already calls `unsubscribe` on each completed bucket,
+/// so `stopping_offsets` accurately reflects the still-active set when `Drop`
+/// runs. We unsubscribe each remaining bucket synchronously via the
+/// `_sync` escape hatches (the underlying `LogScannerStatus` ops don't await),
+/// so this is safe to call from any context — sync, async, a Tokio worker, or
+/// a Python thread holding the GIL.
+///
+/// After cleanup, the `reader_active` guard is cleared so that the original
+/// scanner (held by the binding layer) can accept new subscriptions again.
+///
+/// Caveats:
+/// - Batches already buffered in `LogFetcher.log_fetch_buffer` for an
+///   unsubscribed bucket are not drained here. They'll either be filtered out
+///   by the next `RecordBatchLogReader` (via the "bucket not in
+///   stopping_offsets" branch) or surface to a direct `poll_arrow` caller, who
+///   was sharing scanner state in the first place.
+/// - `Drop` cannot return errors. The `_sync` variants no-op on
+///   partitioned/non-partitioned mismatch, but that mismatch is unreachable
+///   here because the reader was constructed from this scanner and inherited
+///   its partition mode.
+impl Drop for RecordBatchLogReader {
+    fn drop(&mut self) {
+        for (tb, _) in self.stopping_offsets.drain() {
+            if let Some(partition_id) = tb.partition_id() {
+                self.scanner
+                    .unsubscribe_partition_sync(partition_id, tb.bucket_id());
+            } else {
+                self.scanner.unsubscribe_sync(tb.bucket_id());
+            }
+        }
+        self.scanner.clear_reader_active();
+    }
+}
+
+/// Synchronous adapter that implements 
[`arrow::record_batch::RecordBatchReader`].
+///
+/// Created via [`RecordBatchLogReader::to_record_batch_reader`].
+/// Blocks the current thread on each `next()` call using the provided
+/// Tokio runtime handle.
+///
+/// The iterator yields plain [`RecordBatch`]es (bucket/offset metadata from
+/// [`ScanBatch`] is stripped to satisfy the Arrow trait contract).
+pub struct SyncRecordBatchLogReader {
+    reader: RecordBatchLogReader,
+    handle: tokio::runtime::Handle,
+}
+
+impl Iterator for SyncRecordBatchLogReader {
+    type Item = std::result::Result<RecordBatch, arrow::error::ArrowError>;
+
+    fn next(&mut self) -> Option<Self::Item> {
+        match self.handle.block_on(self.reader.next_batch()) {
+            Ok(Some(scan_batch)) => Some(Ok(scan_batch.into_batch())),
+            Ok(None) => None,
+            Err(e) => 
Some(Err(arrow::error::ArrowError::ExternalError(Box::new(e)))),
+        }
+    }
+}
+
+impl arrow::record_batch::RecordBatchReader for SyncRecordBatchLogReader {
+    fn schema(&self) -> SchemaRef {
+        self.reader.schema()
+    }
+}
+
+/// Query latest offsets for all subscribed buckets, handling both partitioned
+/// and non-partitioned tables.
+///
+/// Buckets whose subscribed offset already meets or exceeds the latest offset
+/// are excluded from the result (there is nothing to read). A `latest_offset`
+/// of `0` means the bucket is empty and is silently skipped; a negative value
+/// is unexpected from the server and is logged as a warning before being
+/// skipped.
+async fn query_latest_offsets(
+    admin: &FlussAdmin,
+    scanner: &RecordBatchLogScanner,
+    subscribed: &[(TableBucket, i64)],
+) -> Result<HashMap<TableBucket, i64>> {
+    let table_path = scanner.table_path();
+
+    if !scanner.is_partitioned() {
+        let bucket_ids: Vec<i32> = subscribed.iter().map(|(tb, _)| 
tb.bucket_id()).collect();
+
+        let offsets = admin
+            .list_offsets(table_path, &bucket_ids, OffsetSpec::Latest)
+            .await?;
+
+        let subscribed_offset_by_bucket: HashMap<i32, i64> = subscribed
+            .iter()
+            .map(|(tb, off)| (tb.bucket_id(), *off))
+            .collect();
+
+        let table_id = scanner.table_id();
+        Ok(offsets
+            .into_iter()
+            .filter(|(bucket_id, latest_offset)| {
+                if *latest_offset < 0 {
+                    warn!(
+                        "Server returned negative latest offset 
{latest_offset} for bucket {bucket_id} of table {table_id}; skipping bucket."
+                    );
+                    return false;
+                }
+                if *latest_offset == 0 {
+                    return false;
+                }
+                let Some(&subscribed_offset) = 
subscribed_offset_by_bucket.get(bucket_id)
+                else {
+                    return false;
+                };
+                subscribed_offset < *latest_offset
+            })
+            .map(|(bucket_id, offset)| (TableBucket::new(table_id, bucket_id), 
offset))
+            .collect())
+    } else {
+        query_partitioned_offsets(admin, scanner, subscribed).await
+    }
+}
+
+/// Query offsets for partitioned table subscriptions.
+///
+/// Partition metadata is fetched once per reader construction (not cached),
+/// since each [`RecordBatchLogReader`] is typically short-lived and consumed.
+async fn query_partitioned_offsets(
+    admin: &FlussAdmin,
+    scanner: &RecordBatchLogScanner,
+    subscribed: &[(TableBucket, i64)],
+) -> Result<HashMap<TableBucket, i64>> {
+    let table_path = scanner.table_path();
+    let table_id = scanner.table_id();
+
+    let partition_infos = admin.list_partition_infos(table_path).await?;
+    let partition_id_to_name: HashMap<i64, String> = partition_infos
+        .into_iter()
+        .map(|info| (info.get_partition_id(), info.get_partition_name()))
+        .collect();
+
+    let subscribed_offset_map: HashMap<TableBucket, i64> = 
subscribed.iter().cloned().collect();
+
+    let mut by_partition: HashMap<i64, Vec<i32>> = HashMap::new();
+    for (tb, _) in subscribed {
+        if let Some(partition_id) = tb.partition_id() {
+            by_partition
+                .entry(partition_id)
+                .or_default()
+                .push(tb.bucket_id());
+        }
+    }
+
+    let mut result: HashMap<TableBucket, i64> = HashMap::new();
+
+    for (partition_id, bucket_ids) in by_partition {
+        let partition_name =
+            partition_id_to_name
+                .get(&partition_id)
+                .ok_or_else(|| Error::UnexpectedError {
+                    message: format!("Unknown partition_id: {partition_id}"),
+                    source: None,
+                })?;
+
+        let offsets = admin
+            .list_partition_offsets(table_path, partition_name, &bucket_ids, 
OffsetSpec::Latest)
+            .await?;
+
+        for (bucket_id, latest_offset) in offsets {
+            if latest_offset < 0 {
+                warn!(
+                    "Server returned negative latest offset {latest_offset} 
for bucket {bucket_id} of partition {partition_id} (table {table_id}); skipping 
bucket."
+                );
+                continue;
+            }
+            if latest_offset == 0 {
+                continue;
+            }
+            let tb = TableBucket::new_with_partition(table_id, 
Some(partition_id), bucket_id);
+            let Some(&subscribed_offset) = subscribed_offset_map.get(&tb) else 
{
+                continue;
+            };
+            if subscribed_offset < latest_offset {
+                result.insert(tb, latest_offset);
+            }
+        }
+    }
+
+    Ok(result)
+}
+
+/// Filter and slice scan batches against per-bucket stopping offsets.
+///
+/// For each batch:
+/// - If the batch's bucket is not in `stopping_offsets`, skip it.
+/// - If `base_offset >= stop_at`, the bucket is exhausted; remove from map.
+/// - If `last_offset >= stop_at`, slice to keep only records before stop_at.
+/// - Otherwise, keep the full batch.
+///
+/// Accepted batches with at least one row are pushed to `buffer`; empty
+/// batches (e.g. a server-emitted batch containing no rows, or a slice that
+/// reduces to zero rows) are dropped so consumers never observe an empty
+/// `ScanBatch`. Returns the list of buckets that completed (were removed
+/// from `stopping_offsets`).
+fn filter_batches(
+    scan_batches: Vec<ScanBatch>,
+    stopping_offsets: &mut HashMap<TableBucket, i64>,
+    buffer: &mut VecDeque<ScanBatch>,
+) -> Vec<TableBucket> {
+    let mut completed = Vec::new();
+
+    for scan_batch in scan_batches {
+        let bucket = scan_batch.bucket().clone();
+        let Some(&stop_at) = stopping_offsets.get(&bucket) else {
+            continue;
+        };
+
+        let base_offset = scan_batch.base_offset();
+        let last_offset = scan_batch.last_offset();
+
+        if base_offset >= stop_at {
+            stopping_offsets.remove(&bucket);
+            completed.push(bucket);
+            continue;
+        }
+
+        let kept_batch = if last_offset >= stop_at {
+            let num_to_keep = (stop_at - base_offset) as usize;
+            let b = scan_batch.into_batch();
+            let limit = num_to_keep.min(b.num_rows());
+            ScanBatch::new(bucket.clone(), b.slice(0, limit), base_offset)
+        } else {
+            scan_batch
+        };
+
+        if kept_batch.batch().num_rows() > 0 {
+            buffer.push_back(kept_batch);
+        }
+
+        if last_offset >= stop_at - 1 {
+            stopping_offsets.remove(&bucket);
+            completed.push(bucket);
+        }
+    }
+
+    completed
+}
+
+// TODO: Add Rust-level end-to-end tests with `FlussTestingCluster` (feature
+// `integration_tests`) covering `new_until_latest`, partitioned tables,
+// and `new_until_offsets` stopping semantics. Drop cleanup and the
+// reader-active guard are covered by the Python integration test
+// `test_to_arrow_batch_reader_drop_and_guard`.
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use arrow::array::Int32Array;
+    use arrow_schema::{DataType, Field, Schema};
+    use std::sync::Arc;
+
+    fn test_schema() -> SchemaRef {
+        Arc::new(Schema::new(vec![Field::new("v", DataType::Int32, false)]))
+    }
+
+    fn make_batch(values: &[i32]) -> RecordBatch {
+        RecordBatch::try_new(
+            test_schema(),
+            vec![Arc::new(Int32Array::from(values.to_vec()))],
+        )
+        .unwrap()
+    }
+
+    fn make_scan_batch(bucket: TableBucket, base_offset: i64, values: &[i32]) 
-> ScanBatch {
+        ScanBatch::new(bucket, make_batch(values), base_offset)
+    }
+
+    fn bucket(id: i32) -> TableBucket {
+        TableBucket::new(1, id)
+    }
+
+    #[test]
+    fn filter_batch_entirely_before_stop() {
+        let mut offsets = HashMap::from([(bucket(0), 100)]);
+        let mut buffer = VecDeque::new();
+
+        let batches = vec![make_scan_batch(bucket(0), 10, &[1, 2, 3])];
+        let completed = filter_batches(batches, &mut offsets, &mut buffer);
+
+        assert_eq!(buffer.len(), 1);
+        assert_eq!(buffer[0].batch().num_rows(), 3);
+        assert!(offsets.contains_key(&bucket(0)));
+        assert!(completed.is_empty());
+    }
+
+    #[test]
+    fn filter_batch_crossing_stop_offset_is_sliced() {
+        let mut offsets = HashMap::from([(bucket(0), 12)]);
+        let mut buffer = VecDeque::new();
+
+        // base_offset=10, 5 rows -> offsets 10,11,12,13,14; stop_at=12 -> 
keep 2
+        let batches = vec![make_scan_batch(bucket(0), 10, &[1, 2, 3, 4, 5])];
+        let completed = filter_batches(batches, &mut offsets, &mut buffer);
+
+        assert_eq!(buffer.len(), 1);
+        assert_eq!(buffer[0].batch().num_rows(), 2);
+        assert!(!offsets.contains_key(&bucket(0)));
+        assert_eq!(completed, vec![bucket(0)]);
+    }
+
+    #[test]
+    fn filter_batch_at_or_after_stop_offset_is_skipped() {
+        let mut offsets = HashMap::from([(bucket(0), 10)]);
+        let mut buffer = VecDeque::new();
+
+        // base_offset=10, stop_at=10 -> base >= stop, skip entirely
+        let batches = vec![make_scan_batch(bucket(0), 10, &[1, 2, 3])];
+        let completed = filter_batches(batches, &mut offsets, &mut buffer);
+
+        assert!(buffer.is_empty());
+        assert!(!offsets.contains_key(&bucket(0)));
+        assert_eq!(completed, vec![bucket(0)]);
+    }
+
+    #[test]
+    fn filter_batch_ending_exactly_at_stop_minus_one() {
+        let mut offsets = HashMap::from([(bucket(0), 13)]);
+        let mut buffer = VecDeque::new();
+
+        // base_offset=10, 3 rows -> offsets 10,11,12; last_offset=12, 
stop_at=13
+        // last_offset (12) >= stop_at - 1 (12) => bucket done
+        let batches = vec![make_scan_batch(bucket(0), 10, &[1, 2, 3])];
+        let completed = filter_batches(batches, &mut offsets, &mut buffer);
+
+        assert_eq!(buffer.len(), 1);
+        assert_eq!(buffer[0].batch().num_rows(), 3);
+        assert!(!offsets.contains_key(&bucket(0)));
+        assert_eq!(completed, vec![bucket(0)]);
+    }
+
+    #[test]
+    fn filter_unknown_bucket_is_ignored() {
+        let mut offsets = HashMap::from([(bucket(0), 100)]);
+        let mut buffer = VecDeque::new();
+
+        let batches = vec![make_scan_batch(bucket(99), 0, &[1, 2])];
+        let completed = filter_batches(batches, &mut offsets, &mut buffer);
+
+        assert!(buffer.is_empty());
+        assert!(offsets.contains_key(&bucket(0)));
+        assert!(completed.is_empty());
+    }
+
+    #[test]
+    fn filter_multiple_buckets_independent_tracking() {
+        let mut offsets = HashMap::from([(bucket(0), 12), (bucket(1), 5)]);
+        let mut buffer = VecDeque::new();
+
+        let batches = vec![
+            make_scan_batch(bucket(0), 10, &[1, 2, 3]), // last=12, stop=12 -> 
keep 2, done
+            make_scan_batch(bucket(1), 0, &[10, 20, 30]), // last=2, stop=5 -> 
keep all, not done
+        ];
+        let completed = filter_batches(batches, &mut offsets, &mut buffer);
+
+        assert_eq!(buffer.len(), 2);
+        assert_eq!(buffer[0].batch().num_rows(), 2); // bucket 0: sliced
+        assert_eq!(buffer[1].batch().num_rows(), 3); // bucket 1: full
+        assert!(!offsets.contains_key(&bucket(0))); // bucket 0: done
+        assert!(offsets.contains_key(&bucket(1))); // bucket 1: still tracking
+        assert_eq!(completed, vec![bucket(0)]);
+    }
+
+    #[test]
+    fn filter_empty_batch_at_stop() {
+        let mut offsets = HashMap::from([(bucket(0), 5)]);
+        let mut buffer = VecDeque::new();
+
+        // empty batch: base_offset=5, 0 rows -> last_offset = base-1 = 4
+        // base_offset (5) >= stop_at (5) -> skip, remove
+        let batches = vec![make_scan_batch(bucket(0), 5, &[])];
+        let completed = filter_batches(batches, &mut offsets, &mut buffer);
+
+        assert!(buffer.is_empty());
+        assert!(!offsets.contains_key(&bucket(0)));
+        assert_eq!(completed, vec![bucket(0)]);
+    }
+
+    #[test]
+    fn filter_drops_empty_batch_before_stop() {
+        // Empty batch well below the stop offset: base=5, 0 rows -> last=4, 
stop=100.
+        // base_offset (5) < stop_at (100) and last_offset (4) < stop_at (100),
+        // so it falls into the "keep full batch" branch but must not surface 
to
+        // the consumer because it has zero rows.
+        let mut offsets = HashMap::from([(bucket(0), 100)]);
+        let mut buffer = VecDeque::new();
+
+        let batches = vec![make_scan_batch(bucket(0), 5, &[])];
+        let completed = filter_batches(batches, &mut offsets, &mut buffer);
+
+        assert!(buffer.is_empty());
+        assert!(offsets.contains_key(&bucket(0)));
+        assert!(completed.is_empty());
+    }
+
+    #[test]
+    fn filter_single_row_batch_before_stop() {
+        let mut offsets = HashMap::from([(bucket(0), 10)]);
+        let mut buffer = VecDeque::new();
+
+        let batches = vec![make_scan_batch(bucket(0), 5, &[42])];
+        let completed = filter_batches(batches, &mut offsets, &mut buffer);
+
+        assert_eq!(buffer.len(), 1);
+        assert_eq!(buffer[0].batch().num_rows(), 1);
+        assert!(offsets.contains_key(&bucket(0)));
+        assert!(completed.is_empty());
+    }
+
+    #[test]
+    fn filter_single_row_batch_at_stop_boundary() {
+        let mut offsets = HashMap::from([(bucket(0), 5)]);
+        let mut buffer = VecDeque::new();
+
+        // base_offset=4, 1 row -> last_offset=4, stop=5
+        // last < stop -> keep all; last (4) >= stop-1 (4) -> done
+        let batches = vec![make_scan_batch(bucket(0), 4, &[42])];
+        let completed = filter_batches(batches, &mut offsets, &mut buffer);
+
+        assert_eq!(buffer.len(), 1);
+        assert_eq!(buffer[0].batch().num_rows(), 1);
+        assert!(!offsets.contains_key(&bucket(0)));
+        assert_eq!(completed, vec![bucket(0)]);
+    }
+
+    #[test]
+    fn filter_preserves_scan_batch_metadata() {
+        let mut offsets = HashMap::from([(bucket(3), 100)]);
+        let mut buffer = VecDeque::new();
+
+        let batches = vec![make_scan_batch(bucket(3), 42, &[1, 2])];
+        filter_batches(batches, &mut offsets, &mut buffer);
+
+        let sb = &buffer[0];
+        assert_eq!(*sb.bucket(), bucket(3));
+        assert_eq!(sb.base_offset(), 42);
+    }
+
+    #[test]
+    fn filter_sliced_batch_preserves_base_offset() {
+        let mut offsets = HashMap::from([(bucket(0), 12)]);
+        let mut buffer = VecDeque::new();
+
+        let batches = vec![make_scan_batch(bucket(0), 10, &[1, 2, 3, 4, 5])];
+        filter_batches(batches, &mut offsets, &mut buffer);
+
+        let sb = &buffer[0];
+        assert_eq!(sb.base_offset(), 10);
+        assert_eq!(*sb.bucket(), bucket(0));
+    }
+}
diff --git a/crates/fluss/src/client/table/scanner.rs 
b/crates/fluss/src/client/table/scanner.rs
index c6228e59..86870991 100644
--- a/crates/fluss/src/client/table/scanner.rs
+++ b/crates/fluss/src/client/table/scanner.rs
@@ -257,6 +257,10 @@ pub struct LogScanner {
 ///
 /// More efficient than [`LogScanner`] for batch-level analytics where 
per-record
 /// metadata (offsets, timestamps) is not needed.
+///
+/// This type is intentionally **not** `Clone`. To perform a bounded read, move
+/// the scanner into a [`crate::client::RecordBatchLogReader`] — the compiler
+/// then prevents concurrent polls by construction.
 pub struct RecordBatchLogScanner {
     inner: Arc<LogScannerInner>,
 }
@@ -269,6 +273,10 @@ struct LogScannerInner {
     log_scanner_status: Arc<LogScannerStatus>,
     log_fetcher: LogFetcher,
     is_partitioned_table: bool,
+    arrow_schema: SchemaRef,
+    /// Guards against subscription changes while a
+    /// [`crate::client::RecordBatchLogReader`] is iterating.
+    reader_active: std::sync::atomic::AtomicBool,
 }
 
 impl LogScannerInner {
@@ -280,6 +288,20 @@ impl LogScannerInner {
         projected_fields: Option<Vec<usize>>,
     ) -> Result<Self> {
         let log_scanner_status = Arc::new(LogScannerStatus::new());
+
+        let full_row_type = table_info.get_row_type();
+        let arrow_schema = match &projected_fields {
+            Some(indices) => {
+                let projected_fields_vec: Vec<_> = indices
+                    .iter()
+                    .map(|&i| full_row_type.fields()[i].clone())
+                    .collect();
+                let projected_row_type = 
crate::metadata::RowType::new(projected_fields_vec);
+                to_arrow_schema(&projected_row_type)?
+            }
+            None => to_arrow_schema(full_row_type)?,
+        };
+
         Ok(Self {
             table_path: table_info.table_path.clone(),
             table_id: table_info.table_id,
@@ -288,15 +310,31 @@ impl LogScannerInner {
             log_scanner_status: log_scanner_status.clone(),
             log_fetcher: LogFetcher::new(
                 table_info.clone(),
-                connections.clone(),
-                metadata.clone(),
+                connections,
+                metadata,
                 log_scanner_status.clone(),
                 config,
                 projected_fields,
             )?,
+            arrow_schema,
+            reader_active: std::sync::atomic::AtomicBool::new(false),
         })
     }
 
+    fn check_no_active_reader(&self) -> Result<()> {
+        if self
+            .reader_active
+            .load(std::sync::atomic::Ordering::Acquire)
+        {
+            return Err(Error::IllegalArgument {
+                message: "Cannot modify subscriptions while a 
RecordBatchLogReader is active. \
+                          Drop the reader first."
+                    .to_string(),
+            });
+        }
+        Ok(())
+    }
+
     async fn poll_records(&self, timeout: Duration) -> Result<ScanRecords> {
         let start = Instant::now();
         let deadline = start + timeout;
@@ -337,6 +375,7 @@ impl LogScannerInner {
     }
 
     async fn subscribe(&self, bucket: i32, offset: i64) -> Result<()> {
+        self.check_no_active_reader()?;
         if self.is_partitioned_table {
             return Err(Error::UnsupportedOperation {
                 message: "The table is a partitioned table, please use 
\"subscribe_partition\" to \
@@ -354,6 +393,7 @@ impl LogScannerInner {
     }
 
     async fn subscribe_buckets(&self, bucket_offsets: &HashMap<i32, i64>) -> 
Result<()> {
+        self.check_no_active_reader()?;
         if self.is_partitioned_table {
             return Err(Error::UnsupportedOperation {
                 message:
@@ -376,6 +416,7 @@ impl LogScannerInner {
         bucket: i32,
         offset: i64,
     ) -> Result<()> {
+        self.check_no_active_reader()?;
         if !self.is_partitioned_table {
             return Err(Error::UnsupportedOperation {
                 message: "The table is not a partitioned table, please use 
\"subscribe\" to \
@@ -397,6 +438,7 @@ impl LogScannerInner {
         &self,
         partition_bucket_offsets: &HashMap<(PartitionId, i32), i64>,
     ) -> Result<()> {
+        self.check_no_active_reader()?;
         if !self.is_partitioned_table {
             return Err(UnsupportedOperation {
                 message: "The table is not a partitioned table, please use 
\"subscribe_buckets\" \
@@ -431,6 +473,7 @@ impl LogScannerInner {
     }
 
     async fn unsubscribe(&self, bucket: i32) -> Result<()> {
+        self.check_no_active_reader()?;
         if self.is_partitioned_table {
             return Err(Error::UnsupportedOperation {
                 message:
@@ -446,6 +489,7 @@ impl LogScannerInner {
     }
 
     async fn unsubscribe_partition(&self, partition_id: PartitionId, bucket: 
i32) -> Result<()> {
+        self.check_no_active_reader()?;
         if !self.is_partitioned_table {
             return Err(Error::UnsupportedOperation {
                 message: "Can't unsubscribe a partition for a non-partitioned 
table.".to_string(),
@@ -615,6 +659,95 @@ impl RecordBatchLogScanner {
     ) -> Result<()> {
         self.inner.unsubscribe_partition(partition_id, bucket).await
     }
+
+    /// Returns the Arrow schema for batches produced by this scanner.
+    pub fn schema(&self) -> SchemaRef {
+        self.inner.arrow_schema.clone()
+    }
+
+    pub fn table_path(&self) -> &TablePath {
+        &self.inner.table_path
+    }
+
+    pub fn table_id(&self) -> TableId {
+        self.inner.table_id
+    }
+
+    /// Creates a new handle to the same underlying scanner state.
+    ///
+    /// Binding layers (Python, C++) that hold the scanner behind shared
+    /// ownership (`Arc`) cannot move it into a 
[`crate::client::RecordBatchLogReader`].
+    /// This method produces a second handle so the reader can take ownership
+    /// while the binding retains its reference for subscription management.
+    ///
+    /// **Not intended for general use** — prefer moving the scanner directly.
+    #[doc(hidden)]
+    pub fn new_shared_handle(&self) -> Self {
+        RecordBatchLogScanner {
+            inner: Arc::clone(&self.inner),
+        }
+    }
+
+    /// Atomically marks the scanner as having an active reader.
+    ///
+    /// Returns `Err(IllegalArgument)` if another reader is already active on
+    /// this scanner — only one [`crate::client::RecordBatchLogReader`] may
+    /// iterate per scanner at a time. This mirrors Java's
+    /// `LogScannerImpl.acquire()` single-consumer guard.
+    pub(crate) fn try_set_reader_active(&self) -> Result<()> {
+        use std::sync::atomic::Ordering;
+        self.inner
+            .reader_active
+            .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
+            .map(|_| ())
+            .map_err(|_| Error::IllegalArgument {
+                message: "Another RecordBatchLogReader is already active on 
this scanner. \
+                          Drop the existing reader first."
+                    .to_string(),
+            })
+    }
+
+    /// Clears the active-reader guard, re-enabling subscription changes.
+    pub(crate) fn clear_reader_active(&self) {
+        self.inner
+            .reader_active
+            .store(false, std::sync::atomic::Ordering::Release);
+    }
+
+    /// Synchronous, infallible counterpart to 
[`unsubscribe`](Self::unsubscribe).
+    ///
+    /// Exists so [`crate::client::RecordBatchLogReader`]'s `Drop` impl can
+    /// release lingering subscriptions without `.await`. The async version is
+    /// also synchronous under the hood (it only acquires a lock and removes
+    /// from a map — no IO), so this exposes the same work without the
+    /// async wrapper. Silently no-ops on partitioned/non-partitioned mismatch
+    /// because `Drop` cannot return errors; callers must pick the correct
+    /// variant.
+    ///
+    /// **Not intended for general use** — prefer the async [`unsubscribe`].
+    pub(crate) fn unsubscribe_sync(&self, bucket: i32) {
+        if self.inner.is_partitioned_table {
+            return;
+        }
+        let table_bucket = TableBucket::new(self.inner.table_id, bucket);
+        self.inner
+            .log_scanner_status
+            .unassign_scan_buckets(from_ref(&table_bucket));
+    }
+
+    /// Synchronous, infallible counterpart to
+    /// [`unsubscribe_partition`](Self::unsubscribe_partition). See
+    /// [`unsubscribe_sync`](Self::unsubscribe_sync) for rationale.
+    pub(crate) fn unsubscribe_partition_sync(&self, partition_id: PartitionId, 
bucket: i32) {
+        if !self.inner.is_partitioned_table {
+            return;
+        }
+        let table_bucket =
+            TableBucket::new_with_partition(self.inner.table_id, 
Some(partition_id), bucket);
+        self.inner
+            .log_scanner_status
+            .unassign_scan_buckets(from_ref(&table_bucket));
+    }
 }
 
 struct LogFetcher {
@@ -2009,6 +2142,7 @@ mod tests {
         let result = validate_scan_support(&table_path, &table_info);
         assert!(result.is_ok());
     }
+
     #[tokio::test]
     async fn prepare_fetch_log_requests_uses_configured_fetch_params() -> 
Result<()> {
         let table_path = TablePath::new("db".to_string(), "tbl".to_string());
diff --git a/website/docs/user-guide/python/api-reference.md 
b/website/docs/user-guide/python/api-reference.md
index dc252b68..9bf0b690 100644
--- a/website/docs/user-guide/python/api-reference.md
+++ b/website/docs/user-guide/python/api-reference.md
@@ -181,9 +181,12 @@ Builder for creating a `PrefixLookuper`. Obtain via 
`TableLookup.lookup_by(colum
 | `await .poll(timeout_ms) -> ScanRecords`                      | Poll 
individual records (record scanner only)                                    |
 | `await .poll_arrow(timeout_ms) -> pa.Table`                   | Poll as 
Arrow Table (batch scanner only)                                         |
 | `await .poll_record_batch(timeout_ms) -> list[RecordBatch]`   | Poll batches 
with metadata (batch scanner only)                                  |
+| `.to_arrow_batch_reader() -> pa.RecordBatchReader`            | Lazy Arrow 
RecordBatchReader reading until latest offsets (batch scanner only)    |
 | `await .to_arrow() -> pa.Table`                               | Read all 
subscribed data as Arrow Table (batch scanner only)                     |
 | `await .to_pandas() -> pd.DataFrame`                          | Read all 
subscribed data as DataFrame (batch scanner only)                       |
 
+> **Note:** Overlapping `poll_*` / `to_arrow*` / `to_arrow_batch_reader` calls 
on the same underlying scanner are not supported. Use only one active 
polling/consumption path at a time.
+
 ## `ScanRecords`
 
 Returned by `LogScanner.poll()`. Records are grouped by bucket.
diff --git a/website/docs/user-guide/rust/api-reference.md 
b/website/docs/user-guide/rust/api-reference.md
index 03054f0f..5d3068b5 100644
--- a/website/docs/user-guide/rust/api-reference.md
+++ b/website/docs/user-guide/rust/api-reference.md
@@ -151,6 +151,8 @@ Complete API reference for the Fluss Rust client.
 
 ## `RecordBatchLogScanner`
 
+Overlapping `poll` calls on clones that share state, or `poll` concurrent with 
`RecordBatchLogReader::next_batch`, are not supported. Use one active 
polling/consumption call at a time per underlying scanner state.
+
 | Method                                                                       
                             | Description                                      
        |
 
|-----------------------------------------------------------------------------------------------------------|----------------------------------------------------------|
 | `async fn subscribe(&self, bucket_id: i32, start_offset: i64) -> Result<()>` 
                             | Subscribe to a bucket                            
        |
@@ -162,6 +164,48 @@ Complete API reference for the Fluss Rust client.
 | `async fn poll(&self, timeout: Duration) -> Result<Vec<ScanBatch>>`          
                             | Poll for Arrow record batches                    
        |
 | `fn is_partitioned(&self) -> bool`                                           
                             | Check if the table is partitioned                
        |
 | `fn get_subscribed_buckets(&self) -> Vec<(TableBucket, i64)>`                
                             | Get all current subscriptions as (bucket, 
offset) pairs  |
+| `fn schema(&self) -> SchemaRef`                                              
                             | Get the Arrow schema for batches produced by 
this scanner|
+| `fn table_path(&self) -> &TablePath`                                         
                             | Get the table path                               
        |
+| `fn table_id(&self) -> TableId`                                              
                             | Get the table ID                                 
        |
+
+## `RecordBatchLogReader`
+
+Bounded log reader that consumes data up to specified stopping offsets, then 
terminates.
+Unlike `RecordBatchLogScanner` which polls indefinitely, this reader stops 
automatically.
+
+| Method                                                                       
                               | Description                                    
          |
+|-------------------------------------------------------------------------------------------------------------|----------------------------------------------------------|
+| `async fn new_until_latest(scanner: RecordBatchLogScanner, admin: 
&FlussAdmin) -> Result<Self>`              | Read until the latest offsets at 
time of creation         |
+| `fn new_until_offsets(scanner: RecordBatchLogScanner, stopping_offsets: 
HashMap<TableBucket, i64>) -> Result<Self>` | Read until custom stopping 
offsets per bucket             |
+| `async fn next_batch(&mut self) -> Result<Option<ScanBatch>>`                
                                | Get the next batch with bucket/offset 
metadata, or `None` when all buckets caught up |
+| `async fn collect_all_batches(&mut self) -> Result<Vec<ScanBatch>>`          
                                | Drain all batches (with metadata) until 
stopping offsets are satisfied |
+| `fn schema(&self) -> SchemaRef`                                              
                                | Arrow schema for produced batches             
           |
+| `fn to_record_batch_reader(self, handle: tokio::runtime::Handle) -> 
SyncRecordBatchLogReader`                | Sync adapter implementing 
`arrow::RecordBatchReader` (see below) |
+
+## `SyncRecordBatchLogReader`
+
+Synchronous adapter for `RecordBatchLogReader`. Created via
+`RecordBatchLogReader::to_record_batch_reader(handle)`.
+
+Implements both [`Iterator`] and [`arrow::record_batch::RecordBatchReader`], 
so it
+plugs into the wider Arrow ecosystem — FFI, PyArrow's
+`pa.RecordBatchReader.from_batches`, the C++ Arrow `RecordBatchReader` 
interface,
+DataFusion sources, etc.
+
+Each `next()` call drives the underlying async reader via
+`tokio::runtime::Handle::block_on`. **Do not call from inside a Tokio worker
+thread that belongs to the same runtime** — nested `block_on` panics. Prefer
+`RecordBatchLogReader::next_batch` in async Rust code; use this adapter only at
+sync/FFI boundaries.
+
+Bucket and offset metadata carried by `ScanBatch` is **dropped** here, because
+the Arrow trait contract yields plain `RecordBatch`. If you need offsets or
+bucket identity per batch, use `next_batch` instead.
+
+| Method                                                          | 
Description                                      |
+|-----------------------------------------------------------------|--------------------------------------------------|
+| `fn next(&mut self) -> Option<Result<RecordBatch, ArrowError>>` | Iterator: 
next batch, or `None` when caught up   |
+| `fn schema(&self) -> SchemaRef`                                 | Arrow 
schema for produced batches                |
 
 ## `ScanRecord`
 

Reply via email to