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

yiguolei pushed a commit to branch branch-4.1
in repository https://gitbox.apache.org/repos/asf/doris.git


The following commit(s) were added to refs/heads/branch-4.1 by this push:
     new 8740cb4301f [opt](lance)  add some profiles in lance node (#67005)
8740cb4301f is described below

commit 8740cb4301fa8bfc0736d85251e742c7a3161487
Author: zhangstar333 <[email protected]>
AuthorDate: Thu Aug 27 14:26:14 2026 +0800

    [opt](lance)  add some profiles in lance node (#67005)
    
    ### What problem does this PR solve?
    Problem Summary:
    1. update lance version to 0.1.7
    2. add some profiles in lance node.
    
    ### Release note
    
    None
    
    ### Check List (For Author)
    
    - Test <!-- At least one of them must be included. -->
        - [] Regression test
        - [x] Unit Test
        - [ ] Manual test (add detailed scripts or steps below)
        - [ ] No need to test or manual test. Explain why:
    - [ ] This is a refactor/code format and no logic has been changed.
            - [ ] Previous test can cover this change.
            - [ ] No code files have been changed.
            - [ ] Other reason <!-- Add your reason?  -->
    
    - Behavior changed:
        - [x] No.
        - [ ] Yes. <!-- Explain the behavior change -->
    
    - Does this need documentation?
        - [ ] No.
    - [ ] Yes. <!-- Add document PR link here. eg:
    https://github.com/apache/doris-website/pull/1214 -->
    
    ### Check List (For Reviewer who merge this PR)
    
    - [ ] Confirm the release note
    - [ ] Confirm test cases
    - [ ] Confirm document
    - [ ] Add branch pick label <!-- Add branch pick label that this PR
    should merge into -->
---
 be/src/exec/operator/materialization_opertor.cpp   |   15 +-
 be/src/exec/rowid_fetcher.cpp                      |   31 +-
 be/src/exec/rowid_fetcher.h                        |    7 +
 be/src/format_v2/table/lance_reader.cpp            |  224 ++-
 be/src/format_v2/table/lance_reader.h              |   20 +-
 .../operator/materialization_shared_state_test.cpp |   27 +
 be/test/format_v2/table/lance_reader_test.cpp      |   77 +-
 .../datasource/lance/source/LanceScanNode.java     |    2 -
 .../lance/test_lance_rest_catalog.out              |    1 -
 .../lance/test_lance_vector_search.groovy          |    8 +-
 .../test_lance_vector_search_two_phase.groovy      |    2 -
 thirdparty/download-thirdparty.sh                  |    6 +-
 thirdparty/patches/lance-c-0.1.6-doris.patch       |  632 --------
 thirdparty/patches/lance-c-0.1.7-pr-64.patch       | 1522 ++++++++++++++++++++
 thirdparty/vars.sh                                 |    8 +-
 15 files changed, 1899 insertions(+), 683 deletions(-)

diff --git a/be/src/exec/operator/materialization_opertor.cpp 
b/be/src/exec/operator/materialization_opertor.cpp
index 695efb5ef78..47f65cd8c5f 100644
--- a/be/src/exec/operator/materialization_opertor.cpp
+++ b/be/src/exec/operator/materialization_opertor.cpp
@@ -449,16 +449,20 @@ Status 
MaterializationSharedState::validate_rpc_results(int node_id) {
 
 void MaterializationSharedState::_update_profile_info(int64_t backend_id,
                                                       RuntimeProfile* 
response_profile) {
+    DORIS_CHECK(response_profile != nullptr);
     if (!backend_profile_info_string.contains(backend_id)) {
         backend_profile_info_string.emplace(backend_id,
                                             std::map<std::string, 
fmt::memory_buffer> {});
     }
     auto& info_map = backend_profile_info_string[backend_id];
 
-    auto update_profile_info_key = [&](const std::string& info_key) {
+    auto update_profile_info_key = [&](const std::string& info_key, bool 
warn_if_missing = true) {
         const auto* info_value = response_profile->get_info_string(info_key);
         if (info_value == nullptr) [[unlikely]] {
-            LOG(WARNING) << "Get row id fetch rpc profile success, but no info 
key :" << info_key;
+            if (warn_if_missing) {
+                LOG(WARNING) << "Get row id fetch rpc profile success, but no 
info key :"
+                             << info_key;
+            }
             return;
         }
         if (!info_map.contains(info_key)) {
@@ -473,6 +477,10 @@ void 
MaterializationSharedState::_update_profile_info(int64_t backend_id,
     update_profile_info_key(RowIdStorageReader::FileReadLinesProfile);
     update_profile_info_key(FileScanner::FileReadBytesProfile);
     update_profile_info_key(FileScanner::FileReadTimeProfile);
+    update_profile_info_key(RowIdStorageReader::LanceDatasetOpenTimeProfile, 
false);
+    update_profile_info_key(RowIdStorageReader::LanceRowIdTakeReadTimeProfile, 
false);
+    
update_profile_info_key(RowIdStorageReader::LanceArrowToDorisBlockTimeProfile, 
false);
+    
update_profile_info_key(RowIdStorageReader::LanceRowIdFetchTotalTimeProfile, 
false);
 }
 
 Status MaterializationSharedState::create_muiltget_result(const Columns& 
columns, bool child_eos,
@@ -654,8 +662,7 @@ Status MaterializationOperator::pull(RuntimeState* state, 
Block* output_block, b
              local_state._materialization_state.backend_profile_info_string) {
             auto* child_profile = local_state.operator_profile()->create_child(
                     "RowIDFetcher: BackendId:" + std::to_string(backend_id));
-            for (const auto& [info_key, info_value] :
-                 
local_state._materialization_state.backend_profile_info_string[backend_id]) {
+            for (const auto& [info_key, info_value] : child_info) {
                 child_profile->add_info_string(info_key, "{" + 
fmt::to_string(info_value) + "}");
             }
             local_state.operator_profile()->add_child(child_profile, true);
diff --git a/be/src/exec/rowid_fetcher.cpp b/be/src/exec/rowid_fetcher.cpp
index bddf2ec4ad0..a8c3689d578 100644
--- a/be/src/exec/rowid_fetcher.cpp
+++ b/be/src/exec/rowid_fetcher.cpp
@@ -78,6 +78,7 @@
 #include "util/brpc_client_cache.h" // BrpcClientCache
 #include "util/defer_op.h"
 #include "util/jsonb/serialize.h"
+#include "util/pretty_printer.h"
 
 namespace doris {
 
@@ -724,6 +725,11 @@ const std::string 
RowIdStorageReader::ScannersRunningTimeProfile = "ScannersRunn
 const std::string RowIdStorageReader::InitReaderAvgTimeProfile = 
"InitReaderAvgTime";
 const std::string RowIdStorageReader::GetBlockAvgTimeProfile = 
"GetBlockAvgTime";
 const std::string RowIdStorageReader::FileReadLinesProfile = "FileReadLines";
+const std::string RowIdStorageReader::LanceDatasetOpenTimeProfile = 
"LanceDatasetOpenTime";
+const std::string RowIdStorageReader::LanceRowIdTakeReadTimeProfile = 
"LanceRowIdTakeReadTime";
+const std::string RowIdStorageReader::LanceArrowToDorisBlockTimeProfile =
+        "LanceArrowToDorisBlockTime";
+const std::string RowIdStorageReader::LanceRowIdFetchTotalTimeProfile = 
"LanceRowIdFetchTotalTime";
 const std::string 
RowIdStorageReader::TopNLazyMaterializationSecondPhaseLocalIOCount =
         "TopNLazyMaterializationSecondPhaseLocalIOCount";
 const std::string 
RowIdStorageReader::TopNLazyMaterializationSecondPhaseLocalIOBytes =
@@ -784,7 +790,18 @@ Status RowIdStorageReader::read_lance_rows_by_row_ids(
     RETURN_IF_ERROR(scope_timer_run(
             [&]() { return reader.read_by_row_ids(scan_range_desc, row_ids, 
block); },
             &fetch_statistics->get_block_ms));
-    return reader.close();
+    RETURN_IF_ERROR(reader.close());
+
+    const auto collect_lance_fetch_time = [&](const std::string& timer_name) {
+        if (const auto* counter = runtime_profile->get_counter(timer_name); 
counter != nullptr) {
+            fetch_statistics->lance_fetch_times_ns.emplace(timer_name, 
counter->value());
+        }
+    };
+    collect_lance_fetch_time(LanceDatasetOpenTimeProfile);
+    collect_lance_fetch_time(LanceRowIdTakeReadTimeProfile);
+    collect_lance_fetch_time(LanceArrowToDorisBlockTimeProfile);
+    collect_lance_fetch_time(LanceRowIdFetchTotalTimeProfile);
+    return Status::OK();
 }
 
 Status RowIdStorageReader::read_external_row_from_file_mapping(
@@ -1108,13 +1125,17 @@ Status RowIdStorageReader::read_batch_external_row(
         fmt::memory_buffer file_read_times_buffer;
         format_to(file_read_times_buffer, "[");
 
+        std::map<std::string, int64_t> lance_fetch_times_ns;
         size_t idx = 0;
         for (const auto& [_, scan_info] : scan_rows) {
             format_to(file_read_lines_buffer, "{}, ", scan_info.first.size());
-            *init_reader_avg_ms = fetch_statistics[idx].init_reader_ms;
+            *init_reader_avg_ms += fetch_statistics[idx].init_reader_ms;
             *get_block_avg_ms += fetch_statistics[idx].get_block_ms;
             format_to(file_read_bytes_buffer, "{}, ", 
fetch_statistics[idx].file_read_bytes);
             format_to(file_read_times_buffer, "{}, ", 
fetch_statistics[idx].file_read_times);
+            for (const auto& [time_name, time_value] : 
fetch_statistics[idx].lance_fetch_times_ns) {
+                lance_fetch_times_ns[time_name] += time_value;
+            }
             idx++;
         }
 
@@ -1127,13 +1148,17 @@ Status RowIdStorageReader::read_batch_external_row(
         runtime_profile->add_info_string(InitReaderAvgTimeProfile,
                                          std::to_string(*init_reader_avg_ms) + 
"ms");
         runtime_profile->add_info_string(GetBlockAvgTimeProfile,
-                                         std::to_string(*init_reader_avg_ms) + 
"ms");
+                                         std::to_string(*get_block_avg_ms) + 
"ms");
         runtime_profile->add_info_string(FileReadLinesProfile,
                                          
fmt::to_string(file_read_lines_buffer));
         runtime_profile->add_info_string(FileScanner::FileReadBytesProfile,
                                          
fmt::to_string(file_read_bytes_buffer));
         runtime_profile->add_info_string(FileScanner::FileReadTimeProfile,
                                          
fmt::to_string(file_read_times_buffer));
+        for (const auto& [time_name, time_value] : lance_fetch_times_ns) {
+            runtime_profile->add_info_string(time_name,
+                                             PrettyPrinter::print(time_value, 
TUnit::TIME_NS));
+        }
     }
 
     runtime_profile->to_proto(pprofile, 2);
diff --git a/be/src/exec/rowid_fetcher.h b/be/src/exec/rowid_fetcher.h
index 41acea97c1d..ceb7a51c1a0 100644
--- a/be/src/exec/rowid_fetcher.h
+++ b/be/src/exec/rowid_fetcher.h
@@ -22,8 +22,10 @@
 #include <gen_cpp/DataSinks_types.h>
 #include <gen_cpp/internal_service.pb.h>
 
+#include <map>
 #include <memory>
 #include <semaphore>
+#include <string>
 #include <utility>
 #include <vector>
 
@@ -101,6 +103,10 @@ public:
     static const std::string InitReaderAvgTimeProfile;
     static const std::string GetBlockAvgTimeProfile;
     static const std::string FileReadLinesProfile;
+    static const std::string LanceDatasetOpenTimeProfile;
+    static const std::string LanceRowIdTakeReadTimeProfile;
+    static const std::string LanceArrowToDorisBlockTimeProfile;
+    static const std::string LanceRowIdFetchTotalTimeProfile;
     static const std::string TopNLazyMaterializationSecondPhaseLocalIOCount;
     static const std::string TopNLazyMaterializationSecondPhaseLocalIOBytes;
     static const std::string TopNLazyMaterializationSecondPhaseRemoteIOCount;
@@ -165,6 +171,7 @@ private:
     struct ExternalFetchStatistics {
         int64_t init_reader_ms = 0;
         int64_t get_block_ms = 0;
+        std::map<std::string, int64_t> lance_fetch_times_ns;
         std::string file_read_bytes;
         std::string file_read_times;
     };
diff --git a/be/src/format_v2/table/lance_reader.cpp 
b/be/src/format_v2/table/lance_reader.cpp
index 29d334f4b98..e8ad0c5229a 100644
--- a/be/src/format_v2/table/lance_reader.cpp
+++ b/be/src/format_v2/table/lance_reader.cpp
@@ -30,6 +30,7 @@
 #include <memory>
 
 #include "common/consts.h"
+#include "common/logging.h"
 #include "core/column/column_nullable.h"
 #include "core/column/column_string.h"
 #include "core/data_type/data_type_array.h"
@@ -38,6 +39,7 @@
 #include "core/data_type/data_type_nothing.h"
 #include "core/data_type/data_type_struct.h"
 #include "exec/common/endian.h"
+#include "runtime/file_scan_profile.h"
 #include "storage/utils.h"
 
 namespace doris::format::lance {
@@ -58,6 +60,7 @@ struct LanceBatchDeleter {
 constexpr std::string_view DISTANCE_COLUMN = "_distance";
 constexpr std::string_view ROW_ID_COLUMN = "_rowid";
 constexpr std::string_view ARROW_EXTENSION_NAME = "ARROW:extension:name";
+constexpr const char* LANCE_READER_PROFILE = "LanceReader";
 
 size_t vector_element_width(TVectorElementType::type type) {
     switch (type) {
@@ -303,21 +306,77 @@ Status LanceTableReader::init(TableReadOptions&& options) 
{
 
     _ctz = _runtime_state->timezone_obj();
     const auto& lance_scan_params = _scan_params->lance_scan_params;
+    ADD_CHILD_TIMER_WITH_LEVEL(_scanner_profile, LANCE_READER_PROFILE,
+                               file_scan_profile::TABLE_READER, 1);
+    _dataset_open_time = ADD_CHILD_TIMER_WITH_LEVEL(_scanner_profile, 
"LanceDatasetOpenTime",
+                                                    LANCE_READER_PROFILE, 1);
+    _scanner_configure_time = ADD_CHILD_TIMER_WITH_LEVEL(
+            _scanner_profile, "LanceScannerConfigureTime", 
LANCE_READER_PROFILE, 1);
+    _scanner_read_time = ADD_CHILD_TIMER_WITH_LEVEL(_scanner_profile, 
"LanceScannerReadTime",
+                                                    LANCE_READER_PROFILE, 1);
+    _arrow_to_doris_block_time = ADD_CHILD_TIMER_WITH_LEVEL(
+            _scanner_profile, "LanceArrowToDorisBlockTime", 
LANCE_READER_PROFILE, 1);
+    _execution_iops = ADD_CHILD_COUNTER_WITH_LEVEL(_scanner_profile, 
"LanceExecutionIOOps",
+                                                   TUnit::UNIT, 
LANCE_READER_PROFILE, 1);
+    _execution_requests = ADD_CHILD_COUNTER_WITH_LEVEL(_scanner_profile, 
"LanceExecutionIORequests",
+                                                       TUnit::UNIT, 
LANCE_READER_PROFILE, 1);
+    _execution_bytes_read = ADD_CHILD_COUNTER_WITH_LEVEL(
+            _scanner_profile, "LanceExecutionIOBytesRead", TUnit::BYTES, 
LANCE_READER_PROFILE, 1);
+    _index_partition_cache_miss_loads =
+            ADD_CHILD_COUNTER_WITH_LEVEL(_scanner_profile, 
"LanceIndexPartitionCacheMissLoads",
+                                         TUnit::UNIT, LANCE_READER_PROFILE, 1);
+    _index_comparisons = ADD_CHILD_COUNTER_WITH_LEVEL(_scanner_profile, 
"LanceIndexComparisons",
+                                                      TUnit::UNIT, 
LANCE_READER_PROFILE, 1);
+    // These scan counts are emitted by Lance's FilteredRead execution node. 
For vector searches
+    // with an explicit fragment set, they normally describe the fragments, 
ranges, and rows read
+    // while applying the row-id prefilter. They are scan input counts, not 
ANN result counts.
+    _lance_count_metrics = {
+            {"fragments_scanned",
+             ADD_CHILD_COUNTER_WITH_LEVEL(_scanner_profile, 
"LanceFragmentsScanned", TUnit::UNIT,
+                                          LANCE_READER_PROFILE, 1)},
+            {"ranges_scanned",
+             ADD_CHILD_COUNTER_WITH_LEVEL(_scanner_profile, 
"LanceRowOffsetRangesScanned",
+                                          TUnit::UNIT, LANCE_READER_PROFILE, 
1)},
+            {"rows_scanned", ADD_CHILD_COUNTER_WITH_LEVEL(_scanner_profile, 
"LanceRowsScanned",
+                                                          TUnit::UNIT, 
LANCE_READER_PROFILE, 1)},
+            {"partitions_ranked",
+             ADD_CHILD_COUNTER_WITH_LEVEL(_scanner_profile, 
"LanceIVFPartitionsRanked", TUnit::UNIT,
+                                          LANCE_READER_PROFILE, 1)},
+            {"partitions_searched",
+             ADD_CHILD_COUNTER_WITH_LEVEL(_scanner_profile, 
"LanceIVFPartitionsSearched",
+                                          TUnit::UNIT, LANCE_READER_PROFILE, 
1)},
+            {"deltas_searched",
+             ADD_CHILD_COUNTER_WITH_LEVEL(_scanner_profile, 
"LanceVectorIndexSegmentsSearched",
+                                          TUnit::UNIT, LANCE_READER_PROFILE, 
1)},
+    };
+    _lance_time_metrics = {
+            // This is wait time reported by the same Lance scan execution 
node described above,
+            // rather than Doris scanner scheduling wait time.
+            {"task_wait_time", ADD_CHILD_TIMER_WITH_LEVEL(_scanner_profile, 
"LanceTaskWaitTime",
+                                                          
LANCE_READER_PROFILE, 1)},
+            {"find_partitions_elapsed",
+             ADD_CHILD_TIMER_WITH_LEVEL(_scanner_profile, 
"LanceIVFPartitionRankingTime",
+                                        LANCE_READER_PROFILE, 1)},
+    };
     _vector_search = _scan_params->__isset.lance_scan_params &&
                      lance_scan_params.__isset.external_search_request;
     if (_vector_search) {
         RETURN_IF_ERROR(_validate_external_search_request());
         const auto& request = lance_scan_params.external_search_request;
         const auto& vector = request.search_query.vector_search;
-        const bool use_index = !request.__isset.vector_search_options ||
-                               
!request.vector_search_options.__isset.use_index ||
-                               request.vector_search_options.use_index;
-        _scanner_profile->add_info_string("LanceFragmentTopK", 
std::to_string(vector.top_k));
-        _scanner_profile->add_info_string("LanceFragmentOffset", 
std::to_string(vector.offset));
-        _scanner_profile->add_info_string("LanceVectorDimension",
-                                          
std::to_string(vector.query_vector.dimension));
-        _scanner_profile->add_info_string("LanceUseIndex", use_index ? "true" 
: "false");
-        _fragment_count = ADD_COUNTER(_scanner_profile, "LanceFragmentCount", 
TUnit::UNIT);
+        _scanner_profile->add_info_string("LanceTopK", 
std::to_string(vector.top_k));
+        _scanner_profile->add_info_string("LanceOffset", 
std::to_string(vector.offset));
+        _scanner_profile->add_info_string("LanceTopKPlusOffset",
+                                          std::to_string(vector.top_k + 
vector.offset));
+        _planned_index_segment_count =
+                ADD_CHILD_COUNTER_WITH_LEVEL(_scanner_profile, 
"LancePlannedIndexSegmentCount",
+                                             TUnit::UNIT, 
LANCE_READER_PROFILE, 1);
+        _planned_indexed_fragment_count =
+                ADD_CHILD_COUNTER_WITH_LEVEL(_scanner_profile, 
"LancePlannedIndexedFragmentCount",
+                                             TUnit::UNIT, 
LANCE_READER_PROFILE, 1);
+        _planned_flat_search_fragment_count = ADD_CHILD_COUNTER_WITH_LEVEL(
+                _scanner_profile, "LancePlannedFlatSearchFragmentCount", 
TUnit::UNIT,
+                LANCE_READER_PROFILE, 1);
     }
     if (_scan_params->__isset.lance_scan_params &&
         lance_scan_params.__isset.lance_substrait_filter) {
@@ -415,7 +474,11 @@ Status LanceTableReader::get_block(Block* block, bool* 
eos) {
             }
 
             LanceBatch* raw_batch = nullptr;
-            const int32_t scan_status = lance_scanner_next(_scanner, 
&raw_batch);
+            int32_t scan_status = 0;
+            {
+                SCOPED_TIMER(_scanner_read_time);
+                scan_status = lance_scanner_next(_scanner, &raw_batch);
+            }
             if (scan_status == 1) {
                 _eof = true;
                 _close_scanner();
@@ -427,7 +490,10 @@ Status LanceTableReader::get_block(Block* block, bool* 
eos) {
 
             std::unique_ptr<LanceBatch, LanceBatchDeleter> batch(raw_batch);
             size_t rows = 0;
-            RETURN_IF_ERROR(_fill_block_from_lance_batch(batch.get(), block, 
&rows));
+            {
+                SCOPED_TIMER(_arrow_to_doris_block_time);
+                RETURN_IF_ERROR(_fill_block_from_lance_batch(batch.get(), 
block, &rows));
+            }
             _record_scan_rows(rows);
             raw_rows += rows;
         }
@@ -457,6 +523,15 @@ Status LanceTableReader::read_by_row_ids(const 
TFileRangeDesc& range,
     if (row_ids.empty()) {
         return Status::OK();
     }
+    if (_row_id_take_read_time == nullptr) {
+        _row_id_take_read_time = ADD_CHILD_TIMER_WITH_LEVEL(
+                _scanner_profile, "LanceRowIdTakeReadTime", 
LANCE_READER_PROFILE, 1);
+    }
+    if (_row_id_fetch_total_time == nullptr) {
+        _row_id_fetch_total_time = ADD_CHILD_TIMER_WITH_LEVEL(
+                _scanner_profile, "LanceRowIdFetchTotalTime", 
LANCE_READER_PROFILE, 1);
+    }
+    SCOPED_TIMER(_row_id_fetch_total_time);
 
     RETURN_IF_ERROR(_ensure_dataset_open(range));
     std::vector<const char*> columns;
@@ -467,8 +542,13 @@ Status LanceTableReader::read_by_row_ids(const 
TFileRangeDesc& range,
     columns.emplace_back(nullptr);
 
     ArrowArrayStream stream {};
-    if (lance_dataset_take_rows(_dataset, row_ids.data(), row_ids.size(), 
columns.data(),
-                                &stream) != 0) {
+    int32_t take_rows_status = 0;
+    {
+        SCOPED_TIMER(_row_id_take_read_time);
+        take_rows_status = lance_dataset_take_rows(_dataset, row_ids.data(), 
row_ids.size(),
+                                                   columns.data(), &stream);
+    }
+    if (take_rows_status != 0) {
         if (stream.release != nullptr) {
             stream.release(&stream);
         }
@@ -487,7 +567,12 @@ Status LanceTableReader::read_by_row_ids(const 
TFileRangeDesc& range,
     auto batch_reader = std::move(imported_reader).ValueUnsafe();
     while (true) {
         std::shared_ptr<arrow::RecordBatch> record_batch;
-        const auto read_status = batch_reader->ReadNext(&record_batch);
+        arrow::Status read_status;
+        {
+            // Lance may materialize take_rows lazily while its Arrow stream 
is consumed.
+            SCOPED_TIMER(_row_id_take_read_time);
+            read_status = batch_reader->ReadNext(&record_batch);
+        }
         if (!read_status.ok()) {
             return Status::InternalError("read Lance take-rows batch failed: 
{}",
                                          read_status.message());
@@ -496,7 +581,10 @@ Status LanceTableReader::read_by_row_ids(const 
TFileRangeDesc& range,
             break;
         }
         size_t rows = 0;
-        RETURN_IF_ERROR(_fill_block_from_record_batch(record_batch, block, 
&rows));
+        {
+            SCOPED_TIMER(_arrow_to_doris_block_time);
+            RETURN_IF_ERROR(_fill_block_from_record_batch(record_batch, block, 
&rows));
+        }
         fetched_rows += rows;
     }
     if (fetched_rows != row_ids.size()) {
@@ -650,9 +738,12 @@ Status LanceTableReader::_open_dataset(const DatasetKey& 
key) {
     }
     storage_option_ptrs.emplace_back(nullptr);
 
-    _dataset = lance_dataset_open(
-            key.uri.c_str(), key.storage_options.empty() ? nullptr : 
storage_option_ptrs.data(),
-            static_cast<uint64_t>(key.version));
+    {
+        SCOPED_TIMER(_dataset_open_time);
+        _dataset = lance_dataset_open(
+                key.uri.c_str(), key.storage_options.empty() ? nullptr : 
storage_option_ptrs.data(),
+                static_cast<uint64_t>(key.version));
+    }
     if (_dataset == nullptr) {
         return _lance_error("open Lance dataset");
     }
@@ -660,6 +751,7 @@ Status LanceTableReader::_open_dataset(const DatasetKey& 
key) {
 }
 
 Status LanceTableReader::_open_scanner(const TFileRangeDesc& range) {
+    SCOPED_TIMER(_scanner_configure_time);
     std::vector<const char*> columns;
     columns.reserve(_projected_columns.size() + 1);
     for (size_t idx = 0; idx < _projected_columns.size(); ++idx) {
@@ -691,6 +783,13 @@ Status LanceTableReader::_open_scanner(const 
TFileRangeDesc& range) {
         return _lance_error("create Lance scanner");
     }
     std::unique_ptr<LanceScanner, LanceScannerDeleter> scanner_guard(scanner);
+    const auto collect_scan_statistics = [](void* callback_ctx,
+                                            const LanceScanStatistics* 
statistics) {
+        LanceTableReader::_collect_scan_statistics(callback_ctx, statistics);
+    };
+    if (lance_scanner_set_statistics_callback(scanner, 
collect_scan_statistics, this) != 0) {
+        return _lance_error("set Lance scanner statistics callback");
+    }
 
     if (_global_rowid_output_idx.has_value() && 
lance_scanner_with_row_id(scanner, true) != 0) {
         return _lance_error("enable Lance row id output");
@@ -776,9 +875,16 @@ Status LanceTableReader::_open_scanner(const 
TFileRangeDesc& range) {
             return _lance_error("enable Lance vector prefilter");
         }
         RETURN_IF_ERROR(_configure_vector_search(scanner));
-        DORIS_CHECK(_fragment_count != nullptr);
-        if (lance_params.__isset.fragment_ids) {
-            COUNTER_UPDATE(_fragment_count, 
static_cast<int64_t>(lance_params.fragment_ids.size()));
+        const int64_t fragment_count =
+                lance_params.__isset.fragment_ids
+                        ? 
static_cast<int64_t>(lance_params.fragment_ids.size())
+                        : 0;
+        if (lance_params.__isset.index_segment_uuids && 
!lance_params.index_segment_uuids.empty()) {
+            COUNTER_UPDATE(_planned_index_segment_count,
+                           
static_cast<int64_t>(lance_params.index_segment_uuids.size()));
+            COUNTER_UPDATE(_planned_indexed_fragment_count, fragment_count);
+        } else {
+            COUNTER_UPDATE(_planned_flat_search_fragment_count, 
fragment_count);
         }
     }
     _scanner = scanner_guard.release();
@@ -904,6 +1010,82 @@ Status 
LanceTableReader::_configure_vector_search(LanceScanner* scanner) const {
     return Status::OK();
 }
 
+void LanceTableReader::_collect_scan_statistics(void* callback_ctx, const 
void* opaque_statistics) {
+    const auto* statistics = static_cast<const 
LanceScanStatistics*>(opaque_statistics);
+    if (callback_ctx == nullptr || statistics == nullptr) {
+        LOG(WARNING) << "Lance scan statistics callback received a null 
argument";
+        return;
+    }
+
+    auto* reader = static_cast<LanceTableReader*>(callback_ctx);
+    const auto update_counter = [](RuntimeProfile::Counter* counter, uint64_t 
value,
+                                   std::string_view metric_name) {
+        if (counter == nullptr) {
+            return;
+        }
+        if (value > 
static_cast<uint64_t>(std::numeric_limits<int64_t>::max())) {
+            LOG(WARNING) << "Ignoring Lance scan metric '" << metric_name << 
"' with value "
+                         << value << " because it exceeds INT64_MAX";
+            return;
+        }
+        COUNTER_UPDATE(counter, static_cast<int64_t>(value));
+    };
+
+    update_counter(reader->_execution_iops, statistics->iops, "iops");
+    update_counter(reader->_execution_requests, statistics->requests, 
"requests");
+    update_counter(reader->_execution_bytes_read, statistics->bytes_read, 
"bytes_read");
+    update_counter(reader->_index_partition_cache_miss_loads, 
statistics->index_partitions_loaded,
+                   "index_partitions_loaded");
+    update_counter(reader->_index_comparisons, statistics->index_comparisons, 
"index_comparisons");
+
+    if (statistics->metrics_len != 0 && statistics->metrics == nullptr) {
+        LOG(WARNING) << "Ignoring malformed Lance scan statistics: metrics is 
NULL while "
+                     << "metrics_len is " << statistics->metrics_len;
+        return;
+    }
+    for (size_t index = 0; index < statistics->metrics_len; ++index) {
+        const auto& metric = statistics->metrics[index];
+        if (metric.name_len != 0 && metric.name == nullptr) {
+            LOG(WARNING) << "Ignoring malformed Lance scan metric at index " 
<< index
+                         << ": name is NULL while name_len is " << 
metric.name_len;
+            continue;
+        }
+        const std::string_view name(metric.name == nullptr ? "" : metric.name, 
metric.name_len);
+        RuntimeProfile::Counter* counter = nullptr;
+        switch (metric.kind) {
+        case LANCE_SCAN_METRIC_COUNT: {
+            const auto found = reader->_lance_count_metrics.find(name);
+            if (found != reader->_lance_count_metrics.end()) {
+                counter = found->second;
+            }
+            break;
+        }
+        case LANCE_SCAN_METRIC_TIME_NANOSECONDS: {
+            const auto found = reader->_lance_time_metrics.find(name);
+            if (found != reader->_lance_time_metrics.end()) {
+                counter = found->second;
+            } else if (name == "search_time") {
+                // Scalar-index metrics exist only when Lance includes the 
corresponding
+                // execution node in this scan plan.
+                counter = ADD_CHILD_TIMER_WITH_LEVEL(reader->_scanner_profile,
+                                                     
"LanceScalarIndexQueryTime",
+                                                     LANCE_READER_PROFILE, 1);
+            } else if (name == "serialization_time") {
+                counter = ADD_CHILD_TIMER_WITH_LEVEL(reader->_scanner_profile,
+                                                     
"LanceScalarIndexResultSerializationTime",
+                                                     LANCE_READER_PROFILE, 1);
+            }
+            break;
+        }
+        default:
+            break;
+        }
+        if (counter != nullptr) {
+            update_counter(counter, metric.value, name);
+        }
+    }
+}
+
 void LanceTableReader::_close_scanner() {
     if (_scanner != nullptr) {
         lance_scanner_close(_scanner);
diff --git a/be/src/format_v2/table/lance_reader.h 
b/be/src/format_v2/table/lance_reader.h
index bda465185cb..892aaf518e5 100644
--- a/be/src/format_v2/table/lance_reader.h
+++ b/be/src/format_v2/table/lance_reader.h
@@ -88,6 +88,9 @@ private:
     Status _open_dataset(const DatasetKey& key);
     Status _open_scanner(const TFileRangeDesc& range);
     Status _configure_vector_search(LanceScanner* scanner) const;
+    // Keep lance-c's anonymous statistics typedef out of this header. 
_open_scanner installs the
+    // strongly typed C callback adapter before forwarding the borrowed value 
here.
+    static void _collect_scan_statistics(void* callback_ctx, const void* 
opaque_statistics);
     void _close_scanner();
     void _close_dataset();
     Status _fill_block_from_lance_batch(LanceBatch* batch, Block* block, 
size_t* rows);
@@ -107,7 +110,22 @@ private:
     std::optional<size_t> _global_rowid_output_idx;
     cctz::time_zone _ctz;
     size_t _scanner_batch_size = 0;
-    RuntimeProfile::Counter* _fragment_count = nullptr;
+    RuntimeProfile::Counter* _planned_index_segment_count = nullptr;
+    RuntimeProfile::Counter* _planned_indexed_fragment_count = nullptr;
+    RuntimeProfile::Counter* _planned_flat_search_fragment_count = nullptr;
+    RuntimeProfile::Counter* _dataset_open_time = nullptr;
+    RuntimeProfile::Counter* _scanner_configure_time = nullptr;
+    RuntimeProfile::Counter* _scanner_read_time = nullptr;
+    RuntimeProfile::Counter* _arrow_to_doris_block_time = nullptr;
+    RuntimeProfile::Counter* _row_id_take_read_time = nullptr;
+    RuntimeProfile::Counter* _row_id_fetch_total_time = nullptr;
+    RuntimeProfile::Counter* _execution_iops = nullptr;
+    RuntimeProfile::Counter* _execution_requests = nullptr;
+    RuntimeProfile::Counter* _execution_bytes_read = nullptr;
+    RuntimeProfile::Counter* _index_partition_cache_miss_loads = nullptr;
+    RuntimeProfile::Counter* _index_comparisons = nullptr;
+    std::unordered_map<std::string_view, RuntimeProfile::Counter*> 
_lance_count_metrics;
+    std::unordered_map<std::string_view, RuntimeProfile::Counter*> 
_lance_time_metrics;
     bool _vector_search = false;
     bool _eof = false;
 };
diff --git a/be/test/exec/operator/materialization_shared_state_test.cpp 
b/be/test/exec/operator/materialization_shared_state_test.cpp
index ee0b067824c..95ec24d0879 100644
--- a/be/test/exec/operator/materialization_shared_state_test.cpp
+++ b/be/test/exec/operator/materialization_shared_state_test.cpp
@@ -37,6 +37,21 @@ void add_request_row(PRequestBlockDesc* request_block_desc, 
uint64_t row_id, uin
     request_block_desc->add_file_id(file_id);
 }
 
+void set_lance_fetch_profile(PMultiGetBlockV2* response_block, int64_t scale) {
+    RuntimeProfile profile("ExternalRowIDFetcher");
+    profile.add_info_string("LanceDatasetOpenTime", std::to_string(scale) + 
"ns");
+    profile.add_info_string("LanceRowIdTakeReadTime", std::to_string(2 * 
scale) + "ns");
+    profile.add_info_string("LanceArrowToDorisBlockTime", std::to_string(3 * 
scale) + "ns");
+    profile.add_info_string("LanceRowIdFetchTotalTime", std::to_string(4 * 
scale) + "ns");
+    profile.add_info_string("ScannersRunningTime", "0ms");
+    profile.add_info_string("InitReaderAvgTime", "0ms");
+    profile.add_info_string("GetBlockAvgTime", "0ms");
+    profile.add_info_string("FileReadLines", "[]");
+    profile.add_info_string("FileReadBytes", "[]");
+    profile.add_info_string("FileReadTime", "[]");
+    profile.to_proto(response_block->mutable_profile(), 2);
+}
+
 } // namespace
 
 class MaterializationSharedStateTest : public testing::Test {
@@ -242,6 +257,7 @@ TEST_F(MaterializationSharedStateTest, 
TestMergeMultiResponse) {
         auto s = resp_block1.serialize(0, serialized_block, 
&uncompressed_size, &compressed_size,
                                        &compress_time, CompressionTypePB::LZ4);
         EXPECT_TRUE(s.ok());
+        set_lance_fetch_profile(response_.mutable_blocks(0), 10);
 
         _shared_state->rpc_struct_map[_backend_id1].response = 
std::move(response_);
         // init the response blocks
@@ -268,6 +284,7 @@ TEST_F(MaterializationSharedStateTest, 
TestMergeMultiResponse) {
         auto s = resp_block2.serialize(0, serialized_block, 
&uncompressed_size, &compressed_size,
                                        &compress_time, CompressionTypePB::LZ4);
         EXPECT_TRUE(s.ok());
+        set_lance_fetch_profile(response_.mutable_blocks(0), 1);
 
         _shared_state->rpc_struct_map[_backend_id2].response = 
std::move(response_);
     }
@@ -294,6 +311,16 @@ TEST_F(MaterializationSharedStateTest, 
TestMergeMultiResponse) {
     EXPECT_EQ(merged_value_col->get_data_at(1).data,
               nullptr); // Second value from BE1, replace by null
     EXPECT_EQ(*((int*)merged_value_col->get_data_at(2).data), 200); // Third 
value from BE2
+    const auto& backend1_info = 
_shared_state->backend_profile_info_string.at(_backend_id1);
+    EXPECT_EQ("10ns, ", 
fmt::to_string(backend1_info.at("LanceDatasetOpenTime")));
+    EXPECT_EQ("20ns, ", 
fmt::to_string(backend1_info.at("LanceRowIdTakeReadTime")));
+    EXPECT_EQ("30ns, ", 
fmt::to_string(backend1_info.at("LanceArrowToDorisBlockTime")));
+    EXPECT_EQ("40ns, ", 
fmt::to_string(backend1_info.at("LanceRowIdFetchTotalTime")));
+    const auto& backend2_info = 
_shared_state->backend_profile_info_string.at(_backend_id2);
+    EXPECT_EQ("1ns, ", 
fmt::to_string(backend2_info.at("LanceDatasetOpenTime")));
+    EXPECT_EQ("2ns, ", 
fmt::to_string(backend2_info.at("LanceRowIdTakeReadTime")));
+    EXPECT_EQ("3ns, ", 
fmt::to_string(backend2_info.at("LanceArrowToDorisBlockTime")));
+    EXPECT_EQ("4ns, ", 
fmt::to_string(backend2_info.at("LanceRowIdFetchTotalTime")));
 }
 
 TEST_F(MaterializationSharedStateTest, TestMergeMultiResponseMultiBlocks) {
diff --git a/be/test/format_v2/table/lance_reader_test.cpp 
b/be/test/format_v2/table/lance_reader_test.cpp
index a576f1f63a2..9a2e802217e 100644
--- a/be/test/format_v2/table/lance_reader_test.cpp
+++ b/be/test/format_v2/table/lance_reader_test.cpp
@@ -90,6 +90,24 @@ struct LanceFixtureInfo {
     std::vector<int64_t> fragment_ids;
 };
 
+void expect_lance_profile_hierarchy(RuntimeProfile* profile,
+                                    const std::vector<std::string>& 
metric_names) {
+    TRuntimeProfileTree tree;
+    profile->to_thrift(&tree, 3);
+    ASSERT_FALSE(tree.nodes.empty());
+    const auto& children = tree.nodes[0].child_counters_map;
+    ASSERT_TRUE(children.contains(RuntimeProfile::ROOT_COUNTER));
+    
EXPECT_TRUE(children.at(RuntimeProfile::ROOT_COUNTER).contains("FileScannerV2"));
+    ASSERT_TRUE(children.contains("FileScannerV2"));
+    EXPECT_TRUE(children.at("FileScannerV2").contains("TableReader"));
+    ASSERT_TRUE(children.contains("TableReader"));
+    EXPECT_TRUE(children.at("TableReader").contains("LanceReader"));
+    ASSERT_TRUE(children.contains("LanceReader"));
+    for (const auto& metric_name : metric_names) {
+        EXPECT_TRUE(children.at("LanceReader").contains(metric_name)) << 
metric_name;
+    }
+}
+
 Status get_fixture_info(const std::filesystem::path& dataset_uri, 
LanceFixtureInfo* info) {
     std::unique_ptr<LanceDataset, decltype(&lance_dataset_close)> dataset(
             lance_dataset_open(dataset_uri.c_str(), nullptr, 0), 
lance_dataset_close);
@@ -344,6 +362,12 @@ TEST(LanceTableReaderVectorSearchTest, 
SearchesWholeSnapshotWithOffsetAndDistanc
     EXPECT_FLOAT_EQ(1.0F, rows[0].second);
     EXPECT_EQ(4, rows[1].first);
     EXPECT_FLOAT_EQ(8.25F, rows[1].second);
+    ASSERT_NE(profile.get_info_string("LanceTopK"), nullptr);
+    EXPECT_EQ("2", *profile.get_info_string("LanceTopK"));
+    ASSERT_NE(profile.get_info_string("LanceOffset"), nullptr);
+    EXPECT_EQ("1", *profile.get_info_string("LanceOffset"));
+    ASSERT_NE(profile.get_info_string("LanceTopKPlusOffset"), nullptr);
+    EXPECT_EQ("3", *profile.get_info_string("LanceTopKPlusOffset"));
     EXPECT_TRUE(reader.close().ok());
 }
 
@@ -399,8 +423,6 @@ TEST(LanceTableReaderVectorSearchTest, 
SearchesMultipleFragmentSplits) {
 
     LanceTableReader reader;
     ASSERT_TRUE(init_reader(&reader, columns, &state, &profile, 
&scan_params).ok());
-    ASSERT_NE(profile.get_info_string("LanceUseIndex"), nullptr);
-    EXPECT_EQ(*profile.get_info_string("LanceUseIndex"), "false");
     std::vector<int64_t> row_ids;
     for (const auto fragment_id : fixture.fragment_ids) {
         ASSERT_TRUE(prepare_fixture(&reader, dataset_uri, fixture, 
{fragment_id}).ok());
@@ -413,9 +435,49 @@ TEST(LanceTableReaderVectorSearchTest, 
SearchesMultipleFragmentSplits) {
     }
     std::ranges::sort(row_ids);
     EXPECT_EQ((std::vector<int64_t> {1, 2, 3, 4}), row_ids);
-    ASSERT_NE(profile.get_counter("LanceFragmentCount"), nullptr);
-    EXPECT_EQ(profile.get_counter("LanceFragmentCount")->value(),
+    ASSERT_NE(profile.get_counter("LancePlannedIndexSegmentCount"), nullptr);
+    EXPECT_EQ(0, 
profile.get_counter("LancePlannedIndexSegmentCount")->value());
+    ASSERT_NE(profile.get_counter("LancePlannedIndexedFragmentCount"), 
nullptr);
+    EXPECT_EQ(0, 
profile.get_counter("LancePlannedIndexedFragmentCount")->value());
+    ASSERT_NE(profile.get_counter("LancePlannedFlatSearchFragmentCount"), 
nullptr);
+    
EXPECT_EQ(profile.get_counter("LancePlannedFlatSearchFragmentCount")->value(),
               static_cast<int64_t>(fixture.fragment_ids.size()));
+    ASSERT_NE(profile.get_info_string("LanceTopK"), nullptr);
+    EXPECT_EQ("4", *profile.get_info_string("LanceTopK"));
+    ASSERT_NE(profile.get_info_string("LanceOffset"), nullptr);
+    EXPECT_EQ("0", *profile.get_info_string("LanceOffset"));
+    ASSERT_NE(profile.get_info_string("LanceTopKPlusOffset"), nullptr);
+    EXPECT_EQ("4", *profile.get_info_string("LanceTopKPlusOffset"));
+    EXPECT_NE(profile.get_counter("LanceDatasetOpenTime"), nullptr);
+    EXPECT_NE(profile.get_counter("LanceScannerConfigureTime"), nullptr);
+    EXPECT_NE(profile.get_counter("LanceScannerReadTime"), nullptr);
+    EXPECT_NE(profile.get_counter("LanceRowOffsetRangesScanned"), nullptr);
+    EXPECT_NE(profile.get_counter("LanceTaskWaitTime"), nullptr);
+    EXPECT_EQ(profile.get_counter("LanceExecutionIndexCacheMissLoads"), 
nullptr);
+    EXPECT_EQ(profile.get_counter("LanceRowIdTakeReadTime"), nullptr);
+    EXPECT_EQ(profile.get_counter("LanceRowIdFetchTotalTime"), nullptr);
+    EXPECT_EQ(profile.get_counter("LanceScalarIndexQueryTime"), nullptr);
+    EXPECT_EQ(profile.get_counter("LanceScalarIndexResultSerializationTime"), 
nullptr);
+    expect_lance_profile_hierarchy(&profile, {"LanceDatasetOpenTime",
+                                              "LanceScannerConfigureTime",
+                                              "LanceScannerReadTime",
+                                              "LanceArrowToDorisBlockTime",
+                                              "LanceExecutionIOOps",
+                                              "LanceExecutionIORequests",
+                                              "LanceExecutionIOBytesRead",
+                                              
"LanceIndexPartitionCacheMissLoads",
+                                              "LanceIndexComparisons",
+                                              "LanceFragmentsScanned",
+                                              "LanceRowOffsetRangesScanned",
+                                              "LanceRowsScanned",
+                                              "LanceIVFPartitionsRanked",
+                                              "LanceIVFPartitionsSearched",
+                                              
"LanceVectorIndexSegmentsSearched",
+                                              "LanceTaskWaitTime",
+                                              "LanceIVFPartitionRankingTime",
+                                              "LancePlannedIndexSegmentCount",
+                                              
"LancePlannedIndexedFragmentCount",
+                                              
"LancePlannedFlatSearchFragmentCount"});
     EXPECT_TRUE(reader.close().ok());
 }
 
@@ -519,6 +581,13 @@ TEST(LanceTableReaderVectorSearchTest, 
ReturnsStableGlobalRowIdsAndFetchesPayloa
     EXPECT_EQ("extra", label_values.get_data_at(0).to_string());
     EXPECT_EQ("unit-x", label_values.get_data_at(1).to_string());
     EXPECT_EQ("extra", label_values.get_data_at(2).to_string());
+    EXPECT_NE(fetch_profile.get_counter("LanceDatasetOpenTime"), nullptr);
+    EXPECT_NE(fetch_profile.get_counter("LanceRowIdTakeReadTime"), nullptr);
+    EXPECT_NE(fetch_profile.get_counter("LanceArrowToDorisBlockTime"), 
nullptr);
+    EXPECT_NE(fetch_profile.get_counter("LanceRowIdFetchTotalTime"), nullptr);
+    expect_lance_profile_hierarchy(&fetch_profile,
+                                   {"LanceDatasetOpenTime", 
"LanceRowIdTakeReadTime",
+                                    "LanceArrowToDorisBlockTime", 
"LanceRowIdFetchTotalTime"});
     EXPECT_TRUE(payload_reader.close().ok());
 }
 
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/source/LanceScanNode.java
 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/source/LanceScanNode.java
index 38ab936bd74..394cb6ebf06 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/source/LanceScanNode.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/source/LanceScanNode.java
@@ -436,8 +436,6 @@ public class LanceScanNode extends FileQueryScanNode {
             TVectorSearchParams vector = 
externalSearchRequest.getSearchQuery().getVectorSearch();
             result.append(prefix).append("externalSearchType=VECTOR\n");
             
result.append(prefix).append("lanceVectorColumn=").append(vector.getColumn()).append("\n");
-            
result.append(prefix).append("lanceTopK=").append(vector.getTopK()).append("\n");
-            
result.append(prefix).append("lanceOffset=").append(vector.getOffset()).append("\n");
             result.append(prefix).append("lanceMetric=")
                     .append(vector.isSetMetric() ? 
metricName(vector.getMetric()) : "default")
                     .append("\n");
diff --git 
a/regression-test/data/external_table_p0/lance/test_lance_rest_catalog.out 
b/regression-test/data/external_table_p0/lance/test_lance_rest_catalog.out
index cb8003936c2..5dc72192239 100644
--- a/regression-test/data/external_table_p0/lance/test_lance_rest_catalog.out
+++ b/regression-test/data/external_table_p0/lance/test_lance_rest_catalog.out
@@ -17,4 +17,3 @@ all_types_unprefixed
 -- !rest_predicate_pushdown --
 8
 9
-
diff --git 
a/regression-test/suites/external_table_p0/lance/test_lance_vector_search.groovy
 
b/regression-test/suites/external_table_p0/lance/test_lance_vector_search.groovy
index 7312a70bf29..4464b5ad35b 100644
--- 
a/regression-test/suites/external_table_p0/lance/test_lance_vector_search.groovy
+++ 
b/regression-test/suites/external_table_p0/lance/test_lance_vector_search.groovy
@@ -124,16 +124,12 @@ suite("test_lance_vector_search", "p0,external") {
             FROM `${catalogName}`.`doris`.`vs_ivf_pq_f32`
         """
 
-        // EXPLAIN asserts the logical search parameters Doris sends to the 
backend. It does not
-        // prove which physical Lance index served the query; that proof lives 
in the fixture
-        // generator's plan self-check plus the nprobes=1 discriminator below, 
until lance-c
-        // exposes the selected index at runtime.
+        // EXPLAIN reports the searched vector column, distance metric, fixed 
snapshot and Doris
+        // physical split plan, while lance-c does not expose its selected 
index at runtime.
         explain {
             sql("""SELECT row_id, label, _distance FROM ${indexedTopFive} 
ORDER BY _distance, row_id""")
             contains "externalSearchType=VECTOR"
             contains "lanceVectorColumn=embedding"
-            contains "lanceTopK=5"
-            contains "lanceOffset=0"
             contains "lanceMetric=l2"
             contains "lanceVersion="
             contains "lanceSearchFragments=2"
diff --git 
a/regression-test/suites/external_table_p0/lance/test_lance_vector_search_two_phase.groovy
 
b/regression-test/suites/external_table_p0/lance/test_lance_vector_search_two_phase.groovy
index ad916a8d3bc..d7dde8053a8 100644
--- 
a/regression-test/suites/external_table_p0/lance/test_lance_vector_search_two_phase.groovy
+++ 
b/regression-test/suites/external_table_p0/lance/test_lance_vector_search_two_phase.groovy
@@ -83,8 +83,6 @@ suite("test_lance_vector_search_two_phase", "p0,external") {
                 assertTrue(explainString.contains("limit: 5"))
                 assertTrue(explainString.contains("offset: 1"))
                 assertTrue(explainString.contains("externalSearchType=VECTOR"))
-                assertTrue(explainString.contains("lanceTopK=5"))
-                assertTrue(explainString.contains("lanceOffset=1"))
                 assertTrue(explainString.contains("lanceSearchFragments=2"))
                 
assertTrue(explainString.contains("lanceSearchUnindexedFragments=0"))
                 
assertTrue(explainString.contains("lanceSearchIndexSegments=1"))
diff --git a/thirdparty/download-thirdparty.sh 
b/thirdparty/download-thirdparty.sh
index 7fa675da700..bb9c5928809 100755
--- a/thirdparty/download-thirdparty.sh
+++ b/thirdparty/download-thirdparty.sh
@@ -774,12 +774,12 @@ if [[ " ${TP_ARCHIVES[*]} " =~ " PAIMON_CPP " ]]; then
     echo "Finished patching ${PAIMON_CPP_SOURCE}"
 fi
 
-# Patch lance-c for fragment-scoped nearest-neighbor search and row-ID-based 
fetching.
+# Patch lance-c with the scan execution statistics API from upstream PR #64.
 if [[ " ${TP_ARCHIVES[*]} " =~ " LANCE_C " ]]; then
-    if [[ "${LANCE_C_SOURCE}" == "lance-c-0.1.6" ]]; then
+    if [[ "${LANCE_C_SOURCE}" == "lance-c-0.1.7" ]]; then
         cd "${TP_SOURCE_DIR}/${LANCE_C_SOURCE}"
         if [[ ! -f "${PATCHED_MARK}" ]]; then
-            patch -p1 <"${TP_PATCH_DIR}/lance-c-0.1.6-doris.patch"
+            patch -p1 <"${TP_PATCH_DIR}/lance-c-0.1.7-pr-64.patch"
             touch "${PATCHED_MARK}"
         fi
         cd -
diff --git a/thirdparty/patches/lance-c-0.1.6-doris.patch 
b/thirdparty/patches/lance-c-0.1.6-doris.patch
deleted file mode 100644
index cc49293d9de..00000000000
--- a/thirdparty/patches/lance-c-0.1.6-doris.patch
+++ /dev/null
@@ -1,632 +0,0 @@
-diff --git a/include/lance/lance.h b/include/lance/lance.h
-index 1de72fa..a0a8b5e 100644
---- a/include/lance/lance.h
-+++ b/include/lance/lance.h
-@@ -752,0 +753,25 @@ int32_t lance_dataset_take(
-+/**
-+ * Take rows by dataset row IDs.
-+ *
-+ * Row IDs are values from the `_rowid` scanner column, not zero-based row
-+ * offsets. They must belong to the same dataset snapshot used for this read.
-+ * Missing or deleted row IDs may be omitted from the result. For found rows,
-+ * input order and duplicates are preserved.
-+ *
-+ * @param dataset      Open dataset snapshot.
-+ * @param row_ids      Array of dataset row IDs. May be NULL only when
-+ *                     `num_row_ids` is zero.
-+ * @param num_row_ids  Length of `row_ids`.
-+ * @param columns      NULL-terminated column names, or NULL for all. The
-+ *                     system column `_rowid` may be requested explicitly.
-+ * @param out          Pointer to caller-allocated ArrowArrayStream.
-+ * @return 0 on success, -1 on error.
-+ */
-+int32_t lance_dataset_take_rows(
-+    const LanceDataset* dataset,
-+    const uint64_t* row_ids,
-+    size_t num_row_ids,
-+    const char* const* columns,
-+    struct ArrowArrayStream* out
-+);
-+
-diff --git a/include/lance/lance.hpp b/include/lance/lance.hpp
-index 40aa9e3..d54dc5a 100644
---- a/include/lance/lance.hpp
-+++ b/include/lance/lance.hpp
-@@ -654,0 +655,24 @@ public:
-+    /// Take rows by dataset row IDs. Results exported as ArrowArrayStream.
-+    void take_rows(const uint64_t* row_ids, size_t num_row_ids,
-+                   const std::vector<std::string>& columns,
-+                   ArrowArrayStream* out) const {
-+        std::vector<const char*> col_ptrs;
-+        for (auto& c : columns) col_ptrs.push_back(c.c_str());
-+        col_ptrs.push_back(nullptr);
-+        const char* const* cols_ptr = columns.empty() ? nullptr : 
col_ptrs.data();
-+
-+        if (lance_dataset_take_rows(
-+                handle_.get(), row_ids, num_row_ids, cols_ptr, out) != 0) {
-+            check_error();
-+        }
-+    }
-+
-+    /// Take all columns by dataset row IDs.
-+    void take_rows(const uint64_t* row_ids, size_t num_row_ids,
-+                   ArrowArrayStream* out) const {
-+        if (lance_dataset_take_rows(
-+                handle_.get(), row_ids, num_row_ids, nullptr, out) != 0) {
-+            check_error();
-+        }
-+    }
-+
-diff --git a/src/dataset.rs b/src/dataset.rs
-index 91a735f..364be09 100644
---- a/src/dataset.rs
-+++ b/src/dataset.rs
-@@ -44,0 +45,20 @@ impl LanceDataset {
-+fn projection_from_columns(
-+    dataset: &Dataset,
-+    columns: Option<&[String]>,
-+) -> Result<lance::dataset::ProjectionRequest> {
-+    match columns {
-+        Some(columns) => {
-+            let schema = dataset
-+                .schema()
-+                .project_preserve_system_columns(columns)
-+                .map_err(|err| {
-+                    lance_core::Error::invalid_input(format!("invalid columns 
{columns:?}: {err}"))
-+                })?;
-+            Ok(lance::dataset::ProjectionRequest::from_schema(schema))
-+        }
-+        None => Ok(lance::dataset::ProjectionRequest::from_schema(
-+            dataset.schema().clone(),
-+        )),
-+    }
-+}
-+
-@@ -247,4 +267 @@ unsafe fn dataset_take_inner(
--    let projection = match &col_names {
--        Some(cols) => 
lance::dataset::ProjectionRequest::from_columns(cols.iter(), snap.schema()),
--        None => 
lance::dataset::ProjectionRequest::from_schema(snap.schema().clone()),
--    };
-+    let projection = projection_from_columns(&snap, col_names.as_deref())?;
-@@ -263,0 +281,69 @@ unsafe fn dataset_take_inner(
-+/// Take rows by dataset row IDs, returning results as an ArrowArrayStream.
-+///
-+/// - `row_ids`: array of dataset row IDs, such as values returned in the
-+///   `_rowid` scanner column
-+/// - `num_row_ids`: length of the row ID array
-+/// - `columns`: NULL-terminated column name array, or NULL for all columns
-+/// - `out`: pointer to a stack-allocated `ArrowArrayStream`
-+///
-+/// `row_ids` may be NULL only when `num_row_ids` is zero. Row IDs must belong
-+/// to the same dataset snapshot used for this read. Missing or deleted row 
IDs
-+/// may be omitted from the result by the upstream Lance implementation.
-+///
-+/// Returns 0 on success, -1 on error.
-+#[unsafe(no_mangle)]
-+pub unsafe extern "C" fn lance_dataset_take_rows(
-+    dataset: *const LanceDataset,
-+    row_ids: *const u64,
-+    num_row_ids: usize,
-+    columns: *const *const c_char,
-+    out: *mut FFI_ArrowArrayStream,
-+) -> i32 {
-+    ffi_try!(
-+        unsafe { dataset_take_rows_inner(dataset, row_ids, num_row_ids, 
columns, out) },
-+        neg
-+    )
-+}
-+
-+unsafe fn dataset_take_rows_inner(
-+    dataset: *const LanceDataset,
-+    row_ids: *const u64,
-+    num_row_ids: usize,
-+    columns: *const *const c_char,
-+    out: *mut FFI_ArrowArrayStream,
-+) -> Result<i32> {
-+    if dataset.is_null() {
-+        return Err(lance_core::Error::invalid_input("dataset must not be 
NULL"));
-+    }
-+    if out.is_null() {
-+        return Err(lance_core::Error::invalid_input("out must not be NULL"));
-+    }
-+    if num_row_ids > 0 && row_ids.is_null() {
-+        return Err(lance_core::Error::invalid_input(format!(
-+            "row_ids must not be NULL when num_row_ids = {num_row_ids}"
-+        )));
-+    }
-+
-+    let ds = unsafe { &*dataset };
-+    let row_id_slice = if num_row_ids == 0 {
-+        &[]
-+    } else {
-+        unsafe { std::slice::from_raw_parts(row_ids, num_row_ids) }
-+    };
-+    let col_names = unsafe { helpers::parse_c_string_array(columns)? };
-+
-+    let snap = ds.snapshot();
-+    let projection = projection_from_columns(&snap, col_names.as_deref())?;
-+
-+    let batch = block_on(snap.take_rows(row_id_slice, projection))?;
-+
-+    // Match lance_dataset_take: export the single RecordBatch as an Arrow 
stream.
-+    let schema = batch.schema();
-+    let reader = 
arrow::record_batch::RecordBatchIterator::new(vec![Ok(batch)], schema);
-+    let ffi_stream = FFI_ArrowArrayStream::new(Box::new(reader));
-+    unsafe {
-+        std::ptr::write_unaligned(out, ffi_stream);
-+    }
-+    Ok(0)
-+}
-+
-diff --git a/src/scanner.rs b/src/scanner.rs
-index c1c2f67..de646be 100644
---- a/src/scanner.rs
-+++ b/src/scanner.rs
-@@ -160,0 +161,6 @@ impl LanceScanner {
-+        // Lance validates fragment-scoped nearest searches when nearest() is
-+        // configured. Such searches are supported when the fragment scan is
-+        // the input to a prefilter, so this flag must be set first.
-+        if self.prefilter {
-+            scanner.prefilter(true);
-+        }
-@@ -178,3 +183,0 @@ impl LanceScanner {
--            if self.prefilter {
--                scanner.prefilter(true);
--            }
-@@ -221,0 +225,5 @@ impl LanceScanner {
-+        // nearest() checks the current prefilter setting before accepting a
-+        // fragment-scoped search. Enable it before installing the query.
-+        if self.prefilter {
-+            scanner.prefilter(true);
-+        }
-@@ -239,3 +246,0 @@ impl LanceScanner {
--            if self.prefilter {
--                scanner.prefilter(true);
--            }
-diff --git a/tests/c_api_test.rs b/tests/c_api_test.rs
-index 2babda6..9ee5af9 100644
---- a/tests/c_api_test.rs
-+++ b/tests/c_api_test.rs
-@@ -9,0 +10 @@ use std::ffi::{CString, c_char};
-+use std::process::Command;
-@@ -18 +19 @@ use arrow::record_batch::RecordBatchReader;
--use arrow_array::{Array, Float32Array, Int32Array, RecordBatch, StringArray};
-+use arrow_array::{Array, Float32Array, Int32Array, RecordBatch, StringArray, 
UInt64Array};
-@@ -393,0 +395,94 @@ fn test_dataset_take() {
-+#[test]
-+fn test_dataset_take_rows_empty_and_null_validation() {
-+    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 mut empty_stream = FFI_ArrowArrayStream::empty();
-+    assert_eq!(
-+        unsafe { lance_dataset_take_rows(ds, ptr::null(), 0, ptr::null(), 
&mut empty_stream) },
-+        0
-+    );
-+    let reader = unsafe { ArrowArrayStreamReader::from_raw(&mut empty_stream) 
}.unwrap();
-+    assert_eq!(
-+        reader.map(|batch| batch.unwrap().num_rows()).sum::<usize>(),
-+        0
-+    );
-+
-+    let mut invalid_stream = FFI_ArrowArrayStream::empty();
-+    assert_eq!(
-+        unsafe { lance_dataset_take_rows(ds, ptr::null(), 1, ptr::null(), 
&mut invalid_stream) },
-+        -1
-+    );
-+    let message = unsafe { 
std::ffi::CStr::from_ptr(lance_last_error_message()) }.to_string_lossy();
-+    assert!(
-+        message.contains("row_ids must not be NULL when num_row_ids = 1"),
-+        "unexpected error: {message}"
-+    );
-+
-+    let row_id = 0_u64;
-+    assert_eq!(
-+        unsafe {
-+            lance_dataset_take_rows(ptr::null(), &row_id, 1, ptr::null(), 
&mut invalid_stream)
-+        },
-+        -1
-+    );
-+    assert_eq!(
-+        unsafe { lance_dataset_take_rows(ds, &row_id, 1, ptr::null(), 
ptr::null_mut()) },
-+        -1
-+    );
-+
-+    unsafe { lance_dataset_close(ds) };
-+}
-+
-+#[test]
-+fn test_dataset_take_rows_invalid_column() {
-+    const CHILD_ENV: &str = "LANCE_C_TEST_TAKE_ROWS_INVALID_COLUMN_CHILD";
-+
-+    if std::env::var_os(CHILD_ENV).is_some() {
-+        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 row_id = 0_u64;
-+        let invalid_column = c_str("unknown_column");
-+        let columns = [invalid_column.as_ptr(), ptr::null()];
-+        let mut stream = FFI_ArrowArrayStream::empty();
-+        assert_eq!(
-+            unsafe { lance_dataset_take_rows(ds, &row_id, 1, 
columns.as_ptr(), &mut stream) },
-+            -1
-+        );
-+        assert_eq!(lance_last_error_code(), LanceErrorCode::InvalidArgument);
-+
-+        let message_ptr = lance_last_error_message();
-+        assert!(!message_ptr.is_null());
-+        let message = unsafe { std::ffi::CStr::from_ptr(message_ptr) }
-+            .to_string_lossy()
-+            .into_owned();
-+        unsafe { lance_free_string(message_ptr) };
-+        assert!(
-+            message.contains("unknown_column"),
-+            "unexpected error: {message}"
-+        );
-+
-+        unsafe { lance_dataset_close(ds) };
-+        return;
-+    }
-+
-+    let output = Command::new(std::env::current_exe().unwrap())
-+        .arg("--exact")
-+        .arg("test_dataset_take_rows_invalid_column")
-+        .arg("--nocapture")
-+        .env(CHILD_ENV, "1")
-+        .output()
-+        .unwrap();
-+    assert!(
-+        output.status.success(),
-+        "invalid-column subprocess failed\nstdout:\n{}\nstderr:\n{}",
-+        String::from_utf8_lossy(&output.stdout),
-+        String::from_utf8_lossy(&output.stderr)
-+    );
-+}
-+
-@@ -2497,0 +2593,73 @@ fn create_vector_dataset(num_rows: i32, dim: i32) -> 
(tempfile::TempDir, String)
-+/// Create two vector fragments with deterministic vectors. Every component of
-+/// row `id` is `id as f32`, making nearest-neighbor expectations unambiguous.
-+fn create_multi_fragment_vector_dataset(
-+    rows_per_fragment: i32,
-+    dim: i32,
-+    enable_stable_row_ids: bool,
-+) -> (tempfile::TempDir, String) {
-+    use arrow_array::builder::{FixedSizeListBuilder, Float32Builder};
-+
-+    let tmp = tempfile::tempdir().unwrap();
-+    let uri = tmp
-+        .path()
-+        .join("multi_fragment_vec_ds")
-+        .to_str()
-+        .unwrap()
-+        .to_string();
-+    let schema = Arc::new(Schema::new(vec![
-+        Field::new("id", DataType::Int32, false),
-+        Field::new(
-+            "embedding",
-+            DataType::FixedSizeList(Arc::new(Field::new("item", 
DataType::Float32, true)), dim),
-+            false,
-+        ),
-+    ]));
-+
-+    let make_batch = |first_id: i32| {
-+        let ids: Vec<i32> = (first_id..first_id + 
rows_per_fragment).collect();
-+        let mut embeddings = FixedSizeListBuilder::new(Float32Builder::new(), 
dim);
-+        for id in &ids {
-+            for _ in 0..dim {
-+                embeddings.values().append_value(*id as f32);
-+            }
-+            embeddings.append(true);
-+        }
-+        RecordBatch::try_new(
-+            schema.clone(),
-+            vec![
-+                Arc::new(Int32Array::from(ids)),
-+                Arc::new(embeddings.finish()),
-+            ],
-+        )
-+        .unwrap()
-+    };
-+
-+    let first = make_batch(0);
-+    let second = make_batch(rows_per_fragment);
-+    lance_c::runtime::block_on(async {
-+        Dataset::write(
-+            arrow::record_batch::RecordBatchIterator::new(vec![Ok(first)], 
schema.clone()),
-+            &uri,
-+            Some(lance::dataset::WriteParams {
-+                enable_stable_row_ids,
-+                ..Default::default()
-+            }),
-+        )
-+        .await
-+        .unwrap();
-+        Dataset::write(
-+            arrow::record_batch::RecordBatchIterator::new(vec![Ok(second)], 
schema),
-+            &uri,
-+            Some(lance::dataset::WriteParams {
-+                mode: lance::dataset::WriteMode::Append,
-+                enable_stable_row_ids,
-+                ..Default::default()
-+            }),
-+        )
-+        .await
-+        .unwrap();
-+    });
-+
-+    (tmp, uri)
-+}
-+
-@@ -2723,0 +2892,99 @@ fn test_scanner_nearest_brute_force() {
-+fn 
assert_dataset_take_rows_from_multi_fragment_ann_result(enable_stable_row_ids: 
bool) {
-+    let (_tmp, uri) = create_multi_fragment_vector_dataset(32, 8, 
enable_stable_row_ids);
-+    let uri_c = c_str(&uri);
-+    let ds = unsafe { lance_dataset_open(uri_c.as_ptr(), ptr::null(), 0) };
-+    assert!(!ds.is_null());
-+
-+    // A non-NULL array whose first element is NULL is an explicit empty
-+    // projection. The ANN result should therefore contain only _distance and
-+    // the explicitly requested _rowid system column.
-+    let no_columns: [*const c_char; 1] = [ptr::null()];
-+    let scanner = unsafe { lance_scanner_new(ds, no_columns.as_ptr(), 
ptr::null()) };
-+    assert!(!scanner.is_null());
-+    assert_eq!(unsafe { lance_scanner_with_row_id(scanner, true) }, 0);
-+
-+    let column = c_str("embedding");
-+    let query = [40.0_f32; 8];
-+    assert_eq!(
-+        unsafe {
-+            lance_scanner_nearest(
-+                scanner,
-+                column.as_ptr(),
-+                query.as_ptr().cast(),
-+                query.len(),
-+                LanceDataType::Float32 as i32,
-+                1,
-+            )
-+        },
-+        0
-+    );
-+    assert_eq!(unsafe { lance_scanner_set_use_index(scanner, false) }, 0);
-+
-+    let mut ann_stream = FFI_ArrowArrayStream::empty();
-+    assert_eq!(
-+        unsafe { lance_scanner_to_arrow_stream(scanner, &mut ann_stream) },
-+        0
-+    );
-+    let reader = unsafe { ArrowArrayStreamReader::from_raw(&mut ann_stream) 
}.unwrap();
-+    let ann_batches = reader.map(|batch| batch.unwrap()).collect::<Vec<_>>();
-+    assert_eq!(
-+        ann_batches.iter().map(RecordBatch::num_rows).sum::<usize>(),
-+        1
-+    );
-+    assert_eq!(ann_batches[0].num_columns(), 2);
-+
-+    let distance = ann_batches[0]
-+        .column_by_name("_distance")
-+        .unwrap()
-+        .as_any()
-+        .downcast_ref::<Float32Array>()
-+        .unwrap()
-+        .value(0);
-+    assert_eq!(distance, 0.0);
-+    let row_id = ann_batches[0]
-+        .column_by_name("_rowid")
-+        .unwrap()
-+        .as_any()
-+        .downcast_ref::<UInt64Array>()
-+        .unwrap()
-+        .value(0);
-+    if !enable_stable_row_ids {
-+        assert_ne!(
-+            row_id >> 32,
-+            0,
-+            "expected an address-style row ID from the second fragment"
-+        );
-+    }
-+
-+    let id_column = c_str("id");
-+    let columns = [id_column.as_ptr(), ptr::null()];
-+    let mut take_stream = FFI_ArrowArrayStream::empty();
-+    assert_eq!(
-+        unsafe { lance_dataset_take_rows(ds, &row_id, 1, columns.as_ptr(), 
&mut take_stream) },
-+        0
-+    );
-+    let reader = unsafe { ArrowArrayStreamReader::from_raw(&mut take_stream) 
}.unwrap();
-+    let batches = reader.map(|batch| batch.unwrap()).collect::<Vec<_>>();
-+    assert_eq!(batches.len(), 1);
-+    let ids = batches[0]
-+        .column_by_name("id")
-+        .unwrap()
-+        .as_any()
-+        .downcast_ref::<Int32Array>()
-+        .unwrap();
-+    assert_eq!(ids.values(), &[40]);
-+
-+    unsafe { lance_scanner_close(scanner) };
-+    unsafe { lance_dataset_close(ds) };
-+}
-+
-+#[test]
-+fn test_dataset_take_rows_from_multi_fragment_ann_result() {
-+    assert_dataset_take_rows_from_multi_fragment_ann_result(false);
-+}
-+
-+#[test]
-+fn 
test_dataset_take_rows_from_multi_fragment_ann_result_with_stable_row_ids() {
-+    assert_dataset_take_rows_from_multi_fragment_ann_result(true);
-+}
-+
-@@ -2855,0 +3123,132 @@ fn test_scanner_nearest_filter_postfilter() {
-+#[test]
-+fn test_scanner_nearest_prefilter_with_fragment_ids_next() {
-+    let (_tmp, uri) = create_multi_fragment_vector_dataset(32, 8, false);
-+    let uri_c = c_str(&uri);
-+    let ds = unsafe { lance_dataset_open(uri_c.as_ptr(), ptr::null(), 0) };
-+    assert!(!ds.is_null());
-+
-+    let mut fragment_ids = vec![0; unsafe { lance_dataset_fragment_count(ds) 
} as usize];
-+    assert_eq!(fragment_ids.len(), 2);
-+    assert_eq!(
-+        unsafe { lance_dataset_fragment_ids(ds, fragment_ids.as_mut_ptr()) },
-+        0
-+    );
-+
-+    let filter = c_str("id >= 40");
-+    let scanner = unsafe { lance_scanner_new(ds, ptr::null(), 
filter.as_ptr()) };
-+    assert_eq!(
-+        unsafe { lance_scanner_set_fragment_ids(scanner, 
fragment_ids[1..].as_ptr(), 1) },
-+        0
-+    );
-+
-+    // Match the Doris call order: nearest is configured before prefilter.
-+    let column = c_str("embedding");
-+    let query = [40.0_f32; 8];
-+    assert_eq!(
-+        unsafe {
-+            lance_scanner_nearest(
-+                scanner,
-+                column.as_ptr(),
-+                query.as_ptr().cast(),
-+                query.len(),
-+                LanceDataType::Float32 as i32,
-+                5,
-+            )
-+        },
-+        0
-+    );
-+    assert_eq!(unsafe { lance_scanner_set_prefilter(scanner, true) }, 0);
-+    assert_eq!(unsafe { lance_scanner_set_use_index(scanner, false) }, 0);
-+
-+    let batches = scan_all_rows_from_scanner(scanner);
-+    let mut ids = batches
-+        .iter()
-+        .flat_map(|batch| {
-+            batch
-+                .column_by_name("id")
-+                .unwrap()
-+                .as_any()
-+                .downcast_ref::<Int32Array>()
-+                .unwrap()
-+                .values()
-+                .iter()
-+                .copied()
-+                .collect::<Vec<_>>()
-+        })
-+        .collect::<Vec<_>>();
-+    ids.sort_unstable();
-+    assert_eq!(ids, vec![40, 41, 42, 43, 44]);
-+
-+    unsafe { lance_scanner_close(scanner) };
-+    unsafe { lance_dataset_close(ds) };
-+}
-+
-+#[test]
-+fn test_scanner_nearest_prefilter_with_fragment_ids_arrow_stream() {
-+    let (_tmp, uri) = create_multi_fragment_vector_dataset(32, 8, false);
-+    let uri_c = c_str(&uri);
-+    let ds = unsafe { lance_dataset_open(uri_c.as_ptr(), ptr::null(), 0) };
-+    assert!(!ds.is_null());
-+
-+    let mut fragment_ids = vec![0; unsafe { lance_dataset_fragment_count(ds) 
} as usize];
-+    assert_eq!(fragment_ids.len(), 2);
-+    assert_eq!(
-+        unsafe { lance_dataset_fragment_ids(ds, fragment_ids.as_mut_ptr()) },
-+        0
-+    );
-+
-+    let filter = c_str("id >= 60");
-+    let scanner = unsafe { lance_scanner_new(ds, ptr::null(), 
filter.as_ptr()) };
-+    assert_eq!(
-+        unsafe { lance_scanner_set_fragment_ids(scanner, 
fragment_ids[1..].as_ptr(), 1) },
-+        0
-+    );
-+
-+    let column = c_str("embedding");
-+    let query = [60.0_f32; 8];
-+    assert_eq!(
-+        unsafe {
-+            lance_scanner_nearest(
-+                scanner,
-+                column.as_ptr(),
-+                query.as_ptr().cast(),
-+                query.len(),
-+                LanceDataType::Float32 as i32,
-+                10,
-+            )
-+        },
-+        0
-+    );
-+    assert_eq!(unsafe { lance_scanner_set_prefilter(scanner, true) }, 0);
-+    assert_eq!(unsafe { lance_scanner_set_use_index(scanner, false) }, 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 mut ids = reader
-+        .flat_map(|batch| {
-+            let batch = batch.unwrap();
-+            batch
-+                .column_by_name("id")
-+                .unwrap()
-+                .as_any()
-+                .downcast_ref::<Int32Array>()
-+                .unwrap()
-+                .values()
-+                .iter()
-+                .copied()
-+                .collect::<Vec<_>>()
-+        })
-+        .collect::<Vec<_>>();
-+    ids.sort_unstable();
-+    assert_eq!(ids, vec![60, 61, 62, 63]);
-+
-+    unsafe { lance_scanner_close(scanner) };
-+    unsafe { lance_dataset_close(ds) };
-+}
-+
-diff --git a/tests/cpp/test_cpp_api.cpp b/tests/cpp/test_cpp_api.cpp
-index 43491f2..444fbdc 100644
---- a/tests/cpp/test_cpp_api.cpp
-+++ b/tests/cpp/test_cpp_api.cpp
-@@ -124,0 +125,29 @@ static void test_dataset_take(const std::string& uri) {
-+static void test_dataset_take_rows(const std::string& uri) {
-+    TEST(test_dataset_take_rows);
-+
-+    auto ds = lance::Dataset::open(uri);
-+
-+    // The smoke fixture has one fragment, so its first row IDs are 0, 1, 2.
-+    uint64_t row_ids[] = {0, 1, 2};
-+    ArrowArrayStream stream;
-+    memset(&stream, 0, sizeof(stream));
-+    ds.take_rows(row_ids, 3, &stream);
-+
-+    uint64_t total = 0;
-+    while (true) {
-+        ArrowArray arr;
-+        memset(&arr, 0, sizeof(arr));
-+        int rc = stream.get_next(&stream, &arr);
-+        assert(rc == 0);
-+        if (!arr.release) break;
-+        total += (uint64_t)arr.length;
-+        arr.release(&arr);
-+    }
-+
-+    assert(total == 3);
-+    printf("rows=%llu... ", (unsigned long long)total);
-+
-+    if (stream.release) stream.release(&stream);
-+    PASS();
-+}
-+
-@@ -695,0 +725 @@ int main(int argc, char** argv) {
-+    test_dataset_take_rows(uri);
diff --git a/thirdparty/patches/lance-c-0.1.7-pr-64.patch 
b/thirdparty/patches/lance-c-0.1.7-pr-64.patch
new file mode 100644
index 00000000000..17f91bc7c31
--- /dev/null
+++ b/thirdparty/patches/lance-c-0.1.7-pr-64.patch
@@ -0,0 +1,1522 @@
+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/vars.sh b/thirdparty/vars.sh
index 3836ee9e558..219bdef38b0 100644
--- a/thirdparty/vars.sh
+++ b/thirdparty/vars.sh
@@ -552,10 +552,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.6.tar.gz";
-LANCE_C_NAME="lance-c-v0.1.6.tar.gz"
-LANCE_C_SOURCE="lance-c-0.1.6"
-LANCE_C_MD5SUM="1599faa2532d9ce963db1188f7435a56"
+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"
 
 # 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]

Reply via email to