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

airborne12 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git


The following commit(s) were added to refs/heads/master by this push:
     new 76cbb6bf790 [fix](be) Read instead of recursing when the ANN IVF list 
cache is absent (#67024)
76cbb6bf790 is described below

commit 76cbb6bf7908b0d89833e22ec47d0e6246c7daf0
Author: Jack <[email protected]>
AuthorDate: Mon Aug 24 14:35:14 2026 +0800

    [fix](be) Read instead of recursing when the ANN IVF list cache is absent 
(#67024)
    
    ### What problem does this PR solve?
    
    Issue Number: None
    
    Related PR: #61160
    
    Problem Summary: An IVF-on-disk search with no `AnnIndexIVFListCache`
    installed never returns — it recurses until the stack is exhausted.
    
    **Reproduction**: build an `IVF_ON_DISK` index, save it, load it, and
    search it without calling `AnnIndexIVFListCache::create_global_cache()`
    first. Under ASAN the process dies with `AddressSanitizer:
    stack-overflow`; on a large stack it simply spins.
    
    **Root cause**: `CachedRandomAccessReader::borrow()` falls back to
    `RandomAccessReader::borrow()` when `AnnIndexIVFListCache::instance()`
    is null. faiss documents that base implementation as *"allocates a
    buffer and calls read_at()"*, and `CachedRandomAccessReader::read_at()`
    is overridden to call `borrow()` — so the two call each other without
    bound:
    
    ```
    CachedRandomAccessReader::borrow      (no cache)
      -> faiss::RandomAccessReader::borrow    (base: allocate + read_at)
        -> CachedRandomAccessReader::read_at  (override: calls borrow)
          -> CachedRandomAccessReader::borrow
            -> ...
    ```
    
    The branch reads like a fallback but cannot work at all.
    
    **Reachability, stated plainly**: `exec_env_init` installs the cache
    unconditionally at BE startup, so a running BE does not hit this today.
    What it does hit is unit tests. Every existing IVF-on-disk case in
    `faiss_vector_index_test.cpp` opens with
    `AnnIndexIVFListCache::create_global_cache(...)`, and a new test that
    does not know to do so gets a stack overflow rather than a diagnosable
    failure — which is how this was found, while writing an unrelated ANN
    container test. It also becomes live the moment the cache is made
    optional or its lifetime is shortened.
    
    **Fix**: the no-cache path reads the region itself under `_io_mutex` and
    returns an owning `ReadRef`. No cache means no cache accounting, so it
    uses a plain buffer rather than a `DataPage`; the buffer lives exactly
    as long as the ref the caller holds.
    
    ### Release note
    
    None
---
 be/src/storage/index/ann/faiss_ann_index.cpp       | 24 ++++++-
 .../storage/index/ann/faiss_vector_index_test.cpp  | 76 ++++++++++++++++++++++
 2 files changed, 99 insertions(+), 1 deletion(-)

diff --git a/be/src/storage/index/ann/faiss_ann_index.cpp 
b/be/src/storage/index/ann/faiss_ann_index.cpp
index f143b7ec6a5..821bba40397 100644
--- a/be/src/storage/index/ann/faiss_ann_index.cpp
+++ b/be/src/storage/index/ann/faiss_ann_index.cpp
@@ -368,7 +368,18 @@ struct CachedRandomAccessReader : 
faiss::RandomAccessReader {
 
         auto* cache = AnnIndexIVFListCache::instance();
         if (!cache) {
-            return RandomAccessReader::borrow(offset, nbytes);
+            // Read it ourselves. NOT RandomAccessReader::borrow(): faiss 
documents
+            // its default as "allocates a buffer and calls read_at()", and 
read_at()
+            // below is implemented by calling borrow() -- so delegating to 
the base
+            // is unbounded mutual recursion, never a fallback. No cache means 
no
+            // cache accounting either, hence a plain owning buffer rather 
than a
+            // DataPage; it lives only until the caller drops the ref.
+            std::vector<uint8_t> bytes(nbytes);
+            {
+                std::lock_guard<std::mutex> lock(_io_mutex);
+                _read_clucene(offset, reinterpret_cast<char*>(bytes.data()), 
nbytes);
+            }
+            return std::make_unique<OwnedReadRef>(std::move(bytes));
         }
 
         AnnIndexIVFListCache::CacheKey key(_cache_key_prefix, _file_size,
@@ -408,6 +419,17 @@ struct CachedRandomAccessReader : 
faiss::RandomAccessReader {
 private:
     // ---- ReadRef that pins a cache entry ----
 
+    // Owns the bytes it serves. Used when there is no list cache to pin a 
page in.
+    struct OwnedReadRef : faiss::ReadRef {
+        explicit OwnedReadRef(std::vector<uint8_t> bytes) : 
_bytes(std::move(bytes)) {
+            data_ = _bytes.data();
+            size_ = _bytes.size();
+        }
+
+    private:
+        std::vector<uint8_t> _bytes;
+    };
+
     struct PinnedReadRef : faiss::ReadRef {
         explicit PinnedReadRef(PageCacheHandle handle, const uint8_t* ptr, 
size_t len)
                 : _handle(std::move(handle)) {
diff --git a/be/test/storage/index/ann/faiss_vector_index_test.cpp 
b/be/test/storage/index/ann/faiss_vector_index_test.cpp
index d60d83ecb17..370cd33e0c1 100644
--- a/be/test/storage/index/ann/faiss_vector_index_test.cpp
+++ b/be/test/storage/index/ann/faiss_vector_index_test.cpp
@@ -25,6 +25,7 @@
 #include <barrier>
 #include <chrono>
 #include <cstddef>
+#include <future>
 #include <limits>
 #include <memory>
 #include <random>
@@ -1465,6 +1466,81 @@ TEST_F(VectorSearchTest, IVFOnDiskSaveLoadAndSearch) {
     EXPECT_GT(result.ivf_on_disk_cache_miss_cnt, 0);
 }
 
+// Searching WITHOUT a list cache has to read, not recurse.
+//
+// faiss documents RandomAccessReader::borrow()'s default as "allocates a 
buffer
+// and calls read_at()", and CachedRandomAccessReader::read_at() is 
implemented by
+// calling borrow(). Falling back to the base borrow() when
+// AnnIndexIVFListCache::instance() is null is therefore unbounded mutual
+// recursion. Every other IVF-on-disk test above installs the cache first, 
which
+// is exactly why nothing has ever hit it.
+//
+// exec_env_init installs the cache unconditionally at BE startup, so a 
running BE
+// does not reach this today. It is a trap for any unit test that does not 
know to
+// install one -- it does not fail, it never returns -- and for any change that
+// makes the cache optional.
+//
+// The search runs on a worker with a deadline because before the fix it does 
not
+// return at all: called inline it would hang the whole suite instead of 
failing
+// this one case. Everything the worker touches is shared-owned so a hung 
thread
+// cannot outlive its arguments.
+TEST_F(VectorSearchTest, IVFOnDiskSearchWithoutTheListCacheStillReads) {
+    ASSERT_EQ(AnnIndexIVFListCache::instance(), nullptr)
+            << "this case is about the no-cache path, but something installed 
a cache";
+
+    auto builder = std::make_unique<FaissVectorIndex>();
+    FaissBuildParameter params;
+    params.dim = 32;
+    params.ivf_nlist = 4;
+    params.quantizer = FaissBuildParameter::Quantizer::FLAT;
+    params.index_type = FaissBuildParameter::IndexType::IVF_ON_DISK;
+    builder->build(params);
+
+    const int num_vectors = 200;
+    std::vector<float> vectors;
+    vectors.reserve(static_cast<size_t>(num_vectors) * params.dim);
+    for (int i = 0; i < num_vectors; i++) {
+        auto tmp = vector_search_utils::generate_random_vector(params.dim);
+        vectors.insert(vectors.end(), tmp.begin(), tmp.end());
+    }
+    ASSERT_TRUE(builder->train(num_vectors, vectors.data()).ok());
+    ASSERT_TRUE(builder->add(num_vectors, vectors.data()).ok());
+
+    auto dir = std::make_shared<lucene::store::RAMDirectory>();
+    ASSERT_TRUE(builder->save(dir.get()).ok());
+
+    auto index = std::make_shared<FaissVectorIndex>();
+    index->set_type(AnnIndexType::IVF_ON_DISK);
+    index->set_ivfdata_cache_key_prefix("ut_no_list_cache");
+    ASSERT_TRUE(index->load(dir.get()).ok());
+
+    auto query_vec = std::make_shared<std::vector<float>>(
+            vector_search_utils::generate_random_vector(params.dim));
+    auto roaring = std::make_shared<roaring::Roaring>();
+    for (int i = 0; i < num_vectors; ++i) {
+        roaring->add(i);
+    }
+    auto result = std::make_shared<IndexSearchResult>();
+
+    auto done = std::make_shared<std::promise<Status>>();
+    auto ready = done->get_future();
+    std::thread worker([index, dir, query_vec, roaring, result, done, 
num_vectors] {
+        IVFSearchParameters search_params;
+        search_params.nprobe = 4;
+        search_params.roaring = roaring.get();
+        search_params.rows_of_segment = num_vectors;
+        done->set_value(index->ann_topn_search(query_vec->data(), 10, 
search_params, *result));
+    });
+    const bool returned = ready.wait_for(std::chrono::seconds(60)) == 
std::future_status::ready;
+    if (!returned) {
+        worker.detach();
+        FAIL() << "the search never returned: borrow() recurses when there is 
no list cache";
+    }
+    worker.join();
+    ASSERT_TRUE(ready.get().ok());
+    EXPECT_GT(result->roaring->cardinality(), 0U);
+}
+
 // All threads share a single FaissVectorIndex (and thus a single
 // CachedRandomAccessReader with its _io_mutex).  On the first round
 // (cold cache) threads contend on the same _io_mutex; the first thread


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to