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

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


The following commit(s) were added to refs/heads/main by this push:
     new 1707abc8 feat(go): support a read-builder limit hint for split pruning 
(#946)
1707abc8 is described below

commit 1707abc808bfd29677b02c7458234d3725482ab2
Author: jackylee <[email protected]>
AuthorDate: Sun Oct 4 22:25:25 2026 +0800

    feat(go): support a read-builder limit hint for split pruning (#946)
---
 bindings/c/src/table.rs               | 33 +++++++++++++
 bindings/c/src/tests.rs               | 88 +++++++++++++++++++++++++++++++++++
 bindings/c/src/types.rs               |  2 +
 bindings/go/error.go                  |  6 +++
 bindings/go/read_builder.go           | 40 ++++++++++++++++
 bindings/go/tests/paimon_test.go      | 58 +++++++++++++++++++++++
 crates/paimon/src/table/table_scan.rs | 29 +++++++++++-
 7 files changed, 255 insertions(+), 1 deletion(-)

diff --git a/bindings/c/src/table.rs b/bindings/c/src/table.rs
index b0937a65..0feba23b 100644
--- a/bindings/c/src/table.rs
+++ b/bindings/c/src/table.rs
@@ -432,6 +432,7 @@ unsafe fn new_read_builder_state(
         projected_columns: None,
         filter: None,
         case_sensitive: true,
+        limit: None,
     })
 }
 
@@ -643,6 +644,29 @@ pub unsafe extern "C" fn 
paimon_read_builder_with_case_sensitive(
     std::ptr::null_mut()
 }
 
+/// Set a row-count limit hint for scan planning.
+///
+/// This lets planning stop selecting splits once the retained splits already
+/// cover `limit` rows, cutting the plan-time I/O of listing every split's
+/// stats when only the first N rows are wanted. It is a hint only: it does not
+/// guarantee exactly `limit` rows are returned, so the caller still enforces
+/// the final row limit when reading.
+///
+/// # Safety
+/// `rb` must be a valid pointer from `paimon_table_new_read_builder`, or null 
(returns error).
+#[no_mangle]
+pub unsafe extern "C" fn paimon_read_builder_with_limit(
+    rb: *mut paimon_read_builder,
+    limit: usize,
+) -> *mut paimon_error {
+    if let Err(e) = check_non_null(rb, "rb") {
+        return e;
+    }
+    let state = &mut *((*rb).inner as *mut ReadBuilderState);
+    state.limit = Some(limit);
+    std::ptr::null_mut()
+}
+
 /// Set a filter predicate for scan planning.
 ///
 /// The predicate is consumed (ownership transferred to the read builder).
