This is an automated email from the ASF dual-hosted git repository.
zhangstar333 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/master by this push:
new 952bfcbb40f [chore](lance) update some patch about lance (#67262)
952bfcbb40f is described below
commit 952bfcbb40fa756cb66a7d8e70d559496eb344c8
Author: zhangstar333 <[email protected]>
AuthorDate: Tue Sep 1 19:56:29 2026 +0800
[chore](lance) update some patch about lance (#67262)
update some patch about lance
---
thirdparty/download-thirdparty.sh | 6 +-
thirdparty/patches/lance-c-0.1.7-pr-64.patch | 1522 --------------------------
thirdparty/patches/lance-c-0.1.8-pr-69.patch | 653 +++++++++++
thirdparty/vars.sh | 8 +-
4 files changed, 660 insertions(+), 1529 deletions(-)
diff --git a/thirdparty/download-thirdparty.sh
b/thirdparty/download-thirdparty.sh
index b377cbf08a7..95c67e2cdad 100755
--- a/thirdparty/download-thirdparty.sh
+++ b/thirdparty/download-thirdparty.sh
@@ -803,12 +803,12 @@ if [[ " ${TP_ARCHIVES[*]} " =~ " PAIMON_CPP " ]]; then
echo "Finished patching ${PAIMON_CPP_SOURCE}"
fi
-# Patch lance-c with the scan execution statistics API from upstream PR #64.
+# Apply Doris lance-c patches.
if [[ " ${TP_ARCHIVES[*]} " =~ " LANCE_C " ]]; then
- if [[ "${LANCE_C_SOURCE}" == "lance-c-0.1.7" ]]; then
+ if [[ "${LANCE_C_SOURCE}" == "lance-c-0.1.8" ]]; then
cd "${TP_SOURCE_DIR}/${LANCE_C_SOURCE}"
if [[ ! -f "${PATCHED_MARK}" ]]; then
- patch -p1 <"${TP_PATCH_DIR}/lance-c-0.1.7-pr-64.patch"
+ patch -p1 <"${TP_PATCH_DIR}/lance-c-0.1.8-pr-69.patch"
touch "${PATCHED_MARK}"
fi
cd -
diff --git a/thirdparty/patches/lance-c-0.1.7-pr-64.patch
b/thirdparty/patches/lance-c-0.1.7-pr-64.patch
deleted file mode 100644
index 17f91bc7c31..00000000000
--- a/thirdparty/patches/lance-c-0.1.7-pr-64.patch
+++ /dev/null
@@ -1,1522 +0,0 @@
-From e3320c1e7d5c72234b3b44e9e7e9a93a72fe488c Mon Sep 17 00:00:00 2001
-From: zhangstar333 <[email protected]>
-Date: Mon, 24 Aug 2026 16:37:33 +0800
-Subject: [PATCH 1/3] update
-
----
- include/lance/lance.h | 73 ++++++++++++++++
- include/lance/lance.hpp | 10 +++
- src/scanner.rs | 179 +++++++++++++++++++++++++++++++++++++-
- tests/c_api_test.rs | 188 +++++++++++++++++++++++++++++++++++++++-
- 4 files changed, 448 insertions(+), 2 deletions(-)
-
-diff --git a/include/lance/lance.h b/include/lance/lance.h
-index 986905b..c6c3985 100644
---- a/include/lance/lance.h
-+++ b/include/lance/lance.h
-@@ -863,6 +863,79 @@ int32_t lance_scanner_set_substrait_filter(
- size_t len
- );
-
-+/** Type of a dynamically named scan metric. */
-+typedef enum {
-+ LANCE_SCAN_METRIC_COUNT = 0,
-+ LANCE_SCAN_METRIC_TIME_NANOSECONDS = 1,
-+} LanceScanMetricKind;
-+
-+/**
-+ * Borrowed view of one dynamically named scan metric.
-+ *
-+ * `name` is not NUL-terminated. `name` and this structure are valid only for
-+ * the duration of the LanceScanStatisticsCallback invocation.
-+ */
-+typedef struct {
-+ const char* name;
-+ size_t name_len;
-+ LanceScanMetricKind kind;
-+ uint64_t value;
-+} LanceScanMetric;
-+
-+/**
-+ * Borrowed view of the execution statistics for one finalized scan.
-+ *
-+ * The fixed fields are stable summary metrics. `metrics` contains additional
-+ * implementation-specific counters and timings. Those names are not a stable
-+ * API and are intended for diagnostics and profiles. Dynamic metrics are
-+ * best-effort and may be omitted if they cannot be materialized. `metrics` is
-+ * NULL when `metrics_len` is zero.
-+ */
-+typedef struct {
-+ uint64_t iops;
-+ uint64_t requests;
-+ uint64_t bytes_read;
-+ uint64_t indices_loaded;
-+ uint64_t index_partitions_loaded;
-+ uint64_t index_comparisons;
-+ const LanceScanMetric* metrics;
-+ size_t metrics_len;
-+} LanceScanStatistics;
-+
-+/**
-+ * Receives scan statistics when a stream reaches EOF, fails, or is released.
-+ *
-+ * The statistics and all nested pointers are borrowed and valid only for the
-+ * duration of this call. The callback may run on the thread that consumes or
-+ * releases the scan stream and must therefore be thread-safe. It must return
-+ * normally without throwing an exception or unwinding, and must not call any
-+ * `lance_scanner_*` function with the originating scanner.
-+ *
-+ * Scan statistics are diagnostic and best-effort. The callback must handle
its
-+ * own errors and must not use them to abort or throw across this FFI
boundary.
-+ */
-+typedef void (*LanceScanStatisticsCallback)(
-+ void* callback_ctx,
-+ const LanceScanStatistics* statistics
-+);
-+
-+/**
-+ * Register the execution-statistics callback for this scanner.
-+ *
-+ * Must be called before starting the scan; registering after the scan starts
-+ * returns an error. `callback` must not be NULL. `callback_ctx` may be NULL.
A
-+ * non-NULL `callback_ctx` must remain valid, and `callback` must remain
valid,
-+ * until the stream reaches EOF, fails, or is released. Replaces a previously
-+ * registered callback.
-+ *
-+ * @return 0 on success, -1 on error
-+ */
-+int32_t lance_scanner_set_statistics_callback(
-+ LanceScanner* scanner,
-+ LanceScanStatisticsCallback callback,
-+ void* callback_ctx
-+);
-+
- /** Close and free a scanner handle. */
- void lance_scanner_close(LanceScanner* scanner);
-
-diff --git a/include/lance/lance.hpp b/include/lance/lance.hpp
-index afce358..9440a23 100644
---- a/include/lance/lance.hpp
-+++ b/include/lance/lance.hpp
-@@ -1127,6 +1127,16 @@ class Scanner {
- return substrait_filter(bytes.data(), bytes.size());
- }
-
-+ /// Register a callback for scan execution statistics before starting the
scan.
-+ /// The callback may run on the thread that consumes or releases the
stream. It
-+ /// must be thread-safe, must not throw, and must not re-enter the
originating
-+ /// scanner. A non-null callback context must outlive the exported stream.
-+ Scanner& statistics_callback(LanceScanStatisticsCallback callback, void*
callback_ctx) {
-+ if (lance_scanner_set_statistics_callback(handle_.get(), callback,
callback_ctx) != 0)
-+ check_error();
-+ return *this;
-+ }
-+
- /// Restrict the next k-NN query to a subset of vector index segments.
- /// Pass `len` 16-byte UUIDs concatenated as a single byte buffer
- /// (total bytes = `len * 16`). Pass len=0 (and any pointer) to clear.
-diff --git a/src/scanner.rs b/src/scanner.rs
-index f44b82f..d95089e 100644
---- a/src/scanner.rs
-+++ b/src/scanner.rs
-@@ -14,7 +14,9 @@ use arrow::ffi_stream::FFI_ArrowArrayStream;
- use arrow_schema::SchemaRef;
- use futures::{FutureExt, Stream, StreamExt};
- use lance::Dataset;
--use lance::dataset::scanner::DatasetRecordBatchStream;
-+use lance::dataset::scanner::{
-+ DatasetRecordBatchStream, ExecutionStatsCallback, ExecutionSummaryCounts,
-+};
- use lance_core::Result;
- use lance_index::scalar::FullTextSearchQuery;
- use lance_io::stream::RecordBatchStream;
-@@ -69,6 +71,8 @@ pub struct LanceScanner {
- // the spawned async task can poison the handle from outside this call
- // frame via `poison_flag()`.
- poisoned: Arc<AtomicBool>,
-+ scan_statistics_callback: Option<ExecutionStatsCallback>,
-+ scan_started: AtomicBool,
- // Materialized on first iteration call
- stream: Option<Pin<Box<DatasetRecordBatchStream>>>,
- #[allow(dead_code)]
-@@ -122,6 +126,8 @@ impl LanceScanner {
- prefilter: false,
- fts_query: None,
- poisoned: Arc::new(AtomicBool::new(false)),
-+ scan_statistics_callback: None,
-+ scan_started: AtomicBool::new(false),
- stream: None,
- schema: None,
- }
-@@ -157,6 +163,7 @@ impl LanceScanner {
-
- /// Build the underlying Scanner and open a stream.
- fn materialize_stream(&mut self) -> Result<()> {
-+ self.scan_started.store(true, Ordering::Release);
- let mut scanner = self.dataset.scan();
- if let Some(cols) = &self.columns {
- scanner.project(cols)?;
-@@ -212,6 +219,9 @@ impl LanceScanner {
- if let Some(fts) = &self.fts_query {
- scanner.full_text_search(fts.clone())?;
- }
-+ if let Some(callback) = &self.scan_statistics_callback {
-+ scanner.scan_stats_callback(callback.clone());
-+ }
- let stream = block_on(scanner.try_into_stream())?;
- self.schema = Some(stream.schema());
- self.stream = Some(Box::pin(stream));
-@@ -220,6 +230,7 @@ impl LanceScanner {
-
- /// Build a Scanner (without materializing) and return it.
- fn build_scanner(&self) -> Result<lance::dataset::scanner::Scanner> {
-+ self.scan_started.store(true, Ordering::Release);
- let mut scanner = self.dataset.scan();
- if let Some(cols) = &self.columns {
- scanner.project(cols)?;
-@@ -274,10 +285,122 @@ impl LanceScanner {
- if let Some(fts) = &self.fts_query {
- scanner.full_text_search(fts.clone())?;
- }
-+ if let Some(callback) = &self.scan_statistics_callback {
-+ scanner.scan_stats_callback(callback.clone());
-+ }
- Ok(scanner)
- }
- }
-
-+/// Type of a dynamically named scan metric.
-+#[repr(i32)]
-+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
-+pub enum LanceScanMetricKind {
-+ /// Monotonically accumulated counter.
-+ Count = 0,
-+ /// Accumulated duration in nanoseconds.
-+ TimeNanoseconds = 1,
-+}
-+
-+/// Borrowed view of one dynamically named scan metric.
-+///
-+/// `name` is not NUL-terminated. Both `name` and this structure are valid
only
-+/// for the duration of the scan statistics callback.
-+#[repr(C)]
-+#[derive(Clone, Copy, Debug)]
-+pub struct LanceScanMetric {
-+ pub name: *const c_char,
-+ pub name_len: usize,
-+ pub kind: LanceScanMetricKind,
-+ pub value: u64,
-+}
-+
-+/// Borrowed view of the execution statistics for one completed scan.
-+///
-+/// The fixed fields are stable summary metrics. `metrics` contains additional
-+/// implementation-specific counters and timings and is valid only for the
-+/// duration of the callback.
-+#[repr(C)]
-+#[derive(Clone, Copy, Debug)]
-+pub struct LanceScanStatistics {
-+ pub iops: u64,
-+ pub requests: u64,
-+ pub bytes_read: u64,
-+ pub indices_loaded: u64,
-+ pub index_partitions_loaded: u64,
-+ pub index_comparisons: u64,
-+ pub metrics: *const LanceScanMetric,
-+ pub metrics_len: usize,
-+}
-+
-+/// Callback invoked when a scan stream reaches EOF, fails, or is released.
-+///
-+/// The callback is an FFI boundary and must return normally without unwinding
-+/// or throwing an exception. It must not call back into `lance_scanner_*`
with
-+/// the originating scanner.
-+pub type LanceScanStatisticsCallback =
-+ Option<unsafe extern "C" fn(ctx: *mut c_void, statistics: *const
LanceScanStatistics)>;
-+
-+struct SendScanStatisticsCallback {
-+ callback: unsafe extern "C" fn(*mut c_void, *const LanceScanStatistics),
-+ ctx: *mut c_void,
-+}
-+
-+// SAFETY: The C API requires the callback and its context to remain valid and
-+// safe to invoke from the thread that consumes or releases the scan stream.
-+unsafe impl Send for SendScanStatisticsCallback {}
-+unsafe impl Sync for SendScanStatisticsCallback {}
-+
-+impl SendScanStatisticsCallback {
-+ fn invoke(&self, counts: &ExecutionSummaryCounts) {
-+ // Dynamic profile metrics are best-effort. Use fallible reservation
so
-+ // allocation failure omits them instead of aborting the embedding
process.
-+ let mut metrics = Vec::new();
-+ if let Some(metrics_len) =
counts.all_counts.len().checked_add(counts.all_times.len())
-+ && metrics.try_reserve_exact(metrics_len).is_ok()
-+ {
-+ metrics.extend(
-+ counts
-+ .all_counts
-+ .iter()
-+ .map(|(name, value)| LanceScanMetric {
-+ name: name.as_ptr().cast(),
-+ name_len: name.len(),
-+ kind: LanceScanMetricKind::Count,
-+ value: *value as u64,
-+ }),
-+ );
-+ metrics.extend(
-+ counts
-+ .all_times
-+ .iter()
-+ .map(|(name, value)| LanceScanMetric {
-+ name: name.as_ptr().cast(),
-+ name_len: name.len(),
-+ kind: LanceScanMetricKind::TimeNanoseconds,
-+ value: *value as u64,
-+ }),
-+ );
-+ }
-+
-+ let statistics = LanceScanStatistics {
-+ iops: counts.iops as u64,
-+ requests: counts.requests as u64,
-+ bytes_read: counts.bytes_read as u64,
-+ indices_loaded: counts.indices_loaded as u64,
-+ index_partitions_loaded: counts.parts_loaded as u64,
-+ index_comparisons: counts.index_comparisons as u64,
-+ metrics: if metrics.is_empty() {
-+ ptr::null()
-+ } else {
-+ metrics.as_ptr()
-+ },
-+ metrics_len: metrics.len(),
-+ };
-+ unsafe { (self.callback)(self.ctx, &statistics) };
-+ }
-+}
-+
- // ---------------------------------------------------------------------------
- // Poison check shared by all `lance_scanner_*` entry points
- // ---------------------------------------------------------------------------
-@@ -529,6 +652,60 @@ unsafe fn scanner_set_substrait_filter_inner(
- Ok(0)
- }
-
-+/// Register a callback that receives execution statistics when the scan
stream
-+/// reaches EOF, fails, or is released.
-+///
-+/// The callback and `callback_ctx` must remain valid until the scan stream is
-+/// finalized. Metric names and arrays passed to the callback are borrowed and
-+/// must be copied if the caller needs to retain them. The callback must be
-+/// thread-safe, must return normally without unwinding or throwing an
-+/// exception, and must not call `lance_scanner_*` with the originating
scanner.
-+#[unsafe(no_mangle)]
-+pub unsafe extern "C" fn lance_scanner_set_statistics_callback(
-+ scanner: *mut LanceScanner,
-+ callback: LanceScanStatisticsCallback,
-+ callback_ctx: *mut c_void,
-+) -> i32 {
-+ scanner_poison_check!(scanner, -1);
-+ ffi_try!(
-+ unsafe { scanner_set_statistics_callback_inner(scanner, callback,
callback_ctx) },
-+ neg
-+ )
-+}
-+
-+unsafe fn scanner_set_statistics_callback_inner(
-+ scanner: *mut LanceScanner,
-+ callback: LanceScanStatisticsCallback,
-+ callback_ctx: *mut c_void,
-+) -> Result<i32> {
-+ if scanner.is_null() {
-+ return Err(lance_core::Error::invalid_input_source(
-+ "scanner is NULL".into(),
-+ ));
-+ }
-+ let Some(callback) = callback else {
-+ return Err(lance_core::Error::invalid_input_source(
-+ "statistics callback is NULL".into(),
-+ ));
-+ };
-+
-+ let s = unsafe { &mut *scanner };
-+ if s.scan_started.load(Ordering::Acquire) {
-+ return Err(lance_core::Error::invalid_input_source(
-+ "statistics callback must be registered before the scan
starts".into(),
-+ ));
-+ }
-+
-+ let callback = SendScanStatisticsCallback {
-+ callback,
-+ ctx: callback_ctx,
-+ };
-+ s.scan_statistics_callback = Some(Arc::new(move |counts:
&ExecutionSummaryCounts| {
-+ callback.invoke(counts);
-+ }));
-+ Ok(0)
-+}
-+
- /// Close and free a scanner handle.
- ///
- /// Best-effort (issue #61): this drops a possibly-live
-diff --git a/tests/c_api_test.rs b/tests/c_api_test.rs
-index d6cd928..db35fea 100644
---- a/tests/c_api_test.rs
-+++ b/tests/c_api_test.rs
-@@ -6,7 +6,7 @@
- //! These tests call the `extern "C"` functions directly from Rust,
- //! validating the C API contract without needing a C compiler.
-
--use std::ffi::{CString, c_char};
-+use std::ffi::{CString, c_char, c_void};
- use std::process::Command;
- use std::ptr;
- use std::sync::Arc;
-@@ -99,6 +99,58 @@ fn c_str(s: &str) -> CString {
- CString::new(s).unwrap()
- }
-
-+#[derive(Default)]
-+struct CapturedScanStatistics {
-+ calls: usize,
-+ iops: u64,
-+ requests: u64,
-+ bytes_read: u64,
-+ indices_loaded: u64,
-+ index_partitions_loaded: u64,
-+ index_comparisons: u64,
-+ metrics: Vec<(String, LanceScanMetricKind, u64)>,
-+}
-+
-+unsafe extern "C" fn capture_scan_statistics(
-+ callback_ctx: *mut c_void,
-+ statistics: *const LanceScanStatistics,
-+) {
-+ assert!(!callback_ctx.is_null());
-+ assert!(!statistics.is_null());
-+ let captured = unsafe { &mut
*callback_ctx.cast::<CapturedScanStatistics>() };
-+ let statistics = unsafe { &*statistics };
-+ let metrics = if statistics.metrics_len == 0 {
-+ &[]
-+ } else {
-+ assert!(!statistics.metrics.is_null());
-+ unsafe { std::slice::from_raw_parts(statistics.metrics,
statistics.metrics_len) }
-+ };
-+
-+ captured.calls += 1;
-+ captured.iops = statistics.iops;
-+ captured.requests = statistics.requests;
-+ captured.bytes_read = statistics.bytes_read;
-+ captured.indices_loaded = statistics.indices_loaded;
-+ captured.index_partitions_loaded = statistics.index_partitions_loaded;
-+ captured.index_comparisons = statistics.index_comparisons;
-+ captured.metrics = metrics
-+ .iter()
-+ .map(|metric| {
-+ let name = if metric.name_len == 0 {
-+ &[]
-+ } else {
-+ assert!(!metric.name.is_null());
-+ unsafe { std::slice::from_raw_parts(metric.name.cast::<u8>(),
metric.name_len) }
-+ };
-+ (
-+ std::str::from_utf8(name).unwrap().to_owned(),
-+ metric.kind,
-+ metric.value,
-+ )
-+ })
-+ .collect();
-+}
-+
- /// Helper: build a tiny dataset whose `value` column is nullable AND contains
- /// at least one NULL. Used by tests that need to exercise upstream's
- /// nullability-tightening pre-scan failure path.
-@@ -284,6 +336,140 @@ fn test_scanner_to_arrow_stream() {
- unsafe { lance_dataset_close(ds) };
- }
-
-+#[test]
-+fn test_scanner_statistics_callback_with_next_multi_fragment() {
-+ let (_tmp, uri) = create_multi_fragment_dataset();
-+ let c_uri = c_str(&uri);
-+ let ds = unsafe { lance_dataset_open(c_uri.as_ptr(), ptr::null(), 0) };
-+ assert!(!ds.is_null());
-+ assert_eq!(unsafe { lance_dataset_fragment_count(ds) }, 2);
-+
-+ let scanner = unsafe { lance_scanner_new(ds, ptr::null(), ptr::null()) };
-+ assert!(!scanner.is_null());
-+ let mut captured = CapturedScanStatistics::default();
-+ assert_eq!(
-+ unsafe {
-+ lance_scanner_set_statistics_callback(
-+ scanner,
-+ Some(capture_scan_statistics),
-+ (&mut captured as *mut CapturedScanStatistics).cast(),
-+ )
-+ },
-+ 0
-+ );
-+
-+ loop {
-+ let mut batch = ptr::null_mut();
-+ match unsafe { lance_scanner_next(scanner, &mut batch) } {
-+ 0 => unsafe { lance_batch_free(batch) },
-+ 1 => break,
-+ status => panic!("scanner_next returned error: {status}"),
-+ }
-+ }
-+
-+ assert_eq!(captured.calls, 1);
-+ assert!(captured.bytes_read > 0);
-+ assert!(captured.requests > 0);
-+ assert!(captured.metrics.iter().all(|(name, _, _)| !name.is_empty()));
-+
-+ unsafe { lance_scanner_close(scanner) };
-+ unsafe { lance_dataset_close(ds) };
-+}
-+
-+#[test]
-+fn test_scanner_statistics_callback_with_arrow_stream() {
-+ let (_tmp, uri) = create_test_dataset();
-+ let c_uri = c_str(&uri);
-+ let ds = unsafe { lance_dataset_open(c_uri.as_ptr(), ptr::null(), 0) };
-+ assert!(!ds.is_null());
-+
-+ let scanner = unsafe { lance_scanner_new(ds, ptr::null(), ptr::null()) };
-+ assert!(!scanner.is_null());
-+ let mut captured = CapturedScanStatistics::default();
-+ assert_eq!(
-+ unsafe {
-+ lance_scanner_set_statistics_callback(
-+ scanner,
-+ Some(capture_scan_statistics),
-+ (&mut captured as *mut CapturedScanStatistics).cast(),
-+ )
-+ },
-+ 0
-+ );
-+
-+ let mut stream = FFI_ArrowArrayStream::empty();
-+ assert_eq!(unsafe { lance_scanner_to_arrow_stream(scanner, &mut stream)
}, 0);
-+ let reader = unsafe { ArrowArrayStreamReader::from_raw(&mut stream)
}.unwrap();
-+ assert_eq!(reader.map(|batch| batch.unwrap().num_rows()).sum::<usize>(),
5);
-+ assert_eq!(captured.calls, 1);
-+ assert!(captured.bytes_read > 0);
-+
-+ unsafe { lance_scanner_close(scanner) };
-+ unsafe { lance_dataset_close(ds) };
-+}
-+
-+#[test]
-+fn test_scanner_statistics_callback_rejects_null_inputs() {
-+ assert_eq!(
-+ unsafe {
-+ lance_scanner_set_statistics_callback(
-+ ptr::null_mut(),
-+ Some(capture_scan_statistics),
-+ ptr::null_mut(),
-+ )
-+ },
-+ -1
-+ );
-+ assert_eq!(lance_last_error_code(), LanceErrorCode::InvalidArgument);
-+
-+ let (_tmp, uri) = create_test_dataset();
-+ let c_uri = c_str(&uri);
-+ let ds = unsafe { lance_dataset_open(c_uri.as_ptr(), ptr::null(), 0) };
-+ let scanner = unsafe { lance_scanner_new(ds, ptr::null(), ptr::null()) };
-+ assert_eq!(
-+ unsafe { lance_scanner_set_statistics_callback(scanner, None,
ptr::null_mut()) },
-+ -1
-+ );
-+ assert_eq!(lance_last_error_code(), LanceErrorCode::InvalidArgument);
-+
-+ unsafe { lance_scanner_close(scanner) };
-+ unsafe { lance_dataset_close(ds) };
-+}
-+
-+#[test]
-+fn test_scanner_statistics_callback_rejects_registration_after_scan_started()
{
-+ let (_tmp, uri) = create_test_dataset();
-+ let c_uri = c_str(&uri);
-+ let ds = unsafe { lance_dataset_open(c_uri.as_ptr(), ptr::null(), 0) };
-+ assert!(!ds.is_null());
-+ let scanner = unsafe { lance_scanner_new(ds, ptr::null(), ptr::null()) };
-+ assert!(!scanner.is_null());
-+
-+ let mut batch = ptr::null_mut();
-+ assert_eq!(unsafe { lance_scanner_next(scanner, &mut batch) }, 0);
-+ assert!(!batch.is_null());
-+ unsafe { lance_batch_free(batch) };
-+
-+ let mut captured = CapturedScanStatistics::default();
-+ assert_eq!(
-+ unsafe {
-+ lance_scanner_set_statistics_callback(
-+ scanner,
-+ Some(capture_scan_statistics),
-+ (&mut captured as *mut CapturedScanStatistics).cast(),
-+ )
-+ },
-+ -1
-+ );
-+ assert_eq!(lance_last_error_code(), LanceErrorCode::InvalidArgument);
-+ let error = take_last_error_message();
-+ assert!(error.contains("before the scan starts"), "{error}");
-+
-+ unsafe { lance_scanner_close(scanner) };
-+ assert_eq!(captured.calls, 0);
-+ unsafe { lance_dataset_close(ds) };
-+}
-+
- #[test]
- fn test_scanner_with_filter() {
- let (_tmp, uri) = create_test_dataset();
-
-From 8e6e92140031c06184da079b8f6c194799c0de88 Mon Sep 17 00:00:00 2001
-From: zhangstar333 <[email protected]>
-Date: Mon, 24 Aug 2026 17:04:51 +0800
-Subject: [PATCH 2/3] formatter
-
----
- include/lance/lance.h | 23 ++++---
- include/lance/lance.hpp | 9 +--
- src/scanner.rs | 23 ++++---
- tests/c_api_test.rs | 134 +++++++++++++++++++++++++++++++++++++++-
- 4 files changed, 165 insertions(+), 24 deletions(-)
-
-diff --git a/include/lance/lance.h b/include/lance/lance.h
-index c6c3985..1a6822b 100644
---- a/include/lance/lance.h
-+++ b/include/lance/lance.h
-@@ -883,7 +883,7 @@ typedef struct {
- } LanceScanMetric;
-
- /**
-- * Borrowed view of the execution statistics for one finalized scan.
-+ * Borrowed view of the execution statistics for one fully consumed scan.
- *
- * The fixed fields are stable summary metrics. `metrics` contains additional
- * implementation-specific counters and timings. Those names are not a stable
-@@ -903,13 +903,13 @@ typedef struct {
- } LanceScanStatistics;
-
- /**
-- * Receives scan statistics when a stream reaches EOF, fails, or is released.
-+ * Receives scan statistics after a stream is fully consumed to EOF.
- *
- * The statistics and all nested pointers are borrowed and valid only for the
-- * duration of this call. The callback may run on the thread that consumes or
-- * releases the scan stream and must therefore be thread-safe. It must return
-- * normally without throwing an exception or unwinding, and must not call any
-- * `lance_scanner_*` function with the originating scanner.
-+ * duration of this call. The callback may run on the thread that observes EOF
-+ * and must therefore be thread-safe. It must return normally without throwing
-+ * an exception or unwinding, and must not call any `lance_scanner_*` function
-+ * with the originating scanner.
- *
- * Scan statistics are diagnostic and best-effort. The callback must handle
its
- * own errors and must not use them to abort or throw across this FFI
boundary.
-@@ -925,8 +925,15 @@ typedef void (*LanceScanStatisticsCallback)(
- * Must be called before starting the scan; registering after the scan starts
- * returns an error. `callback` must not be NULL. `callback_ctx` may be NULL.
A
- * non-NULL `callback_ctx` must remain valid, and `callback` must remain
valid,
-- * until the stream reaches EOF, fails, or is released. Replaces a previously
-- * registered callback.
-+ * until the callback returns or, if the callback has not run, until the
owning
-+ * scan stream is released. For `lance_scanner_next` and
-+ * `lance_scanner_poll_next`, the scanner owns the stream. For an exported
-+ * ArrowArrayStream, the Arrow stream owns it independently of the scanner.
-+ *
-+ * The callback is invoked exactly once when the stream is fully consumed to
-+ * EOF. It is not guaranteed to run if execution fails, the scan is cancelled,
-+ * or the scanner / ArrowArrayStream is released before EOF. Replaces a
-+ * previously registered callback.
- *
- * @return 0 on success, -1 on error
- */
-diff --git a/include/lance/lance.hpp b/include/lance/lance.hpp
-index 9440a23..4268801 100644
---- a/include/lance/lance.hpp
-+++ b/include/lance/lance.hpp
-@@ -1127,10 +1127,11 @@ class Scanner {
- return substrait_filter(bytes.data(), bytes.size());
- }
-
-- /// Register a callback for scan execution statistics before starting the
scan.
-- /// The callback may run on the thread that consumes or releases the
stream. It
-- /// must be thread-safe, must not throw, and must not re-enter the
originating
-- /// scanner. A non-null callback context must outlive the exported stream.
-+ /// Register a callback for scan statistics after successful full
exhaustion.
-+ /// The callback is not guaranteed on error, cancellation, or early
release. It
-+ /// may run on the thread that observes EOF, must be thread-safe, must
not throw,
-+ /// and must not re-enter the originating scanner. The callback and a
non-null
-+ /// context must remain valid until the callback returns or the stream is
released.
- Scanner& statistics_callback(LanceScanStatisticsCallback callback, void*
callback_ctx) {
- if (lance_scanner_set_statistics_callback(handle_.get(), callback,
callback_ctx) != 0)
- check_error();
-diff --git a/src/scanner.rs b/src/scanner.rs
-index d95089e..414c269 100644
---- a/src/scanner.rs
-+++ b/src/scanner.rs
-@@ -315,7 +315,7 @@ pub struct LanceScanMetric {
- pub value: u64,
- }
-
--/// Borrowed view of the execution statistics for one completed scan.
-+/// Borrowed view of the execution statistics for one fully consumed scan.
- ///
- /// The fixed fields are stable summary metrics. `metrics` contains additional
- /// implementation-specific counters and timings and is valid only for the
-@@ -333,7 +333,7 @@ pub struct LanceScanStatistics {
- pub metrics_len: usize,
- }
-
--/// Callback invoked when a scan stream reaches EOF, fails, or is released.
-+/// Callback invoked after a scan stream is fully consumed to EOF.
- ///
- /// The callback is an FFI boundary and must return normally without unwinding
- /// or throwing an exception. It must not call back into `lance_scanner_*`
with
-@@ -347,7 +347,7 @@ struct SendScanStatisticsCallback {
- }
-
- // SAFETY: The C API requires the callback and its context to remain valid and
--// safe to invoke from the thread that consumes or releases the scan stream.
-+// safe to invoke from the thread that observes the scan stream's EOF.
- unsafe impl Send for SendScanStatisticsCallback {}
- unsafe impl Sync for SendScanStatisticsCallback {}
-
-@@ -652,14 +652,17 @@ unsafe fn scanner_set_substrait_filter_inner(
- Ok(0)
- }
-
--/// Register a callback that receives execution statistics when the scan
stream
--/// reaches EOF, fails, or is released.
-+/// Register a callback that receives execution statistics after the scan
stream
-+/// is fully consumed to EOF.
- ///
--/// The callback and `callback_ctx` must remain valid until the scan stream is
--/// finalized. Metric names and arrays passed to the callback are borrowed and
--/// must be copied if the caller needs to retain them. The callback must be
--/// thread-safe, must return normally without unwinding or throwing an
--/// exception, and must not call `lance_scanner_*` with the originating
scanner.
-+/// The callback is not guaranteed to run if execution fails, the scan is
-+/// cancelled, or the scanner / exported Arrow stream is released before EOF.
-+/// The callback and `callback_ctx` must remain valid until the callback
returns
-+/// or, if it has not run, until the owning scan stream is released. Metric
names
-+/// and arrays passed to the callback are borrowed and must be copied if the
-+/// caller needs to retain them. The callback must be thread-safe, must return
-+/// normally without unwinding or throwing an exception, and must not call
-+/// `lance_scanner_*` with the originating scanner.
- #[unsafe(no_mangle)]
- pub unsafe extern "C" fn lance_scanner_set_statistics_callback(
- scanner: *mut LanceScanner,
-diff --git a/tests/c_api_test.rs b/tests/c_api_test.rs
-index db35fea..c1bb71d 100644
---- a/tests/c_api_test.rs
-+++ b/tests/c_api_test.rs
-@@ -372,7 +372,43 @@ fn
test_scanner_statistics_callback_with_next_multi_fragment() {
- assert!(captured.requests > 0);
- assert!(captured.metrics.iter().all(|(name, _, _)| !name.is_empty()));
-
-+ let mut batch = ptr::null_mut();
-+ assert_eq!(unsafe { lance_scanner_next(scanner, &mut batch) }, 1);
-+ assert!(batch.is_null());
-+ assert_eq!(captured.calls, 1, "callback must run exactly once");
-+
-+ unsafe { lance_scanner_close(scanner) };
-+ unsafe { lance_dataset_close(ds) };
-+}
-+
-+#[test]
-+fn test_scanner_statistics_callback_not_called_on_early_scanner_close() {
-+ let (_tmp, uri) = create_test_dataset();
-+ let c_uri = c_str(&uri);
-+ let ds = unsafe { lance_dataset_open(c_uri.as_ptr(), ptr::null(), 0) };
-+ assert!(!ds.is_null());
-+
-+ let scanner = unsafe { lance_scanner_new(ds, ptr::null(), ptr::null()) };
-+ assert!(!scanner.is_null());
-+ let mut captured = CapturedScanStatistics::default();
-+ assert_eq!(
-+ unsafe {
-+ lance_scanner_set_statistics_callback(
-+ scanner,
-+ Some(capture_scan_statistics),
-+ (&mut captured as *mut CapturedScanStatistics).cast(),
-+ )
-+ },
-+ 0
-+ );
-+
-+ let mut batch = ptr::null_mut();
-+ assert_eq!(unsafe { lance_scanner_next(scanner, &mut batch) }, 0);
-+ assert!(!batch.is_null());
-+ unsafe { lance_batch_free(batch) };
-+
- unsafe { lance_scanner_close(scanner) };
-+ assert_eq!(captured.calls, 0);
- unsafe { lance_dataset_close(ds) };
- }
-
-@@ -398,9 +434,15 @@ fn test_scanner_statistics_callback_with_arrow_stream() {
- );
-
- let mut stream = FFI_ArrowArrayStream::empty();
-- assert_eq!(unsafe { lance_scanner_to_arrow_stream(scanner, &mut stream)
}, 0);
-+ assert_eq!(
-+ unsafe { lance_scanner_to_arrow_stream(scanner, &mut stream) },
-+ 0
-+ );
- let reader = unsafe { ArrowArrayStreamReader::from_raw(&mut stream)
}.unwrap();
-- assert_eq!(reader.map(|batch| batch.unwrap().num_rows()).sum::<usize>(),
5);
-+ assert_eq!(
-+ reader.map(|batch| batch.unwrap().num_rows()).sum::<usize>(),
-+ 5
-+ );
- assert_eq!(captured.calls, 1);
- assert!(captured.bytes_read > 0);
-
-@@ -408,6 +450,74 @@ fn test_scanner_statistics_callback_with_arrow_stream() {
- unsafe { lance_dataset_close(ds) };
- }
-
-+#[test]
-+fn
test_scanner_statistics_callback_not_called_on_early_arrow_stream_release() {
-+ let (_tmp, uri) = create_test_dataset();
-+ let c_uri = c_str(&uri);
-+ let ds = unsafe { lance_dataset_open(c_uri.as_ptr(), ptr::null(), 0) };
-+ assert!(!ds.is_null());
-+
-+ let scanner = unsafe { lance_scanner_new(ds, ptr::null(), ptr::null()) };
-+ assert!(!scanner.is_null());
-+ let mut captured = CapturedScanStatistics::default();
-+ assert_eq!(
-+ unsafe {
-+ lance_scanner_set_statistics_callback(
-+ scanner,
-+ Some(capture_scan_statistics),
-+ (&mut captured as *mut CapturedScanStatistics).cast(),
-+ )
-+ },
-+ 0
-+ );
-+
-+ let mut stream = FFI_ArrowArrayStream::empty();
-+ assert_eq!(
-+ unsafe { lance_scanner_to_arrow_stream(scanner, &mut stream) },
-+ 0
-+ );
-+ let mut reader = unsafe { ArrowArrayStreamReader::from_raw(&mut stream)
}.unwrap();
-+ assert!(reader.next().unwrap().is_ok());
-+ drop(reader);
-+
-+ assert_eq!(captured.calls, 0);
-+ unsafe { lance_scanner_close(scanner) };
-+ assert_eq!(captured.calls, 0);
-+ unsafe { lance_dataset_close(ds) };
-+}
-+
-+#[test]
-+fn test_scanner_statistics_callback_not_called_on_materialization_error() {
-+ let (_tmp, uri) = create_test_dataset();
-+ let c_uri = c_str(&uri);
-+ let ds = unsafe { lance_dataset_open(c_uri.as_ptr(), ptr::null(), 0) };
-+ assert!(!ds.is_null());
-+
-+ let bad_filter = c_str("NOT A VALID >>> FILTER ???");
-+ let scanner = unsafe { lance_scanner_new(ds, ptr::null(),
bad_filter.as_ptr()) };
-+ assert!(!scanner.is_null());
-+ let mut captured = CapturedScanStatistics::default();
-+ assert_eq!(
-+ unsafe {
-+ lance_scanner_set_statistics_callback(
-+ scanner,
-+ Some(capture_scan_statistics),
-+ (&mut captured as *mut CapturedScanStatistics).cast(),
-+ )
-+ },
-+ 0
-+ );
-+
-+ let mut batch = ptr::null_mut();
-+ assert_eq!(unsafe { lance_scanner_next(scanner, &mut batch) }, -1);
-+ assert!(batch.is_null());
-+ assert_eq!(captured.calls, 0);
-+
-+ unsafe { lance_scanner_close(scanner) };
-+ assert_eq!(captured.calls, 0);
-+ unsafe { lance_dataset_close(ds) };
-+}
-+
- #[test]
- fn test_scanner_statistics_callback_rejects_null_inputs() {
- assert_eq!(
-@@ -1367,6 +1477,17 @@ fn test_poll_next_basic() {
- let c_uri = c_str(&uri_clone);
- let ds = unsafe { lance_dataset_open(c_uri.as_ptr(), ptr::null(), 0)
};
- let scanner = unsafe { lance_scanner_new(ds, ptr::null(),
ptr::null()) };
-+ let mut captured = CapturedScanStatistics::default();
-+ assert_eq!(
-+ unsafe {
-+ lance_scanner_set_statistics_callback(
-+ scanner,
-+ Some(capture_scan_statistics),
-+ (&mut captured as *mut CapturedScanStatistics).cast(),
-+ )
-+ },
-+ 0
-+ );
-
- use std::sync::atomic::{AtomicBool, Ordering};
- static WOKE: AtomicBool = AtomicBool::new(false);
-@@ -1401,6 +1522,15 @@ fn test_poll_next_basic() {
- assert!(iterations < 1000, "poll loop should not spin forever");
- }
- assert_eq!(total_rows, 5);
-+ assert_eq!(captured.calls, 1);
-+
-+ let mut batch: *mut LanceBatch = ptr::null_mut();
-+ assert_eq!(
-+ unsafe { lance_scanner_poll_next(scanner, test_waker,
ptr::null_mut(), &mut batch) },
-+ LancePollStatus::Finished
-+ );
-+ assert!(batch.is_null());
-+ assert_eq!(captured.calls, 1, "callback must run exactly once");
-
- unsafe { lance_scanner_close(scanner) };
- unsafe { lance_dataset_close(ds) };
-
-From fa168ef99951d0396e50c060e540dee93e14e2be Mon Sep 17 00:00:00 2001
-From: zhangstar333 <[email protected]>
-Date: Mon, 24 Aug 2026 20:47:41 +0800
-Subject: [PATCH 3/3] update
-
----
- include/lance/lance.h | 55 ++++++++---
- include/lance/lance.hpp | 22 ++++-
- src/scanner.rs | 51 +++++++---
- tests/c_api_test.rs | 188 +++++++++++++++++++++++++++++++------
- tests/cpp/test_c_api.c | 39 +++++++-
- tests/cpp/test_cpp_api.cpp | 28 +++++-
- 6 files changed, 319 insertions(+), 64 deletions(-)
-
-diff --git a/include/lance/lance.h b/include/lance/lance.h
-index 1a6822b..5b12f3d 100644
---- a/include/lance/lance.h
-+++ b/include/lance/lance.h
-@@ -873,7 +873,8 @@ typedef enum {
- * Borrowed view of one dynamically named scan metric.
- *
- * `name` is not NUL-terminated. `name` and this structure are valid only for
-- * the duration of the LanceScanStatisticsCallback invocation.
-+ * the duration of the LanceScanStatisticsCallback invocation. Metric order is
-+ * unspecified.
- */
- typedef struct {
- const char* name;
-@@ -905,11 +906,22 @@ typedef struct {
- /**
- * Receives scan statistics after a stream is fully consumed to EOF.
- *
-- * The statistics and all nested pointers are borrowed and valid only for the
-- * duration of this call. The callback may run on the thread that observes EOF
-- * and must therefore be thread-safe. It must return normally without throwing
-- * an exception or unwinding, and must not call any `lance_scanner_*` function
-- * with the originating scanner.
-+ * `statistics` is non-NULL. It and all nested pointers are borrowed and valid
-+ * only for the duration of this call. The callback may run on the thread that
-+ * observes EOF and must therefore be thread-safe. It must return normally
-+ * without throwing an exception or unwinding, and must not call any
-+ * `lance_scanner_*` function with the originating scanner.
-+ *
-+ * From callback entry until the enclosing operation that observes EOF has
-+ * returned to its caller, the callback must not directly or indirectly cause
-+ * `get_schema`, `get_next`, `get_last_error`, or `release` to be called on
any
-+ * ArrowArrayStream derived from the originating scanner, nor cause such a
-+ * stream to be moved, destroyed, or otherwise accessed. This includes
signaling
-+ * or scheduling another thread to act based only on callback completion: the
-+ * callback returns before the enclosing stream operation does. Such
interaction
-+ * is reentrant and has undefined behavior. Normal access may resume only
after
-+ * the enclosing ArrowArrayStream `get_next`, `lance_scanner_next`, or
-+ * `lance_scanner_poll_next` call returns to its caller.
- *
- * Scan statistics are diagnostic and best-effort. The callback must handle
its
- * own errors and must not use them to abort or throw across this FFI
boundary.
-@@ -925,15 +937,27 @@ typedef void (*LanceScanStatisticsCallback)(
- * Must be called before starting the scan; registering after the scan starts
- * returns an error. `callback` must not be NULL. `callback_ctx` may be NULL.
A
- * non-NULL `callback_ctx` must remain valid, and `callback` must remain
valid,
-- * until the callback returns or, if the callback has not run, until the
owning
-- * scan stream is released. For `lance_scanner_next` and
-- * `lance_scanner_poll_next`, the scanner owns the stream. For an exported
-- * ArrowArrayStream, the Arrow stream owns it independently of the scanner.
-- *
-- * The callback is invoked exactly once when the stream is fully consumed to
-- * EOF. It is not guaranteed to run if execution fails, the scan is cancelled,
-- * or the scanner / ArrowArrayStream is released before EOF. Replaces a
-- * previously registered callback.
-+ * until all of the following are true: the scanner is closed, every in-flight
-+ * `lance_scanner_scan_async` call has delivered its completion callback, and
-+ * every ArrowArrayStream derived from the scanner has been released. The
-+ * registration remains installed after a callback returns and applies to
-+ * streams created later from the same scanner. For `lance_scanner_next` and
-+ * `lance_scanner_poll_next`, the scanner owns the stream. Exported and
-+ * asynchronous ArrowArrayStreams own their registrations independently of the
-+ * scanner and may invoke the callback after the scanner is closed. Concurrent
-+ * streams may invoke the callback concurrently.
-+ *
-+ * The callback is invoked exactly once for each derived stream that is fully
-+ * consumed to EOF. It is not guaranteed to run for a stream if execution
fails,
-+ * the scan is cancelled, or the scanner / ArrowArrayStream is released before
-+ * EOF. Before scanning starts, a new registration replaces the previous one;
-+ * after a successful replacement, the previous callback and context are no
-+ * longer retained and may be retired.
-+ *
-+ * From callback entry until the enclosing EOF-observing operation returns,
the
-+ * callback must not directly or indirectly cause interaction with any
-+ * ArrowArrayStream derived from this scanner; see LanceScanStatisticsCallback
-+ * for the complete reentrancy restriction.
- *
- * @return 0 on success, -1 on error
- */
-@@ -950,6 +974,7 @@ void lance_scanner_close(LanceScanner* scanner);
-
- /**
- * Materialize the scan as an ArrowArrayStream (blocking).
-+ * The scanner remains valid, and each call creates an independent stream.
- *
- * Reading the exported stream may surface a mid-iteration panic as one
- * error through the Arrow C stream contract (nonzero get_next plus
-diff --git a/include/lance/lance.hpp b/include/lance/lance.hpp
-index 4268801..8aa97e2 100644
---- a/include/lance/lance.hpp
-+++ b/include/lance/lance.hpp
-@@ -1128,10 +1128,22 @@ class Scanner {
- }
-
- /// Register a callback for scan statistics after successful full
exhaustion.
-- /// The callback is not guaranteed on error, cancellation, or early
release. It
-- /// may run on the thread that observes EOF, must be thread-safe, must
not throw,
-- /// and must not re-enter the originating scanner. The callback and a
non-null
-- /// context must remain valid until the callback returns or the stream is
released.
-+ /// The registration applies to every stream derived from this scanner,
including
-+ /// concurrent streams and streams created after an earlier callback
returns. The
-+ /// callback is not guaranteed on error, cancellation, or early release.
It may
-+ /// run on the thread that observes EOF, must be thread-safe, must not
throw, and
-+ /// must not re-enter the originating scanner. The callback and a
non-null context
-+ /// must remain valid until the scanner is closed, all async scan
requests have
-+ /// delivered their completion callbacks, and all derived streams are
released.
-+ /// From callback entry until the enclosing operation that observes EOF
has
-+ /// returned to its caller, the callback must not directly or indirectly
cause
-+ /// any ArrowArrayStream derived from this Scanner to be accessed, called,
-+ /// released, moved, or destroyed. This includes signaling or scheduling
another
-+ /// thread to act based only on callback completion: the callback returns
before
-+ /// the enclosing stream operation does. Such interaction is reentrant
and has
-+ /// undefined behavior. Normal access may resume only after the enclosing
-+ /// ArrowArrayStream `get_next`, `lance_scanner_next`, or
-+ /// `lance_scanner_poll_next` call returns.
- Scanner& statistics_callback(LanceScanStatisticsCallback callback, void*
callback_ctx) {
- if (lance_scanner_set_statistics_callback(handle_.get(), callback,
callback_ctx) != 0)
- check_error();
-@@ -1153,7 +1165,7 @@ class Scanner {
- return index_segments(reinterpret_cast<const uint8_t*>(uuids.data()),
uuids.size());
- }
-
-- /// Materialize the scan as an ArrowArrayStream (blocking).
-+ /// Materialize an independent ArrowArrayStream (blocking). The scanner
remains valid.
- void to_arrow_stream(ArrowArrayStream* out) {
- if (lance_scanner_to_arrow_stream(handle_.get(), out) != 0)
- check_error();
-diff --git a/src/scanner.rs b/src/scanner.rs
-index 414c269..ef9d290 100644
---- a/src/scanner.rs
-+++ b/src/scanner.rs
-@@ -305,7 +305,7 @@ pub enum LanceScanMetricKind {
- /// Borrowed view of one dynamically named scan metric.
- ///
- /// `name` is not NUL-terminated. Both `name` and this structure are valid
only
--/// for the duration of the scan statistics callback.
-+/// for the duration of the scan statistics callback. Metric order is
unspecified.
- #[repr(C)]
- #[derive(Clone, Copy, Debug)]
- pub struct LanceScanMetric {
-@@ -318,8 +318,10 @@ pub struct LanceScanMetric {
- /// Borrowed view of the execution statistics for one fully consumed scan.
- ///
- /// The fixed fields are stable summary metrics. `metrics` contains additional
--/// implementation-specific counters and timings and is valid only for the
--/// duration of the callback.
-+/// implementation-specific counters and timings whose names are not a stable
API
-+/// and are intended only for diagnostics and profiles. Dynamic metrics are
-+/// best-effort and may be omitted if they cannot be materialized. `metrics`
is
-+/// null when `metrics_len` is zero and is valid only for the callback
duration.
- #[repr(C)]
- #[derive(Clone, Copy, Debug)]
- pub struct LanceScanStatistics {
-@@ -333,13 +335,20 @@ pub struct LanceScanStatistics {
- pub metrics_len: usize,
- }
-
--/// Callback invoked after a scan stream is fully consumed to EOF.
-+/// Callback invoked once for each derived scan stream that is fully consumed
to EOF.
- ///
- /// The callback is an FFI boundary and must return normally without unwinding
- /// or throwing an exception. It must not call back into `lance_scanner_*`
with
--/// the originating scanner.
-+/// the originating scanner. From callback entry until the enclosing operation
-+/// that observes EOF has returned to its caller, the callback must not
directly
-+/// or indirectly cause any Arrow C stream derived from the originating
scanner to
-+/// be called, released, moved, destroyed, or otherwise accessed. This
includes
-+/// signaling or scheduling another thread to act based only on callback
completion:
-+/// the callback returns before the enclosing stream operation does. Such
interaction
-+/// is reentrant and has undefined behavior. Normal access may resume only
after the
-+/// enclosing `get_next`, `lance_scanner_next`, or `lance_scanner_poll_next`
returns.
- pub type LanceScanStatisticsCallback =
-- Option<unsafe extern "C" fn(ctx: *mut c_void, statistics: *const
LanceScanStatistics)>;
-+ Option<unsafe extern "C" fn(callback_ctx: *mut c_void, statistics: *const
LanceScanStatistics)>;
-
- struct SendScanStatisticsCallback {
- callback: unsafe extern "C" fn(*mut c_void, *const LanceScanStatistics),
-@@ -347,7 +356,9 @@ struct SendScanStatisticsCallback {
- }
-
- // SAFETY: The C API requires the callback and its context to remain valid and
--// safe to invoke from the thread that observes the scan stream's EOF.
-+// safe to invoke until the scanner is closed, every in-flight asynchronous
scan
-+// has delivered its completion callback, and every derived stream has been
-+// released. Concurrent derived streams may invoke the callback concurrently.
- unsafe impl Send for SendScanStatisticsCallback {}
- unsafe impl Sync for SendScanStatisticsCallback {}
-
-@@ -657,12 +668,24 @@ unsafe fn scanner_set_substrait_filter_inner(
- ///
- /// The callback is not guaranteed to run if execution fails, the scan is
- /// cancelled, or the scanner / exported Arrow stream is released before EOF.
--/// The callback and `callback_ctx` must remain valid until the callback
returns
--/// or, if it has not run, until the owning scan stream is released. Metric
names
--/// and arrays passed to the callback are borrowed and must be copied if the
--/// caller needs to retain them. The callback must be thread-safe, must return
--/// normally without unwinding or throwing an exception, and must not call
--/// `lance_scanner_*` with the originating scanner.
-+/// The registration applies to every stream derived from this scanner,
including
-+/// streams created after an earlier callback has returned. The callback and
-+/// `callback_ctx` must remain valid until the scanner is closed, every
in-flight
-+/// asynchronous scan has delivered its completion callback, and every derived
-+/// stream has been released.
-+/// Metric names and arrays passed to the callback are borrowed and must be
copied
-+/// if the caller needs to retain them. The callback must be thread-safe, must
-+/// return normally without unwinding or throwing an exception, and must not
call
-+/// `lance_scanner_*` with the originating scanner. From callback entry until
the
-+/// enclosing operation that observes EOF has returned to its caller, the
callback
-+/// must not directly or indirectly cause any Arrow C stream derived from that
-+/// scanner to be called, released, moved, destroyed, or otherwise accessed.
This
-+/// includes signaling or scheduling another thread to act based only on
callback
-+/// completion: the callback returns before the enclosing stream operation
does.
-+/// Such interaction is reentrant and has undefined behavior. Normal access
may
-+/// resume only after the enclosing `get_next`, `lance_scanner_next`, or
-+/// `lance_scanner_poll_next` returns. Replacing the registration before
scanning
-+/// starts immediately releases the previous registration.
- #[unsafe(no_mangle)]
- pub unsafe extern "C" fn lance_scanner_set_statistics_callback(
- scanner: *mut LanceScanner,
-@@ -732,7 +755,7 @@ pub unsafe extern "C" fn lance_scanner_close(scanner: *mut
LanceScanner) {
- /// Materialize the scan as an Arrow C Data Interface `ArrowArrayStream`.
- ///
- /// This is the preferred API for simple integrations — blocks the calling
thread.
--/// The scanner is consumed by this call and should not be used afterward
(close it).
-+/// The scanner remains valid and may be used to create additional streams.
- ///
- /// The exported stream is panic-guarded (issue #61): a panic during export
- /// poisons the scanner — this call returns -1 with `LANCE_ERR_PANIC`, and
-diff --git a/tests/c_api_test.rs b/tests/c_api_test.rs
-index c1bb71d..daa7425 100644
---- a/tests/c_api_test.rs
-+++ b/tests/c_api_test.rs
-@@ -10,6 +10,7 @@ use std::ffi::{CString, c_char, c_void};
- use std::process::Command;
- use std::ptr;
- use std::sync::Arc;
-+use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering as AtomicOrdering};
-
- use arrow::ffi::from_ffi;
- use arrow::ffi::{FFI_ArrowArray, FFI_ArrowSchema};
-@@ -151,6 +152,29 @@ unsafe extern "C" fn capture_scan_statistics(
- .collect();
- }
-
-+#[derive(Default)]
-+struct AtomicScanStatisticsCapture {
-+ calls: AtomicUsize,
-+ invalid_statistics: AtomicBool,
-+}
-+
-+unsafe extern "C" fn capture_scan_statistics_atomically(
-+ callback_ctx: *mut c_void,
-+ statistics: *const LanceScanStatistics,
-+) {
-+ if callback_ctx.is_null() {
-+ return;
-+ }
-+ let captured = unsafe {
&*callback_ctx.cast::<AtomicScanStatisticsCapture>() };
-+ if statistics.is_null() {
-+ captured
-+ .invalid_statistics
-+ .store(true, AtomicOrdering::SeqCst);
-+ return;
-+ }
-+ captured.calls.fetch_add(1, AtomicOrdering::SeqCst);
-+}
-+
- /// Helper: build a tiny dataset whose `value` column is nullable AND contains
- /// at least one NULL. Used by tests that need to exercise upstream's
- /// nullability-tightening pre-scan failure path.
-@@ -413,7 +437,7 @@ fn
test_scanner_statistics_callback_not_called_on_early_scanner_close() {
- }
-
- #[test]
--fn test_scanner_statistics_callback_with_arrow_stream() {
-+fn test_scanner_statistics_callback_applies_to_reused_scanner() {
- let (_tmp, uri) = create_test_dataset();
- let c_uri = c_str(&uri);
- let ds = unsafe { lance_dataset_open(c_uri.as_ptr(), ptr::null(), 0) };
-@@ -433,20 +457,87 @@ fn test_scanner_statistics_callback_with_arrow_stream() {
- 0
- );
-
-- let mut stream = FFI_ArrowArrayStream::empty();
-+ let mut first_stream = FFI_ArrowArrayStream::empty();
- assert_eq!(
-- unsafe { lance_scanner_to_arrow_stream(scanner, &mut stream) },
-+ unsafe { lance_scanner_to_arrow_stream(scanner, &mut first_stream) },
- 0
- );
-- let reader = unsafe { ArrowArrayStreamReader::from_raw(&mut stream)
}.unwrap();
-+ let first_reader = unsafe { ArrowArrayStreamReader::from_raw(&mut
first_stream) }.unwrap();
- assert_eq!(
-- reader.map(|batch| batch.unwrap().num_rows()).sum::<usize>(),
-+ first_reader
-+ .map(|batch| batch.unwrap().num_rows())
-+ .sum::<usize>(),
- 5
- );
- assert_eq!(captured.calls, 1);
-- assert!(captured.bytes_read > 0);
-
-+ let mut second_stream = FFI_ArrowArrayStream::empty();
-+ assert_eq!(
-+ unsafe { lance_scanner_to_arrow_stream(scanner, &mut second_stream) },
-+ 0
-+ );
- unsafe { lance_scanner_close(scanner) };
-+
-+ let second_reader = unsafe { ArrowArrayStreamReader::from_raw(&mut
second_stream) }.unwrap();
-+ assert_eq!(
-+ second_reader
-+ .map(|batch| batch.unwrap().num_rows())
-+ .sum::<usize>(),
-+ 5
-+ );
-+ assert_eq!(captured.calls, 2);
-+
-+ unsafe { lance_dataset_close(ds) };
-+}
-+
-+#[test]
-+fn test_scanner_statistics_callback_supports_concurrent_exported_streams() {
-+ struct SendableArrowStream(FFI_ArrowArrayStream);
-+ unsafe impl Send for SendableArrowStream {}
-+
-+ fn consume_stream(mut stream: SendableArrowStream) -> usize {
-+ let reader = unsafe { ArrowArrayStreamReader::from_raw(&mut stream.0)
}.unwrap();
-+ reader.map(|batch| batch.unwrap().num_rows()).sum()
-+ }
-+
-+ let (_tmp, uri) = create_test_dataset();
-+ let c_uri = c_str(&uri);
-+ let ds = unsafe { lance_dataset_open(c_uri.as_ptr(), ptr::null(), 0) };
-+ assert!(!ds.is_null());
-+
-+ let scanner = unsafe { lance_scanner_new(ds, ptr::null(), ptr::null()) };
-+ assert!(!scanner.is_null());
-+ let captured = Arc::new(AtomicScanStatisticsCapture::default());
-+ assert_eq!(
-+ unsafe {
-+ lance_scanner_set_statistics_callback(
-+ scanner,
-+ Some(capture_scan_statistics_atomically),
-+ Arc::as_ptr(&captured).cast_mut().cast(),
-+ )
-+ },
-+ 0
-+ );
-+
-+ let mut first_stream = FFI_ArrowArrayStream::empty();
-+ let mut second_stream = FFI_ArrowArrayStream::empty();
-+ assert_eq!(
-+ unsafe { lance_scanner_to_arrow_stream(scanner, &mut first_stream) },
-+ 0
-+ );
-+ assert_eq!(
-+ unsafe { lance_scanner_to_arrow_stream(scanner, &mut second_stream) },
-+ 0
-+ );
-+ unsafe { lance_scanner_close(scanner) };
-+
-+ let first = std::thread::spawn(move ||
consume_stream(SendableArrowStream(first_stream)));
-+ let second = std::thread::spawn(move ||
consume_stream(SendableArrowStream(second_stream)));
-+ assert_eq!(first.join().unwrap(), 5);
-+ assert_eq!(second.join().unwrap(), 5);
-+ assert_eq!(captured.calls.load(AtomicOrdering::SeqCst), 2);
-+ assert!(!captured.invalid_statistics.load(AtomicOrdering::SeqCst));
-+
- unsafe { lance_dataset_close(ds) };
- }
-
-@@ -546,6 +637,55 @@ fn test_scanner_statistics_callback_rejects_null_inputs()
{
- unsafe { lance_dataset_close(ds) };
- }
-
-+#[test]
-+fn test_scanner_statistics_callback_replaces_registration_before_scan() {
-+ let (_tmp, uri) = create_test_dataset();
-+ let c_uri = c_str(&uri);
-+ let ds = unsafe { lance_dataset_open(c_uri.as_ptr(), ptr::null(), 0) };
-+ assert!(!ds.is_null());
-+ let scanner = unsafe { lance_scanner_new(ds, ptr::null(), ptr::null()) };
-+ assert!(!scanner.is_null());
-+
-+ let mut replaced = CapturedScanStatistics::default();
-+ let mut active = CapturedScanStatistics::default();
-+ assert_eq!(
-+ unsafe {
-+ lance_scanner_set_statistics_callback(
-+ scanner,
-+ Some(capture_scan_statistics),
-+ (&mut replaced as *mut CapturedScanStatistics).cast(),
-+ )
-+ },
-+ 0
-+ );
-+ assert_eq!(
-+ unsafe {
-+ lance_scanner_set_statistics_callback(
-+ scanner,
-+ Some(capture_scan_statistics),
-+ (&mut active as *mut CapturedScanStatistics).cast(),
-+ )
-+ },
-+ 0
-+ );
-+
-+ let mut stream = FFI_ArrowArrayStream::empty();
-+ assert_eq!(
-+ unsafe { lance_scanner_to_arrow_stream(scanner, &mut stream) },
-+ 0
-+ );
-+ let reader = unsafe { ArrowArrayStreamReader::from_raw(&mut stream)
}.unwrap();
-+ assert_eq!(
-+ reader.map(|batch| batch.unwrap().num_rows()).sum::<usize>(),
-+ 5
-+ );
-+ assert_eq!(replaced.calls, 0);
-+ assert_eq!(active.calls, 1);
-+
-+ unsafe { lance_scanner_close(scanner) };
-+ unsafe { lance_dataset_close(ds) };
-+}
-+
- #[test]
- fn test_scanner_statistics_callback_rejects_registration_after_scan_started()
{
- let (_tmp, uri) = create_test_dataset();
-@@ -814,6 +954,17 @@ fn test_scanner_scan_async() {
-
- let scanner = unsafe { lance_scanner_new(ds, ptr::null(), ptr::null()) };
- assert!(!scanner.is_null());
-+ let captured = Arc::new(AtomicScanStatisticsCapture::default());
-+ assert_eq!(
-+ unsafe {
-+ lance_scanner_set_statistics_callback(
-+ scanner,
-+ Some(capture_scan_statistics_atomically),
-+ Arc::as_ptr(&captured).cast_mut().cast(),
-+ )
-+ },
-+ 0
-+ );
-
- // Synchronization primitive for the async callback.
- struct CallbackResult {
-@@ -845,6 +996,7 @@ fn test_scanner_scan_async() {
- on_complete,
- Arc::as_ptr(&pair_clone) as *mut std::ffi::c_void,
- );
-+ lance_scanner_close(scanner);
- }
-
- // Wait for callback.
-@@ -861,8 +1013,9 @@ fn test_scanner_scan_async() {
- let reader = unsafe { ArrowArrayStreamReader::from_raw(ffi_stream)
}.unwrap();
- let total_rows: usize = reader.map(|r| r.unwrap().num_rows()).sum();
- assert_eq!(total_rows, 5);
-+ assert_eq!(captured.calls.load(AtomicOrdering::SeqCst), 1);
-+ assert!(!captured.invalid_statistics.load(AtomicOrdering::SeqCst));
-
-- unsafe { lance_scanner_close(scanner) };
- unsafe { lance_dataset_close(ds) };
- }
-
-@@ -1477,18 +1630,6 @@ fn test_poll_next_basic() {
- let c_uri = c_str(&uri_clone);
- let ds = unsafe { lance_dataset_open(c_uri.as_ptr(), ptr::null(), 0)
};
- let scanner = unsafe { lance_scanner_new(ds, ptr::null(),
ptr::null()) };
-- let mut captured = CapturedScanStatistics::default();
-- assert_eq!(
-- unsafe {
-- lance_scanner_set_statistics_callback(
-- scanner,
-- Some(capture_scan_statistics),
-- (&mut captured as *mut CapturedScanStatistics).cast(),
-- )
-- },
-- 0
-- );
--
- use std::sync::atomic::{AtomicBool, Ordering};
- static WOKE: AtomicBool = AtomicBool::new(false);
- unsafe extern "C" fn test_waker(_ctx: *mut std::ffi::c_void) {
-@@ -1522,15 +1663,6 @@ fn test_poll_next_basic() {
- assert!(iterations < 1000, "poll loop should not spin forever");
- }
- assert_eq!(total_rows, 5);
-- assert_eq!(captured.calls, 1);
--
-- let mut batch: *mut LanceBatch = ptr::null_mut();
-- assert_eq!(
-- unsafe { lance_scanner_poll_next(scanner, test_waker,
ptr::null_mut(), &mut batch) },
-- LancePollStatus::Finished
-- );
-- assert!(batch.is_null());
-- assert_eq!(captured.calls, 1, "callback must run exactly once");
-
- unsafe { lance_scanner_close(scanner) };
- unsafe { lance_dataset_close(ds) };
-diff --git a/tests/cpp/test_c_api.c b/tests/cpp/test_c_api.c
-index b3b78f0..4499cb9 100644
---- a/tests/cpp/test_c_api.c
-+++ b/tests/cpp/test_c_api.c
-@@ -39,6 +39,36 @@
- }
\
- } while (0)
-
-+typedef struct {
-+ uint64_t calls;
-+ uint64_t bytes_read;
-+ int invalid;
-+} ScanStatisticsCapture;
-+
-+static void capture_scan_statistics(
-+ void *callback_ctx,
-+ const LanceScanStatistics *statistics
-+) {
-+ if (callback_ctx == NULL) return;
-+ ScanStatisticsCapture *captured = (ScanStatisticsCapture *)callback_ctx;
-+ if (statistics == NULL ||
-+ (statistics->metrics_len > 0 && statistics->metrics == NULL)) {
-+ captured->invalid = 1;
-+ return;
-+ }
-+ for (size_t i = 0; i < statistics->metrics_len; ++i) {
-+ const LanceScanMetric *metric = &statistics->metrics[i];
-+ if ((metric->name_len > 0 && metric->name == NULL) ||
-+ (metric->kind != LANCE_SCAN_METRIC_COUNT &&
-+ metric->kind != LANCE_SCAN_METRIC_TIME_NANOSECONDS)) {
-+ captured->invalid = 1;
-+ return;
-+ }
-+ }
-+ captured->calls += 1;
-+ captured->bytes_read = statistics->bytes_read;
-+}
-+
- static void test_open_and_metadata(const char *uri) {
- printf(" test_open_and_metadata... ");
-
-@@ -84,10 +114,14 @@ static void test_scan(const char *uri) {
- /* Full scan via ArrowArrayStream */
- LanceScanner *scanner = lance_scanner_new(ds, NULL, NULL);
- ASSERT(scanner != NULL, "scanner creation failed");
-+ ScanStatisticsCapture captured = {0};
-+ int32_t rc = lance_scanner_set_statistics_callback(
-+ scanner, capture_scan_statistics, &captured);
-+ ASSERT(rc == 0, "statistics callback registration failed");
-
- struct ArrowArrayStream stream;
- memset(&stream, 0, sizeof(stream));
-- int32_t rc = lance_scanner_to_arrow_stream(scanner, &stream);
-+ rc = lance_scanner_to_arrow_stream(scanner, &stream);
- ASSERT(rc == 0, "to_arrow_stream failed");
-
- /* Read schema from stream */
-@@ -113,6 +147,9 @@ static void test_scan(const char *uri) {
- }
-
- ASSERT(total_rows == expected_rows, "row count mismatch");
-+ ASSERT(captured.calls == 1, "statistics callback count mismatch");
-+ ASSERT(captured.bytes_read > 0, "statistics should report bytes read");
-+ ASSERT(captured.invalid == 0, "statistics callback received invalid
data");
- printf("rows=%llu... ", (unsigned long long)total_rows);
-
- if (stream.release) stream.release(&stream);
-diff --git a/tests/cpp/test_cpp_api.cpp b/tests/cpp/test_cpp_api.cpp
-index f8ae701..3293bfb 100644
---- a/tests/cpp/test_cpp_api.cpp
-+++ b/tests/cpp/test_cpp_api.cpp
-@@ -25,6 +25,25 @@
- #define TEST(name) printf(" %s... ", #name)
- #define PASS() printf("OK\n")
-
-+struct ScanStatisticsCapture {
-+ uint64_t calls = 0;
-+ uint64_t bytes_read = 0;
-+ bool invalid = false;
-+};
-+
-+static void capture_scan_statistics(
-+ void* callback_ctx,
-+ const LanceScanStatistics* statistics) noexcept {
-+ if (!callback_ctx) return;
-+ auto* captured = static_cast<ScanStatisticsCapture*>(callback_ctx);
-+ if (!statistics || (statistics->metrics_len > 0 && !statistics->metrics))
{
-+ captured->invalid = true;
-+ return;
-+ }
-+ captured->calls += 1;
-+ captured->bytes_read = statistics->bytes_read;
-+}
-+
- static void test_dataset_open(const std::string& uri) {
- TEST(test_dataset_open);
-
-@@ -70,7 +89,11 @@ static void test_scanner_fluent(const std::string& uri) {
-
- // Fluent builder pattern.
- auto scanner = ds.scan();
-- scanner.limit(5).offset(0).batch_size(2);
-+ ScanStatisticsCapture captured;
-+ scanner.limit(5)
-+ .offset(0)
-+ .batch_size(2)
-+ .statistics_callback(capture_scan_statistics, &captured);
-
- ArrowArrayStream stream;
- memset(&stream, 0, sizeof(stream));
-@@ -89,6 +112,9 @@ static void test_scanner_fluent(const std::string& uri) {
- }
-
- assert(total == 5);
-+ assert(captured.calls == 1);
-+ assert(captured.bytes_read > 0);
-+ assert(!captured.invalid);
- printf("rows=%llu... ", (unsigned long long)total);
-
- if (stream.release) stream.release(&stream);
diff --git a/thirdparty/patches/lance-c-0.1.8-pr-69.patch
b/thirdparty/patches/lance-c-0.1.8-pr-69.patch
new file mode 100644
index 00000000000..ee38eb33bcb
--- /dev/null
+++ b/thirdparty/patches/lance-c-0.1.8-pr-69.patch
@@ -0,0 +1,653 @@
+From a4d6e489c627fe4b0e49d9a2991c9436313debc0 Mon Sep 17 00:00:00 2001
+From: zhangstar333 <[email protected]>
+Date: Tue, 1 Sep 2026 11:15:00 +0800
+Subject: [PATCH 1/2] update
+
+---
+ src/fts_query.rs | 2 ++
+ src/scanner.rs | 44 ++++++++++++++++++++++---
+ tests/c_api_test.rs | 78 +++++++++++++++++++++++++++++++++++++++++++++
+ 3 files changed, 120 insertions(+), 4 deletions(-)
+
+diff --git a/src/fts_query.rs b/src/fts_query.rs
+index cd194c7..e85ed99 100644
+--- a/src/fts_query.rs
++++ b/src/fts_query.rs
+@@ -54,6 +54,7 @@ pub(crate) struct FtsQueryContextInner {
+ pub(crate) query: FullTextSearchQuery,
+ pub(crate) segments: Vec<IndexMetadata>,
+ pub(crate) scorer: Arc<MemBM25Scorer>,
++ pub(crate) has_unindexed_fragments: bool,
+ }
+
+ impl FtsQueryContextInner {
+@@ -216,6 +217,7 @@ async fn prepare_fts_query_context(
+ query,
+ segments,
+ scorer,
++ has_unindexed_fragments: !unindexed_fragment_ids.is_empty(),
+ })
+ }
+
+diff --git a/src/scanner.rs b/src/scanner.rs
+index 5b8c34e..f60f0c5 100644
+--- a/src/scanner.rs
++++ b/src/scanner.rs
+@@ -179,6 +179,45 @@ impl LanceScanner {
+ Ok(())
+ }
+
++ /// Restrict an INDEX_ONLY prepared FTS scan to fragments covered by the
++ /// selected committed segments. This is deliberately separate from
++ /// `fast_search`: that option is scanner-wide, changes unrelated scalar
++ /// index fallback behavior, and also forces `_rowid` into the output.
++ fn apply_prepared_fts_fragment_filter(
++ &self,
++ scanner: &mut lance::dataset::scanner::Scanner,
++ context: &FtsQueryContextInner,
++ segments: &[IndexMetadata],
++ ) -> Result<()> {
++ if !context.has_unindexed_fragments {
++ return Ok(());
++ }
++
++ let mut selected_fragment_ids = std::collections::HashSet::new();
++ for segment in segments {
++ let fragment_bitmap =
segment.fragment_bitmap.as_ref().ok_or_else(|| {
++ lance_core::Error::internal(format!(
++ "prepared FTS segment {} lost its validated fragment
coverage",
++ segment.uuid
++ ))
++ })?;
++ selected_fragment_ids.extend(fragment_bitmap.iter());
++ }
++
++ let selected_fragments = self
++ .dataset
++ .get_fragments()
++ .into_iter()
++ .filter(|fragment| {
++ u32::try_from(fragment.id())
++ .is_ok_and(|fragment_id|
selected_fragment_ids.contains(&fragment_id))
++ })
++ .map(|fragment| fragment.metadata().clone())
++ .collect();
++ scanner.with_fragments(selected_fragments);
++ Ok(())
++ }
++
+ fn apply_filter(&self, scanner: &mut lance::dataset::scanner::Scanner) ->
Result<()> {
+ if let Some(substrait) = &self.substrait_filter {
+ scanner.filter_substrait(substrait)?;
+@@ -282,11 +321,8 @@ impl LanceScanner {
+ let distributed_fts = if let Some(context) = &self.fts_context {
+ context.validate_dataset_identity(&self.dataset)?;
+ let segments = select_fts_segments(context,
self.fts_index_segments.as_deref())?;
++ self.apply_prepared_fts_fragment_filter(&mut scanner, context,
&segments)?;
+ scanner.full_text_search(context.query.clone())?;
+- // Both STRICT and INDEX_ONLY context scans must use only the
+- // committed segments pinned in the context. In STRICT mode all
+- // current fragments were already proven covered during prepare.
+- scanner.fast_search();
+ Some(PreparedFtsExecution {
+ context: Arc::clone(context),
+ segments,
+diff --git a/tests/c_api_test.rs b/tests/c_api_test.rs
+index 74b9f85..3627c4e 100644
+--- a/tests/c_api_test.rs
++++ b/tests/c_api_test.rs
+@@ -5762,6 +5762,84 @@ fn load_fts_segment_uuids(uri: &str, column: &str) ->
Vec<[u8; 16]> {
+ })
+ }
+
++#[test]
++fn test_prepared_fts_row_id_output_is_explicit() {
++ let (_tmp, uri) = create_test_dataset();
++ let uri_c = c_str(&uri);
++ let column = c_str("name");
++ let query = c_str("alice");
++ let inverted_params =
c_str(r#"{"base_tokenizer":"simple","language":"English"}"#);
++
++ let dataset = unsafe { lance_dataset_open(uri_c.as_ptr(), ptr::null(), 0)
};
++ assert_eq!(
++ unsafe {
++ lance_dataset_create_scalar_index(
++ dataset,
++ column.as_ptr(),
++ ptr::null(),
++ LanceScalarIndexType::Inverted as i32,
++ inverted_params.as_ptr(),
++ false,
++ )
++ },
++ 0
++ );
++ let context = unsafe {
++ lance_dataset_prepare_fts_query(
++ dataset,
++ column.as_ptr(),
++ query.as_ptr(),
++ 0,
++ LanceFtsCoverageMode::Strict as i32,
++ )
++ };
++ assert!(!context.is_null(), "{}", unsafe {
++ std::ffi::CStr::from_ptr(lance_last_error_message()).to_string_lossy()
++ });
++
++ let id = c_str("id");
++ let columns = [id.as_ptr(), ptr::null()];
++ let scan_schema = |with_row_id: bool| {
++ let scanner = unsafe { lance_scanner_new(dataset, columns.as_ptr(),
ptr::null()) };
++ assert!(!scanner.is_null());
++ if with_row_id {
++ assert_eq!(unsafe { lance_scanner_with_row_id(scanner, true) },
0);
++ }
++ assert_eq!(
++ unsafe { lance_scanner_set_fts_query_context(scanner, context) },
++ 0
++ );
++ let mut stream = FFI_ArrowArrayStream::empty();
++ assert_eq!(
++ unsafe { lance_scanner_to_arrow_stream(scanner, &mut stream) },
++ 0,
++ "{}",
++ unsafe {
std::ffi::CStr::from_ptr(lance_last_error_message()).to_string_lossy() }
++ );
++ let reader = unsafe { ArrowArrayStreamReader::from_raw(&mut
stream).unwrap() };
++ let schema = reader.schema();
++ let rows: usize = reader.map(|batch| batch.unwrap().num_rows()).sum();
++ assert!(rows > 0);
++ unsafe { lance_scanner_close(scanner) };
++ schema
++ };
++
++ let without_row_id = scan_schema(false);
++ assert_eq!(without_row_id.fields().len(), 2);
++ assert!(without_row_id.field_with_name("id").is_ok());
++ assert!(without_row_id.field_with_name("_score").is_ok());
++ assert!(without_row_id.field_with_name("_rowid").is_err());
++
++ let with_row_id = scan_schema(true);
++ assert_eq!(with_row_id.fields().len(), 3);
++ assert!(with_row_id.field_with_name("id").is_ok());
++ assert!(with_row_id.field_with_name("_score").is_ok());
++ assert!(with_row_id.field_with_name("_rowid").is_ok());
++
++ unsafe { lance_fts_query_context_close(context) };
++ unsafe { lance_dataset_close(dataset) };
++}
++
+ #[test]
+ fn test_prepare_fts_query_index_only_allows_unindexed_fragment() {
+ let (_tmp, uri) = create_test_dataset();
+
+From 6f0fae4cc51bf144685564b1d2e9f6f7afc71f8a Mon Sep 17 00:00:00 2001
+From: zhangstar333 <[email protected]>
+Date: Tue, 1 Sep 2026 12:34:05 +0800
+Subject: [PATCH 2/2] update
+
+---
+ src/fts_query.rs | 2 -
+ src/scanner.rs | 253 ++++++++++++++++++++++++++++++++++----------
+ tests/c_api_test.rs | 79 ++++++++++++++
+ 3 files changed, 279 insertions(+), 55 deletions(-)
+
+diff --git a/src/fts_query.rs b/src/fts_query.rs
+index e85ed99..cd194c7 100644
+--- a/src/fts_query.rs
++++ b/src/fts_query.rs
+@@ -54,7 +54,6 @@ pub(crate) struct FtsQueryContextInner {
+ pub(crate) query: FullTextSearchQuery,
+ pub(crate) segments: Vec<IndexMetadata>,
+ pub(crate) scorer: Arc<MemBM25Scorer>,
+- pub(crate) has_unindexed_fragments: bool,
+ }
+
+ impl FtsQueryContextInner {
+@@ -217,7 +216,6 @@ async fn prepare_fts_query_context(
+ query,
+ segments,
+ scorer,
+- has_unindexed_fragments: !unindexed_fragment_ids.is_empty(),
+ })
+ }
+
+diff --git a/src/scanner.rs b/src/scanner.rs
+index f60f0c5..0c29b17 100644
+--- a/src/scanner.rs
++++ b/src/scanner.rs
+@@ -12,13 +12,13 @@ use std::task::{Context, Poll, RawWaker, RawWakerVTable,
Waker};
+
+ use arrow::ffi_stream::FFI_ArrowArrayStream;
+ use arrow_schema::SchemaRef;
+-use datafusion::physical_plan::ExecutionPlan;
++use datafusion::physical_plan::{ExecutionPlan, empty::EmptyExec};
+ use futures::{FutureExt, Stream, StreamExt};
+ use lance::Dataset;
+ use lance::dataset::scanner::{
+ DatasetRecordBatchStream, ExecutionStatsCallback, ExecutionSummaryCounts,
+ };
+-use lance::io::exec::fts::MatchQueryExec;
++use lance::io::exec::fts::{FlatMatchQueryExec, MatchQueryExec};
+ use lance_core::Result;
+ use lance_index::scalar::FullTextSearchQuery;
+ use lance_io::stream::RecordBatchStream;
+@@ -179,45 +179,6 @@ impl LanceScanner {
+ Ok(())
+ }
+
+- /// Restrict an INDEX_ONLY prepared FTS scan to fragments covered by the
+- /// selected committed segments. This is deliberately separate from
+- /// `fast_search`: that option is scanner-wide, changes unrelated scalar
+- /// index fallback behavior, and also forces `_rowid` into the output.
+- fn apply_prepared_fts_fragment_filter(
+- &self,
+- scanner: &mut lance::dataset::scanner::Scanner,
+- context: &FtsQueryContextInner,
+- segments: &[IndexMetadata],
+- ) -> Result<()> {
+- if !context.has_unindexed_fragments {
+- return Ok(());
+- }
+-
+- let mut selected_fragment_ids = std::collections::HashSet::new();
+- for segment in segments {
+- let fragment_bitmap =
segment.fragment_bitmap.as_ref().ok_or_else(|| {
+- lance_core::Error::internal(format!(
+- "prepared FTS segment {} lost its validated fragment
coverage",
+- segment.uuid
+- ))
+- })?;
+- selected_fragment_ids.extend(fragment_bitmap.iter());
+- }
+-
+- let selected_fragments = self
+- .dataset
+- .get_fragments()
+- .into_iter()
+- .filter(|fragment| {
+- u32::try_from(fragment.id())
+- .is_ok_and(|fragment_id|
selected_fragment_ids.contains(&fragment_id))
+- })
+- .map(|fragment| fragment.metadata().clone())
+- .collect();
+- scanner.with_fragments(selected_fragments);
+- Ok(())
+- }
+-
+ fn apply_filter(&self, scanner: &mut lance::dataset::scanner::Scanner) ->
Result<()> {
+ if let Some(substrait) = &self.substrait_filter {
+ scanner.filter_substrait(substrait)?;
+@@ -321,7 +282,6 @@ impl LanceScanner {
+ let distributed_fts = if let Some(context) = &self.fts_context {
+ context.validate_dataset_identity(&self.dataset)?;
+ let segments = select_fts_segments(context,
self.fts_index_segments.as_deref())?;
+- self.apply_prepared_fts_fragment_filter(&mut scanner, context,
&segments)?;
+ scanner.full_text_search(context.query.clone())?;
+ Some(PreparedFtsExecution {
+ context: Arc::clone(context),
+@@ -361,14 +321,24 @@ impl PreparedScanner {
+ return self.scanner.try_into_stream().await;
+ };
+ let plan = self.scanner.create_plan().await?;
+- let (plan, replaced) = replace_match_query_exec(
++ let selected_segments_have_current_fragments =
segments_have_current_fragments(
++ &distributed_fts.context.dataset,
++ &distributed_fts.segments,
++ )?;
++ let (plan, rewritten) = rewrite_prepared_fts_plan(
+ plan,
+ &distributed_fts.segments,
+ &distributed_fts.context.scorer,
++ selected_segments_have_current_fragments,
+ )?;
+- if replaced != 1 {
++ if rewritten.match_query_execs > 1
++ || rewritten.flat_match_query_execs > 1
++ || rewritten.match_query_execs + rewritten.flat_match_query_execs
== 0
++ || (selected_segments_have_current_fragments &&
rewritten.match_query_execs != 1)
++ {
+ return Err(lance_core::Error::internal(format!(
+- "expected exactly one MatchQueryExec in prepared FTS plan,
replaced {replaced}"
++ "unexpected prepared FTS plan for selected segments with
current fragment coverage {selected_segments_have_current_fragments}: rewrote
{} MatchQueryExec node(s) and removed {} FlatMatchQueryExec node(s)",
++ rewritten.match_query_execs, rewritten.flat_match_query_execs
+ )));
+ }
+ let stream = lance_datafusion::exec::execute_plan(
+@@ -415,22 +385,81 @@ fn select_fts_segments(
+ Ok(selected)
+ }
+
+-fn replace_match_query_exec(
++fn segments_have_current_fragments(
++ dataset: &lance::Dataset,
++ segments: &[IndexMetadata],
++) -> Result<bool> {
++ let current_fragment_ids = dataset
++ .get_fragments()
++ .into_iter()
++ .map(|fragment| {
++ u32::try_from(fragment.id()).map_err(|_| {
++ lance_core::Error::internal(format!(
++ "current fragment id {} exceeds the validated u32 FTS
coverage range",
++ fragment.id()
++ ))
++ })
++ })
++ .collect::<Result<std::collections::HashSet<_>>>()?;
++ for segment in segments {
++ let fragment_bitmap = segment.fragment_bitmap.as_ref().ok_or_else(|| {
++ lance_core::Error::internal(format!(
++ "prepared FTS segment {} lost its validated fragment
coverage",
++ segment.uuid
++ ))
++ })?;
++ if fragment_bitmap
++ .iter()
++ .any(|fragment_id| current_fragment_ids.contains(&fragment_id))
++ {
++ return Ok(true);
++ }
++ }
++ Ok(false)
++}
++
++#[derive(Default)]
++struct PreparedFtsPlanRewriteCounts {
++ match_query_execs: usize,
++ flat_match_query_execs: usize,
++}
++
++fn rewrite_prepared_fts_plan(
+ plan: Arc<dyn ExecutionPlan>,
+ segments: &[IndexMetadata],
+ scorer: &Arc<lance_index::scalar::inverted::MemBM25Scorer>,
+-) -> Result<(Arc<dyn ExecutionPlan>, usize)> {
++ selected_segments_have_current_fragments: bool,
++) -> Result<(Arc<dyn ExecutionPlan>, PreparedFtsPlanRewriteCounts)> {
++ // Lance's ordinary FTS planner adds a flat-search branch for fragments
not
++ // covered by the logical index. A prepared INDEX_ONLY scan must omit that
++ // branch, but using Scanner::with_fragments to do so would turn an
++ // otherwise unfiltered index search into a full row-id prefilter scan.
++ if plan.downcast_ref::<FlatMatchQueryExec>().is_some() {
++ return Ok((
++ Arc::new(EmptyExec::new(plan.schema())),
++ PreparedFtsPlanRewriteCounts {
++ match_query_execs: 0,
++ flat_match_query_execs: 1,
++ },
++ ));
++ }
++
+ let children = plan.children();
+- let mut replaced = 0;
++ let mut rewritten = PreparedFtsPlanRewriteCounts::default();
+ let rebuilt = if children.is_empty() {
+ plan
+ } else {
+ let mut new_children = Vec::with_capacity(children.len());
+ for child in children {
+- let (new_child, child_replaced) =
+- replace_match_query_exec(Arc::clone(child), segments,
scorer)?;
++ let (new_child, child_rewritten) = rewrite_prepared_fts_plan(
++ Arc::clone(child),
++ segments,
++ scorer,
++ selected_segments_have_current_fragments,
++ )?;
+ new_children.push(new_child);
+- replaced += child_replaced;
++ rewritten.match_query_execs += child_rewritten.match_query_execs;
++ rewritten.flat_match_query_execs +=
child_rewritten.flat_match_query_execs;
+ }
+ plan.with_new_children(new_children).map_err(|error| {
+ lance_core::Error::internal(format!(
+@@ -440,6 +469,10 @@ fn replace_match_query_exec(
+ };
+
+ if let Some(exec) = rebuilt.downcast_ref::<MatchQueryExec>() {
++ rewritten.match_query_execs += 1;
++ if !selected_segments_have_current_fragments {
++ return Ok((Arc::new(EmptyExec::new(rebuilt.schema())),
rewritten));
++ }
+ let replacement = MatchQueryExec::new_with_segments(
+ Arc::clone(exec.dataset()),
+ exec.query().clone(),
+@@ -448,9 +481,9 @@ fn replace_match_query_exec(
+ segments.to_vec(),
+ )
+ .with_base_scorer(Arc::clone(scorer));
+- return Ok((Arc::new(replacement), replaced + 1));
++ return Ok((Arc::new(replacement), rewritten));
+ }
+- Ok((rebuilt, replaced))
++ Ok((rebuilt, rewritten))
+ }
+
+ /// Type of a dynamically named scan metric.
+@@ -2118,6 +2151,9 @@ mod tests {
+ use super::*;
+ use crate::dataset::{lance_dataset_close, lance_dataset_open};
+ use crate::error::{lance_last_error_code, lance_last_error_message};
++ use crate::fts_query::{
++ LanceFtsCoverageMode, lance_dataset_prepare_fts_query,
lance_fts_query_context_close,
++ };
+ use std::ffi::{CStr, CString};
+ use std::sync::atomic::{AtomicI32, AtomicUsize};
+ use std::sync::{Barrier, mpsc};
+@@ -2125,6 +2161,9 @@ mod tests {
+
+ use arrow_array::{Int32Array, RecordBatch, StringArray};
+ use arrow_schema::{DataType, Field, Schema};
++ use lance::index::DatasetIndexExt;
++ use lance::io::exec::PreFilterSource;
++ use lance_index::{IndexType, scalar::InvertedIndexParams};
+
+ /// Write a 3-row dataset to a tempdir, returning (tempdir, uri).
+ fn create_test_dataset() -> (tempfile::TempDir, String) {
+@@ -2169,6 +2208,114 @@ mod tests {
+ .store(true, Ordering::SeqCst);
+ }
+
++ fn prepared_fts_plan_shape(plan: &Arc<dyn ExecutionPlan>) -> (usize,
usize, usize) {
++ let mut match_query_execs = 0;
++ let mut flat_match_query_execs = 0;
++ let mut filtered_row_id_prefilters = 0;
++ if let Some(exec) = plan.downcast_ref::<MatchQueryExec>() {
++ match_query_execs += 1;
++ if matches!(exec.prefilter_source(),
PreFilterSource::FilteredRowIds(_)) {
++ filtered_row_id_prefilters += 1;
++ }
++ }
++ if plan.downcast_ref::<FlatMatchQueryExec>().is_some() {
++ flat_match_query_execs += 1;
++ }
++ for child in plan.children() {
++ let (child_match, child_flat, child_filtered) =
++ prepared_fts_plan_shape(&Arc::clone(child));
++ match_query_execs += child_match;
++ flat_match_query_execs += child_flat;
++ filtered_row_id_prefilters += child_filtered;
++ }
++ (
++ match_query_execs,
++ flat_match_query_execs,
++ filtered_row_id_prefilters,
++ )
++ }
++
++ #[test]
++ fn prepared_fts_index_only_plan_does_not_scan_indexed_fragment_row_ids() {
++ let (_tmp, uri) = create_test_dataset();
++ block_on(async {
++ let mut dataset = Dataset::open(&uri).await.unwrap();
++ dataset
++ .create_index(
++ &["name"],
++ IndexType::Inverted,
++ None,
++ &InvertedIndexParams::default(),
++ false,
++ )
++ .await
++ .unwrap();
++
++ let schema = Arc::new(Schema::new(vec![
++ Field::new("id", DataType::Int32, false),
++ Field::new("name", DataType::Utf8, true),
++ ]));
++ let batch = RecordBatch::try_new(
++ schema.clone(),
++ vec![
++ Arc::new(Int32Array::from(vec![4])),
++ Arc::new(StringArray::from(vec!["a"])),
++ ],
++ )
++ .unwrap();
++ dataset
++ .append(
++
arrow::record_batch::RecordBatchIterator::new(vec![Ok(batch)], schema),
++ None,
++ )
++ .await
++ .unwrap();
++ });
++
++ let (dataset, scanner) = open_dataset_and_scanner(&uri);
++ let column = CString::new("name").unwrap();
++ let query = CString::new("a").unwrap();
++ let context = unsafe {
++ lance_dataset_prepare_fts_query(
++ dataset,
++ column.as_ptr(),
++ query.as_ptr(),
++ 0,
++ LanceFtsCoverageMode::IndexOnly as i32,
++ )
++ };
++ assert!(!context.is_null());
++ assert_eq!(
++ unsafe { lance_scanner_set_fts_query_context(scanner, context) },
++ 0
++ );
++
++ let prepared = unsafe { &*scanner }.build_scanner().unwrap();
++ let distributed = prepared.distributed_fts.as_ref().unwrap();
++ let segments = distributed.segments.clone();
++ let scorer = Arc::clone(&distributed.context.scorer);
++ let plan = block_on(prepared.scanner.create_plan()).unwrap();
++ assert_eq!(
++ prepared_fts_plan_shape(&plan),
++ (1, 1, 0),
++ "an unfiltered prepared FTS plan must not materialize selected
fragment row IDs"
++ );
++
++ let has_current_fragments =
++ segments_have_current_fragments(&distributed.context.dataset,
&segments).unwrap();
++ let (rewritten, counts) =
++ rewrite_prepared_fts_plan(plan, &segments, &scorer,
has_current_fragments).unwrap();
++ assert_eq!(counts.match_query_execs, 1);
++ assert_eq!(counts.flat_match_query_execs, 1);
++ assert_eq!(prepared_fts_plan_shape(&rewritten), (1, 0, 0));
++
++ unsafe {
++ lance_scanner_close(scanner);
++ lance_fts_query_context_close(context);
++ lance_dataset_close(dataset);
++ }
++ }
++
+ /// Assert the pending thread-local error is `Panic` carrying the poison
+ /// message; consumes it so the next assertion starts from a clean slate.
+ fn assert_poison_error_pending() {
+diff --git a/tests/c_api_test.rs b/tests/c_api_test.rs
+index 3627c4e..8805764 100644
+--- a/tests/c_api_test.rs
++++ b/tests/c_api_test.rs
+@@ -5943,6 +5943,85 @@ fn
test_prepare_fts_query_index_only_allows_unindexed_fragment() {
+ unsafe { lance_dataset_close(dataset) };
+ }
+
++#[test]
++fn test_prepared_fts_index_only_empty_segment_returns_empty_shard() {
++ use lance::index::DatasetIndexExt;
++ use lance_index::{IndexType, scalar::InvertedIndexParams};
++
++ let (_tmp, uri) = create_test_dataset();
++ lance_c::runtime::block_on(async {
++ let mut dataset = Dataset::open(&uri).await.unwrap();
++ let params = InvertedIndexParams::default();
++ dataset
++ .create_index_builder(&["name"], IndexType::Inverted, ¶ms)
++ .name("empty_name_fts".to_string())
++ .train(false)
++ .await
++ .unwrap();
++ let segments = dataset
++ .load_indices_by_name("empty_name_fts")
++ .await
++ .unwrap();
++ assert_eq!(segments.len(), 1);
++ assert!(
++ segments[0]
++ .fragment_bitmap
++ .as_ref()
++ .is_some_and(|fragment_bitmap| fragment_bitmap.is_empty())
++ );
++ });
++
++ let uri_c = c_str(&uri);
++ let column = c_str("name");
++ let query = c_str("alice");
++ let dataset = unsafe { lance_dataset_open(uri_c.as_ptr(), ptr::null(), 0)
};
++ let context = unsafe {
++ lance_dataset_prepare_fts_query(
++ dataset,
++ column.as_ptr(),
++ query.as_ptr(),
++ 0,
++ LanceFtsCoverageMode::IndexOnly as i32,
++ )
++ };
++ assert!(!context.is_null(), "{}", unsafe {
++ std::ffi::CStr::from_ptr(lance_last_error_message()).to_string_lossy()
++ });
++ let segment_uuids = load_fts_segment_uuids(&uri, "name");
++ assert_eq!(segment_uuids.len(), 1);
++
++ let scanner = unsafe { lance_scanner_new(dataset, ptr::null(),
ptr::null()) };
++ assert_eq!(
++ unsafe { lance_scanner_set_fts_query_context(scanner, context) },
++ 0
++ );
++ assert_eq!(
++ unsafe {
++ lance_scanner_set_fts_index_segments(
++ scanner,
++ segment_uuids.as_ptr().cast::<u8>(),
++ segment_uuids.len(),
++ )
++ },
++ 0
++ );
++
++ let mut stream = FFI_ArrowArrayStream::empty();
++ assert_eq!(
++ unsafe { lance_scanner_to_arrow_stream(scanner, &mut stream) },
++ 0,
++ "{}",
++ unsafe {
std::ffi::CStr::from_ptr(lance_last_error_message()).to_string_lossy() }
++ );
++ let reader = unsafe { ArrowArrayStreamReader::from_raw(&mut
stream).unwrap() };
++ let total_rows: usize = reader.map(|batch|
batch.unwrap().num_rows()).sum();
++ assert_eq!(total_rows, 0);
++
++ unsafe { lance_scanner_close(scanner) };
++ unsafe { lance_fts_query_context_close(context) };
++ unsafe { lance_dataset_close(dataset) };
++}
++
+ #[test]
+ fn test_prepared_fts_global_scorer_is_shared_across_segment_splits() {
+ use lance::index::DatasetIndexExt;
diff --git a/thirdparty/vars.sh b/thirdparty/vars.sh
index c9fec64e032..84f4a73edaa 100644
--- a/thirdparty/vars.sh
+++ b/thirdparty/vars.sh
@@ -589,10 +589,10 @@ PUGIXML_SOURCE=pugixml-1.15
PUGIXML_MD5SUM="3b894c29455eb33a40b165c6e2de5895"
# lance-c
-LANCE_C_DOWNLOAD="https://github.com/lance-format/lance-c/archive/refs/tags/v0.1.7.tar.gz"
-LANCE_C_NAME="lance-c-v0.1.7.tar.gz"
-LANCE_C_SOURCE="lance-c-0.1.7"
-LANCE_C_MD5SUM="15ef7cd20a2e1606384251cb2d41d42f"
+LANCE_C_DOWNLOAD="https://github.com/lance-format/lance-c/archive/refs/tags/v0.1.8.tar.gz"
+LANCE_C_NAME="lance-c-v0.1.8.tar.gz"
+LANCE_C_SOURCE="lance-c-0.1.8"
+LANCE_C_MD5SUM="2a4af9398cdec19d5d379a27353b1266"
# all thirdparties which need to be downloaded is set in array TP_ARCHIVES
export TP_ARCHIVES=(
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]