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 8e3ed756 [c++]  Limit batch scanner (#614)
8e3ed756 is described below

commit 8e3ed75610ddc282dfe38fde128340c35a62eeac
Author: Anton Borisov <[email protected]>
AuthorDate: Sun Jun 14 01:40:26 2026 +0100

    [c++]  Limit batch scanner (#614)
    
    * [cpp] Limit scan
    
    * add test
---
 bindings/cpp/include/fluss.hpp                     |  39 ++++
 bindings/cpp/src/lib.rs                            | 129 +++++++++++--
 bindings/cpp/src/table.cpp                         | 143 +++++++++++---
 bindings/cpp/test/test_kv_table.cpp                |  70 +++++++
 bindings/cpp/test/test_log_table.cpp               | 206 +++++++++++++++++++++
 crates/fluss/src/client/table/batch_scanner.rs     |  32 +++-
 website/docs/user-guide/cpp/api-reference.md       |  11 ++
 website/docs/user-guide/cpp/example/log-tables.md  |  20 ++
 .../user-guide/cpp/example/primary-key-tables.md   |  17 ++
 9 files changed, 628 insertions(+), 39 deletions(-)

diff --git a/bindings/cpp/include/fluss.hpp b/bindings/cpp/include/fluss.hpp
index c27b039b..16dc04ee 100644
--- a/bindings/cpp/include/fluss.hpp
+++ b/bindings/cpp/include/fluss.hpp
@@ -45,6 +45,7 @@ struct Table;
 struct AppendWriter;
 struct WriteResult;
 struct LogScanner;
+struct BatchScanner;
 struct UpsertWriter;
 struct Lookuper;
 struct PrefixLookuper;
@@ -1155,6 +1156,11 @@ class ScanRecords {
     std::shared_ptr<const detail::ScanData> data_;
 };
 
+namespace detail {
+// Defined in table.cpp; builds ArrowRecordBatch wrappers from FFI Arrow 
batches.
+struct ArrowBatchImporter;
+}  // namespace detail
+
 class ArrowRecordBatch {
    public:
     std::shared_ptr<arrow::RecordBatch> GetArrowRecordBatch() const { return 
batch_; }
@@ -1173,6 +1179,7 @@ class ArrowRecordBatch {
 
    private:
     friend class LogScanner;
+    friend struct detail::ArrowBatchImporter;
     explicit ArrowRecordBatch(std::shared_ptr<arrow::RecordBatch> batch, 
int64_t table_id,
                               int64_t partition_id, int32_t bucket_id,
                               int64_t base_offset) noexcept;
@@ -1346,6 +1353,7 @@ class Lookuper;
 class PrefixLookuper;
 class WriteResult;
 class LogScanner;
+class BatchScanner;
 class Admin;
 class Table;
 class TableAppend;
@@ -1619,9 +1627,13 @@ class TableScan {
     TableScan& ProjectByIndex(std::vector<size_t> column_indices);
     TableScan& ProjectByName(std::vector<std::string> column_names);
 
+    TableScan& Limit(int32_t row_number);
+
     Result CreateLogScanner(LogScanner& out);
     Result CreateRecordBatchLogScanner(LogScanner& out);
 
+    Result CreateBucketBatchScanner(const TableBucket& bucket, BatchScanner& 
out);
+
    private:
     friend class Table;
     explicit TableScan(ffi::Table* table) noexcept;
@@ -1632,6 +1644,7 @@ class TableScan {
     ffi::Table* table_{nullptr};
     std::vector<size_t> projection_;
     std::vector<std::string> name_projection_;
+    std::optional<int32_t> limit_;
 };
 
 class WriteResult {
@@ -1787,4 +1800,30 @@ class LogScanner {
     ffi::LogScanner* scanner_{nullptr};
 };
 
+// One-shot bounded scan of a single bucket, from 
TableScan::CreateBucketBatchScanner.
+class BatchScanner {
+   public:
+    BatchScanner() noexcept;
+    ~BatchScanner() noexcept;
+
+    BatchScanner(const BatchScanner&) = delete;
+    BatchScanner& operator=(const BatchScanner&) = delete;
+    BatchScanner(BatchScanner&& other) noexcept;
+    BatchScanner& operator=(BatchScanner&& other) noexcept;
+
+    bool Available() const;
+    const TableBucket& Bucket() const { return bucket_; }
+
+    Result NextBatch(ArrowRecordBatches& out);
+    Result CollectAllBatches(ArrowRecordBatches& out);
+
+   private:
+    friend class TableScan;
+    explicit BatchScanner(ffi::BatchScanner* scanner) noexcept;
+
+    void Destroy() noexcept;
+    ffi::BatchScanner* scanner_{nullptr};
+    TableBucket bucket_{};
+};
+
 }  // namespace fluss
diff --git a/bindings/cpp/src/lib.rs b/bindings/cpp/src/lib.rs
index b5417ae4..25589783 100644
--- a/bindings/cpp/src/lib.rs
+++ b/bindings/cpp/src/lib.rs
@@ -18,7 +18,7 @@
 mod types;
 
 use std::str::FromStr;
-use std::sync::{Arc, LazyLock};
+use std::sync::{Arc, LazyLock, Mutex};
 use std::time::Duration;
 
 use fluss as fcore;
@@ -286,6 +286,7 @@ mod ffi {
         type AppendWriter;
         type WriteResult;
         type LogScanner;
+        type BatchScanner;
         type UpsertWriter;
         type Lookuper;
         type PrefixLookuper;
@@ -379,6 +380,14 @@ mod ffi {
         unsafe fn delete_table(table: *mut Table);
         fn new_append_writer(self: &Table) -> FfiPtrResult;
         fn create_scanner(self: &Table, column_indices: Vec<usize>, batch: 
bool) -> FfiPtrResult;
+        fn create_bucket_batch_scanner(
+            self: &Table,
+            column_indices: Vec<usize>,
+            limit: i32,
+            table_id: i64,
+            partition_id: i64,
+            bucket_id: i32,
+        ) -> FfiPtrResult;
         fn get_table_info_from_table(self: &Table) -> FfiTableInfo;
         fn get_table_path(self: &Table) -> FfiTablePath;
         fn has_primary_key(self: &Table) -> bool;
@@ -735,6 +744,11 @@ mod ffi {
         fn poll_record_batch(self: &LogScanner, timeout_ms: i64) -> 
FfiArrowRecordBatchesResult;
         fn free_arrow_ffi_structures(array_ptr: usize, schema_ptr: usize);
 
+        // BatchScanner
+        unsafe fn delete_batch_scanner(scanner: *mut BatchScanner);
+        fn next_batch(self: &BatchScanner) -> FfiArrowRecordBatchesResult;
+        fn collect_all_batches(self: &BatchScanner) -> 
FfiArrowRecordBatchesResult;
+
         // ScanResultInner accessors
         fn sv_has_error(self: &ScanResultInner) -> bool;
         fn sv_error_code(self: &ScanResultInner) -> i32;
@@ -978,6 +992,10 @@ pub struct LogScanner {
     projected_columns: Vec<fcore::metadata::Column>,
 }
 
+pub struct BatchScanner {
+    inner: Mutex<fcore::client::LimitBatchScanner>,
+}
+
 pub struct UpsertWriter {
     inner: fcore::client::UpsertWriter,
     table_info: fcore::metadata::TableInfo,
@@ -1053,6 +1071,26 @@ fn err_ptr_from_core(e: &fcore::error::Error) -> 
ffi::FfiPtrResult {
     }
 }
 
+fn empty_arrow_batches_result(result: ffi::FfiResult) -> 
ffi::FfiArrowRecordBatchesResult {
+    ffi::FfiArrowRecordBatchesResult {
+        result,
+        arrow_batches: ffi::FfiArrowRecordBatches { batches: vec![] },
+    }
+}
+
+/// Wrap the result of `core_scan_batches_to_ffi` in an 
`FfiArrowRecordBatchesResult`.
+fn arrow_batches_result(
+    converted: Result<ffi::FfiArrowRecordBatches, String>,
+) -> ffi::FfiArrowRecordBatchesResult {
+    match converted {
+        Ok(arrow_batches) => ffi::FfiArrowRecordBatchesResult {
+            result: ok_result(),
+            arrow_batches,
+        },
+        Err(e) => empty_arrow_batches_result(client_err(e)),
+    }
+}
+
 // Connection implementation
 fn new_connection(config: &ffi::FfiConfig) -> ffi::FfiPtrResult {
     let assigner_type = match config
@@ -1691,6 +1729,49 @@ impl Table {
         })
     }
 
+    fn create_bucket_batch_scanner(
+        &self,
+        column_indices: Vec<usize>,
+        limit: i32,
+        table_id: i64,
+        partition_id: i64,
+        bucket_id: i32,
+    ) -> ffi::FfiPtrResult {
+        RUNTIME.block_on(async {
+            let fluss_table = self.fluss_table();
+            let scan = fluss_table.new_scan();
+
+            let scan = if column_indices.is_empty() {
+                scan
+            } else {
+                match scan.project(&column_indices) {
+                    Ok(s) => s,
+                    Err(e) => return err_ptr_from_core(&e),
+                }
+            };
+
+            let scan = match scan.limit(limit) {
+                Ok(s) => s,
+                Err(e) => return err_ptr_from_core(&e),
+            };
+
+            // A negative partition id encodes "no partition" across the FFI 
boundary.
+            let partition = (partition_id >= 0).then_some(partition_id);
+            let bucket =
+                fcore::metadata::TableBucket::new_with_partition(table_id, 
partition, bucket_id);
+
+            let scanner = match scan.create_bucket_batch_scanner(bucket) {
+                Ok(s) => s,
+                Err(e) => return err_ptr_from_core(&e),
+            };
+
+            let ptr = Box::into_raw(Box::new(BatchScanner {
+                inner: Mutex::new(scanner),
+            }));
+            ok_ptr(ptr as usize)
+        })
+    }
+
     fn get_table_info_from_table(&self) -> ffi::FfiTableInfo {
         types::core_table_info_to_ffi(&self.table_info)
     }
@@ -2248,20 +2329,38 @@ impl LogScanner {
         let result = RUNTIME.block_on(async { inner_batch.poll(timeout).await 
});
 
         match result {
-            Ok(batches) => match types::core_scan_batches_to_ffi(&batches) {
-                Ok(arrow_batches) => ffi::FfiArrowRecordBatchesResult {
-                    result: ok_result(),
-                    arrow_batches,
-                },
-                Err(e) => ffi::FfiArrowRecordBatchesResult {
-                    result: client_err(e),
-                    arrow_batches: ffi::FfiArrowRecordBatches { batches: 
vec![] },
-                },
-            },
-            Err(e) => ffi::FfiArrowRecordBatchesResult {
-                result: err_from_core_error(&e),
-                arrow_batches: ffi::FfiArrowRecordBatches { batches: vec![] },
-            },
+            Ok(batches) => 
arrow_batches_result(types::core_scan_batches_to_ffi(&batches)),
+            Err(e) => empty_arrow_batches_result(err_from_core_error(&e)),
+        }
+    }
+}
+
+// BatchScanner implementation
+unsafe fn delete_batch_scanner(scanner: *mut BatchScanner) {
+    if !scanner.is_null() {
+        unsafe {
+            drop(Box::from_raw(scanner));
+        }
+    }
+}
+
+impl BatchScanner {
+    fn next_batch(&self) -> ffi::FfiArrowRecordBatchesResult {
+        let mut scanner = self.inner.lock().unwrap();
+        match RUNTIME.block_on(scanner.next_batch()) {
+            Ok(Some(batch)) => 
arrow_batches_result(types::core_scan_batches_to_ffi(
+                std::slice::from_ref(&batch),
+            )),
+            Ok(None) => empty_arrow_batches_result(ok_result()),
+            Err(e) => empty_arrow_batches_result(err_from_core_error(&e)),
+        }
+    }
+
+    fn collect_all_batches(&self) -> ffi::FfiArrowRecordBatchesResult {
+        let mut scanner = self.inner.lock().unwrap();
+        match RUNTIME.block_on(scanner.collect_all_batches()) {
+            Ok(batches) => 
arrow_batches_result(types::core_scan_batches_to_ffi(&batches)),
+            Err(e) => empty_arrow_batches_result(err_from_core_error(&e)),
         }
     }
 }
diff --git a/bindings/cpp/src/table.cpp b/bindings/cpp/src/table.cpp
index 9b5d087e..9f4d9e46 100644
--- a/bindings/cpp/src/table.cpp
+++ b/bindings/cpp/src/table.cpp
@@ -1267,6 +1267,10 @@ Result TableScan::DoCreateScanner(LogScanner& out, bool 
is_record_batch) {
     if (table_ == nullptr) {
         return utils::make_client_error("Table not available");
     }
+    if (limit_.has_value()) {
+        return utils::make_client_error(
+            "Log scanners don't support limit pushdown; use 
CreateBucketBatchScanner()");
+    }
 
     try {
         auto resolved_indices = !name_projection_.empty() ? 
ResolveNameProjection() : projection_;
@@ -1286,6 +1290,41 @@ Result TableScan::DoCreateScanner(LogScanner& out, bool 
is_record_batch) {
     }
 }
 
+TableScan& TableScan::Limit(int32_t row_number) {
+    limit_ = row_number;
+    return *this;
+}
+
+Result TableScan::CreateBucketBatchScanner(const TableBucket& bucket, 
BatchScanner& out) {
+    if (table_ == nullptr) {
+        return utils::make_client_error("Table not available");
+    }
+    if (!limit_.has_value()) {
+        return utils::make_client_error(
+            "CreateBucketBatchScanner requires a limit set via Limit()");
+    }
+
+    try {
+        auto resolved_indices = !name_projection_.empty() ? 
ResolveNameProjection() : projection_;
+        rust::Vec<size_t> rust_indices;
+        for (size_t idx : resolved_indices) {
+            rust_indices.push_back(idx);
+        }
+        int64_t partition_id = bucket.partition_id.value_or(-1);
+        auto ffi_result = table_->create_bucket_batch_scanner(
+            std::move(rust_indices), *limit_, bucket.table_id, partition_id, 
bucket.bucket_id);
+        auto result = utils::from_ffi_result(ffi_result.result);
+        if (result.Ok()) {
+            out.scanner_ = utils::ptr_from_ffi<ffi::BatchScanner>(ffi_result);
+            out.bucket_ = bucket;
+        }
+        return result;
+    } catch (const std::exception& e) {
+        // ResolveNameProjection() may throw
+        return utils::make_client_error(e.what());
+    }
+}
+
 // ============================================================================
 // WriteResult
 // ============================================================================
@@ -1791,6 +1830,34 @@ int64_t ArrowRecordBatch::GetLastOffset() const {
     return this->base_offset_ + this->NumRows() - 1;
 }
 
+namespace detail {
+// Imports FFI Arrow batches (C Data Interface) into C++ ArrowRecordBatch
+// wrappers. A friend of ArrowRecordBatch so it can build the wrapper's private
+// ctor; shared by LogScanner::PollRecordBatch and BatchScanner.
+struct ArrowBatchImporter {
+    static Result Import(ffi::FfiArrowRecordBatches& src, ArrowRecordBatches& 
out) {
+        out.batches.clear();
+        for (const auto& ffi_batch : src.batches) {
+            auto* c_array = reinterpret_cast<struct 
ArrowArray*>(ffi_batch.array_ptr);
+            auto* c_schema = reinterpret_cast<struct 
ArrowSchema*>(ffi_batch.schema_ptr);
+
+            auto import_result = arrow::ImportRecordBatch(c_array, c_schema);
+            // Free the Rust-allocated container structures whether or not the
+            // import succeeded, to avoid leaks.
+            ffi::free_arrow_ffi_structures(ffi_batch.array_ptr, 
ffi_batch.schema_ptr);
+            if (!import_result.ok()) {
+                return utils::make_client_error("Failed to import Arrow record 
batch: " +
+                                                
import_result.status().ToString());
+            }
+            out.batches.push_back(std::unique_ptr<ArrowRecordBatch>(new 
ArrowRecordBatch(
+                import_result.ValueOrDie(), ffi_batch.table_id, 
ffi_batch.partition_id,
+                ffi_batch.bucket_id, ffi_batch.base_offset)));
+        }
+        return utils::make_ok();
+    }
+};
+}  // namespace detail
+
 Result LogScanner::PollRecordBatch(int64_t timeout_ms, ArrowRecordBatches& 
out) {
     if (!Available()) {
         return utils::make_client_error("LogScanner not available");
@@ -1801,35 +1868,65 @@ Result LogScanner::PollRecordBatch(int64_t timeout_ms, 
ArrowRecordBatches& out)
     if (!result.Ok()) {
         return result;
     }
+    return detail::ArrowBatchImporter::Import(ffi_result.arrow_batches, out);
+}
+
+// ============================================================================
+// BatchScanner
+// ============================================================================
+
+BatchScanner::BatchScanner() noexcept = default;
+
+BatchScanner::BatchScanner(ffi::BatchScanner* scanner) noexcept : 
scanner_(scanner) {}
+
+BatchScanner::~BatchScanner() noexcept { Destroy(); }
+
+void BatchScanner::Destroy() noexcept {
+    if (scanner_) {
+        ffi::delete_batch_scanner(scanner_);
+        scanner_ = nullptr;
+    }
+}
 
-    // Convert the FFI Arrow record batches to C++ ArrowRecordBatch objects
-    out.batches.clear();
-    for (const auto& ffi_batch : ffi_result.arrow_batches.batches) {
-        auto* c_array = reinterpret_cast<struct 
ArrowArray*>(ffi_batch.array_ptr);
-        auto* c_schema = reinterpret_cast<struct 
ArrowSchema*>(ffi_batch.schema_ptr);
+BatchScanner::BatchScanner(BatchScanner&& other) noexcept
+    : scanner_(other.scanner_), bucket_(other.bucket_) {
+    other.scanner_ = nullptr;
+}
 
-        auto import_result = arrow::ImportRecordBatch(c_array, c_schema);
-        if (import_result.ok()) {
-            auto batch_ptr = import_result.ValueOrDie();
-            auto batch_wrapper = std::unique_ptr<ArrowRecordBatch>(new 
ArrowRecordBatch(
-                std::move(batch_ptr), ffi_batch.table_id, 
ffi_batch.partition_id,
-                ffi_batch.bucket_id, ffi_batch.base_offset));
-            out.batches.push_back(std::move(batch_wrapper));
+BatchScanner& BatchScanner::operator=(BatchScanner&& other) noexcept {
+    if (this != &other) {
+        Destroy();
+        scanner_ = other.scanner_;
+        bucket_ = other.bucket_;
+        other.scanner_ = nullptr;
+    }
+    return *this;
+}
 
-            // Free the container structures that were allocated in Rust after 
successful import
-            ffi::free_arrow_ffi_structures(ffi_batch.array_ptr, 
ffi_batch.schema_ptr);
-        } else {
-            // Import failed, free the container structures to avoid leaks and 
return error
-            ffi::free_arrow_ffi_structures(ffi_batch.array_ptr, 
ffi_batch.schema_ptr);
+bool BatchScanner::Available() const { return scanner_ != nullptr; }
 
-            // Return an error indicating that the import failed
-            std::string error_msg =
-                "Failed to import Arrow record batch: " + 
import_result.status().ToString();
-            return utils::make_client_error(error_msg);
-        }
+Result BatchScanner::NextBatch(ArrowRecordBatches& out) {
+    if (!Available()) {
+        return utils::make_client_error("BatchScanner not available");
+    }
+    auto ffi_result = scanner_->next_batch();
+    auto result = utils::from_ffi_result(ffi_result.result);
+    if (!result.Ok()) {
+        return result;
     }
+    return detail::ArrowBatchImporter::Import(ffi_result.arrow_batches, out);
+}
 
-    return utils::make_ok();
+Result BatchScanner::CollectAllBatches(ArrowRecordBatches& out) {
+    if (!Available()) {
+        return utils::make_client_error("BatchScanner not available");
+    }
+    auto ffi_result = scanner_->collect_all_batches();
+    auto result = utils::from_ffi_result(ffi_result.result);
+    if (!result.Ok()) {
+        return result;
+    }
+    return detail::ArrowBatchImporter::Import(ffi_result.arrow_batches, out);
 }
 
 }  // namespace fluss
diff --git a/bindings/cpp/test/test_kv_table.cpp 
b/bindings/cpp/test/test_kv_table.cpp
index 2ef7e60f..d3d81e98 100644
--- a/bindings/cpp/test/test_kv_table.cpp
+++ b/bindings/cpp/test/test_kv_table.cpp
@@ -155,6 +155,76 @@ TEST_F(KvTableTest, UpsertDeleteAndLookup) {
     ASSERT_OK(adm.DropTable(table_path, false));
 }
 
+TEST_F(KvTableTest, LimitScan) {
+    auto& adm = admin();
+    auto& conn = connection();
+
+    fluss::TablePath table_path("fluss", "test_limit_scan_pk_cpp");
+
+    auto schema = fluss::Schema::NewBuilder()
+                      .AddColumn("id", fluss::DataType::Int())
+                      .AddColumn("name", fluss::DataType::String())
+                      .SetPrimaryKeys({"id"})
+                      .Build();
+
+    auto table_descriptor = fluss::TableDescriptor::NewBuilder()
+                                .SetSchema(schema)
+                                .SetBucketCount(1)
+                                .SetProperty("table.replication.factor", "1")
+                                .Build();
+
+    fluss_test::CreateTable(adm, table_path, table_descriptor);
+
+    fluss::Table table;
+    ASSERT_OK(conn.GetTable(table_path, table));
+
+    auto table_upsert = table.NewUpsert();
+    fluss::UpsertWriter upsert_writer;
+    ASSERT_OK(table_upsert.CreateWriter(upsert_writer));
+    std::vector<std::pair<int32_t, std::string>> rows = {
+        {1, "Verso"}, {2, "Noco"}, {3, "Esquie"}, {4, "Aline"}, {5, 
"Gustave"}};
+    for (const auto& [id, name] : rows) {
+        fluss::GenericRow row(2);
+        row.SetInt32(0, id);
+        row.SetString(1, name);
+        ASSERT_OK(upsert_writer.Upsert(row));
+    }
+    ASSERT_OK(upsert_writer.Flush());
+
+    int64_t table_id = table.GetTableInfo().table_id;
+    fluss::TableBucket bucket{table_id, 0};
+
+    fluss::BatchScanner scanner;
+    ASSERT_OK(table.NewScan().Limit(3).CreateBucketBatchScanner(bucket, 
scanner));
+    EXPECT_TRUE(scanner.Bucket() == bucket);
+    fluss::ArrowRecordBatches first;
+    ASSERT_OK(scanner.NextBatch(first));
+    ASSERT_FALSE(first.Empty()) << "first NextBatch should return a batch";
+    int64_t scanned = 0;
+    for (const auto& b : first) {
+        EXPECT_EQ(b->GetBucketId(), 0);
+        scanned += b->NumRows();
+    }
+    EXPECT_GT(scanned, 0);
+    EXPECT_LE(scanned, 3);
+
+    fluss::ArrowRecordBatches spent;
+    ASSERT_OK(scanner.NextBatch(spent));
+    EXPECT_TRUE(spent.Empty()) << "scanner must be spent after one batch";
+
+    fluss::BatchScanner scanner2;
+    ASSERT_OK(table.NewScan().Limit(100).CreateBucketBatchScanner(bucket, 
scanner2));
+    fluss::ArrowRecordBatches all;
+    ASSERT_OK(scanner2.CollectAllBatches(all));
+    int64_t total = 0;
+    for (const auto& b : all) {
+        total += b->NumRows();
+    }
+    EXPECT_EQ(total, 5);
+
+    ASSERT_OK(adm.DropTable(table_path, false));
+}
+
 TEST_F(KvTableTest, LookupWithNestedArrayArrayView) {
     auto& adm = admin();
     auto& conn = connection();
diff --git a/bindings/cpp/test/test_log_table.cpp 
b/bindings/cpp/test/test_log_table.cpp
index 5678e4bb..cae0bcb3 100644
--- a/bindings/cpp/test/test_log_table.cpp
+++ b/bindings/cpp/test/test_log_table.cpp
@@ -171,6 +171,212 @@ TEST_F(LogTableTest, AppendRecordBatchAndScan) {
     ASSERT_OK(adm.DropTable(table_path, false));
 }
 
+TEST_F(LogTableTest, LimitScan) {
+    auto& adm = admin();
+    auto& conn = connection();
+
+    fluss::TablePath table_path("fluss", "test_limit_scan_cpp");
+
+    auto schema = fluss::Schema::NewBuilder()
+                      .AddColumn("c1", fluss::DataType::Int())
+                      .AddColumn("c2", fluss::DataType::String())
+                      .Build();
+
+    auto table_descriptor = fluss::TableDescriptor::NewBuilder()
+                                .SetSchema(schema)
+                                .SetBucketCount(1)
+                                .SetBucketKeys({"c1"})
+                                .SetProperty("table.replication.factor", "1")
+                                .Build();
+
+    fluss_test::CreateTable(adm, table_path, table_descriptor);
+
+    fluss::Table table;
+    ASSERT_OK(conn.GetTable(table_path, table));
+
+    auto table_append = table.NewAppend();
+    fluss::AppendWriter append_writer;
+    ASSERT_OK(table_append.CreateWriter(append_writer));
+    {
+        auto c1 = arrow::Int32Builder();
+        c1.AppendValues({1, 2, 3, 4, 5}).ok();
+        auto c2 = arrow::StringBuilder();
+        c2.AppendValues({"a", "b", "c", "d", "e"}).ok();
+        auto batch = arrow::RecordBatch::Make(
+            arrow::schema({arrow::field("c1", arrow::int32()), 
arrow::field("c2", arrow::utf8())}),
+            5, {c1.Finish().ValueOrDie(), c2.Finish().ValueOrDie()});
+        ASSERT_OK(append_writer.AppendArrowBatch(batch));
+    }
+    ASSERT_OK(append_writer.Flush());
+
+    int64_t table_id = table.GetTableInfo().table_id;
+    fluss::TableBucket bucket{table_id, 0};
+
+    fluss::BatchScanner scanner;
+    ASSERT_OK(table.NewScan().Limit(3).CreateBucketBatchScanner(bucket, 
scanner));
+    EXPECT_TRUE(scanner.Bucket() == bucket);
+
+    fluss::ArrowRecordBatches first;
+    ASSERT_OK(scanner.NextBatch(first));
+    ASSERT_FALSE(first.Empty()) << "first NextBatch should return a batch";
+    int64_t rows = 0;
+    for (const auto& b : first) {
+        EXPECT_EQ(b->GetTableId(), table_id);
+        EXPECT_EQ(b->GetBucketId(), 0);
+        rows += b->NumRows();
+    }
+    // The server may return fewer rows than the limit, but never more.
+    EXPECT_GT(rows, 0);
+    EXPECT_LE(rows, 3);
+
+    fluss::ArrowRecordBatches spent;
+    ASSERT_OK(scanner.NextBatch(spent));
+    EXPECT_TRUE(spent.Empty()) << "scanner must be spent after one batch";
+
+    fluss::BatchScanner scanner2;
+    ASSERT_OK(table.NewScan().Limit(10).CreateBucketBatchScanner(bucket, 
scanner2));
+    fluss::ArrowRecordBatches all;
+    ASSERT_OK(scanner2.CollectAllBatches(all));
+    int64_t total = 0;
+    for (const auto& b : all) {
+        total += b->NumRows();
+    }
+    EXPECT_EQ(total, 5) << "limit 10 over a 5-row bucket returns all rows";
+
+    ASSERT_OK(adm.DropTable(table_path, false));
+}
+
+TEST_F(LogTableTest, LimitScanProjection) {
+    auto& adm = admin();
+    auto& conn = connection();
+
+    fluss::TablePath table_path("fluss", "test_limit_scan_projection_cpp");
+
+    auto schema = fluss::Schema::NewBuilder()
+                      .AddColumn("c1", fluss::DataType::Int())
+                      .AddColumn("c2", fluss::DataType::String())
+                      .AddColumn("c3", fluss::DataType::BigInt())
+                      .Build();
+
+    auto table_descriptor = fluss::TableDescriptor::NewBuilder()
+                                .SetSchema(schema)
+                                .SetBucketCount(1)
+                                .SetBucketKeys({"c1"})
+                                .SetProperty("table.replication.factor", "1")
+                                .Build();
+
+    fluss_test::CreateTable(adm, table_path, table_descriptor);
+
+    fluss::Table table;
+    ASSERT_OK(conn.GetTable(table_path, table));
+
+    auto table_append = table.NewAppend();
+    fluss::AppendWriter append_writer;
+    ASSERT_OK(table_append.CreateWriter(append_writer));
+    {
+        auto c1 = arrow::Int32Builder();
+        c1.AppendValues({1, 2}).ok();
+        auto c2 = arrow::StringBuilder();
+        c2.AppendValues({"a", "b"}).ok();
+        auto c3 = arrow::Int64Builder();
+        c3.AppendValues({100, 200}).ok();
+        auto batch = arrow::RecordBatch::Make(
+            arrow::schema({arrow::field("c1", arrow::int32()), 
arrow::field("c2", arrow::utf8()),
+                           arrow::field("c3", arrow::int64())}),
+            2, {c1.Finish().ValueOrDie(), c2.Finish().ValueOrDie(), 
c3.Finish().ValueOrDie()});
+        ASSERT_OK(append_writer.AppendArrowBatch(batch));
+    }
+    ASSERT_OK(append_writer.Flush());
+
+    fluss::TableBucket bucket{table.GetTableInfo().table_id, 0};
+
+    // Projecting [c1, c3] skips the middle c2 string column.
+    fluss::BatchScanner scanner;
+    ASSERT_OK(
+        table.NewScan().ProjectByIndex({0, 
2}).Limit(10).CreateBucketBatchScanner(bucket, scanner));
+    fluss::ArrowRecordBatches all;
+    ASSERT_OK(scanner.CollectAllBatches(all));
+    ASSERT_EQ(all.Size(), 1u);
+
+    auto rb = all[0]->GetArrowRecordBatch();
+    ASSERT_EQ(rb->num_columns(), 2);
+    EXPECT_EQ(rb->schema()->field(0)->name(), "c1");
+    EXPECT_EQ(rb->schema()->field(1)->name(), "c3");
+    ASSERT_EQ(rb->num_rows(), 2);
+    auto c1a = std::static_pointer_cast<arrow::Int32Array>(rb->column(0));
+    auto c3a = std::static_pointer_cast<arrow::Int64Array>(rb->column(1));
+    EXPECT_EQ(c1a->Value(0), 1);
+    EXPECT_EQ(c1a->Value(1), 2);
+    EXPECT_EQ(c3a->Value(0), 100);
+    EXPECT_EQ(c3a->Value(1), 200);
+
+    ASSERT_OK(adm.DropTable(table_path, false));
+}
+
+TEST_F(LogTableTest, LimitScanErrors) {
+    auto& adm = admin();
+    auto& conn = connection();
+
+    fluss::TablePath table_path("fluss", "test_limit_scan_errors_cpp");
+
+    auto schema = fluss::Schema::NewBuilder().AddColumn("c1", 
fluss::DataType::Int()).Build();
+    auto table_descriptor = fluss::TableDescriptor::NewBuilder()
+                                .SetSchema(schema)
+                                .SetBucketCount(1)
+                                .SetBucketKeys({"c1"})
+                                .SetProperty("table.replication.factor", "1")
+                                .Build();
+    fluss_test::CreateTable(adm, table_path, table_descriptor);
+
+    fluss::Table table;
+    ASSERT_OK(conn.GetTable(table_path, table));
+    int64_t table_id = table.GetTableInfo().table_id;
+
+    {
+        fluss::BatchScanner s;
+        EXPECT_FALSE(
+            
table.NewScan().CreateBucketBatchScanner(fluss::TableBucket{table_id, 0}, 
s).Ok());
+    }
+    for (int32_t bad : {0, -5}) {
+        fluss::BatchScanner s;
+        EXPECT_FALSE(table.NewScan()
+                         .Limit(bad)
+                         
.CreateBucketBatchScanner(fluss::TableBucket{table_id, 0}, s)
+                         .Ok());
+    }
+    for (const auto& bad :
+         {fluss::TableBucket{table_id + 9999, 0}, fluss::TableBucket{table_id, 
99}}) {
+        fluss::BatchScanner s;
+        EXPECT_FALSE(table.NewScan().Limit(1).CreateBucketBatchScanner(bad, 
s).Ok());
+    }
+    {
+        fluss::LogScanner s;
+        EXPECT_FALSE(table.NewScan().Limit(5).CreateLogScanner(s).Ok());
+        fluss::LogScanner s2;
+        
EXPECT_FALSE(table.NewScan().Limit(5).CreateRecordBatchLogScanner(s2).Ok());
+    }
+    ASSERT_OK(adm.DropTable(table_path, false));
+
+    // A non-ARROW (INDEXED) log table rejects a limit scan.
+    fluss::TablePath indexed_path("fluss", "test_limit_scan_indexed_cpp");
+    auto indexed_descriptor = fluss::TableDescriptor::NewBuilder()
+                                  .SetSchema(schema)
+                                  .SetBucketCount(1)
+                                  .SetBucketKeys({"c1"})
+                                  .SetLogFormat("INDEXED")
+                                  .SetProperty("table.replication.factor", "1")
+                                  .Build();
+    fluss_test::CreateTable(adm, indexed_path, indexed_descriptor);
+    fluss::Table indexed_table;
+    ASSERT_OK(conn.GetTable(indexed_path, indexed_table));
+    {
+        fluss::BatchScanner s;
+        fluss::TableBucket b{indexed_table.GetTableInfo().table_id, 0};
+        
EXPECT_FALSE(indexed_table.NewScan().Limit(1).CreateBucketBatchScanner(b, 
s).Ok());
+    }
+    ASSERT_OK(adm.DropTable(indexed_path, false));
+}
+
 TEST_F(LogTableTest, ListOffsets) {
     auto& adm = admin();
     auto& conn = connection();
diff --git a/crates/fluss/src/client/table/batch_scanner.rs 
b/crates/fluss/src/client/table/batch_scanner.rs
index 5d0cf0c6..090a7428 100644
--- a/crates/fluss/src/client/table/batch_scanner.rs
+++ b/crates/fluss/src/client/table/batch_scanner.rs
@@ -410,7 +410,7 @@ mod tests {
     use crate::row::binary::BinaryWriter;
     use crate::row::compacted::CompactedRowWriter;
     use crate::test_utils::build_table_info_with_columns;
-    use arrow::array::{Array, Int32Array, Int64Array};
+    use arrow::array::{Array, Int32Array, Int64Array, StringArray};
 
     fn build_two_col_table_info() -> TableInfo {
         build_table_info_with_columns(
@@ -570,6 +570,36 @@ mod tests {
         assert_eq!((c3.value(0), c3.value(1)), (100, 200));
     }
 
+    #[test]
+    fn decode_log_batch_non_prefix_projection_reorders_columns() {
+        let table_info = build_two_col_table_info();
+        let raw = build_log_records(&table_info, 0, &[(1, "alice"), (2, 
"bob")]);
+
+        // `[name, id]`: reversed, so neither a leading prefix nor in source 
order.
+        let (batch, _) = decode_log_batch(&table_info, Some(&[1usize, 
0usize]), raw, usize::MAX)
+            .expect("decode reordered projection");
+
+        assert_eq!(batch.num_columns(), 2);
+        assert_eq!(batch.schema().field(0).name(), "name");
+        assert_eq!(batch.schema().field(1).name(), "id");
+
+        let names = batch
+            .column(0)
+            .as_any()
+            .downcast_ref::<StringArray>()
+            .unwrap();
+        assert_eq!(names.value(0), "alice");
+        assert_eq!(names.value(1), "bob");
+
+        let ids = batch
+            .column(1)
+            .as_any()
+            .downcast_ref::<Int32Array>()
+            .unwrap();
+        assert_eq!(ids.value(0), 1);
+        assert_eq!(ids.value(1), 2);
+    }
+
     #[test]
     fn decode_log_batch_truncates_to_last_limit_rows() {
         let table_info = build_two_col_table_info();
diff --git a/website/docs/user-guide/cpp/api-reference.md 
b/website/docs/user-guide/cpp/api-reference.md
index 60a43eb4..f2977114 100644
--- a/website/docs/user-guide/cpp/api-reference.md
+++ b/website/docs/user-guide/cpp/api-reference.md
@@ -148,8 +148,10 @@ Complete API reference for the Fluss C++ client.
 
|----------------------------------------------------------------------|-----------------------------------------------|
 | `ProjectByIndex(std::vector<size_t> column_indices) -> TableScan&`   | 
Project columns by index                      |
 | `ProjectByName(std::vector<std::string> column_names) -> TableScan&` | 
Project columns by name                       |
+| `Limit(int32_t row_number) -> TableScan&`                            | Set a 
positive row limit (enables `CreateBucketBatchScanner`; rejected by log 
scanners) |
 | `CreateLogScanner(LogScanner& out) -> Result`                        | 
Create a record-based log scanner             |
 | `CreateRecordBatchLogScanner(LogScanner& out) -> Result`             | 
Create an Arrow RecordBatch-based log scanner |
+| `CreateBucketBatchScanner(const TableBucket& bucket, BatchScanner& out) -> 
Result` | Bounded scan of one bucket (requires `Limit`) |
 
 ## `AppendWriter`
 
@@ -202,6 +204,15 @@ Performs prefix (bucket-key) lookups, returning all rows 
whose primary key start
 | `Poll(int64_t timeout_ms, ScanRecords& out) -> Result`                       
                        | Poll individual records                   |
 | `PollRecordBatch(int64_t timeout_ms, ArrowRecordBatches& out) -> Result`     
                        | Poll Arrow RecordBatches                  |
 
+## `BatchScanner`
+
+One-shot bounded scan of a single bucket. Obtained from 
`TableScan::CreateBucketBatchScanner()`. The scan runs on the first call below, 
yields its single batch once, then is spent.
+
+| Method                                                       |  Description  
                                      |
+|--------------------------------------------------------------|-----------------------------------------------------|
+| `NextBatch(ArrowRecordBatches& out) -> Result`               | Run the scan; 
returns the batch once, then empty    |
+| `CollectAllBatches(ArrowRecordBatches& out) -> Result`       | Drain the 
scanner into all of its batches           |
+
 ## `GenericRow`
 
 `GenericRow` is a **write-only** row used for append, upsert, delete, and 
lookup key construction. For reading field values from scan or lookup results, 
see [`RowView`](#rowview) and [`LookupResult`](#lookupresult).
diff --git a/website/docs/user-guide/cpp/example/log-tables.md 
b/website/docs/user-guide/cpp/example/log-tables.md
index 0125a4ce..ca243f5d 100644
--- a/website/docs/user-guide/cpp/example/log-tables.md
+++ b/website/docs/user-guide/cpp/example/log-tables.md
@@ -159,3 +159,23 @@ table.NewScan().ProjectByName({"event_id", 
"timestamp"}).CreateLogScanner(name_p
 fluss::LogScanner projected_arrow_scanner;
 table.NewScan().ProjectByIndex({0, 
2}).CreateRecordBatchLogScanner(projected_arrow_scanner);
 ```
+
+## Limit Scan
+
+For a bounded read of up to `n` rows from a single bucket, use a batch scanner 
instead of subscribing. It issues one request; `NextBatch` yields the batch 
once, then reports empty.
+
+```cpp
+int64_t table_id = table.GetTableInfo().table_id;
+fluss::TableBucket bucket{table_id, 0};
+
+fluss::BatchScanner scanner;
+table.NewScan().Limit(10).CreateBucketBatchScanner(bucket, scanner);
+
+fluss::ArrowRecordBatches batches;
+scanner.NextBatch(batches);  // or CollectAllBatches(batches)
+for (const auto& batch : batches) {
+    std::cout << "rows: " << batch->NumRows() << std::endl;
+}
+```
+
+The limit applies per bucket; scan each bucket to cover a multi-bucket table.
diff --git a/website/docs/user-guide/cpp/example/primary-key-tables.md 
b/website/docs/user-guide/cpp/example/primary-key-tables.md
index f26b5477..f0dfad90 100644
--- a/website/docs/user-guide/cpp/example/primary-key-tables.md
+++ b/website/docs/user-guide/cpp/example/primary-key-tables.md
@@ -130,3 +130,20 @@ if (result.Found()) {
     std::cout << "Not found" << std::endl;
 }
 ```
+
+## Limit Scan
+
+To read up to `n` rows of a bucket's current state without supplying keys, use 
a batch scanner. The server returns the deduplicated current rows as Arrow 
batches, convenient for previews or analytics.
+
+```cpp
+int64_t table_id = table.GetTableInfo().table_id;
+fluss::TableBucket bucket{table_id, 0};
+
+fluss::BatchScanner scanner;
+table.NewScan().Limit(10).CreateBucketBatchScanner(bucket, scanner);
+
+fluss::ArrowRecordBatches batches;
+scanner.CollectAllBatches(batches);
+```
+
+The limit applies per bucket; scan each bucket to cover a multi-bucket table.


Reply via email to