@@ -691,6 +715,7 @@ pub unsafe extern "C" fn paimon_read_builder_new_scan(
     let scan_state = TableScanState {
         table: state.table.clone(),
         filter: state.filter.clone(),
+        limit: state.limit,
     };
     let inner = Box::into_raw(Box::new(scan_state)) as *mut c_void;
     paimon_result_table_scan {
@@ -733,6 +758,11 @@ pub unsafe extern "C" fn paimon_read_builder_new_read(
         rb_rust.with_filter(filter.clone());
     }
 
+    // Apply limit hint if set
+    if let Some(limit) = state.limit {
+        rb_rust.with_limit(limit);
+    }
+
     match rb_rust.new_read() {
         Ok(table_read) => {
             let read_state = TableReadState {
@@ -788,6 +818,9 @@ pub unsafe extern "C" fn paimon_table_scan_plan(
     if let Some(ref filter) = scan_state.filter {
         rb.with_filter(filter.clone());
     }
+    if let Some(limit) = scan_state.limit {
+        rb.with_limit(limit);
+    }
     let table_scan = rb.new_scan();
 
     match runtime().block_on(table_scan.plan()) {
diff --git a/bindings/c/src/tests.rs b/bindings/c/src/tests.rs
index d839a3ad..b8dbd785 100644
--- a/bindings/c/src/tests.rs
+++ b/bindings/c/src/tests.rs
@@ -1224,6 +1224,94 @@ fn test_read_with_data() {
     unsafe { unwrap_table(handle) };
 }
 
+#[test]
+fn test_read_builder_with_limit_prunes_plan_splits() {
+    // `with_limit` is a plan-time hint: planning stops retaining splits once 
the
+    // kept ones already cover the limit (mirrors core `apply_limit_pushdown`).
+    // Three separate commits give multiple splits with known row counts, so 
this
+    // asserts the FFI threads the hint through to planning across splits: a 
zero
+    // limit prunes every split, a small limit keeps a strict non-empty subset,
+    // and a generous limit leaves the plan untouched.
+    let path = "memory:/test_read_limit";
+    let file_io = memory_file_io();
+    setup_table_dirs(&file_io, path);
+    // Force a tiny split target so each committed file becomes its own split,
+    // giving multiple known-count splits instead of one bundled split.
+    let schema = Schema::builder()
+        .column("id", DataType::Int(IntType::new()))
+        .column("name", DataType::VarChar(VarCharType::string_type()))
+        .option("source.split.target-size", "1")
+        .build()
+        .unwrap();
+    let table = Table::new(
+        file_io.clone(),
+        Identifier::new("default", "test"),
+        path.to_string(),
+        TableSchema::new(0, &schema),
+        None,
+    );
+    write_data_rust(&table, &[make_batch(vec![1, 2], vec!["a", "b"])]);
+    write_data_rust(&table, &[make_batch(vec![3, 4], vec!["c", "d"])]);
+    write_data_rust(&table, &[make_batch(vec![5, 6], vec!["e", "f"])]);
+    let handle = unsafe { wrap_table(table) };
+
+    unsafe fn plan_split_count(handle: *const paimon_table, limit: 
Option<usize>) -> usize {
+        let rb = paimon_table_new_read_builder(handle).read_builder;
+        if let Some(limit) = limit {
+            assert!(paimon_read_builder_with_limit(rb, limit).is_null());
+        }
+        let scan = paimon_read_builder_new_scan(rb).scan;
+        let plan_result = paimon_table_scan_plan(scan);
+        assert!(plan_result.error.is_null());
+        let count = paimon_plan_num_splits(plan_result.plan);
+        paimon_plan_free(plan_result.plan);
+        paimon_table_scan_free(scan);
+        paimon_read_builder_free(rb);
+        count
+    }
+
+    unsafe {
+        let baseline = plan_split_count(handle, None);
+        assert!(
+            baseline >= 2,
+            "three commits should plan multiple splits, got {baseline}"
+        );
+        assert_eq!(
+            plan_split_count(handle, Some(0)),
+            0,
+            "a zero limit must prune every split at plan time"
+        );
+        // A small positive limit is covered by the first retained split 
alone, so
+        // planning keeps a strict non-empty subset instead of every split. A 
limit
+        // that wrapped to a huge value (the negative-input bug) would instead 
keep
+        // the full baseline here.
+        let pruned = plan_split_count(handle, Some(1));
+        assert!(
+            pruned >= 1 && pruned < baseline,
+            "limit 1 should keep a strict non-empty subset of {baseline} 
splits, got {pruned}"
+        );
+        assert_eq!(
+            plan_split_count(handle, Some(1000)),
+            baseline,
+            "a limit above the row count must not prune any split"
+        );
+        // A limit above i64::MAX must not wrap negative in the accumulator's
+        // signed comparison and truncate the scan: SIZE_MAX and i64::MAX + 1
+        // keep every split, like any limit the row count cannot reach.
+        assert_eq!(
+            plan_split_count(handle, Some(usize::MAX)),
+            baseline,
+            "SIZE_MAX must not truncate the plan"
+        );
+        assert_eq!(
+            plan_split_count(handle, Some((i64::MAX as usize) + 1)),
+            baseline,
+            "i64::MAX + 1 must not truncate the plan"
+        );
+        unwrap_table(handle);
+    }
+}
+
 #[test]
 fn test_read_resources_share_budget_and_release_reservations() {
     let path = "memory:/test_read_resources";
diff --git a/bindings/c/src/types.rs b/bindings/c/src/types.rs
index 90fe6670..2110d9d0 100644
--- a/bindings/c/src/types.rs
+++ b/bindings/c/src/types.rs
@@ -269,12 +269,14 @@ pub(crate) struct ReadBuilderState {
     pub projected_columns: Option<Vec<String>>,
     pub filter: Option<Predicate>,
     pub case_sensitive: bool,
+    pub limit: Option<usize>,
 }
 
 /// Internal state for TableScan that stores table and filter.
 pub(crate) struct TableScanState {
     pub table: Table,
     pub filter: Option<Predicate>,
+    pub limit: Option<usize>,
 }
 
 #[repr(C)]
diff --git a/bindings/go/error.go b/bindings/go/error.go
index c85b9e08..af005841 100644
--- a/bindings/go/error.go
+++ b/bindings/go/error.go
@@ -31,6 +31,12 @@ import (
 // ErrClosed is returned when an operation is attempted on a closed resource.
 var ErrClosed = errors.New("paimon: use of closed resource")
 
+// ErrNegativeLimit is returned by ReadBuilder.WithLimit when the limit is
+// negative. A negative Go int would wrap to a huge value through the unsigned
+// C boundary and make scan planning stop after the first split, silently
+// dropping rows, so it is rejected up front.
+var ErrNegativeLimit = errors.New("paimon: read-builder limit must not be 
negative")
+
 // ErrorCode represents categories of errors from paimon.
 type ErrorCode int32
 
diff --git a/bindings/go/read_builder.go b/bindings/go/read_builder.go
index 3f52c3c7..8a2e3653 100644
--- a/bindings/go/read_builder.go
+++ b/bindings/go/read_builder.go
@@ -92,6 +92,26 @@ func (rb *ReadBuilder) WithCaseSensitive(caseSensitive bool) 
error {
        return ffiReadBuilderWithCaseSensitive.symbol(rb.ctx)(rb.inner, 
caseSensitive)
 }
 
+// WithLimit sets a row-count limit hint for scan planning. Planning stops
+// retaining splits once the ones already kept cover the limit, so reading a
+// preview or sample of the first N rows keeps fewer splits. Planning still 
reads
+// the manifest entries to learn each split's row count, so this bounds how 
many
+// splits are retained rather than avoiding all planning I/O. It is a hint 
only:
+// it does not guarantee exactly limit rows are returned, so callers still 
enforce
+// the final row limit when reading.
+//
+// A negative limit is rejected with ErrNegativeLimit: it would wrap to a huge
+// value through the unsigned C boundary and stop planning after the first 
split.
+func (rb *ReadBuilder) WithLimit(limit int) error {
+       if limit < 0 {
+               return ErrNegativeLimit
+       }
+       if rb.inner == nil {
+               return ErrClosed
+       }
+       return ffiReadBuilderWithLimit.symbol(rb.ctx)(rb.inner, uintptr(limit))
+}
+
 // WithFilter sets a filter predicate for scan planning and read-side pruning.
 //
 // The predicate is used in two phases:
@@ -247,6 +267,26 @@ var ffiReadBuilderWithCaseSensitive = newFFI(ffiOpts{
        }
 })
 
+// size_t is passed pointer-width, matching ffiTableReadToArrow's 
offset/length.
+var ffiReadBuilderWithLimit = newFFI(ffiOpts{
+       sym:    "paimon_read_builder_with_limit",
+       rType:  &ffi.TypePointer,
+       aTypes: []*ffi.Type{&ffi.TypePointer, &ffi.TypePointer},
+}, func(ctx context.Context, ffiCall ffiCall) func(rb *paimonReadBuilder, 
limit uintptr) error {
+       return func(rb *paimonReadBuilder, limit uintptr) error {
+               var errPtr *paimonError
+               ffiCall(
+                       unsafe.Pointer(&errPtr),
+                       unsafe.Pointer(&rb),
+                       unsafe.Pointer(&limit),
+               )
+               if errPtr != nil {
+                       return parseError(ctx, errPtr)
+               }
+               return nil
+       }
+})
+
 var ffiReadBuilderWithFilter = newFFI(ffiOpts{
        sym:    "paimon_read_builder_with_filter",
        rType:  &ffi.TypePointer,
diff --git a/bindings/go/tests/paimon_test.go b/bindings/go/tests/paimon_test.go
index 2b6a3651..e7b0c943 100644
--- a/bindings/go/tests/paimon_test.go
+++ b/bindings/go/tests/paimon_test.go
@@ -287,6 +287,64 @@ func openTestTable(t *testing.T) *paimon.Table {
        return openTableAt(t, warehouse, "simple_log_table")
 }
 
+func TestReadBuilderWithLimitPrunesPlanSplits(t *testing.T) {
+       table := openTestTable(t)
+
+       planSplitCount := func(setLimit func(*paimon.ReadBuilder) error) int {
+               rb, err := table.NewReadBuilder()
+               if err != nil {
+                       t.Fatalf("Failed to create read builder: %v", err)
+               }
+               defer rb.Close()
+               if setLimit != nil {
+                       if err := setLimit(rb); err != nil {
+                               t.Fatalf("Failed to set limit: %v", err)
+                       }
+               }
+               scan, err := rb.NewScan()
+               if err != nil {
+                       t.Fatalf("Failed to create scan: %v", err)
+               }
+               defer scan.Close()
+               plan, err := scan.Plan()
+               if err != nil {
+                       t.Fatalf("Failed to plan: %v", err)
+               }
+               defer plan.Close()
+               return len(plan.Splits())
+       }
+
+       baseline := planSplitCount(nil)
+       if baseline < 1 {
+               t.Fatalf("expected >= 1 split without a limit, got %d", 
baseline)
+       }
+       // A zero limit prunes every split at plan time, proving the hint 
reaches
+       // planning through the FFI (mirrors core apply_limit_pushdown 
semantics).
+       if got := planSplitCount(func(rb *paimon.ReadBuilder) error { return 
rb.WithLimit(0) }); got != 0 {
+               t.Fatalf("expected 0 splits with WithLimit(0), got %d", got)
+       }
+       // A limit far above the row count must not prune any split.
+       if got := planSplitCount(func(rb *paimon.ReadBuilder) error { return 
rb.WithLimit(1 << 30) }); got != baseline {
+               t.Fatalf("expected %d splits with a large limit, got %d", 
baseline, got)
+       }
+}
+
+// A negative limit must be rejected before it reaches the unsigned C boundary,
+// where it would wrap to a huge value and make planning stop after the first
+// split. The check runs ahead of the closed-builder guard, so it needs no
+// warehouse: the distinct ErrNegativeLimit (not ErrClosed) proves the reject
+// arm ran rather than the nil-inner arm.
+func TestReadBuilderWithLimitRejectsNegative(t *testing.T) {
+       rb := &paimon.ReadBuilder{}
+       err := rb.WithLimit(-1)
+       if !errors.Is(err, paimon.ErrNegativeLimit) {
+               t.Fatalf("expected ErrNegativeLimit for a negative limit, got 
%v", err)
+       }
+       if errors.Is(err, paimon.ErrClosed) {
+               t.Fatalf("negative limit must be rejected as invalid input, not 
as a closed builder")
+       }
+}
+
 func TestWriteCommitReadRoundTrip(t *testing.T) {
        table := openCopiedTestTable(t)
 
diff --git a/crates/paimon/src/table/table_scan.rs 
b/crates/paimon/src/table/table_scan.rs
index cf88f85d..3b93a05e 100644
--- a/crates/paimon/src/table/table_scan.rs
+++ b/crates/paimon/src/table/table_scan.rs
@@ -782,7 +782,12 @@ impl LimitPushdownAccumulator {
             self.fallback_splits.push(split.clone());
             self.limited_splits.push(split);
             self.scanned_row_count += merged_count;
-            self.limit_early_stopped = self.scanned_row_count >= self.limit as 
i64;
+            // Compare without a lossy signed cast: `limit` is `usize`, so
+            // `limit as i64` turns any value above `i64::MAX` (e.g. a C caller
+            // passing `SIZE_MAX`) negative, which would early-stop after the
+            // first counted split. `scanned_row_count` is always >= 0, so an
+            // unsigned comparison keeps a huge limit from truncating the scan.
+            self.limit_early_stopped = self.scanned_row_count as u128 >= 
self.limit as u128;
         } else {
             self.fallback_splits.push(split);
         }
@@ -3612,6 +3617,28 @@ mod tests {
         );
     }
 
+    #[test]
+    fn 
test_incremental_limit_accumulator_does_not_truncate_on_oversized_limit() {
+        // A limit above i64::MAX (e.g. a C caller passing SIZE_MAX) must not 
wrap
+        // negative through a signed cast and early-stop after the first 
counted
+        // split; real row counts can never reach it, so every split is kept.
+        for limit in [usize::MAX, (i64::MAX as usize) + 1] {
+            let mut accumulator = LimitPushdownAccumulator::new(limit);
+            assert!(!accumulator.push(limit_test_split("a.parquet", 2)));
+            assert!(!accumulator.push(limit_test_split("b.parquet", 3)));
+            let result = accumulator.finish();
+            assert!(
+                !result.limit_early_stopped,
+                "limit {limit} must not early-stop"
+            );
+            assert_eq!(
+                split_file_names(&result.splits),
+                vec!["a.parquet", "b.parquet"],
+                "limit {limit} must keep all splits"
+            );
+        }
+    }
+
     #[test]
     fn 
test_incremental_limit_accumulator_returns_fallback_when_limit_not_reached() {
         let mut accumulator = LimitPushdownAccumulator::new(100);

Reply via email to