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 63638ecc [c++] add prefix lookup (#606)
63638ecc is described below

commit 63638eccf292fa1e71ac750c2e82f283af8349a7
Author: Anton Borisov <[email protected]>
AuthorDate: Mon Jun 8 00:11:17 2026 +0100

    [c++] add prefix lookup (#606)
    
    * [c++] add prefix lookup
    
    * reword comment
---
 bindings/cpp/include/fluss.hpp                     | 157 ++++++++
 bindings/cpp/src/lib.rs                            | 410 +++++++++++++++++++++
 bindings/cpp/src/table.cpp                         | 216 +++++++++++
 bindings/cpp/test/test_kv_table.cpp                | 249 +++++++++++++
 website/docs/user-guide/cpp/api-reference.md       |  19 +
 .../docs/user-guide/cpp/example/prefix-lookup.md   | 107 ++++++
 6 files changed, 1158 insertions(+)

diff --git a/bindings/cpp/include/fluss.hpp b/bindings/cpp/include/fluss.hpp
index d019b427..c27b039b 100644
--- a/bindings/cpp/include/fluss.hpp
+++ b/bindings/cpp/include/fluss.hpp
@@ -47,9 +47,11 @@ struct WriteResult;
 struct LogScanner;
 struct UpsertWriter;
 struct Lookuper;
+struct PrefixLookuper;
 struct ScanResultInner;
 struct GenericRowInner;
 struct LookupResultInner;
+struct PrefixLookupResultInner;
 struct ArrayWriterInner;
 struct ArrayViewInner;
 }  // namespace ffi
@@ -610,6 +612,19 @@ struct ScanData {
     ScanData(const ScanData&) = delete;
     ScanData& operator=(const ScanData&) = delete;
 };
+
+/// Backing store for a prefix lookup result; mirrors ScanData.
+struct PrefixData {
+    ffi::PrefixLookupResultInner* raw;
+    ColumnMap columns;
+
+    PrefixData(ffi::PrefixLookupResultInner* r, ColumnMap cols)
+        : raw(r), columns(std::move(cols)) {}
+    ~PrefixData();
+
+    PrefixData(const PrefixData&) = delete;
+    PrefixData& operator=(const PrefixData&) = delete;
+};
 }  // namespace detail
 
 /**
@@ -650,6 +665,7 @@ class ArrayView {
    private:
     friend class RowView;
     friend class LookupResult;
+    friend class PrefixRowView;
     explicit ArrayView(ffi::ArrayViewInner* inner) : inner_(inner) {}
     void Destroy() noexcept;
     ffi::ArrayViewInner* inner_{nullptr};
@@ -780,6 +796,7 @@ class GenericRow {
     friend class AppendWriter;
     friend class UpsertWriter;
     friend class Lookuper;
+    friend class PrefixLookuper;
 
     using ColumnInfo = detail::ColumnInfo;
     using ColumnMap = detail::ColumnMap;
@@ -894,6 +911,118 @@ class RowView : public detail::NamedGetters<RowView> {
     size_t rec_idx_;
 };
 
+/// Read-only view over one row of a prefix lookup result.
+///
+/// Like RowView, but backed by PrefixData (a prefix lookup result) rather than
+/// scan data. Shares ownership of the underlying handle via reference 
counting,
+/// so it can safely outlive the PrefixLookupResult that produced it.
+class PrefixRowView : public detail::NamedGetters<PrefixRowView> {
+    friend struct detail::NamedGetters<PrefixRowView>;
+
+   public:
+    PrefixRowView(std::shared_ptr<const detail::PrefixData> data, size_t 
rec_idx)
+        : data_(std::move(data)), rec_idx_(rec_idx) {}
+
+    // ── Index-based getters ──────────────────────────────────────────
+    size_t FieldCount() const;
+    TypeId GetType(size_t idx) const;
+    bool IsNull(size_t idx) const;
+    bool GetBool(size_t idx) const;
+    int32_t GetInt32(size_t idx) const;
+    int64_t GetInt64(size_t idx) const;
+    float GetFloat32(size_t idx) const;
+    double GetFloat64(size_t idx) const;
+    std::string_view GetString(size_t idx) const;
+    std::pair<const uint8_t*, size_t> GetBytes(size_t idx) const;
+    fluss::Date GetDate(size_t idx) const;
+    fluss::Time GetTime(size_t idx) const;
+    fluss::Timestamp GetTimestamp(size_t idx) const;
+    bool IsDecimal(size_t idx) const;
+    std::string GetDecimalString(size_t idx) const;
+
+    // ── Array getters ────────────────────────────────────────────────
+    size_t GetArraySize(size_t idx) const;
+    TypeId GetArrayElementType(size_t idx) const;
+    bool IsArrayElementNull(size_t idx, size_t element) const;
+    bool GetArrayBool(size_t idx, size_t element) const;
+    int32_t GetArrayInt32(size_t idx, size_t element) const;
+    int64_t GetArrayInt64(size_t idx, size_t element) const;
+    float GetArrayFloat32(size_t idx, size_t element) const;
+    double GetArrayFloat64(size_t idx, size_t element) const;
+    std::string GetArrayString(size_t idx, size_t element) const;
+    std::vector<uint8_t> GetArrayBytes(size_t idx, size_t element) const;
+    fluss::Date GetArrayDate(size_t idx, size_t element) const;
+    fluss::Time GetArrayTime(size_t idx, size_t element) const;
+    fluss::Timestamp GetArrayTimestamp(size_t idx, size_t element) const;
+    std::string GetArrayDecimalString(size_t idx, size_t element) const;
+    ArrayView GetArrayView(size_t idx) const;
+
+    // Name-based getters inherited from detail::NamedGetters<PrefixRowView>
+    using detail::NamedGetters<PrefixRowView>::IsNull;
+    using detail::NamedGetters<PrefixRowView>::GetBool;
+    using detail::NamedGetters<PrefixRowView>::GetInt32;
+    using detail::NamedGetters<PrefixRowView>::GetInt64;
+    using detail::NamedGetters<PrefixRowView>::GetFloat32;
+    using detail::NamedGetters<PrefixRowView>::GetFloat64;
+    using detail::NamedGetters<PrefixRowView>::GetString;
+    using detail::NamedGetters<PrefixRowView>::GetBytes;
+    using detail::NamedGetters<PrefixRowView>::GetDate;
+    using detail::NamedGetters<PrefixRowView>::GetTime;
+    using detail::NamedGetters<PrefixRowView>::GetTimestamp;
+    using detail::NamedGetters<PrefixRowView>::GetDecimalString;
+    using detail::NamedGetters<PrefixRowView>::GetArraySize;
+    using detail::NamedGetters<PrefixRowView>::GetArrayElementType;
+    using detail::NamedGetters<PrefixRowView>::IsArrayElementNull;
+    using detail::NamedGetters<PrefixRowView>::GetArrayBool;
+    using detail::NamedGetters<PrefixRowView>::GetArrayInt32;
+    using detail::NamedGetters<PrefixRowView>::GetArrayInt64;
+    using detail::NamedGetters<PrefixRowView>::GetArrayFloat32;
+    using detail::NamedGetters<PrefixRowView>::GetArrayFloat64;
+    using detail::NamedGetters<PrefixRowView>::GetArrayString;
+    using detail::NamedGetters<PrefixRowView>::GetArrayBytes;
+    using detail::NamedGetters<PrefixRowView>::GetArrayDate;
+    using detail::NamedGetters<PrefixRowView>::GetArrayTime;
+    using detail::NamedGetters<PrefixRowView>::GetArrayTimestamp;
+    using detail::NamedGetters<PrefixRowView>::GetArrayDecimalString;
+    using detail::NamedGetters<PrefixRowView>::GetArrayView;
+
+   private:
+    size_t Resolve(const std::string& name) const {
+        if (!data_) {
+            throw std::runtime_error("PrefixRowView: name-based access not 
available");
+        }
+        return detail::ResolveColumn(data_->columns, name);
+    }
+    std::shared_ptr<const detail::PrefixData> data_;
+    size_t rec_idx_;
+};
+
+/// Read-only result of a prefix lookup: zero-or-more matched rows, via 
Size()/GetRow(i).
+class PrefixLookupResult {
+   public:
+    PrefixLookupResult() = default;
+
+    /// Number of matched rows.
+    size_t Size() const;
+    bool IsEmpty() const { return Size() == 0; }
+
+    /// Returns a view over the row at `index`. Throws std::out_of_range if
+    /// `index >= Size()`.
+    PrefixRowView GetRow(size_t index) const {
+        if (!data_) {
+            throw std::logic_error("PrefixLookupResult: not available 
(moved-from or null)");
+        }
+        if (index >= Size()) {
+            throw std::out_of_range("PrefixLookupResult::GetRow: index out of 
range");
+        }
+        return PrefixRowView(data_, index);
+    }
+
+   private:
+    friend class PrefixLookuper;
+    std::shared_ptr<const detail::PrefixData> data_;
+};
+
 /// Identifies a specific bucket, optionally within a partition.
 struct TableBucket {
     int64_t table_id;
@@ -1214,6 +1343,7 @@ class LookupResult : public 
detail::NamedGetters<LookupResult> {
 class AppendWriter;
 class UpsertWriter;
 class Lookuper;
+class PrefixLookuper;
 class WriteResult;
 class LogScanner;
 class Admin;
@@ -1401,6 +1531,10 @@ class Table {
     TableLookup NewLookup();
     TableScan NewScan();
 
+    /// Creates a prefix lookuper. `lookup_columns` must be the table's 
bucket-key
+    /// columns (a strict prefix of the primary key); fails otherwise.
+    Result NewPrefixLookup(std::vector<std::string> lookup_columns, 
PrefixLookuper& out);
+
     TableInfo GetTableInfo() const;
     TablePath GetTablePath() const;
     bool HasPrimaryKey() const;
@@ -1600,6 +1734,29 @@ class Lookuper {
     ffi::Lookuper* lookuper_{nullptr};
 };
 
+/// Performs bucket-key prefix lookups; create via Table::NewPrefixLookup(). 
Move-only.
+class PrefixLookuper {
+   public:
+    PrefixLookuper() noexcept;
+    ~PrefixLookuper() noexcept;
+
+    PrefixLookuper(const PrefixLookuper&) = delete;
+    PrefixLookuper& operator=(const PrefixLookuper&) = delete;
+    PrefixLookuper(PrefixLookuper&& other) noexcept;
+    PrefixLookuper& operator=(PrefixLookuper&& other) noexcept;
+
+    bool Available() const;
+
+    /// Looks up all rows matching the prefix columns set on `prefix_row`.
+    Result PrefixLookup(const GenericRow& prefix_row, PrefixLookupResult& out);
+
+   private:
+    friend class Table;
+    PrefixLookuper(ffi::PrefixLookuper* lookuper) noexcept;
+    void Destroy() noexcept;
+    ffi::PrefixLookuper* lookuper_{nullptr};
+};
+
 class LogScanner {
    public:
     LogScanner() noexcept;
diff --git a/bindings/cpp/src/lib.rs b/bindings/cpp/src/lib.rs
index b9d5a03e..b5417ae4 100644
--- a/bindings/cpp/src/lib.rs
+++ b/bindings/cpp/src/lib.rs
@@ -23,7 +23,10 @@ use std::time::Duration;
 
 use fluss as fcore;
 use fluss::PartitionId;
+use fluss::client::PrefixKeyLookuper;
 use fluss::error::Error;
+use fluss::metadata::{Column, TableInfo};
+use fluss::row::{Datum, GenericRow};
 use fluss::rpc::FlussError as CoreFlussError;
 
 static RUNTIME: LazyLock<tokio::runtime::Runtime> = LazyLock::new(|| {
@@ -285,11 +288,13 @@ mod ffi {
         type LogScanner;
         type UpsertWriter;
         type Lookuper;
+        type PrefixLookuper;
 
         // Opaque types for optimized FFI
         type ScanResultInner;
         type GenericRowInner;
         type LookupResultInner;
+        type PrefixLookupResultInner;
         type ArrayWriterInner;
         type ArrayViewInner;
 
@@ -379,6 +384,7 @@ mod ffi {
         fn has_primary_key(self: &Table) -> bool;
         fn create_upsert_writer(self: &Table, column_indices: Vec<usize>) -> 
FfiPtrResult;
         fn new_lookuper(self: &Table) -> FfiPtrResult;
+        fn new_prefix_lookuper(self: &Table, lookup_column_names: Vec<String>) 
-> FfiPtrResult;
 
         // GenericRowInner — opaque row for writes
         fn new_generic_row(field_count: usize) -> Box<GenericRowInner>;
@@ -541,6 +547,152 @@ mod ffi {
         fn lv_get_array_view(self: &LookupResultInner, field: usize)
         -> Result<Box<ArrayViewInner>>;
 
+        // PrefixLookuper
+        unsafe fn delete_prefix_lookuper(lookuper: *mut PrefixLookuper);
+        fn prefix_lookup(
+            self: &mut PrefixLookuper,
+            prefix_row: &GenericRowInner,
+        ) -> Box<PrefixLookupResultInner>;
+
+        // PrefixLookupResultInner accessors — like LookupResultInner but 
indexed
+        // by record, since a prefix lookup returns zero-or-more rows.
+        fn plv_has_error(self: &PrefixLookupResultInner) -> bool;
+        fn plv_error_code(self: &PrefixLookupResultInner) -> i32;
+        fn plv_error_message(self: &PrefixLookupResultInner) -> &str;
+        fn plv_row_count(self: &PrefixLookupResultInner) -> usize;
+        fn plv_field_count(self: &PrefixLookupResultInner) -> usize;
+        fn plv_column_name(self: &PrefixLookupResultInner, field: usize) -> 
Result<&str>;
+        fn plv_column_type(self: &PrefixLookupResultInner, field: usize) -> 
Result<i32>;
+        fn plv_is_null(self: &PrefixLookupResultInner, rec: usize, field: 
usize) -> Result<bool>;
+        fn plv_get_bool(self: &PrefixLookupResultInner, rec: usize, field: 
usize) -> Result<bool>;
+        fn plv_get_i32(self: &PrefixLookupResultInner, rec: usize, field: 
usize) -> Result<i32>;
+        fn plv_get_i64(self: &PrefixLookupResultInner, rec: usize, field: 
usize) -> Result<i64>;
+        fn plv_get_f32(self: &PrefixLookupResultInner, rec: usize, field: 
usize) -> Result<f32>;
+        fn plv_get_f64(self: &PrefixLookupResultInner, rec: usize, field: 
usize) -> Result<f64>;
+        fn plv_get_str(self: &PrefixLookupResultInner, rec: usize, field: 
usize) -> Result<&str>;
+        fn plv_get_bytes(self: &PrefixLookupResultInner, rec: usize, field: 
usize)
+        -> Result<&[u8]>;
+        fn plv_get_date_days(
+            self: &PrefixLookupResultInner,
+            rec: usize,
+            field: usize,
+        ) -> Result<i32>;
+        fn plv_get_time_millis(
+            self: &PrefixLookupResultInner,
+            rec: usize,
+            field: usize,
+        ) -> Result<i32>;
+        fn plv_get_ts_millis(
+            self: &PrefixLookupResultInner,
+            rec: usize,
+            field: usize,
+        ) -> Result<i64>;
+        fn plv_get_ts_nanos(
+            self: &PrefixLookupResultInner,
+            rec: usize,
+            field: usize,
+        ) -> Result<i32>;
+        fn plv_is_ts_ltz(self: &PrefixLookupResultInner, rec: usize, field: 
usize) -> Result<bool>;
+        fn plv_get_decimal_str(
+            self: &PrefixLookupResultInner,
+            rec: usize,
+            field: usize,
+        ) -> Result<String>;
+
+        fn plv_get_array_size(
+            self: &PrefixLookupResultInner,
+            rec: usize,
+            field: usize,
+        ) -> Result<usize>;
+        fn plv_get_array_is_null(
+            self: &PrefixLookupResultInner,
+            rec: usize,
+            field: usize,
+            element: usize,
+        ) -> Result<bool>;
+        fn plv_get_array_bool(
+            self: &PrefixLookupResultInner,
+            rec: usize,
+            field: usize,
+            element: usize,
+        ) -> Result<bool>;
+        fn plv_get_array_i32(
+            self: &PrefixLookupResultInner,
+            rec: usize,
+            field: usize,
+            element: usize,
+        ) -> Result<i32>;
+        fn plv_get_array_i64(
+            self: &PrefixLookupResultInner,
+            rec: usize,
+            field: usize,
+            element: usize,
+        ) -> Result<i64>;
+        fn plv_get_array_f32(
+            self: &PrefixLookupResultInner,
+            rec: usize,
+            field: usize,
+            element: usize,
+        ) -> Result<f32>;
+        fn plv_get_array_f64(
+            self: &PrefixLookupResultInner,
+            rec: usize,
+            field: usize,
+            element: usize,
+        ) -> Result<f64>;
+        fn plv_get_array_str(
+            self: &PrefixLookupResultInner,
+            rec: usize,
+            field: usize,
+            element: usize,
+        ) -> Result<String>;
+        fn plv_get_array_bytes(
+            self: &PrefixLookupResultInner,
+            rec: usize,
+            field: usize,
+            element: usize,
+        ) -> Result<Vec<u8>>;
+        fn plv_get_array_date_days(
+            self: &PrefixLookupResultInner,
+            rec: usize,
+            field: usize,
+            element: usize,
+        ) -> Result<i32>;
+        fn plv_get_array_time_millis(
+            self: &PrefixLookupResultInner,
+            rec: usize,
+            field: usize,
+            element: usize,
+        ) -> Result<i32>;
+        fn plv_get_array_ts_millis(
+            self: &PrefixLookupResultInner,
+            rec: usize,
+            field: usize,
+            element: usize,
+        ) -> Result<i64>;
+        fn plv_get_array_ts_nanos(
+            self: &PrefixLookupResultInner,
+            rec: usize,
+            field: usize,
+            element: usize,
+        ) -> Result<i32>;
+        fn plv_get_array_decimal_str(
+            self: &PrefixLookupResultInner,
+            rec: usize,
+            field: usize,
+            element: usize,
+        ) -> Result<String>;
+        fn plv_get_array_element_type(
+            self: &PrefixLookupResultInner,
+            rec: usize,
+            field: usize,
+        ) -> Result<i32>;
+        fn plv_get_array_view(
+            self: &PrefixLookupResultInner,
+            rec: usize,
+            field: usize,
+        ) -> Result<Box<ArrayViewInner>>;
+
         // ArrayViewInner — opaque recursive array reader for C++ bindings
         fn av_size(self: &ArrayViewInner) -> usize;
         fn av_element_type_id(self: &ArrayViewInner) -> i32;
@@ -836,6 +988,13 @@ pub struct Lookuper {
     table_info: fcore::metadata::TableInfo,
 }
 
+pub struct PrefixLookuper {
+    inner: PrefixKeyLookuper,
+    table_info: TableInfo,
+    /// Full-schema indices of the lookup columns, used to compact the input 
row.
+    lookup_column_indices: Vec<usize>,
+}
+
 /// Error code for client-side errors that did not originate from the server 
API protocol.
 /// Must be non-zero so that CPP `Result::Ok()` (which checks `error_code == 
0`) correctly
 /// detects client-side errors as failures. The value -2 is outside the server 
API error
@@ -1595,6 +1754,38 @@ impl Table {
         }));
         ok_ptr(ptr as usize)
     }
+
+    fn new_prefix_lookuper(&self, lookup_column_names: Vec<String>) -> 
ffi::FfiPtrResult {
+        let _enter = RUNTIME.enter();
+
+        let table_lookup = match self.fluss_table().new_lookup() {
+            Ok(l) => l,
+            Err(e) => return err_ptr_from_core(&e),
+        };
+
+        // `create_lookuper` validates the column names (rejecting unknown 
ones),
+        // so by the time it succeeds every name resolves to an index.
+        let row_type = self.table_info.row_type();
+        let lookup_column_indices: Vec<usize> = lookup_column_names
+            .iter()
+            .filter_map(|name| row_type.get_field_index(name))
+            .collect();
+
+        let lookuper = match table_lookup
+            .lookup_by(lookup_column_names)
+            .create_lookuper()
+        {
+            Ok(l) => l,
+            Err(e) => return err_ptr_from_core(&e),
+        };
+
+        let ptr = Box::into_raw(Box::new(PrefixLookuper {
+            inner: lookuper,
+            table_info: self.table_info.clone(),
+            lookup_column_indices,
+        }));
+        ok_ptr(ptr as usize)
+    }
 }
 
 // AppendWriter implementation
@@ -1837,6 +2028,85 @@ impl Lookuper {
     }
 }
 
+// PrefixLookuper implementation
+unsafe fn delete_prefix_lookuper(lookuper: *mut PrefixLookuper) {
+    if !lookuper.is_null() {
+        unsafe {
+            drop(Box::from_raw(lookuper));
+        }
+    }
+}
+
+impl PrefixLookuper {
+    /// Compact a sparse input row (prefix columns set at their schema 
positions)
+    /// into the dense, lookup-column-ordered row the core prefix encoder 
expects.
+    fn dense_prefix_row<'a>(&self, mut row: GenericRow<'a>) -> GenericRow<'a> {
+        let mut dense = GenericRow::new(self.lookup_column_indices.len());
+        for (dense_idx, &schema_idx) in 
self.lookup_column_indices.iter().enumerate() {
+            if schema_idx < row.values.len() {
+                dense.values[dense_idx] =
+                    std::mem::replace(&mut row.values[schema_idx], 
Datum::Null);
+            }
+        }
+        dense
+    }
+
+    fn prefix_lookup(&mut self, prefix_row: &GenericRowInner) -> 
Box<PrefixLookupResultInner> {
+        let schema = self.table_info.get_schema();
+        let generic_row = match types::resolve_row_types(&prefix_row.row, 
Some(schema)) {
+            Ok(r) => self.dense_prefix_row(r),
+            Err(e) => {
+                return Box::new(PrefixLookupResultInner::from_error(
+                    CLIENT_ERROR_CODE,
+                    e.to_string(),
+                ));
+            }
+        };
+
+        let lookup_result = match 
RUNTIME.block_on(self.inner.lookup(&generic_row)) {
+            Ok(r) => r,
+            Err(e) => {
+                let ffi_err = err_from_core_error(&e);
+                return Box::new(PrefixLookupResultInner::from_error(
+                    ffi_err.error_code,
+                    ffi_err.error_message,
+                ));
+            }
+        };
+
+        let lookup_rows = match lookup_result.get_rows() {
+            Ok(rows) => rows,
+            Err(e) => {
+                let ffi_err = err_from_core_error(&e);
+                return Box::new(PrefixLookupResultInner::from_error(
+                    ffi_err.error_code,
+                    ffi_err.error_message,
+                ));
+            }
+        };
+
+        let mut rows = Vec::with_capacity(lookup_rows.len());
+        for row in &lookup_rows {
+            match types::compacted_row_to_owned(row, &self.table_info) {
+                Ok(owned_row) => rows.push(owned_row),
+                Err(e) => {
+                    return Box::new(PrefixLookupResultInner::from_error(
+                        CLIENT_ERROR_CODE,
+                        e.to_string(),
+                    ));
+                }
+            }
+        }
+
+        let columns = self.table_info.get_schema().columns().to_vec();
+        Box::new(PrefixLookupResultInner {
+            error: None,
+            rows,
+            columns,
+        })
+    }
+}
+
 // LogScanner implementation
 unsafe fn delete_log_scanner(scanner: *mut LogScanner) {
     if !scanner.is_null() {
@@ -3246,6 +3516,146 @@ impl LookupResultInner {
     }
 }
 
+// ============================================================================
+// Opaque types: PrefixLookupResultInner (prefix lookup read path)
+//
+// Like LookupResultInner but holds 0..n rows; accessors take a record index.
+// ============================================================================
+
+pub struct PrefixLookupResultInner {
+    error: Option<(i32, String)>,
+    rows: Vec<GenericRow<'static>>,
+    columns: Vec<Column>,
+}
+
+macro_rules! plv_array_element_getters {
+    ($( $method:ident, $reader_fn:ident, $ret:ty; )+) => {
+        $(
+            fn $method(&self, rec: usize, field: usize, element: usize) -> 
Result<$ret, String> {
+                let r = self.plv_row(rec)?;
+                row_reader::$reader_fn(r, &self.columns, field, element)
+            }
+        )+
+    };
+}
+
+impl PrefixLookupResultInner {
+    fn from_error(code: i32, msg: String) -> Self {
+        Self {
+            error: Some((code, msg)),
+            rows: Vec::new(),
+            columns: Vec::new(),
+        }
+    }
+
+    fn plv_has_error(&self) -> bool {
+        self.error.is_some()
+    }
+
+    fn plv_error_code(&self) -> i32 {
+        self.error.as_ref().map_or(0, |e| e.0)
+    }
+
+    fn plv_error_message(&self) -> &str {
+        self.error.as_ref().map_or("", |e| e.1.as_str())
+    }
+
+    fn plv_row_count(&self) -> usize {
+        self.rows.len()
+    }
+
+    fn plv_field_count(&self) -> usize {
+        self.columns.len()
+    }
+
+    fn plv_column_type(&self, field: usize) -> Result<i32, String> {
+        row_reader::column_type(&self.columns, field)
+    }
+
+    fn plv_column_name(&self, field: usize) -> Result<&str, String> {
+        row_reader::column_name(&self.columns, field)
+    }
+
+    fn plv_row(&self, rec: usize) -> Result<&GenericRow<'static>, String> {
+        self.rows
+            .get(rec)
+            .ok_or_else(|| format!("record index {rec} out of range ({} 
rows)", self.rows.len()))
+    }
+
+    // Field accessors — delegate to shared row_reader helpers.
+    fn plv_is_null(&self, rec: usize, field: usize) -> Result<bool, String> {
+        row_reader::is_null(self.plv_row(rec)?, &self.columns, field)
+    }
+    fn plv_get_bool(&self, rec: usize, field: usize) -> Result<bool, String> {
+        row_reader::get_bool(self.plv_row(rec)?, &self.columns, field)
+    }
+    fn plv_get_i32(&self, rec: usize, field: usize) -> Result<i32, String> {
+        row_reader::get_i32(self.plv_row(rec)?, &self.columns, field)
+    }
+    fn plv_get_i64(&self, rec: usize, field: usize) -> Result<i64, String> {
+        row_reader::get_i64(self.plv_row(rec)?, &self.columns, field)
+    }
+    fn plv_get_f32(&self, rec: usize, field: usize) -> Result<f32, String> {
+        row_reader::get_f32(self.plv_row(rec)?, &self.columns, field)
+    }
+    fn plv_get_f64(&self, rec: usize, field: usize) -> Result<f64, String> {
+        row_reader::get_f64(self.plv_row(rec)?, &self.columns, field)
+    }
+    fn plv_get_str(&self, rec: usize, field: usize) -> Result<&str, String> {
+        row_reader::get_str(self.plv_row(rec)?, &self.columns, field)
+    }
+    fn plv_get_bytes(&self, rec: usize, field: usize) -> Result<&[u8], String> 
{
+        row_reader::get_bytes(self.plv_row(rec)?, &self.columns, field)
+    }
+    fn plv_get_date_days(&self, rec: usize, field: usize) -> Result<i32, 
String> {
+        row_reader::get_date_days(self.plv_row(rec)?, &self.columns, field)
+    }
+    fn plv_get_time_millis(&self, rec: usize, field: usize) -> Result<i32, 
String> {
+        row_reader::get_time_millis(self.plv_row(rec)?, &self.columns, field)
+    }
+    fn plv_get_ts_millis(&self, rec: usize, field: usize) -> Result<i64, 
String> {
+        row_reader::get_ts_millis(self.plv_row(rec)?, &self.columns, field)
+    }
+    fn plv_get_ts_nanos(&self, rec: usize, field: usize) -> Result<i32, 
String> {
+        row_reader::get_ts_nanos(self.plv_row(rec)?, &self.columns, field)
+    }
+    fn plv_is_ts_ltz(&self, _rec: usize, field: usize) -> Result<bool, String> 
{
+        row_reader::is_ts_ltz(&self.columns, field)
+    }
+    fn plv_get_decimal_str(&self, rec: usize, field: usize) -> Result<String, 
String> {
+        row_reader::get_decimal_str(self.plv_row(rec)?, &self.columns, field)
+    }
+    fn plv_get_array_size(&self, rec: usize, field: usize) -> Result<usize, 
String> {
+        row_reader::get_array_size(self.plv_row(rec)?, &self.columns, field)
+    }
+    plv_array_element_getters! {
+        plv_get_array_is_null, get_array_is_null, bool;
+        plv_get_array_bool,    get_array_bool,    bool;
+        plv_get_array_i32,     get_array_i32,     i32;
+        plv_get_array_i64,     get_array_i64,     i64;
+        plv_get_array_f32,     get_array_f32,     f32;
+        plv_get_array_f64,     get_array_f64,     f64;
+        plv_get_array_str,     get_array_str,     String;
+        plv_get_array_bytes,   get_array_bytes,   Vec<u8>;
+        plv_get_array_date_days,   get_array_date_days,   i32;
+        plv_get_array_time_millis, get_array_time_millis, i32;
+        plv_get_array_ts_millis,   get_array_ts_millis,   i64;
+        plv_get_array_ts_nanos,    get_array_ts_nanos,    i32;
+        plv_get_array_decimal_str, get_array_decimal_str, String;
+    }
+    fn plv_get_array_element_type(&self, _rec: usize, field: usize) -> 
Result<i32, String> {
+        row_reader::get_array_element_type_id(&self.columns, field)
+    }
+    fn plv_get_array_view(&self, rec: usize, field: usize) -> 
Result<Box<ArrayViewInner>, String> {
+        let r = self.plv_row(rec)?;
+        let (arr, elem) = row_reader::get_array_and_elem_type(r, 
&self.columns, field)?;
+        Ok(Box::new(ArrayViewInner {
+            array: arr,
+            element_type: elem.clone(),
+        }))
+    }
+}
+
 // ============================================================================
 // Opaque types: ArrayViewInner (recursive array reader)
 //
diff --git a/bindings/cpp/src/table.cpp b/bindings/cpp/src/table.cpp
index f389f7ac..9b5d087e 100644
--- a/bindings/cpp/src/table.cpp
+++ b/bindings/cpp/src/table.cpp
@@ -431,6 +431,12 @@ detail::ScanData::~ScanData() {
     }
 }
 
+detail::PrefixData::~PrefixData() {
+    if (raw) {
+        rust::Box<ffi::PrefixLookupResultInner>::from_raw(raw);
+    }
+}
+
 // ============================================================================
 // RowView — zero-copy read-only row view for scan results
 // ============================================================================
@@ -587,6 +593,137 @@ ArrayView RowView::GetArrayView(size_t idx) const {
     return ArrayView(box.into_raw());
 }
 
+// ============================================================================
+// PrefixLookupResult / PrefixRowView — read path for prefix lookups
+// ============================================================================
+
+size_t PrefixLookupResult::Size() const { return data_ ? 
data_->raw->plv_row_count() : 0; }
+
+size_t PrefixRowView::FieldCount() const { return data_ ? 
data_->raw->plv_field_count() : 0; }
+
+TypeId PrefixRowView::GetType(size_t idx) const {
+    CHECK_DATA("PrefixRowView");
+    return static_cast<TypeId>(data_->raw->plv_column_type(idx));
+}
+
+bool PrefixRowView::IsNull(size_t idx) const {
+    CHECK_DATA("PrefixRowView");
+    return data_->raw->plv_is_null(rec_idx_, idx);
+}
+bool PrefixRowView::GetBool(size_t idx) const {
+    CHECK_DATA("PrefixRowView");
+    return data_->raw->plv_get_bool(rec_idx_, idx);
+}
+int32_t PrefixRowView::GetInt32(size_t idx) const {
+    CHECK_DATA("PrefixRowView");
+    return data_->raw->plv_get_i32(rec_idx_, idx);
+}
+int64_t PrefixRowView::GetInt64(size_t idx) const {
+    CHECK_DATA("PrefixRowView");
+    return data_->raw->plv_get_i64(rec_idx_, idx);
+}
+float PrefixRowView::GetFloat32(size_t idx) const {
+    CHECK_DATA("PrefixRowView");
+    return data_->raw->plv_get_f32(rec_idx_, idx);
+}
+double PrefixRowView::GetFloat64(size_t idx) const {
+    CHECK_DATA("PrefixRowView");
+    return data_->raw->plv_get_f64(rec_idx_, idx);
+}
+std::string_view PrefixRowView::GetString(size_t idx) const {
+    CHECK_DATA("PrefixRowView");
+    auto s = data_->raw->plv_get_str(rec_idx_, idx);
+    return std::string_view(s.data(), s.size());
+}
+std::pair<const uint8_t*, size_t> PrefixRowView::GetBytes(size_t idx) const {
+    CHECK_DATA("PrefixRowView");
+    auto bytes = data_->raw->plv_get_bytes(rec_idx_, idx);
+    return {bytes.data(), bytes.size()};
+}
+Date PrefixRowView::GetDate(size_t idx) const {
+    CHECK_DATA("PrefixRowView");
+    return Date{data_->raw->plv_get_date_days(rec_idx_, idx)};
+}
+Time PrefixRowView::GetTime(size_t idx) const {
+    CHECK_DATA("PrefixRowView");
+    return Time{data_->raw->plv_get_time_millis(rec_idx_, idx)};
+}
+Timestamp PrefixRowView::GetTimestamp(size_t idx) const {
+    CHECK_DATA("PrefixRowView");
+    return Timestamp{data_->raw->plv_get_ts_millis(rec_idx_, idx),
+                     data_->raw->plv_get_ts_nanos(rec_idx_, idx)};
+}
+bool PrefixRowView::IsDecimal(size_t idx) const { return GetType(idx) == 
TypeId::Decimal; }
+std::string PrefixRowView::GetDecimalString(size_t idx) const {
+    CHECK_DATA("PrefixRowView");
+    return std::string(data_->raw->plv_get_decimal_str(rec_idx_, idx));
+}
+
+size_t PrefixRowView::GetArraySize(size_t idx) const {
+    CHECK_DATA("PrefixRowView");
+    return data_->raw->plv_get_array_size(rec_idx_, idx);
+}
+TypeId PrefixRowView::GetArrayElementType(size_t idx) const {
+    CHECK_DATA("PrefixRowView");
+    return 
static_cast<TypeId>(data_->raw->plv_get_array_element_type(rec_idx_, idx));
+}
+bool PrefixRowView::IsArrayElementNull(size_t idx, size_t element) const {
+    CHECK_DATA("PrefixRowView");
+    return data_->raw->plv_get_array_is_null(rec_idx_, idx, element);
+}
+bool PrefixRowView::GetArrayBool(size_t idx, size_t element) const {
+    CHECK_DATA("PrefixRowView");
+    return data_->raw->plv_get_array_bool(rec_idx_, idx, element);
+}
+int32_t PrefixRowView::GetArrayInt32(size_t idx, size_t element) const {
+    CHECK_DATA("PrefixRowView");
+    return data_->raw->plv_get_array_i32(rec_idx_, idx, element);
+}
+int64_t PrefixRowView::GetArrayInt64(size_t idx, size_t element) const {
+    CHECK_DATA("PrefixRowView");
+    return data_->raw->plv_get_array_i64(rec_idx_, idx, element);
+}
+float PrefixRowView::GetArrayFloat32(size_t idx, size_t element) const {
+    CHECK_DATA("PrefixRowView");
+    return data_->raw->plv_get_array_f32(rec_idx_, idx, element);
+}
+double PrefixRowView::GetArrayFloat64(size_t idx, size_t element) const {
+    CHECK_DATA("PrefixRowView");
+    return data_->raw->plv_get_array_f64(rec_idx_, idx, element);
+}
+std::string PrefixRowView::GetArrayString(size_t idx, size_t element) const {
+    CHECK_DATA("PrefixRowView");
+    return std::string(data_->raw->plv_get_array_str(rec_idx_, idx, element));
+}
+std::vector<uint8_t> PrefixRowView::GetArrayBytes(size_t idx, size_t element) 
const {
+    CHECK_DATA("PrefixRowView");
+    auto rv = data_->raw->plv_get_array_bytes(rec_idx_, idx, element);
+    return {rv.data(), rv.data() + rv.size()};
+}
+fluss::Date PrefixRowView::GetArrayDate(size_t idx, size_t element) const {
+    CHECK_DATA("PrefixRowView");
+    return fluss::Date{data_->raw->plv_get_array_date_days(rec_idx_, idx, 
element)};
+}
+fluss::Time PrefixRowView::GetArrayTime(size_t idx, size_t element) const {
+    CHECK_DATA("PrefixRowView");
+    return fluss::Time{data_->raw->plv_get_array_time_millis(rec_idx_, idx, 
element)};
+}
+fluss::Timestamp PrefixRowView::GetArrayTimestamp(size_t idx, size_t element) 
const {
+    CHECK_DATA("PrefixRowView");
+    auto millis = data_->raw->plv_get_array_ts_millis(rec_idx_, idx, element);
+    auto nanos = data_->raw->plv_get_array_ts_nanos(rec_idx_, idx, element);
+    return fluss::Timestamp{millis, nanos};
+}
+std::string PrefixRowView::GetArrayDecimalString(size_t idx, size_t element) 
const {
+    CHECK_DATA("PrefixRowView");
+    return std::string(data_->raw->plv_get_array_decimal_str(rec_idx_, idx, 
element));
+}
+ArrayView PrefixRowView::GetArrayView(size_t idx) const {
+    CHECK_DATA("PrefixRowView");
+    auto box = data_->raw->plv_get_array_view(rec_idx_, idx);
+    return ArrayView(box.into_raw());
+}
+
 // ============================================================================
 // ScanRecords — backed by opaque Rust ScanResultInner
 // ============================================================================
@@ -914,6 +1051,25 @@ TableLookup Table::NewLookup() { return 
TableLookup(table_); }
 
 TableScan Table::NewScan() { return TableScan(table_); }
 
+Result Table::NewPrefixLookup(std::vector<std::string> lookup_columns, 
PrefixLookuper& out) {
+    if (table_ == nullptr) {
+        return utils::make_client_error("Table not available");
+    }
+
+    rust::Vec<rust::String> cols;
+    cols.reserve(lookup_columns.size());
+    for (const auto& c : lookup_columns) {
+        cols.push_back(rust::String(c));
+    }
+
+    auto ffi_result = table_->new_prefix_lookuper(std::move(cols));
+    auto result = utils::from_ffi_result(ffi_result.result);
+    if (result.Ok()) {
+        out = 
PrefixLookuper(utils::ptr_from_ffi<ffi::PrefixLookuper>(ffi_result));
+    }
+    return result;
+}
+
 const std::shared_ptr<GenericRow::ColumnMap>& Table::GetColumnMap() const {
     if (!column_map_ && Available()) {
         auto info = GetTableInfo();
@@ -1405,6 +1561,66 @@ Result Lookuper::Lookup(const GenericRow& pk_row, 
LookupResult& out) {
     return utils::make_ok();
 }
 
+// ============================================================================
+// PrefixLookuper
+// ============================================================================
+
+PrefixLookuper::PrefixLookuper() noexcept = default;
+
+PrefixLookuper::PrefixLookuper(ffi::PrefixLookuper* lookuper) noexcept : 
lookuper_(lookuper) {}
+
+PrefixLookuper::~PrefixLookuper() noexcept { Destroy(); }
+
+void PrefixLookuper::Destroy() noexcept {
+    if (lookuper_) {
+        ffi::delete_prefix_lookuper(lookuper_);
+        lookuper_ = nullptr;
+    }
+}
+
+PrefixLookuper::PrefixLookuper(PrefixLookuper&& other) noexcept : 
lookuper_(other.lookuper_) {
+    other.lookuper_ = nullptr;
+}
+
+PrefixLookuper& PrefixLookuper::operator=(PrefixLookuper&& other) noexcept {
+    if (this != &other) {
+        Destroy();
+        lookuper_ = other.lookuper_;
+        other.lookuper_ = nullptr;
+    }
+    return *this;
+}
+
+bool PrefixLookuper::Available() const { return lookuper_ != nullptr; }
+
+Result PrefixLookuper::PrefixLookup(const GenericRow& prefix_row, 
PrefixLookupResult& out) {
+    if (!Available()) {
+        return utils::make_client_error("PrefixLookuper not available");
+    }
+    if (!prefix_row.Available()) {
+        return utils::make_client_error("GenericRow not available");
+    }
+
+    auto result_box = lookuper_->prefix_lookup(*prefix_row.inner_);
+    if (result_box->plv_has_error()) {
+        return utils::make_error(result_box->plv_error_code(),
+                                 std::string(result_box->plv_error_message()));
+    }
+
+    // Take ownership of the FFI box first (~PrefixData calls from_raw), so the
+    // column-map loop below can't leak it if a string/map allocation throws.
+    // The map is built eagerly and shared by all PrefixRowViews.
+    auto data = std::make_shared<detail::PrefixData>(result_box.into_raw(), 
detail::ColumnMap{});
+    auto col_count = data->raw->plv_field_count();
+    for (size_t i = 0; i < col_count; ++i) {
+        auto name = data->raw->plv_column_name(i);
+        data->columns[std::string(name.data(), name.size())] = {
+            i, static_cast<TypeId>(data->raw->plv_column_type(i))};
+    }
+    out.data_ = std::move(data);
+    return utils::make_ok();
+}
+
 // ============================================================================
 // LogScanner
 // ============================================================================
diff --git a/bindings/cpp/test/test_kv_table.cpp 
b/bindings/cpp/test/test_kv_table.cpp
index 5cc8f79d..2ef7e60f 100644
--- a/bindings/cpp/test/test_kv_table.cpp
+++ b/bindings/cpp/test/test_kv_table.cpp
@@ -410,6 +410,255 @@ TEST_F(KvTableTest, CompositePrimaryKeys) {
     ASSERT_OK(adm.DropTable(table_path, false));
 }
 
+TEST_F(KvTableTest, PrefixLookupByBucketKey) {
+    auto& adm = admin();
+    auto& conn = connection();
+
+    fluss::TablePath table_path("fluss", "test_prefix_lookup_cpp");
+
+    // Bucket key (a, b) is a strict prefix of the PK (a, b, c), enabling 
prefix lookup on (a, b).
+    auto schema = fluss::Schema::NewBuilder()
+                      .AddColumn("a", fluss::DataType::Int())
+                      .AddColumn("b", fluss::DataType::String())
+                      .AddColumn("c", fluss::DataType::BigInt())
+                      .AddColumn("d", fluss::DataType::String())
+                      .SetPrimaryKeys({"a", "b", "c"})
+                      .Build();
+
+    auto table_descriptor = fluss::TableDescriptor::NewBuilder()
+                                .SetSchema(schema)
+                                .SetBucketKeys({"a", "b"})
+                                .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));
+
+    struct TestData {
+        int32_t a;
+        std::string b;
+        int64_t c;
+        std::string d;
+    };
+    std::vector<TestData> test_data = {
+        {1, "aaaaaaaaa", 1, "value1"},
+        {1, "aaaaaaaaa", 2, "value2"},
+        {1, "aaaaaaaaa", 3, "value3"},
+        {2, "aaaaaaaaa", 4, "value4"},
+    };
+
+    for (const auto& row_data : test_data) {
+        auto row = table.NewRow();
+        row.Set("a", row_data.a);
+        row.Set("b", row_data.b);
+        row.Set("c", row_data.c);
+        row.Set("d", row_data.d);
+        ASSERT_OK(upsert_writer.Upsert(row));
+    }
+    ASSERT_OK(upsert_writer.Flush());
+
+    fluss::PrefixLookuper prefix_lookuper;
+    ASSERT_OK(table.NewPrefixLookup({"a", "b"}, prefix_lookuper));
+
+    // Prefix (1, "aaaaaaaaa") matches 3 rows, returned in primary-key order.
+    {
+        auto key = table.NewRow();
+        key.Set("a", 1);
+        key.Set("b", "aaaaaaaaa");
+        fluss::PrefixLookupResult result;
+        ASSERT_OK(prefix_lookuper.PrefixLookup(key, result));
+        ASSERT_EQ(result.Size(), 3u);
+        for (size_t i = 0; i < result.Size(); ++i) {
+            auto row = result.GetRow(i);
+            EXPECT_EQ(row.GetInt32("a"), 1);
+            EXPECT_EQ(row.GetString("b"), "aaaaaaaaa");
+            EXPECT_EQ(row.GetInt64("c"), static_cast<int64_t>(i) + 1);
+            EXPECT_EQ(row.GetString("d"), "value" + std::to_string(i + 1));
+        }
+    }
+
+    // Prefix (2, "aaaaaaaaa") matches a single row.
+    {
+        auto key = table.NewRow();
+        key.Set("a", 2);
+        key.Set("b", "aaaaaaaaa");
+        fluss::PrefixLookupResult result;
+        ASSERT_OK(prefix_lookuper.PrefixLookup(key, result));
+        ASSERT_EQ(result.Size(), 1u);
+        auto row = result.GetRow(0);
+        EXPECT_EQ(row.GetInt64("c"), 4);
+        EXPECT_EQ(row.GetString("d"), "value4");
+    }
+
+    // A prefix with no matching rows yields an empty result.
+    {
+        auto key = table.NewRow();
+        key.Set("a", 3);
+        key.Set("b", "a");
+        fluss::PrefixLookupResult result;
+        ASSERT_OK(prefix_lookuper.PrefixLookup(key, result));
+        EXPECT_TRUE(result.IsEmpty());
+    }
+
+    ASSERT_OK(adm.DropTable(table_path, false));
+}
+
+TEST_F(KvTableTest, PrefixLookupValidationErrors) {
+    auto& adm = admin();
+    auto& conn = connection();
+
+    fluss::TablePath table_path("fluss", "test_prefix_lookup_validation_cpp");
+
+    auto schema = fluss::Schema::NewBuilder()
+                      .AddColumn("a", fluss::DataType::Int())
+                      .AddColumn("b", fluss::DataType::String())
+                      .AddColumn("c", fluss::DataType::BigInt())
+                      .SetPrimaryKeys({"a", "b", "c"})
+                      .Build();
+
+    auto table_descriptor = fluss::TableDescriptor::NewBuilder()
+                                .SetSchema(schema)
+                                .SetBucketKeys({"a", "b"})
+                                .SetProperty("table.replication.factor", "1")
+                                .Build();
+
+    fluss_test::CreateTable(adm, table_path, table_descriptor);
+
+    fluss::Table table;
+    ASSERT_OK(conn.GetTable(table_path, table));
+
+    // Lookup columns must list the bucket keys (a, b) in order.
+    {
+        fluss::PrefixLookuper lookuper;
+        auto result = table.NewPrefixLookup({"b", "a"}, lookuper);
+        ASSERT_FALSE(result.Ok());
+        EXPECT_EQ(result.error_code, fluss::ErrorCode::CLIENT_ERROR);
+        EXPECT_NE(result.error_message.find("must contain all bucket keys"), 
std::string::npos);
+    }
+
+    // Lookup columns must equal the bucket keys, not a longer prefix of the 
PK.
+    {
+        fluss::PrefixLookuper lookuper;
+        auto result = table.NewPrefixLookup({"a", "b", "c"}, lookuper);
+        ASSERT_FALSE(result.Ok());
+        EXPECT_EQ(result.error_code, fluss::ErrorCode::CLIENT_ERROR);
+        EXPECT_NE(result.error_message.find("must contain all bucket keys"), 
std::string::npos);
+    }
+
+    ASSERT_OK(adm.DropTable(table_path, false));
+}
+
+TEST_F(KvTableTest, PrefixLookupPartitioned) {
+    auto& adm = admin();
+    auto& conn = connection();
+
+    fluss::TablePath table_path("fluss", "test_prefix_lookup_partitioned_cpp");
+
+    // Partitioned by region; bucket key (a, b) is a prefix of the PK minus 
the partition column.
+    auto schema = fluss::Schema::NewBuilder()
+                      .AddColumn("region", fluss::DataType::String())
+                      .AddColumn("a", fluss::DataType::Int())
+                      .AddColumn("b", fluss::DataType::String())
+                      .AddColumn("c", fluss::DataType::BigInt())
+                      .AddColumn("d", fluss::DataType::String())
+                      .SetPrimaryKeys({"region", "a", "b", "c"})
+                      .Build();
+
+    auto table_descriptor = fluss::TableDescriptor::NewBuilder()
+                                .SetSchema(schema)
+                                .SetPartitionKeys({"region"})
+                                .SetBucketKeys({"a", "b"})
+                                .SetProperty("table.replication.factor", "1")
+                                .Build();
+
+    fluss_test::CreateTable(adm, table_path, table_descriptor);
+    fluss_test::CreatePartitions(adm, table_path, "region", {"US", "EU"});
+
+    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));
+
+    struct TestData {
+        std::string region;
+        int32_t a;
+        std::string b;
+        int64_t c;
+        std::string d;
+    };
+    std::vector<TestData> test_data = {
+        {"US", 1, "aaaaaaaaa", 1, "us-1"},
+        {"US", 1, "aaaaaaaaa", 2, "us-2"},
+        {"US", 2, "aaaaaaaaa", 3, "us-3"},
+        {"EU", 1, "aaaaaaaaa", 4, "eu-1"},
+        {"EU", 1, "bbbbbbbbb", 5, "eu-2"},
+    };
+
+    for (const auto& row_data : test_data) {
+        auto row = table.NewRow();
+        row.Set("region", row_data.region);
+        row.Set("a", row_data.a);
+        row.Set("b", row_data.b);
+        row.Set("c", row_data.c);
+        row.Set("d", row_data.d);
+        ASSERT_OK(upsert_writer.Upsert(row));
+    }
+    ASSERT_OK(upsert_writer.Flush());
+
+    fluss::PrefixLookuper prefix_lookuper;
+    ASSERT_OK(table.NewPrefixLookup({"region", "a", "b"}, prefix_lookuper));
+
+    // (US, 1, "aaaaaaaaa") matches 2 rows.
+    {
+        auto key = table.NewRow();
+        key.Set("region", "US");
+        key.Set("a", 1);
+        key.Set("b", "aaaaaaaaa");
+        fluss::PrefixLookupResult result;
+        ASSERT_OK(prefix_lookuper.PrefixLookup(key, result));
+        ASSERT_EQ(result.Size(), 2u);
+        for (size_t i = 0; i < result.Size(); ++i) {
+            auto row = result.GetRow(i);
+            EXPECT_EQ(row.GetString("region"), "US");
+            EXPECT_EQ(row.GetInt32("a"), 1);
+            EXPECT_EQ(row.GetString("b"), "aaaaaaaaa");
+        }
+    }
+
+    // (EU, 1, "bbbbbbbbb") matches a single row.
+    {
+        auto key = table.NewRow();
+        key.Set("region", "EU");
+        key.Set("a", 1);
+        key.Set("b", "bbbbbbbbb");
+        fluss::PrefixLookupResult result;
+        ASSERT_OK(prefix_lookuper.PrefixLookup(key, result));
+        ASSERT_EQ(result.Size(), 1u);
+        EXPECT_EQ(result.GetRow(0).GetString("d"), "eu-2");
+    }
+
+    // A non-existent partition yields an empty result.
+    {
+        auto key = table.NewRow();
+        key.Set("region", "APAC");
+        key.Set("a", 1);
+        key.Set("b", "aaaaaaaaa");
+        fluss::PrefixLookupResult result;
+        ASSERT_OK(prefix_lookuper.PrefixLookup(key, result));
+        EXPECT_TRUE(result.IsEmpty());
+    }
+
+    ASSERT_OK(adm.DropTable(table_path, false));
+}
+
 TEST_F(KvTableTest, PartialUpdate) {
     auto& adm = admin();
     auto& conn = connection();
diff --git a/website/docs/user-guide/cpp/api-reference.md 
b/website/docs/user-guide/cpp/api-reference.md
index e9b94c9d..60a43eb4 100644
--- a/website/docs/user-guide/cpp/api-reference.md
+++ b/website/docs/user-guide/cpp/api-reference.md
@@ -116,6 +116,7 @@ Complete API reference for the Fluss C++ client.
 | `NewAppend() -> TableAppend`  | Create an append builder for log tables  |
 | `NewUpsert() -> TableUpsert`  | Create an upsert builder for PK tables   |
 | `NewLookup() -> TableLookup`  | Create a lookup builder for PK tables    |
+| `NewPrefixLookup(std::vector<std::string> cols, PrefixLookuper& out) -> 
Result` | Create a prefix (bucket-key) lookuper |
 | `NewScan() -> TableScan`      | Create a scan builder                    |
 | `GetTableInfo() -> TableInfo` | Get table metadata                       |
 | `GetTablePath() -> TablePath` | Get the table path                       |
@@ -180,6 +181,14 @@ Complete API reference for the Fluss C++ client.
 
|---------------------------------------------------------------|-----------------------------|
 | `Lookup(const GenericRow& pk_row, LookupResult& out) -> Result` | Lookup a 
row by primary key |
 
+## `PrefixLookuper`
+
+Performs prefix (bucket-key) lookups, returning all rows whose primary key 
starts with the given prefix. Obtained from `Table::NewPrefixLookup()`. See the 
[Prefix Lookup example](./example/prefix-lookup.md).
+
+| Method                                                                       
   |  Description                                  |
+|---------------------------------------------------------------------------------|-----------------------------------------------|
+| `PrefixLookup(const GenericRow& prefix_row, PrefixLookupResult& out) -> 
Result` | Look up all rows matching the prefix columns  |
+
 ## `LogScanner`
 
 | Method                                                                       
                        |  Description                              |
@@ -410,6 +419,16 @@ Same array getters as 
[`RowView`](#array-getters-index-based) — `GetArraySize`
 | `GetTimestamp(const std::string& name) -> Timestamp`    | Get timestamp by 
column name       |
 | `GetDecimalString(const std::string& name) -> std::string` | Get decimal as 
string by column name |
 
+## `PrefixLookupResult`
+
+Read-only result of a prefix lookup — zero or more matched rows. Each row is a 
`PrefixRowView` exposing the same getters as [`RowView`](#rowview) (index- and 
name-based, including arrays).
+
+| Method                                  | Description                        
               |
+|-----------------------------------------|---------------------------------------------------|
+| `Size() -> size_t`                      | Number of matched rows             
               |
+| `IsEmpty() -> bool`                     | Whether the result has no rows     
               |
+| `GetRow(size_t index) -> PrefixRowView` | Get the row at `index` (throws if 
out of range)   |
+
 ## `ArrowRecordBatch`
 
 | Method                                                         | Description 
                         |
diff --git a/website/docs/user-guide/cpp/example/prefix-lookup.md 
b/website/docs/user-guide/cpp/example/prefix-lookup.md
new file mode 100644
index 00000000..e5d52015
--- /dev/null
+++ b/website/docs/user-guide/cpp/example/prefix-lookup.md
@@ -0,0 +1,107 @@
+---
+sidebar_position: 7
+---
+# Prefix Lookup
+
+Prefix lookup returns all rows whose primary key starts with a given prefix. 
It is enabled by choosing a **bucket key that is a strict prefix of the primary 
key** — rows sharing the same bucket-key prefix land in the same bucket, so one 
bucket lookup returns them all.
+
+## Table Requirements
+
+- The table must have a primary key.
+- The bucket key must be a strict prefix of the primary key (on partitioned 
tables, of the *non-partition* portion of the primary key).
+- The bucket key cannot equal the full primary key — that is a normal 
primary-key lookup; use 
[`Lookuper`](./primary-key-tables.md#looking-up-records) instead.
+- The columns passed to `NewPrefixLookup()` must equal `partition_keys ++ 
bucket_key` (in that order, if partitioned).
+
+`NewPrefixLookup()` validates these rules and returns a non-`Ok` `Result` 
(with `error_code == ErrorCode::CLIENT_ERROR`) describing the violation.
+
+## Non-Partitioned Table
+
+Pick a schema where the bucket key is a prefix of the primary key:
+
+```cpp
+auto schema = fluss::Schema::NewBuilder()
+    .AddColumn("user_id", fluss::DataType::Int())
+    .AddColumn("session_id", fluss::DataType::String())
+    .AddColumn("event_seq", fluss::DataType::BigInt())
+    .AddColumn("event_data", fluss::DataType::String())
+    .SetPrimaryKeys({"user_id", "session_id", "event_seq"})
+    .Build();
+
+// Bucket key (user_id, session_id) is a prefix of the primary key.
+auto descriptor = fluss::TableDescriptor::NewBuilder()
+    .SetSchema(schema)
+    .SetBucketKeys({"user_id", "session_id"})
+    .SetBucketCount(3)
+    .Build();
+
+fluss::TablePath table_path("fluss", "sessions");
+admin.CreateTable(table_path, descriptor, true);
+```
+
+Create the lookuper with the prefix columns, then call `PrefixLookup`:
+
+```cpp
+fluss::Table table;
+conn.GetTable(table_path, table);
+
+fluss::PrefixLookuper prefix_lookuper;
+table.NewPrefixLookup({"user_id", "session_id"}, prefix_lookuper);
+
+auto prefix = table.NewRow();
+prefix.Set("user_id", 1);
+prefix.Set("session_id", "sess-a");
+
+fluss::PrefixLookupResult result;
+prefix_lookuper.PrefixLookup(prefix, result);
+
+for (size_t i = 0; i < result.Size(); ++i) {
+    auto row = result.GetRow(i);
+    std::cout << "seq=" << row.GetInt64("event_seq")
+              << ", data=" << row.GetString("event_data") << std::endl;
+}
+```
+
+Unlike primary-key lookup (which returns a single row via 
`LookupResult::Found()`), prefix lookup returns zero or more rows via `Size()` 
/ `GetRow(i)`, in primary-key order.
+
+## Partitioned Table
+
+On a partitioned table, the partition columns are stripped from the primary 
key before the bucket-prefix rule is evaluated. The lookup key must still carry 
the partition values so the client can route the request to the right partition 
— so the columns passed to `NewPrefixLookup()` are `partition_keys ++ 
bucket_key`.
+
+```cpp
+auto schema = fluss::Schema::NewBuilder()
+    .AddColumn("region", fluss::DataType::String())
+    .AddColumn("user_id", fluss::DataType::Int())
+    .AddColumn("session_id", fluss::DataType::String())
+    .AddColumn("event_seq", fluss::DataType::BigInt())
+    .AddColumn("event_data", fluss::DataType::String())
+    .SetPrimaryKeys({"region", "user_id", "session_id", "event_seq"})
+    .Build();
+
+auto descriptor = fluss::TableDescriptor::NewBuilder()
+    .SetSchema(schema)
+    .SetPartitionKeys({"region"})
+    // Bucket key (user_id, session_id) is a prefix of the PK minus the 
partition column.
+    .SetBucketKeys({"user_id", "session_id"})
+    .SetBucketCount(3)
+    .Build();
+```
+
+```cpp
+fluss::PrefixLookuper prefix_lookuper;
+// lookup columns = partition keys ++ bucket key
+table.NewPrefixLookup({"region", "user_id", "session_id"}, prefix_lookuper);
+
+auto prefix = table.NewRow();
+prefix.Set("region", "US");          // partition column
+prefix.Set("user_id", 1);
+prefix.Set("session_id", "sess-a");
+
+fluss::PrefixLookupResult result;
+prefix_lookuper.PrefixLookup(prefix, result);
+
+for (size_t i = 0; i < result.Size(); ++i) {
+    auto row = result.GetRow(i);
+    std::cout << "seq=" << row.GetInt64("event_seq")
+              << ", data=" << row.GetString("event_data") << std::endl;
+}
+```

Reply via email to