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 72a587f5413 [fix](be) Use file-backed staging for SNII ANN indexes 
(#66856)
72a587f5413 is described below

commit 72a587f541321fcb656ed8a3de5c9aad4fdec12d
Author: Jack <[email protected]>
AuthorDate: Mon Aug 24 10:16:50 2026 +0800

    [fix](be) Use file-backed staging for SNII ANN indexes (#66856)
    
    ### What problem does this PR solve?
    
    Issue Number: None
    
    Related PR: #66052
    
    Problem Summary:
    
    SNII staged every serialized Faiss ANN sub-file in a growing in-memory
    vector. A large HNSW or IVF index therefore retained a second complete
    copy of the serialized index and could exhaust BE memory during index
    construction.
    
    A 64 MiB staging reproduction increased allocated heap by 67,388,976
    bytes before this change. Fault-injection reproductions also showed that
    final buffered append or fsync failures could escape
    `AnnIndexColumnWriter::finish()` as `CLuceneError`; the IVF data path
    additionally leaked its raw output owner.
    
    This change reuses the self-cleaning `StagedBlobFile` abstraction for
    ANN sub-files and keeps only the fixed CLucene output buffer resident.
    It also gives both `ann.faiss` and `ann.ivfdata` explicit RAII
    ownership, closes them in a catchable context, and converts staging
    failures to `Status`.
    
    The successful Faiss sub-file bytes, SNII container layout, persisted
    index format, and query behavior are unchanged. Existing SNII indexes
    remain readable after upgrade.
    
    ### Release note
    
    Reduce peak BE memory while building SNII ANN indexes by staging
    serialized Faiss files on temporary storage instead of retaining a
    complete in-memory copy.
---
 be/src/storage/index/ann/ann_index_writer.cpp      |  13 +-
 be/src/storage/index/ann/faiss_ann_index.cpp       | 190 ++++++----
 be/src/storage/index/index_file_writer.cpp         |  54 ++-
 be/src/storage/index/index_file_writer.h           |  14 +
 be/src/storage/index/snii/bkd/staged_blob_file.cpp |  28 +-
 be/src/storage/index/snii/bkd/staged_blob_file.h   |  10 +-
 .../storage/index/snii/snii_bkd_index_writer.cpp   |  22 +-
 be/src/storage/index/snii/snii_bkd_index_writer.h  |  10 +-
 .../index/snii/snii_blob_staging_directory.cpp     | 111 +++---
 .../index/snii/snii_blob_staging_directory.h       |  48 ++-
 .../index/snii/writer/snii_compound_writer.cpp     |  42 ++-
 .../index/snii/writer/snii_compound_writer.h       |   4 +
 be/src/storage/segment/segment_writer.cpp          |  39 ++
 be/src/storage/segment/segment_writer.h            |   6 +
 be/src/storage/segment/vertical_segment_writer.cpp |  39 ++
 be/src/storage/segment/vertical_segment_writer.h   |   6 +
 .../storage/index/snii/snii_ann_container_test.cpp | 418 ++++++++++++++++++++-
 .../storage/index/snii/snii_bkd_adapter_test.cpp   |  45 +++
 be/test/storage/index/snii/staged_file_probe.h     |  94 +++++
 .../snii/writer/snii_compound_writer_test.cpp      |  87 +++++
 20 files changed, 1081 insertions(+), 199 deletions(-)

diff --git a/be/src/storage/index/ann/ann_index_writer.cpp 
b/be/src/storage/index/ann/ann_index_writer.cpp
index d041eb7900d..391b4161557 100644
--- a/be/src/storage/index/ann/ann_index_writer.cpp
+++ b/be/src/storage/index/ann/ann_index_writer.cpp
@@ -171,6 +171,17 @@ Status AnnIndexColumnWriter::_build_and_save(Int64 
min_train_rows, Int64 effecti
     // full-segment build buffer is released before saving the index.
     PODArray<float> empty_buffered_vectors;
     _buffered_vectors.swap(empty_buffered_vectors);
-    return _vector_index->save(_dir.get());
+    Status status = _vector_index->save(_dir.get());
+    if (!status.ok()) {
+        // A failed save keeps whatever it managed to write, and under SNII 
that is
+        // an ANN-sized staging file plus its descriptor. Nothing downstream
+        // unwinds it: the segment flush returns before clear() and
+        // close_inverted_index(), and ADD INDEX holds every producer until the
+        // whole rowset has been closed. Drop BOTH owners of the staging area 
here
+        // -- the index file writer's, and this writer's own.
+        _index_file_writer->discard_ann_staging_directory(_index_meta);
+        _dir.reset();
+    }
+    return status;
 }
 } // namespace doris::segment_v2
diff --git a/be/src/storage/index/ann/faiss_ann_index.cpp 
b/be/src/storage/index/ann/faiss_ann_index.cpp
index bf14128dc78..f143b7ec6a5 100644
--- a/be/src/storage/index/ann/faiss_ann_index.cpp
+++ b/be/src/storage/index/ann/faiss_ann_index.cpp
@@ -219,17 +219,41 @@ FaissVectorIndex::~FaissVectorIndex() {
     }
 }
 
+namespace {
+
+Status close_index_output(std::unique_ptr<lucene::store::IndexOutput>& output,
+                          const char* description) {
+    DCHECK(output != nullptr);
+    try {
+        output->close();
+    } catch (const CLuceneError& e) {
+        output.reset();
+        return Status::Error<ErrorCode::INVERTED_INDEX_CLUCENE_ERROR>("Failed 
to close {}: {}",
+                                                                      
description, e.what());
+    }
+    output.reset();
+    return Status::OK();
+}
+
+} // namespace
+
 struct FaissIndexWriter : faiss::IOWriter {
 public:
-    FaissIndexWriter() = default;
-    FaissIndexWriter(lucene::store::IndexOutput* output) : _output(output) {}
+    explicit FaissIndexWriter(lucene::store::IndexOutput* output) : 
_output(output) {}
     ~FaissIndexWriter() override {
         if (_output != nullptr) {
-            _output->close();
-            delete _output;
+            try {
+                _output->close();
+            } catch (const CLuceneError& e) {
+                // Serialization already failed, so this is cleanup only. A
+                // destructor must not turn an I/O error into std::terminate.
+                LOG(WARNING) << "Failed to close vector index output during 
cleanup: " << e.what();
+            }
         }
     }
 
+    Status close() { return close_index_output(_output, "vector index 
output"); }
+
     size_t operator()(const void* ptr, size_t size, size_t nitems) override {
         size_t bytes = size * nitems;
         if (bytes > 0) {
@@ -252,7 +276,7 @@ public:
         return nitems;
     };
 
-    lucene::store::IndexOutput* _output = nullptr;
+    std::unique_ptr<lucene::store::IndexOutput> _output;
 };
 
 struct FaissIndexReader : faiss::IOReader {
@@ -953,84 +977,88 @@ doris::Status FaissVectorIndex::range_search(const float* 
query_vec, const float
 doris::Status FaissVectorIndex::save(lucene::store::Directory* dir) {
     auto start_time = std::chrono::high_resolution_clock::now();
 
-    if (_index_type == AnnIndexType::IVF_ON_DISK) {
-        // IVF_ON_DISK: write ivf data to a separate file, then write index 
metadata.
-        //
-        // Why do we replace invlists here in save() instead of at build() 
time?
-        // During build/train/add, IndexIVF needs a writable 
ArrayInvertedLists to
-        // receive vectors via add_entries(). PreadInvertedLists inherits from
-        // ReadOnlyInvertedLists and does not support writes. The original
-        // OnDiskInvertedLists does support writes but requires mmap on a real 
file,
-        // which is unavailable at build time (Directory is only passed to 
save()).
-        // So the standard faiss pattern is: build in-memory with 
ArrayInvertedLists,
-        // then convert to on-disk format at serialization time. The 
replace_invlists
-        // call in Step 2 is purely a serialization format switch (to emit 
"ilod"
-        // fourcc instead of "ilar"), not a runtime data structure change.
-        //
-        // Step 1: The in-memory index has ArrayInvertedLists. Write them to 
ann.ivfdata
-        //         by converting to OnDiskInvertedLists format:
-        //         For each list: [codes: capacity*code_size][ids: 
capacity*sizeof(idx_t)]
-        auto* ivf = dynamic_cast<faiss::IndexIVF*>(_index.get());
-        DCHECK(ivf != nullptr);
-        auto* ails = dynamic_cast<faiss::ArrayInvertedLists*>(ivf->invlists);
-        DCHECK(ails != nullptr);
-
-        const size_t nlist = ails->nlist;
-        const size_t code_size = ails->code_size;
-
-        // Build OnDiskOneList metadata and write data to ann.ivfdata
-        std::vector<faiss::OnDiskOneList> lists(nlist);
-        lucene::store::IndexOutput* ivfdata_output = 
dir->createOutput(faiss_ivfdata_file_name);
-        size_t offset = 0;
-        for (size_t i = 0; i < nlist; i++) {
-            size_t list_size = ails->list_size(i);
-            lists[i].size = list_size;
-            lists[i].capacity = list_size;
-            lists[i].offset = offset;
-
-            if (list_size > 0) {
-                // Write codes
-                const uint8_t* codes = ails->get_codes(i);
-                size_t codes_bytes = list_size * code_size;
-                ivfdata_output->writeBytes(codes, 
cast_set<Int32>(codes_bytes));
-
-                // Write ids
-                const faiss::idx_t* ids = ails->get_ids(i);
-                size_t ids_bytes = list_size * sizeof(faiss::idx_t);
-                ivfdata_output->writeBytes(reinterpret_cast<const 
uint8_t*>(ids),
-                                           cast_set<Int32>(ids_bytes));
-            }
+    try {
+        if (_index_type == AnnIndexType::IVF_ON_DISK) {
+            // IVF_ON_DISK: write ivf data to a separate file, then write 
index metadata.
+            //
+            // Why do we replace invlists here in save() instead of at build() 
time?
+            // During build/train/add, IndexIVF needs a writable 
ArrayInvertedLists to
+            // receive vectors via add_entries(). PreadInvertedLists inherits 
from
+            // ReadOnlyInvertedLists and does not support writes. The original
+            // OnDiskInvertedLists does support writes but requires mmap on a 
real file,
+            // which is unavailable at build time (Directory is only passed to 
save()).
+            // So the standard faiss pattern is: build in-memory with 
ArrayInvertedLists,
+            // then convert to on-disk format at serialization time. The 
replace_invlists
+            // call in Step 2 is purely a serialization format switch (to emit 
"ilod"
+            // fourcc instead of "ilar"), not a runtime data structure change.
+            //
+            // Step 1: The in-memory index has ArrayInvertedLists. Write them 
to ann.ivfdata
+            //         by converting to OnDiskInvertedLists format:
+            //         For each list: [codes: capacity*code_size][ids: 
capacity*sizeof(idx_t)]
+            auto* ivf = dynamic_cast<faiss::IndexIVF*>(_index.get());
+            DCHECK(ivf != nullptr);
+            auto* ails = 
dynamic_cast<faiss::ArrayInvertedLists*>(ivf->invlists);
+            DCHECK(ails != nullptr);
+
+            const size_t nlist = ails->nlist;
+            const size_t code_size = ails->code_size;
+
+            // Build OnDiskOneList metadata and write data to ann.ivfdata
+            std::vector<faiss::OnDiskOneList> lists(nlist);
+            auto ivfdata_output = std::unique_ptr<lucene::store::IndexOutput>(
+                    dir->createOutput(faiss_ivfdata_file_name));
+            size_t offset = 0;
+            for (size_t i = 0; i < nlist; i++) {
+                size_t list_size = ails->list_size(i);
+                lists[i].size = list_size;
+                lists[i].capacity = list_size;
+                lists[i].offset = offset;
+
+                if (list_size > 0) {
+                    // Write codes
+                    const uint8_t* codes = ails->get_codes(i);
+                    size_t codes_bytes = list_size * code_size;
+                    ivfdata_output->writeBytes(codes, 
cast_set<Int32>(codes_bytes));
+
+                    // Write ids
+                    const faiss::idx_t* ids = ails->get_ids(i);
+                    size_t ids_bytes = list_size * sizeof(faiss::idx_t);
+                    ivfdata_output->writeBytes(reinterpret_cast<const 
uint8_t*>(ids),
+                                               cast_set<Int32>(ids_bytes));
+                }
 
-            offset += list_size * (code_size + sizeof(faiss::idx_t));
+                offset += list_size * (code_size + sizeof(faiss::idx_t));
+            }
+            size_t totsize = offset;
+            RETURN_IF_ERROR(close_index_output(ivfdata_output, "IVF data 
output"));
+
+            // Step 2: Replace ArrayInvertedLists with OnDiskInvertedLists so 
that
+            //         write_index serializes in "ilod" format (metadata only).
+            auto* od = new faiss::OnDiskInvertedLists();
+            od->nlist = nlist;
+            od->code_size = code_size;
+            od->lists = std::move(lists);
+            od->totsize = totsize;
+            od->ptr = nullptr;
+            od->read_only = true;
+            // filename is not used during load (we use separate ivfdata file),
+            // but write it for format completeness.
+            od->filename = faiss_ivfdata_file_name;
+            ivf->replace_invlists(od, true);
+
+            // Step 3: Write index metadata to ann.faiss (includes "ilod" 
fourcc)
+            FaissIndexWriter writer(dir->createOutput(faiss_index_fila_name));
+            RETURN_IF_CATCH_EXCEPTION(faiss::write_index(_index.get(), 
&writer));
+            RETURN_IF_ERROR(writer.close());
+        } else {
+            // HNSW / IVF: write the full index to ann.faiss
+            FaissIndexWriter writer(dir->createOutput(faiss_index_fila_name));
+            RETURN_IF_CATCH_EXCEPTION(faiss::write_index(_index.get(), 
&writer));
+            RETURN_IF_ERROR(writer.close());
         }
-        size_t totsize = offset;
-        ivfdata_output->close();
-        delete ivfdata_output;
-
-        // Step 2: Replace ArrayInvertedLists with OnDiskInvertedLists so that
-        //         write_index serializes in "ilod" format (metadata only).
-        auto* od = new faiss::OnDiskInvertedLists();
-        od->nlist = nlist;
-        od->code_size = code_size;
-        od->lists = std::move(lists);
-        od->totsize = totsize;
-        od->ptr = nullptr;
-        od->read_only = true;
-        // filename is not used during load (we use separate ivfdata file),
-        // but write it for format completeness.
-        od->filename = faiss_ivfdata_file_name;
-        ivf->replace_invlists(od, true);
-
-        // Step 3: Write index metadata to ann.faiss (includes "ilod" fourcc)
-        lucene::store::IndexOutput* idx_output = 
dir->createOutput(faiss_index_fila_name);
-        auto writer = std::make_unique<FaissIndexWriter>(idx_output);
-        faiss::write_index(_index.get(), writer.get());
-
-    } else {
-        // HNSW / IVF: write the full index to ann.faiss
-        lucene::store::IndexOutput* idx_output = 
dir->createOutput(faiss_index_fila_name);
-        auto writer = std::make_unique<FaissIndexWriter>(idx_output);
-        faiss::write_index(_index.get(), writer.get());
+    } catch (const CLuceneError& e) {
+        return Status::Error<ErrorCode::INVERTED_INDEX_CLUCENE_ERROR>(
+                "Failed to save vector index: {}", e.what());
     }
 
     auto end_time = std::chrono::high_resolution_clock::now();
diff --git a/be/src/storage/index/index_file_writer.cpp 
b/be/src/storage/index/index_file_writer.cpp
index e00dc047efc..540f01e3e6a 100644
--- a/be/src/storage/index/index_file_writer.cpp
+++ b/be/src/storage/index/index_file_writer.cpp
@@ -134,8 +134,8 @@ Status IndexFileWriter::_insert_directory_into_map(int64_t 
index_id,
 
 Result<std::shared_ptr<DorisFSDirectory>> IndexFileWriter::open(const 
TabletIndex* index_meta) {
     // No index under SNII writes through a CLucene filesystem directory: text
-    // postings go through the SPIMI writer, and an ANN index stages into 
memory
-    // (see open_ann_directory) so that nothing has to be cleaned off disk.
+    // postings go through the SPIMI writer, and an ANN index uses 
self-cleaning
+    // per-file staging (see open_ann_directory).
     if (_storage_format == InvertedIndexStorageFormatPB::SNII) {
         return 
ResultError(Status::Error<ErrorCode::INVERTED_INDEX_NOT_SUPPORTED>(
                 "SNII format does not open CLucene filesystem directories"));
@@ -186,6 +186,37 @@ Result<std::shared_ptr<lucene::store::Directory>> 
IndexFileWriter::_open_snii_an
     return dir;
 }
 
+void IndexFileWriter::discard_ann_staging_directory(const TabletIndex* 
index_meta) {
+    // Only SNII stages an ANN index somewhere disposable. Branching here 
rather
+    // than in the caller keeps the format knowledge on the side that owns it,
+    // exactly as open_ann_directory() does.
+    if (_storage_format != InvertedIndexStorageFormatPB::SNII) {
+        return;
+    }
+    DCHECK(index_meta != nullptr);
+    const auto key = std::make_pair(index_meta->index_id(), 
index_meta->get_index_suffix());
+    _indices_dirs.erase(key);
+    _snii_blob_dir_metas.erase(key);
+}
+
+void IndexFileWriter::abandon_snii_staging() {
+    if (_storage_format != InvertedIndexStorageFormatPB::SNII) {
+        return;
+    }
+    // Empty each directory before dropping the map. Releasing only this 
writer's
+    // reference would free nothing while a producer is still alive holding the
+    // same directory through its own _dir -- which is exactly the state a 
segment
+    // that failed before clear() is in. Emptying makes the abort independent 
of
+    // who else is still holding on.
+    for (const auto& [key, dir] : _indices_dirs) {
+        if (std::strcmp(dir->getObjectName(),
+                        snii_doris::SniiBlobStagingDirectory::getClassName()) 
== 0) {
+            
static_cast<snii_doris::SniiBlobStagingDirectory*>(dir.get())->discard_staged_files();
+        }
+    }
+    _release_snii_blob_directories();
+}
+
 Status IndexFileWriter::_seal_snii_blob_directories() {
     DORIS_CHECK(_storage_format == InvertedIndexStorageFormatPB::SNII);
     for (const auto& [key, dir] : _indices_dirs) {
@@ -202,25 +233,26 @@ Status IndexFileWriter::_seal_snii_blob_directories() {
         DORIS_CHECK(std::strcmp(dir->getObjectName(),
                                 
snii_doris::SniiBlobStagingDirectory::getClassName()) == 0);
 
-        // Nothing here can throw: the staged bytes are plain buffers, and each
-        // source keeps its own alive, so finish() may pull them after this
-        // directory is gone.
+        // The sources TAKE the staged files, so they own them alone from here 
on:
+        // finish() may pull the bytes after this directory is gone, and it can
+        // unlink each sub-file as soon as it has copied it instead of waiting 
for
+        // whoever else happens to still hold the directory.
         auto* staging = 
static_cast<snii_doris::SniiBlobStagingDirectory*>(dir.get());
         // All cold: a faiss index is read at QUERY time, never at container 
open,
         // so nothing here belongs in the hot area the text metadata groups 
share.
         RETURN_IF_ERROR(add_snii_blob_index(meta_it->second.get(),
                                             
doris::snii::format::LogicalIndexKind::kAnn,
-                                            staging->blob_sources(), {}));
+                                            staging->take_blob_sources(), {}));
     }
     return Status::OK();
 }
 
 void IndexFileWriter::_release_snii_blob_directories() {
-    // Dropping the map is the whole release: a staging directory holds its 
bytes
-    // in memory and owns no file, so there is nothing on disk to remove and --
-    // unlike DorisFSDirectory::deleteDirectory() -- no throwing call to make 
from
-    // a Status-returning close path. Any buffer a registered blob source still
-    // needs stays alive through that source until finish() has pulled it.
+    // Dropping the map is all this has to do: sealing already handed the 
staged
+    // files to the blob sources, which unlink them as finish() copies them, 
and
+    // an unsealed directory unlinks its own on the way out. So -- unlike
+    // DorisFSDirectory::deleteDirectory() -- there is no throwing cleanup 
call to
+    // make from this Status-returning close path.
     _indices_dirs.clear();
     _snii_blob_dir_metas.clear();
 }
diff --git a/be/src/storage/index/index_file_writer.h 
b/be/src/storage/index/index_file_writer.h
index f77454f90e6..f1f312a7149 100644
--- a/be/src/storage/index/index_file_writer.h
+++ b/be/src/storage/index/index_file_writer.h
@@ -91,6 +91,20 @@ public:
     // need the DorisFSDirectory subclass.
     Result<std::shared_ptr<lucene::store::Directory>> open_ann_directory(
             const TabletIndex* index_meta);
+    // SNII only: drops the staging directory of one ANN index whose 
serialization
+    // failed. Its sub-files unlink themselves once their last owner is gone, 
and
+    // the producer releases its own reference alongside this call -- without 
that
+    // a failed save keeps an ANN-sized file and its descriptor on the temp
+    // filesystem until this writer is destroyed, which for a rowset build is 
not
+    // until every other segment has been written. V1/V2 keep their directory: 
its
+    // files ARE the index output, and begin_close() is what removes them.
+    void discard_ann_staging_directory(const TabletIndex* index_meta);
+    // SNII only: drops the staging of EVERY index on this writer because the
+    // segment they belong to is being abandoned. Layered above the per-index
+    // discard, not a duplicate of it: that one fires the instant one ANN
+    // serialization fails, while this one covers a segment that failed AFTER 
its
+    // indexes staged successfully, when nothing else will ever seal them.
+    void abandon_snii_staging();
     // Write-path facts for one SNII index flush.
     struct SniiAddIndexOptions {
         // This flush serves a stream/broker load (DataWriteType::TYPE_DIRECT):
diff --git a/be/src/storage/index/snii/bkd/staged_blob_file.cpp 
b/be/src/storage/index/snii/bkd/staged_blob_file.cpp
index 7b63e024b1f..b327612cce5 100644
--- a/be/src/storage/index/snii/bkd/staged_blob_file.cpp
+++ b/be/src/storage/index/snii/bkd/staged_blob_file.cpp
@@ -26,6 +26,7 @@
 
 #include "common/check.h"
 #include "storage/index/snii/writer/temp_dir.h"
+#include "util/debug_points.h"
 
 namespace doris::snii::bkd {
 
@@ -60,6 +61,8 @@ StagedBlobFile::~StagedBlobFile() {
 Status StagedBlobFile::append(Slice data) {
     DORIS_CHECK_GE(fd_, 0);
     DORIS_CHECK(!finalized_);
+    DBUG_EXECUTE_IF("StagedBlobFile::append_error",
+                    { return Status::IOError("injected blob staging append 
failure"); })
     const uint8_t* cursor = data.data();
     size_t remaining = data.size();
     while (remaining > 0) {
@@ -80,13 +83,24 @@ Status StagedBlobFile::append(Slice data) {
 Status StagedBlobFile::finalize() {
     DORIS_CHECK_GE(fd_, 0);
     DORIS_CHECK(!finalized_);
-    // The descriptor stays open on purpose: read_at reads through this same 
one,
-    // so the file survives an unlink and cannot be swapped out from under us.
-    // A deferred write error would surface at close(), which happens in 
remove()
-    // after the container has already sealed -- so force it out here instead.
-    if (::fsync(fd_) != 0) {
-        return Status::IOError("failed to flush a blob staging file: {}", 
std::strerror(errno));
-    }
+    // Kept as a seam for the seal-failure plumbing (an error here has to 
become a
+    // Status and unwind the staging). Nothing in the seal itself can fail any
+    // more; the reachable staging failure is append().
+    DBUG_EXECUTE_IF("StagedBlobFile::finalize_error",
+                    { return Status::IOError("injected blob staging finalize 
failure"); })
+    // NO fsync. This file is scratch: SniiCompoundWriter copies every byte of 
it
+    // into the real container, IndexFileWriter::begin_close() makes THAT 
durable,
+    // and remove() then unlinks this inode -- so its contents can never 
recover
+    // anything. Forcing a GiB-scale HNSW/IVF payload to storage here would 
buy a
+    // full durable write plus a latency barrier before the second, real write,
+    // once per sub-file, and could fail a build on scratch writeback alone.
+    //
+    // Error reporting does not depend on it either: the descriptor stays open 
on
+    // purpose (read_at reads through this same one, so the file survives an
+    // unlink and cannot be swapped out from under us), and a writeback failure
+    // surfaces there as EIO -- which read_at reports, as it does a short 
read. A
+    // damaged scratch file therefore still fails the build instead of being
+    // checksummed into the container.
     finalized_ = true;
     return Status::OK();
 }
diff --git a/be/src/storage/index/snii/bkd/staged_blob_file.h 
b/be/src/storage/index/snii/bkd/staged_blob_file.h
index 8d7d67fc29a..f9bd73dceeb 100644
--- a/be/src/storage/index/snii/bkd/staged_blob_file.h
+++ b/be/src/storage/index/snii/bkd/staged_blob_file.h
@@ -25,7 +25,9 @@
 #include "storage/index/snii/common/slice.h"
 #include "storage/index/snii/io/file_writer.h"
 
-// Build-time staging for one blob sub-file (design 10).
+// Build-time staging for one blob sub-file (design 10). Native BKD and ANN 
both
+// use this file because their compound-container producers finish long before
+// the container pulls the payload.
 //
 // The container is a PULL consumer: SniiCompoundWriter::add_blob_index 
registers
 // a BlobFileSource and only asks for the bytes at finish(), because placement
@@ -51,8 +53,10 @@ public:
     StagedBlobFile(const StagedBlobFile&) = delete;
     StagedBlobFile& operator=(const StagedBlobFile&) = delete;
 
-    // io::FileWriter. append() is the producer side; finalize() flushes and
-    // switches the file to readable.
+    // io::FileWriter. append() is the producer side; finalize() switches the 
file
+    // to readable. It makes NO durability promise -- this is scratch that the
+    // container copies out and then unlinks, so a barrier here would be a 
second
+    // full write for nothing (see the .cpp).
     Status append(Slice data) override;
     Status finalize() override;
     uint64_t bytes_written() const override { return bytes_written_; }
diff --git a/be/src/storage/index/snii/snii_bkd_index_writer.cpp 
b/be/src/storage/index/snii/snii_bkd_index_writer.cpp
index 11fd8a7e9b8..eaa2e518e84 100644
--- a/be/src/storage/index/snii/snii_bkd_index_writer.cpp
+++ b/be/src/storage/index/snii/snii_bkd_index_writer.cpp
@@ -196,14 +196,17 @@ Status SniiBkdIndexColumnWriter::finish() {
     RETURN_IF_ERROR(null_writer.finish(_rid, &null_sink));
 
     // The container pulls at IndexFileWriter::finish_close(), long after this
-    // returns, so the staging file has to outlive this object. Ownership moves
-    // into the read callback itself.
-    _data = std::move(data);
-    std::shared_ptr<::doris::snii::bkd::StagedBlobFile> staged = _data;
+    // returns, so the staging file has to outlive this object. Ownership MOVES
+    // into the read callback, which is then its only owner: the per-blob 
release
+    // in SniiCompoundWriter::finish() unlinks bkd_data as soon as the bytes 
are
+    // in the container, instead of whenever this writer happens to be 
destroyed.
+    // Keeping a second reference here would defeat that -- a producer 
routinely
+    // outlives the seal (see the header).
+    std::shared_ptr<::doris::snii::bkd::StagedBlobFile> staged = 
std::move(data);
     ::doris::snii::writer::BlobFileSource cold;
     cold.name = kDataFileName;
     cold.length = staged->bytes_written();
-    cold.read_fn = [staged](uint64_t offset, size_t len, uint8_t* out) {
+    cold.read_fn = [staged = std::move(staged)](uint64_t offset, size_t len, 
uint8_t* out) {
         return staged->read_at(offset, len, out);
     };
 
@@ -219,11 +222,12 @@ Status SniiBkdIndexColumnWriter::finish() {
 }
 
 void SniiBkdIndexColumnWriter::close_on_error() {
-    // The builder unlinks its own spilled runs; the staging file removes 
itself
-    // on destruction. Dropping both here means an aborted segment leaves no 
temp
-    // file behind even if this writer is kept alive for a while.
+    // The builder unlinks its own spilled runs. Dropping it here means an 
aborted
+    // segment leaves no temp file behind even if this writer is kept alive 
for a
+    // while. There is nothing else to drop: bkd_data only exists between
+    // finish()'s create() and its handover to the read callback, and it 
unlinks
+    // itself if finish() fails in between.
     _builder.reset();
-    _data.reset();
 }
 
 } // namespace doris::segment_v2
diff --git a/be/src/storage/index/snii/snii_bkd_index_writer.h 
b/be/src/storage/index/snii/snii_bkd_index_writer.h
index 62571b86fe8..537138af795 100644
--- a/be/src/storage/index/snii/snii_bkd_index_writer.h
+++ b/be/src/storage/index/snii/snii_bkd_index_writer.h
@@ -90,10 +90,12 @@ private:
     std::vector<uint32_t> _null_docids;
 
     std::unique_ptr<::doris::snii::bkd::BkdBuilder> _builder;
-    // Staged bkd_data, kept alive until the container has pulled its bytes at
-    // IndexFileWriter::finish_close(). Destroying it earlier would unlink the
-    // temp file out from under the pull.
-    std::shared_ptr<::doris::snii::bkd::StagedBlobFile> _data;
+    // No staged-file member on purpose. bkd_data has to outlive this writer --
+    // the container pulls it at IndexFileWriter::finish_close() -- but it must
+    // not outlive the pull, and this writer routinely does: IndexBuilder's 
SNII
+    // ADD INDEX path holds every producer in _index_column_writers until the
+    // whole rowset has been closed. finish() therefore hands the file to the
+    // read callback and keeps no reference of its own.
 };
 
 } // namespace segment_v2
diff --git a/be/src/storage/index/snii/snii_blob_staging_directory.cpp 
b/be/src/storage/index/snii/snii_blob_staging_directory.cpp
index c8b39bdb23d..92e7a7eed8a 100644
--- a/be/src/storage/index/snii/snii_blob_staging_directory.cpp
+++ b/be/src/storage/index/snii/snii_blob_staging_directory.cpp
@@ -19,25 +19,22 @@
 
 #include <fmt/format.h>
 
-#include <cstring>
 #include <utility>
 
 #include "common/check.h"
 #include "common/status.h"
+#include "storage/index/snii/bkd/staged_blob_file.h"
 
 namespace doris::segment_v2::snii_doris {
 
-// Appends into one staged buffer. BufferedIndexOutput already batches the
-// writer's byte-at-a-time calls, so flushBuffer sees 64 KiB chunks and the
-// buffer grows geometrically through vector::insert.
-//
-// The buffer is held by shared_ptr because a blob source handed to the 
container
-// keeps it alive on its own: an output may be closed and destroyed, and the
-// directory itself dropped, long before finish() pulls the bytes.
+// Appends into one staged file. BufferedIndexOutput batches the writer's
+// byte-at-a-time calls, so flushBuffer issues 64 KiB sequential writes while 
the
+// complete ANN payload remains outside the process heap.
 class SniiBlobStagingDirectory::StagingIndexOutput final
         : public lucene::store::BufferedIndexOutput {
 public:
-    explicit StagingIndexOutput(std::shared_ptr<Buffer> buffer) : 
_buffer(std::move(buffer)) {}
+    explicit StagingIndexOutput(std::shared_ptr<snii::bkd::StagedBlobFile> 
file)
+            : _file(std::move(file)) {}
 
     ~StagingIndexOutput() override {
         // MUST close here, qualified. ~BufferedIndexOutput also calls close() 
if
@@ -49,15 +46,25 @@ public:
         try {
             StagingIndexOutput::close();
         } catch (const CLuceneError&) {
-            // A destructor may not throw. Nothing here can fail anyway --
-            // flushBuffer only appends to a vector -- but the base close() is
-            // declared throwing, so the guard has to exist.
+            // A destructor may not throw. The normal success path closes
+            // explicitly so it can report this staging I/O failure.
         }
     }
 
-    void close() override { BufferedIndexOutput::close(); }
+    void close() override {
+        if (_closed) {
+            return;
+        }
+        BufferedIndexOutput::close();
+        Status status = _file->finalize();
+        if (!status.ok()) {
+            const std::string message = status.to_string();
+            _CLTHROWA(CL_ERR_IO, message.c_str());
+        }
+        _closed = true;
+    }
 
-    int64_t length() const override { return 
static_cast<int64_t>(_buffer->size()); }
+    int64_t length() const override { return 
static_cast<int64_t>(_file->bytes_written()); }
 
 protected:
     void flushBuffer(const uint8_t* b, const int32_t size) override {
@@ -66,11 +73,16 @@ protected:
         if (b == nullptr || size <= 0) {
             return;
         }
-        _buffer->insert(_buffer->end(), b, b + size);
+        Status status = _file->append(snii::Slice(b, 
static_cast<size_t>(size)));
+        if (!status.ok()) {
+            const std::string message = status.to_string();
+            _CLTHROWA(CL_ERR_IO, message.c_str());
+        }
     }
 
 private:
-    const std::shared_ptr<Buffer> _buffer;
+    const std::shared_ptr<snii::bkd::StagedBlobFile> _file;
+    bool _closed = false;
 };
 
 SniiBlobStagingDirectory::~SniiBlobStagingDirectory() = default;
@@ -83,7 +95,7 @@ const char* SniiBlobStagingDirectory::getObjectName() const {
     return getClassName();
 }
 
-const std::shared_ptr<SniiBlobStagingDirectory::Buffer>* 
SniiBlobStagingDirectory::find_file(
+const std::shared_ptr<snii::bkd::StagedBlobFile>* 
SniiBlobStagingDirectory::find_file(
         const char* name) const {
     // A null name is a caller bug, not an absent file; reporting "absent" 
would
     // hide it, and every caller formats `name` into its error message.
@@ -117,13 +129,13 @@ int64_t SniiBlobStagingDirectory::fileModified(const 
char* name) const {
 }
 
 int64_t SniiBlobStagingDirectory::fileLength(const char* name) const {
-    const std::shared_ptr<Buffer>* buffer = find_file(name);
-    if (buffer == nullptr) {
+    const std::shared_ptr<snii::bkd::StagedBlobFile>* file = find_file(name);
+    if (file == nullptr) {
         const std::string message =
                 fmt::format("File does not exist in the SNII staging 
directory: {}", name);
         _CLTHROWA(CL_ERR_IO, message.c_str()); // CLuceneError STRDUPs the 
message
     }
-    return static_cast<int64_t>((*buffer)->size());
+    return static_cast<int64_t>((*file)->bytes_written());
 }
 
 bool SniiBlobStagingDirectory::openInput(const char* name, 
lucene::store::IndexInput*& ret,
@@ -151,12 +163,18 @@ void SniiBlobStagingDirectory::touchFile(const char* 
/*name*/) {
 lucene::store::IndexOutput* SniiBlobStagingDirectory::createOutput(const char* 
name) {
     DORIS_CHECK(name != nullptr);
     // Same semantics as a filesystem directory: creating an existing name
-    // truncates it. The buffer is replaced rather than cleared, so a blob 
source
-    // already taken over the old content keeps reading the old content 
instead of
-    // seeing it mutate underneath.
-    auto buffer = std::make_shared<Buffer>();
-    _files[name] = buffer;
-    return _CLNEW StagingIndexOutput(std::move(buffer));
+    // truncates it. The file is replaced rather than reused, so a blob source
+    // already taken over the old content keeps reading the old content instead
+    // of seeing it mutate underneath.
+    std::unique_ptr<snii::bkd::StagedBlobFile> created;
+    Status status = snii::bkd::StagedBlobFile::create(name, &created);
+    if (!status.ok()) {
+        const std::string message = status.to_string();
+        _CLTHROWA(CL_ERR_IO, message.c_str());
+    }
+    auto file = std::shared_ptr<snii::bkd::StagedBlobFile>(std::move(created));
+    _files[name] = file;
+    return _CLNEW StagingIndexOutput(std::move(file));
 }
 
 bool SniiBlobStagingDirectory::doDeleteFile(const char* name) {
@@ -169,8 +187,8 @@ bool SniiBlobStagingDirectory::doDeleteFile(const char* 
name) {
 
 void SniiBlobStagingDirectory::close() {
     // Deliberately keeps the staged files: close() is what a CLucene writer 
calls
-    // when it is done producing, and the harvest happens afterwards. The 
buffers
-    // die with the directory, or with the last blob source over them.
+    // when it is done producing, and the harvest happens afterwards. Each temp
+    // file is unlinked by its final StagedBlobFile owner.
 }
 
 std::string SniiBlobStagingDirectory::toString() const {
@@ -178,33 +196,40 @@ std::string SniiBlobStagingDirectory::toString() const {
                        staged_bytes());
 }
 
-std::vector<snii::writer::BlobFileSource> 
SniiBlobStagingDirectory::blob_sources() const {
+std::vector<snii::writer::BlobFileSource> 
SniiBlobStagingDirectory::take_blob_sources() {
+    // Moved out before anything is built from it: a source has to be the 
file's
+    // only owner, or the per-blob release in SniiCompoundWriter::finish() 
frees
+    // nothing and the staging survives until this directory does.
+    std::map<std::string, std::shared_ptr<snii::bkd::StagedBlobFile>> taken;
+    taken.swap(_files);
     std::vector<snii::writer::BlobFileSource> sources;
-    sources.reserve(_files.size());
+    sources.reserve(taken.size());
     // std::map iterates in name order, which is the order the filesystem 
harvest
     // produced by sorting list(). Two builds of one index therefore lay their
     // sub-files out identically in the container.
-    for (const auto& [name, buffer] : _files) {
+    for (auto& [name, staged] : taken) {
+        auto file = std::move(staged);
+        const uint64_t length = file->bytes_written();
         sources.push_back(snii::writer::BlobFileSource {
                 .name = name,
-                .length = buffer->size(),
-                .read_fn = [buffer](uint64_t offset, size_t len, uint8_t* out) 
-> Status {
-                    if (offset > buffer->size() || len > buffer->size() - 
offset) {
-                        return Status::Error<ErrorCode::INTERNAL_ERROR>(
-                                "SNII staging read [{}, +{}) is outside the 
staged {} bytes",
-                                offset, len, buffer->size());
-                    }
-                    std::memcpy(out, buffer->data() + offset, len);
-                    return Status::OK();
-                }});
+                .length = length,
+                .read_fn = [file = std::move(file)](uint64_t offset, size_t 
len, uint8_t* out)
+                        -> Status { return file->read_at(offset, len, out); 
}});
     }
     return sources;
 }
 
+void SniiBlobStagingDirectory::discard_staged_files() {
+    // Same swap-with-empty as take_blob_sources(): clear() would leave the 
map's
+    // nodes, and it is the shared_ptr elements themselves that unlink the 
files.
+    std::map<std::string, std::shared_ptr<snii::bkd::StagedBlobFile>> dropped;
+    dropped.swap(_files);
+}
+
 uint64_t SniiBlobStagingDirectory::staged_bytes() const {
     uint64_t total = 0;
-    for (const auto& [name, buffer] : _files) {
-        total += buffer->size();
+    for (const auto& [name, file] : _files) {
+        total += file->bytes_written();
     }
     return total;
 }
diff --git a/be/src/storage/index/snii/snii_blob_staging_directory.h 
b/be/src/storage/index/snii/snii_blob_staging_directory.h
index a1f4256a3e9..7618272ad82 100644
--- a/be/src/storage/index/snii/snii_blob_staging_directory.h
+++ b/be/src/storage/index/snii/snii_blob_staging_directory.h
@@ -31,22 +31,23 @@
 
 class CLuceneError;
 
+namespace doris::snii::bkd {
+class StagedBlobFile;
+}
+
 namespace doris::segment_v2::snii_doris {
 
-// Write-only, memory-backed lucene::store::Directory: the staging area an ANN
+// Write-only, file-backed lucene::store::Directory: the staging area an ANN
 // index is built into before it is sealed as a blob logical index of a SNII
 // container.
 //
 // WHY IT EXISTS. A SNII container cannot take the faiss bytes as they are
 // produced -- blob payloads are streamed by SniiCompoundWriter::finish(), 
after
 // the text physical sections -- so the bytes must be parked somewhere in
-// between. Borrowing a CLucene filesystem directory for that, as the V1/V2
-// formats do, buys two problems SNII has no use for: the temp directory is
-// removed by exactly one call, so every early return on the close path leaks 
it
-// until a BE restart wipes the tmp dir; and that call, deleteDirectory(),
-// throws CLuceneError, which must not cross a Status-returning close. Parking
-// the bytes here removes both -- nothing lands on disk, and there is nothing 
to
-// delete.
+// between. A complete in-memory copy is unbounded for HNSW and IVF indexes.
+// Each sub-file therefore uses the same self-cleaning staging file as native
+// BKD. This retains only BufferedIndexOutput's fixed buffer while still 
avoiding
+// the throwing, whole-directory cleanup used by the V1/V2 formats.
 //
 // SCOPE. Only the write side is real. The faiss writer uses createOutput() and
 // toString() and nothing else, and a sealed ANN blob is read back through
@@ -74,16 +75,27 @@ public:
     static const char* getClassName();
     const char* getObjectName() const override;
 
-    // Blob sources over the staged buffers, in name order -- the same order 
the
+    // Blob sources over the staged files, in name order -- the same order the
     // filesystem harvest produced by sorting list(), so the container lays two
     // builds of one index out identically.
     //
-    // Each source keeps its buffer alive on its own, so the sources stay valid
-    // after this directory is destroyed: SniiCompoundWriter::finish() pulls 
them
-    // long after the ANN writer is gone.
-    std::vector<snii::writer::BlobFileSource> blob_sources() const;
-
-    // Bytes currently held in memory across every staged sub-file.
+    // TAKES the files. This directory holds nothing afterwards and the 
returned
+    // sources are their ONLY owners, which is what lets the container unlink 
each
+    // sub-file the moment it has copied its bytes (see the per-blob release in
+    // SniiCompoundWriter::finish()). Leaving a second owner here would defeat
+    // that: an ANN producer routinely outlives the seal -- IndexBuilder's SNII
+    // ADD INDEX path keeps every AnnIndexColumnWriter alive until the whole
+    // rowset has been closed -- so one rowset's staging would pile up at once.
+    std::vector<snii::writer::BlobFileSource> take_blob_sources();
+
+    // Drops every staged file NOW, whoever else still holds this directory. 
The
+    // abort path needs that: an ANN producer keeps the directory alive through
+    // its own _dir, so releasing only the index file writer's reference would
+    // free nothing.
+    void discard_staged_files();
+
+    // Logical bytes across every staged sub-file. Zero once 
take_blob_sources()
+    // has handed them over.
     uint64_t staged_bytes() const;
 
 protected:
@@ -92,11 +104,9 @@ protected:
 private:
     class StagingIndexOutput;
 
-    using Buffer = std::vector<uint8_t>;
-
-    const std::shared_ptr<Buffer>* find_file(const char* name) const;
+    const std::shared_ptr<snii::bkd::StagedBlobFile>* find_file(const char* 
name) const;
 
-    std::map<std::string, std::shared_ptr<Buffer>> _files;
+    std::map<std::string, std::shared_ptr<snii::bkd::StagedBlobFile>> _files;
 };
 
 } // namespace doris::segment_v2::snii_doris
diff --git a/be/src/storage/index/snii/writer/snii_compound_writer.cpp 
b/be/src/storage/index/snii/writer/snii_compound_writer.cpp
index e085071fcfc..c250302f0bf 100644
--- a/be/src/storage/index/snii/writer/snii_compound_writer.cpp
+++ b/be/src/storage/index/snii/writer/snii_compound_writer.cpp
@@ -31,6 +31,7 @@
 #include "storage/index/snii/format/metadata_directory.h"
 #include "storage/index/snii/format/tail_pointer.h"
 #include "storage/index/snii/reader/snii_segment_reader.h"
+#include "util/defer_op.h"
 
 namespace doris::snii::writer {
 
@@ -45,6 +46,19 @@ Status SniiCompoundWriter::poison(Status status) {
     DCHECK(!status.ok());
     if (failed_.ok()) {
         failed_ = std::move(status);
+        // First transition only. A poisoned compound can never seal, so every
+        // registered blob source is already dead -- and a source is routinely 
the
+        // SOLE owner of a staging file holding a whole faiss index or a BKD 
leaf
+        // region, because the producer hands it over at registration. Waiting 
for
+        // finish() to release them is not enough: after a poison, production
+        // usually never calls it. SegmentWriter::_write_inverted_index() 
returns
+        // before close_inverted_index(), and SegmentCreator::flush() keeps the
+        // failed writer, so the files and their descriptors would stay pinned
+        // until this writer is destroyed.
+        //
+        // Safe from inside finish()'s own loops: this only swaps the contents 
of
+        // each entry's source vectors, never resizes blobs_.
+        release_all_blob_sources();
     }
     return failed_;
 }
@@ -314,6 +328,13 @@ void 
SniiCompoundWriter::release_blob_sources(std::vector<BlobFileSource>* files
     files->swap(released);
 }
 
+void SniiCompoundWriter::release_all_blob_sources() {
+    for (PendingBlobIndex& blob : blobs_) {
+        release_blob_sources(&blob.cold_files);
+        release_blob_sources(&blob.hot_files);
+    }
+}
+
 SniiIndexInput SniiStreamedIndexSession::attach_encoded_norms(SniiIndexInput 
in,
                                                               
TrackedEncodedNorms* encoded_norms,
                                                               uint64_t 
reserved_bytes) {
@@ -699,9 +720,9 @@ Status SniiCompoundWriter::write_tail() {
 Status SniiCompoundWriter::finish() {
     if (out_ == nullptr)
         return Status::Error<ErrorCode::INVALID_ARGUMENT, false>("compound: 
null file writer");
-    if (!failed_.ok()) {
-        return failed_;
-    }
+    // poison() -- the only writer of failed_ -- already released every blob
+    // source at the transition, so there is nothing left to drop here.
+    if (!failed_.ok()) return failed_;
     if (finished_)
         return Status::Error<ErrorCode::INTERNAL_ERROR, false>("compound: 
finish called twice");
     // Crash-safety invariant 6: a begun-but-unfinished streamed session 
already
@@ -713,6 +734,7 @@ Status SniiCompoundWriter::finish() {
                 "compound: finish with an unfinished streamed index session; 
the half-fed "
                 "index must never be sealed away silently");
     finished_ = true;
+    Defer release_blobs([this] { release_all_blob_sources(); });
 
     RETURN_IF_ERROR(ensure_bootstrap()); // empty container still gets a header
     // Aux sections were written per index at add/finish_streamed time, right 
after each
@@ -724,13 +746,13 @@ Status SniiCompoundWriter::finish() {
         status = write_blob_files(blob.cold_files, &blob.cold_refs);
         if (!status.ok()) return poison(status);
         // The sources are dead the instant their bytes are in the container 
and
-        // their extents are in cold_refs -- and a source can OWN its bytes 
(the
-        // ANN staging directory hands over shared buffers holding a whole 
faiss
-        // index), so holding the vector until this writer is destroyed would 
pin
-        // that memory across every remaining blob and, because a rowset build
-        // keeps one writer per segment alive until every segment has been 
closed,
-        // across every segment of the rowset. Released per blob rather than 
after
-        // the loop so a multi-blob container never holds two at once.
+        // their extents are in cold_refs -- and a source can own resources 
(the
+        // ANN staging directory hands over staged files holding a whole Faiss
+        // index), so holding the vector until this writer is destroyed would 
retain
+        // those files across every remaining blob and, because a rowset build 
keeps
+        // one writer per segment alive until every segment has been closed, 
across
+        // every segment of the rowset. Released per blob rather than after 
the loop
+        // so a multi-blob container never retains two at once.
         release_blob_sources(&blob.cold_files);
     }
     status = write_tail();
diff --git a/be/src/storage/index/snii/writer/snii_compound_writer.h 
b/be/src/storage/index/snii/writer/snii_compound_writer.h
index e37dbfcf42c..aa2c2d266f4 100644
--- a/be/src/storage/index/snii/writer/snii_compound_writer.h
+++ b/be/src/storage/index/snii/writer/snii_compound_writer.h
@@ -299,6 +299,10 @@ private:
     // it serves, so keeping it alive keeps that memory resident for the 
writer's
     // whole life.
     static void release_blob_sources(std::vector<BlobFileSource>* files);
+    // A terminal finish failure can leave both already-visited and pending 
blobs.
+    // Drop every callback owner immediately instead of retaining its staging
+    // resource until this compound writer is destroyed.
+    void release_all_blob_sources();
     Status write_blob_files(const std::vector<BlobFileSource>& files,
                             std::vector<format::NamedBlobFileRef>* refs);
     // Emits the blob hot-file region and the blob directory entries; see the 
.cpp.
diff --git a/be/src/storage/segment/segment_writer.cpp 
b/be/src/storage/segment/segment_writer.cpp
index 70de1bcc30d..2fa8233286d 100644
--- a/be/src/storage/segment/segment_writer.cpp
+++ b/be/src/storage/segment/segment_writer.cpp
@@ -499,7 +499,33 @@ Status SegmentWriter::finalize_columns_data() {
     return Status::OK();
 }
 
+void SegmentWriter::_abandon_index_staging() {
+    // No clear() here: abandon_snii_staging() empties the staging directories
+    // themselves, so it does not matter whether the column writers -- which 
hold
+    // the same directories -- are still alive.
+    if (_index_file_writer != nullptr) {
+        _index_file_writer->abandon_snii_staging();
+    }
+}
+
+// A failure below can land AFTER the ANN and BKD indexes have already been 
built
+// into their staging files. The caller then returns before 
close_inverted_index(),
+// so the seal that would have consumed them never runs, and the rowset writer
+// keeps this segment's IndexFileWriter -- with its staging files and their 
open
+// descriptors -- until the whole load or compaction unwinds. Drop them here.
+//
+// ONLY on failure. On the success path close_inverted_index() is what consumes
+// the staging, so dropping it here would silently seal a container with no ANN
+// or BKD index in it.
 Status SegmentWriter::finalize_columns_index(uint64_t* index_size) {
+    Status status = _finalize_columns_index_impl(index_size);
+    if (!status.ok()) {
+        _abandon_index_staging();
+    }
+    return status;
+}
+
+Status SegmentWriter::_finalize_columns_index_impl(uint64_t* index_size) {
     uint64_t index_start = _file_writer->bytes_appended();
     // Record each index range separately. Vertical compaction writes column 
groups as
     // data+index pairs, so a single [first index, EOF) range would include 
later column data.
@@ -568,8 +594,21 @@ Status SegmentWriter::finalize_footer(uint64_t* 
segment_file_size,
     return Status::OK();
 }
 
+// Wrapped for the same reason as finalize_columns_index(): a footer or 
file-close
+// failure lands after the indexes have staged, and the caller returns before
+// close_inverted_index(). An inner failure has already abandoned the staging, 
so
+// the second call is a no-op.
 Status SegmentWriter::finalize(uint64_t* segment_file_size, uint64_t* 
index_size,
                                SegmentIndexFileCacheInfo* 
index_file_cache_info) {
+    Status status = _finalize_impl(segment_file_size, index_size, 
index_file_cache_info);
+    if (!status.ok()) {
+        _abandon_index_staging();
+    }
+    return status;
+}
+
+Status SegmentWriter::_finalize_impl(uint64_t* segment_file_size, uint64_t* 
index_size,
+                                     SegmentIndexFileCacheInfo* 
index_file_cache_info) {
     MonotonicStopWatch timer;
     timer.start();
     // check disk capacity
diff --git a/be/src/storage/segment/segment_writer.h 
b/be/src/storage/segment/segment_writer.h
index cba1ac4f2f1..f6d5346971f 100644
--- a/be/src/storage/segment/segment_writer.h
+++ b/be/src/storage/segment/segment_writer.h
@@ -129,6 +129,12 @@ public:
     uint64_t primary_keys_size() const { return _primary_keys_size; }
 
 private:
+    // Bodies of finalize()/finalize_columns_index(); the public wrappers add 
the
+    // abandon-on-failure step. See the .cpp.
+    Status _finalize_impl(uint64_t* segment_file_size, uint64_t* index_size,
+                          SegmentIndexFileCacheInfo* index_file_cache_info);
+    Status _finalize_columns_index_impl(uint64_t* index_size);
+    void _abandon_index_staging();
     friend class TestSegmentWriter;
     DISALLOW_COPY_AND_ASSIGN(SegmentWriter);
     Status _create_column_writer(uint32_t cid, const TabletColumn& column,
diff --git a/be/src/storage/segment/vertical_segment_writer.cpp 
b/be/src/storage/segment/vertical_segment_writer.cpp
index 63511be63b1..ce79424bceb 100644
--- a/be/src/storage/segment/vertical_segment_writer.cpp
+++ b/be/src/storage/segment/vertical_segment_writer.cpp
@@ -561,7 +561,33 @@ uint64_t 
VerticalSegmentWriter::_estimated_remaining_size() {
     return size;
 }
 
+void VerticalSegmentWriter::_abandon_index_staging() {
+    // No clear() here: abandon_snii_staging() empties the staging directories
+    // themselves, so it does not matter whether the column writers -- which 
hold
+    // the same directories -- are still alive.
+    if (_index_file_writer != nullptr) {
+        _index_file_writer->abandon_snii_staging();
+    }
+}
+
+// A failure below can land AFTER the ANN and BKD indexes have already been 
built
+// into their staging files. The caller then returns before 
close_inverted_index(),
+// so the seal that would have consumed them never runs, and the rowset writer
+// keeps this segment's IndexFileWriter -- with its staging files and their 
open
+// descriptors -- until the whole load or compaction unwinds. Drop them here.
+//
+// ONLY on failure. On the success path close_inverted_index() is what consumes
+// the staging, so dropping it here would silently seal a container with no ANN
+// or BKD index in it.
 Status VerticalSegmentWriter::finalize_columns_index(uint64_t* index_size) {
+    Status status = _finalize_columns_index_impl(index_size);
+    if (!status.ok()) {
+        _abandon_index_staging();
+    }
+    return status;
+}
+
+Status VerticalSegmentWriter::_finalize_columns_index_impl(uint64_t* 
index_size) {
     uint64_t index_start = _file_writer->bytes_appended();
     // Record the common index range for cloud index-only file-cache preload.
     // This VerticalSegmentWriter path is used when cloud load, compaction, or 
schema change flushes
@@ -616,8 +642,21 @@ Status VerticalSegmentWriter::finalize_footer(uint64_t* 
segment_file_size,
     return Status::OK();
 }
 
+// Wrapped for the same reason as finalize_columns_index(): a footer or 
file-close
+// failure lands after the indexes have staged, and the caller returns before
+// close_inverted_index(). An inner failure has already abandoned the staging, 
so
+// the second call is a no-op.
 Status VerticalSegmentWriter::finalize(uint64_t* segment_file_size, uint64_t* 
index_size,
                                        SegmentIndexFileCacheInfo* 
index_file_cache_info) {
+    Status status = _finalize_impl(segment_file_size, index_size, 
index_file_cache_info);
+    if (!status.ok()) {
+        _abandon_index_staging();
+    }
+    return status;
+}
+
+Status VerticalSegmentWriter::_finalize_impl(uint64_t* segment_file_size, 
uint64_t* index_size,
+                                             SegmentIndexFileCacheInfo* 
index_file_cache_info) {
     MonotonicStopWatch timer;
     timer.start();
     // check disk capacity
diff --git a/be/src/storage/segment/vertical_segment_writer.h 
b/be/src/storage/segment/vertical_segment_writer.h
index 40a33b6472f..e93829ce36f 100644
--- a/be/src/storage/segment/vertical_segment_writer.h
+++ b/be/src/storage/segment/vertical_segment_writer.h
@@ -135,6 +135,12 @@ public:
     }
 
 private:
+    // Bodies of finalize()/finalize_columns_index(); the public wrappers add 
the
+    // abandon-on-failure step. See the .cpp.
+    Status _finalize_impl(uint64_t* segment_file_size, uint64_t* index_size,
+                          SegmentIndexFileCacheInfo* index_file_cache_info);
+    Status _finalize_columns_index_impl(uint64_t* index_size);
+    void _abandon_index_staging();
     void _init_column_meta(ColumnMetaPB* meta, uint32_t column_id, const 
TabletColumn& column,
                            const ColumnWriterOptions& opts);
     Status _create_column_writer(uint32_t cid, const TabletColumn& column,
diff --git a/be/test/storage/index/snii/snii_ann_container_test.cpp 
b/be/test/storage/index/snii/snii_ann_container_test.cpp
index 2b665c1d4fa..63aa983edf4 100644
--- a/be/test/storage/index/snii/snii_ann_container_test.cpp
+++ b/be/test/storage/index/snii/snii_ann_container_test.cpp
@@ -22,8 +22,9 @@
 // what faiss emits. What was missing was the adapter on both ends:
 //
 //   write: the ANN writer had nowhere to write, because SNII opens no CLucene
-//          filesystem directory. It now gets a memory-backed staging directory
-//          (open_ann_directory) that begin_close() seals into a kAnn blob.
+//          filesystem directory. It now gets a bounded, file-backed staging
+//          directory (open_ann_directory) that begin_close() seals into a kAnn
+//          blob.
 //   read:  _open() refused too. A blob entry records ABSOLUTE container 
offsets,
 //          the same thing a V2 compound entry records, so DorisCompoundReader 
is
 //          reused over the container stream rather than reimplemented.
@@ -42,21 +43,30 @@
 #include <CLucene.h>
 #include <gen_cpp/olap_file.pb.h>
 #include <gtest/gtest.h>
+#ifdef ADDRESS_SANITIZER
+#include <sanitizer/allocator_interface.h>
+#endif
 
 #include <algorithm>
+#include <array>
 #include <cstring>
 #include <filesystem>
 #include <memory>
+#include <roaring/roaring.hh>
+#include <set>
 #include <string>
 #include <vector>
 
 #include "common/config.h"
 #include "common/status.h"
+#include "exec/scan/vector_search_user_params.h"
 #include "io/fs/local_file_system.h"
 #include "runtime/exec_env.h"
+#include "storage/cache/ann_index_ivf_list_cache.h"
 #include "storage/index/ann/ann_index_files.h"
 #include "storage/index/ann/ann_index_reader.h"
 #include "storage/index/ann/ann_index_writer.h"
+#include "storage/index/ann/ann_search_params.h"
 #include "storage/index/index_file_reader.h"
 #include "storage/index/index_file_writer.h"
 #include "storage/index/inverted/inverted_index_compound_reader.h"
@@ -64,6 +74,8 @@
 #include "storage/index/snii/format/metadata_directory.h"
 #include "storage/index/snii/io/local_file.h"
 #include "storage/index/snii/reader/snii_segment_reader.h"
+#include "storage/index/snii/snii_blob_staging_directory.h"
+#include "storage/index/snii/staged_file_probe.h"
 #include "storage/olap_common.h"
 #include "storage/options.h"
 #include "storage/tablet/tablet_schema.h"
@@ -148,6 +160,16 @@ TabletIndex make_ivf_on_disk_index_meta() {
     return meta;
 }
 
+// Faiss opens its sub-files as ann.faiss / ann.ivfdata, so this tag selects
+// exactly the ANN staging and leaves the native BKD staging of other suites
+// alone. Observed on the filesystem rather than through the directory's own
+// bookkeeping: the bookkeeping is what the retention below disagrees with.
+constexpr const char* kAnnStageTag = "ann.";
+
+std::set<std::string> staged_ann_files() {
+    return doris::snii_test::snii_staged_files(kAnnStageTag);
+}
+
 TabletIndex make_fake_bkd_index_meta() {
     TabletIndexPB pb;
     pb.set_index_type(IndexType::INVERTED);
@@ -181,26 +203,54 @@ protected:
         // the wrong entry would then also disagree on fileLength.
         _synthetic["fake_bkd_data"] = synthetic_bytes(0x11, 4096 + 17);
         _synthetic["fake_bkd_index"] = synthetic_bytes(0x77, 1024 + 3);
+        // IVF-on-disk reads its lists through AnnIndexIVFListCache, which
+        // exec_env_init installs unconditionally at BE startup -- so this is 
the
+        // production path, not a test convenience. Installed only if nobody 
else
+        // in this binary already has one, and torn down only by whoever 
installed
+        // it: create_global_cache DCHECKs on a second install, and clobbering
+        // another fixture's cache is how shared-state bugs start.
+        if (AnnIndexIVFListCache::instance() == nullptr) {
+            (void)AnnIndexIVFListCache::create_global_cache(8 * 1024 * 1024);
+            _owns_ivf_list_cache = true;
+        }
     }
 
     void TearDown() override {
+        if (_owns_ivf_list_cache) {
+            AnnIndexIVFListCache::destroy_global_cache();
+            _owns_ivf_list_cache = false;
+        }
         
EXPECT_TRUE(io::global_local_filesystem()->delete_directory(kTestDir).ok());
     }
 
-    // Builds one ANN index on `writer` and seals it into the writer's staging
-    // area, i.e. everything up to (but not including) begin_close().
-    void feed_ann_index(IndexFileWriter* writer, const TabletIndex* meta) {
-        AnnIndexColumnWriter ann(writer, meta);
-        ASSERT_TRUE(ann.init().ok());
+    // Feeds the standard vector set into an ANN writer the CALLER owns. The
+    // producer therefore outlives the call -- which is the shape both a failed
+    // segment flush and the whole ADD INDEX path leave behind, and the shape
+    // finish_ann_index() below cannot express.
+    static Status feed_and_finish(AnnIndexColumnWriter* ann) {
         const std::vector<float> vectors = make_vectors();
         std::vector<size_t> offsets(kRows + 1);
         for (uint32_t i = 0; i <= kRows; ++i) {
             offsets[i] = static_cast<size_t>(i) * kDim;
         }
-        ASSERT_TRUE(ann.add_array_values(sizeof(float), vectors.data(), 
/*null_map=*/nullptr,
-                                         reinterpret_cast<const 
uint8_t*>(offsets.data()), kRows)
-                            .ok());
-        ASSERT_TRUE(ann.finish().ok());
+        RETURN_IF_ERROR(ann->add_array_values(sizeof(float), vectors.data(), 
/*null_map=*/nullptr,
+                                              reinterpret_cast<const 
uint8_t*>(offsets.data()),
+                                              kRows));
+        return ann->finish();
+    }
+
+    // NOLINTNEXTLINE(readability-non-const-parameter): AnnIndexColumnWriter's
+    // constructor takes a mutable IndexFileWriter*, so this cannot be const.
+    Status finish_ann_index(IndexFileWriter* writer, const TabletIndex* meta) {
+        AnnIndexColumnWriter ann(writer, meta);
+        RETURN_IF_ERROR(ann.init());
+        return feed_and_finish(&ann);
+    }
+
+    // Builds one ANN index on `writer` and seals it into the writer's staging
+    // area, i.e. everything up to (but not including) begin_close().
+    void feed_ann_index(IndexFileWriter* writer, const TabletIndex* meta) {
+        assert_ok(finish_ann_index(writer, meta));
     }
 
     // One SNII container holding an ANN blob and a NON-ANN blob, and the path
@@ -244,6 +294,7 @@ protected:
     TabletIndex _meta;
     TabletIndex _fake_bkd_meta;
     TabletIndex _ivf_on_disk_meta;
+    bool _owns_ivf_list_cache = false;
     std::map<std::string, std::vector<uint8_t>> _synthetic;
 };
 
@@ -334,6 +385,64 @@ TEST_F(SniiAnnContainerTest, 
FaissLoadsTheIndexBackOutOfTheContainer) {
     assert_ok(ann_reader->load_index(&io_ctx));
 }
 
+// IVF-on-disk is the only ANN shape that emits two sub-files, and ann.ivfdata 
--
+// the bulk of the index -- is the one whose bytes reach faiss through
+// StagedBlobFile::read_at and DorisCompoundReader rather than through faiss's 
own
+// file handling. Nothing else exercises that: the load test above uses _meta 
(a
+// single ann.faiss), IvfOnDiskSubFilesAreSealedInAscendingNameOrder checks 
only
+// the two names and their order, and the standalone IVF save/load test runs 
on a
+// RAMDirectory. A wrong length or a wrong absolute extent on ann.ivfdata would
+// leave every one of those green and surface only when the index is queried.
+//
+// The oracle is make_vectors(), not anything the container reported: a row's 
own
+// vector must come back as its own row id at distance zero. nlist is 4 and the
+// default ivf_nprobe is 32, so every list is probed and the answer is exact.
+TEST_F(SniiAnnContainerTest, IvfOnDiskAnswersAQueryThroughTheProductionReader) 
{
+    const std::string prefix = std::string(kTestDir) + "/ivf_query_seg";
+    io::FileWriterPtr file_writer;
+    assert_ok(io::global_local_filesystem()->create_file(
+            InvertedIndexDescriptor::get_index_file_path_v2(prefix), 
&file_writer));
+    IndexFileWriter writer(io::global_local_filesystem(), prefix, 
"snii_ivf_query_rowset",
+                           /*seg_id=*/0, InvertedIndexStorageFormatPB::SNII, 
std::move(file_writer),
+                           /*can_use_ram_dir=*/false,
+                           /*tablet_id=*/9912);
+    feed_ann_index(&writer, &_ivf_on_disk_meta);
+    ASSERT_FALSE(testing::Test::HasFatalFailure());
+    assert_ok(writer.begin_close());
+    assert_ok(writer.finish_close());
+
+    auto reader = 
std::make_shared<IndexFileReader>(io::global_local_filesystem(), prefix,
+                                                    
InvertedIndexStorageFormatPB::SNII);
+    assert_ok(reader->init());
+    auto ann_reader = std::make_shared<AnnIndexReader>(&_ivf_on_disk_meta, 
reader);
+    io::IOContext io_ctx;
+    assert_ok(ann_reader->load_index(&io_ctx));
+
+    constexpr uint32_t kProbeRow = 17;
+    const std::vector<float> vectors = make_vectors();
+    const std::vector<float> query(vectors.begin() + 
static_cast<int64_t>(kProbeRow) * kDim,
+                                   vectors.begin() + 
static_cast<int64_t>(kProbeRow + 1) * kDim);
+    roaring::Roaring candidates;
+    candidates.addRange(0, kRows);
+
+    AnnTopNParam param {.query_value = query.data(),
+                        .query_value_size = kDim,
+                        .limit = 4,
+                        ._user_params = VectorSearchUserParams {},
+                        .roaring = &candidates,
+                        .rows_of_segment = kRows,
+                        .enable_result_cache = false};
+    AnnIndexStats stats;
+    assert_ok(ann_reader->query(&io_ctx, &param, &stats));
+
+    ASSERT_NE(param.row_ids, nullptr);
+    ASSERT_FALSE(param.row_ids->empty());
+    EXPECT_EQ((*param.row_ids)[0], kProbeRow)
+            << "the nearest neighbour of a row's own vector must be that row";
+    ASSERT_NE(param.distance, nullptr);
+    EXPECT_NEAR(param.distance[0], 0.0F, 1e-4F);
+}
+
 // A BKD blob has its own reader and no CLucene representation at all. Serving 
a
 // directory over it would hand a caller bytes no CLucene code can parse, so 
the
 // read adapter refuses by KIND. Probing an ABSENT id cannot test this: the
@@ -394,6 +503,39 @@ TEST_F(SniiAnnContainerTest, 
AnnStagingUnderSniiCreatesNoFilesystemDirectory) {
     assert_ok(writer.finish_close());
 }
 
+// ANN serialization can be much larger than the final rowset writer's other
+// resident state. Staging must therefore retain only a fixed-size write 
buffer,
+// not a second in-memory copy of the complete faiss output.
+TEST_F(SniiAnnContainerTest, LargeAnnOutputUsesBoundedHeapForStaging) {
+#ifndef ADDRESS_SANITIZER
+    GTEST_SKIP() << "heap sampling requires the ASAN allocator interface";
+#else
+    constexpr size_t kChunkBytes = 64U << 10;
+    constexpr size_t kStagedBytes = 64U << 20;
+    // The ASAN counter is process-wide, so leave ample room for unrelated
+    // background allocations. The old vector staging retained the complete
+    // 64 MiB payload and therefore still exceeds this limit by a wide margin.
+    constexpr size_t kMaximumHeapGrowth = kStagedBytes / 2;
+
+    snii_doris::SniiBlobStagingDirectory staging;
+    std::unique_ptr<lucene::store::IndexOutput> 
output(staging.createOutput("ann.faiss"));
+    std::array<uint8_t, kChunkBytes> chunk {};
+
+    const size_t baseline = __sanitizer_get_current_allocated_bytes();
+    size_t peak = baseline;
+    for (size_t written = 0; written < kStagedBytes; written += chunk.size()) {
+        output->writeBytes(chunk.data(), static_cast<int32_t>(chunk.size()));
+        peak = std::max(peak, __sanitizer_get_current_allocated_bytes());
+    }
+    output->close();
+    peak = std::max(peak, __sanitizer_get_current_allocated_bytes());
+
+    EXPECT_EQ(staging.staged_bytes(), kStagedBytes);
+    EXPECT_LT(peak - baseline, kMaximumHeapGrowth)
+            << "ANN staging retained heap proportional to the serialized 
index";
+#endif
+}
+
 // Scoped debug-point switch: enables the fault injection sites for one test 
and
 // restores the process-wide config afterwards.
 class ScopedDebugPoints {
@@ -412,6 +554,260 @@ private:
     const bool _was_enabled;
 };
 
+TEST_F(SniiAnnContainerTest, AnnStagingFinalizeFailureIsReturnedFromFinish) {
+    ScopedDebugPoints debug_points;
+    debug_points.enable("StagedBlobFile::finalize_error");
+
+    const std::string prefix = std::string(kTestDir) + "/finalize_failure_seg";
+    io::FileWriterPtr file_writer;
+    assert_ok(io::global_local_filesystem()->create_file(
+            InvertedIndexDescriptor::get_index_file_path_v2(prefix), 
&file_writer));
+    IndexFileWriter writer(io::global_local_filesystem(), prefix, 
"snii_ann_finalize_failure",
+                           /*seg_id=*/0, InvertedIndexStorageFormatPB::SNII, 
std::move(file_writer),
+                           /*can_use_ram_dir=*/false,
+                           /*tablet_id=*/9904);
+
+    Status finish_status = Status::OK();
+    ASSERT_NO_THROW({ finish_status = finish_ann_index(&writer, &_meta); });
+    EXPECT_FALSE(finish_status.ok());
+    EXPECT_NE(finish_status.to_string().find("injected blob staging finalize 
failure"),
+              std::string::npos)
+            << finish_status.to_string();
+}
+
+TEST_F(SniiAnnContainerTest, FinalBufferedAppendFailureIsReturnedFromFinish) {
+    ScopedDebugPoints debug_points;
+    debug_points.enable("StagedBlobFile::append_error");
+
+    const std::string prefix = std::string(kTestDir) + "/append_failure_seg";
+    io::FileWriterPtr file_writer;
+    assert_ok(io::global_local_filesystem()->create_file(
+            InvertedIndexDescriptor::get_index_file_path_v2(prefix), 
&file_writer));
+    IndexFileWriter writer(io::global_local_filesystem(), prefix, 
"snii_ann_append_failure",
+                           /*seg_id=*/0, InvertedIndexStorageFormatPB::SNII, 
std::move(file_writer),
+                           /*can_use_ram_dir=*/false,
+                           /*tablet_id=*/9905);
+
+    Status finish_status = Status::OK();
+    ASSERT_NO_THROW({ finish_status = finish_ann_index(&writer, &_meta); });
+    EXPECT_FALSE(finish_status.ok());
+    EXPECT_NE(finish_status.to_string().find("injected blob staging append 
failure"),
+              std::string::npos)
+            << finish_status.to_string();
+}
+
+TEST_F(SniiAnnContainerTest, IvfDataWriteFailuresAreReturnedFromFinish) {
+    struct FaultCase {
+        const char* debug_point;
+        const char* message;
+        const char* suffix;
+        int64_t tablet_id;
+    };
+    const std::array faults {
+            FaultCase {"StagedBlobFile::append_error", "injected blob staging 
append failure",
+                       "append", 9906},
+            FaultCase {"StagedBlobFile::finalize_error", "injected blob 
staging finalize failure",
+                       "finalize", 9907},
+    };
+
+    for (const auto& fault : faults) {
+        SCOPED_TRACE(fault.debug_point);
+        ScopedDebugPoints debug_points;
+        debug_points.enable(fault.debug_point);
+
+        const std::string prefix = std::string(kTestDir) + "/ivf_" + 
fault.suffix + "_failure_seg";
+        io::FileWriterPtr file_writer;
+        assert_ok(io::global_local_filesystem()->create_file(
+                InvertedIndexDescriptor::get_index_file_path_v2(prefix), 
&file_writer));
+        IndexFileWriter writer(io::global_local_filesystem(), prefix, 
"snii_ivf_write_failure",
+                               /*seg_id=*/0, 
InvertedIndexStorageFormatPB::SNII,
+                               std::move(file_writer),
+                               /*can_use_ram_dir=*/false, fault.tablet_id);
+
+        Status finish_status = Status::OK();
+        ASSERT_NO_THROW({ finish_status = finish_ann_index(&writer, 
&_ivf_on_disk_meta); });
+        EXPECT_FALSE(finish_status.ok());
+        EXPECT_NE(finish_status.to_string().find("Failed to close IVF data 
output"),
+                  std::string::npos)
+                << finish_status.to_string();
+        EXPECT_NE(finish_status.to_string().find(fault.message), 
std::string::npos)
+                << finish_status.to_string();
+    }
+}
+
+// A failed faiss serialization must unlink its staging file THERE, not 
whenever
+// the writers happen to be destroyed. Both owners are still alive at that 
point
+// -- the ANN writer through _dir, the IndexFileWriter through _indices_dirs --
+// and neither caller unwinds them: 
VerticalSegmentWriter::finalize_columns_index()
+// returns before clear() and close_inverted_index(), and IndexBuilder's SNII 
ADD
+// INDEX path keeps every producer alive until the whole rowset has been 
closed.
+// So both writers are held here on purpose; the failure tests above scope 
their
+// producer out inside finish_ann_index() and cannot see this.
+TEST_F(SniiAnnContainerTest, AFailedSaveUnlinksItsStagingFile) {
+    struct Case {
+        const char* what;
+        const TabletIndex* meta;
+        int64_t tablet_id;
+    };
+    const std::array cases {
+            Case {.what = "hnsw", .meta = &_meta, .tablet_id = 9908},
+            Case {.what = "ivf_on_disk", .meta = &_ivf_on_disk_meta, 
.tablet_id = 9909},
+    };
+
+    for (const auto& one : cases) {
+        SCOPED_TRACE(one.what);
+        const std::set<std::string> before = staged_ann_files();
+        ScopedDebugPoints debug_points;
+        // append(), not the seal: a write is the staging failure production 
can
+        // actually hit (ENOSPC on the scratch volume), and the seal makes no
+        // durability call any more.
+        debug_points.enable("StagedBlobFile::append_error");
+
+        const std::string prefix = std::string(kTestDir) + "/failed_save_" + 
one.what + "_seg";
+        io::FileWriterPtr file_writer;
+        assert_ok(io::global_local_filesystem()->create_file(
+                InvertedIndexDescriptor::get_index_file_path_v2(prefix), 
&file_writer));
+        IndexFileWriter writer(io::global_local_filesystem(), prefix, 
"snii_ann_failed_save_rowset",
+                               /*seg_id=*/0, 
InvertedIndexStorageFormatPB::SNII,
+                               std::move(file_writer), 
/*can_use_ram_dir=*/false, one.tablet_id);
+
+        AnnIndexColumnWriter ann(&writer, one.meta);
+        assert_ok(ann.init());
+        Status finish_status = Status::OK();
+        ASSERT_NO_THROW({ finish_status = feed_and_finish(&ann); });
+        ASSERT_FALSE(finish_status.ok()) << "the injected fault must fail the 
save";
+        // The specific fault, not just any error: a save that failed for some
+        // other reason would not prove anything about the staging file.
+        ASSERT_NE(finish_status.to_string().find("injected blob staging append 
failure"),
+                  std::string::npos)
+                << finish_status.to_string();
+
+        EXPECT_EQ(staged_ann_files(), before)
+                << "the failed save left its staging file on the temp 
filesystem while the ANN "
+                   "writer and the IndexFileWriter are both still alive";
+    }
+}
+
+// Sealing hands the staged files to the container, so a producer that outlives
+// the seal must not keep them pinned. IndexBuilder's SNII ADD INDEX path
+// (_handle_single_rowset_snii) is exactly that shape: it builds every segment,
+// closes every IndexFileWriter, and only then clears _index_column_writers. If
+// the handover does not happen, one whole rowset's ANN staging sits on the 
temp
+// filesystem at once -- which is precisely what SniiCompoundWriter::finish()
+// releases per blob to avoid.
+TEST_F(SniiAnnContainerTest, 
SealingDrainsStagedFilesWhileProducersAreStillAlive) {
+    struct Segment {
+        std::unique_ptr<IndexFileWriter> file_writer;
+        std::unique_ptr<AnnIndexColumnWriter> producer;
+    };
+    constexpr int kSegments = 3;
+
+    const std::set<std::string> before = staged_ann_files();
+    std::vector<Segment> segments;
+    for (int seg_id = 0; seg_id < kSegments; ++seg_id) {
+        const std::string prefix =
+                std::string(kTestDir) + "/held_producer_seg" + 
std::to_string(seg_id);
+        io::FileWriterPtr file_writer;
+        assert_ok(io::global_local_filesystem()->create_file(
+                InvertedIndexDescriptor::get_index_file_path_v2(prefix), 
&file_writer));
+        Segment segment;
+        segment.file_writer = std::make_unique<IndexFileWriter>(
+                io::global_local_filesystem(), prefix, 
"snii_ann_held_producer_rowset", seg_id,
+                InvertedIndexStorageFormatPB::SNII, std::move(file_writer),
+                /*can_use_ram_dir=*/false, /*tablet_id=*/9910);
+        segment.producer =
+                
std::make_unique<AnnIndexColumnWriter>(segment.file_writer.get(), &_meta);
+        assert_ok(segment.producer->init());
+        Status finish_status = Status::OK();
+        ASSERT_NO_THROW({ finish_status = 
feed_and_finish(segment.producer.get()); });
+        assert_ok(finish_status);
+        segments.push_back(std::move(segment));
+    }
+    // Sanity: their absence below must mean the seal drained them, not that 
they
+    // were never staged.
+    ASSERT_EQ(staged_ann_files().size(), before.size() + kSegments);
+
+    for (auto& segment : segments) {
+        assert_ok(segment.file_writer->begin_close());
+    }
+    // Every producer is STILL alive here, exactly as it is in IndexBuilder 
when
+    // it runs its begin_close loop.
+    EXPECT_EQ(staged_ann_files(), before)
+            << "sealing left the staged files pinned by the producers";
+
+    for (auto& segment : segments) {
+        assert_ok(segment.file_writer->finish_close());
+    }
+}
+
+// A segment that fails AFTER its ANN indexes have staged must not leave them
+// behind. This is the state such a failure leaves: every index staged fine, 
the
+// producers are still alive because clear() has not run, and nobody will ever
+// call begin_close() -- so the seal that normally consumes the staging never
+// happens. Distinct from AFailedSaveUnlinksItsStagingFile, where serialization
+// itself failed and the per-index discard fires immediately.
+TEST_F(SniiAnnContainerTest, AbandoningASegmentDropsEveryStagedAnnFile) {
+    const std::set<std::string> before = staged_ann_files();
+    const std::string prefix = std::string(kTestDir) + "/abandoned_seg";
+    io::FileWriterPtr file_writer;
+    assert_ok(io::global_local_filesystem()->create_file(
+            InvertedIndexDescriptor::get_index_file_path_v2(prefix), 
&file_writer));
+    IndexFileWriter writer(io::global_local_filesystem(), prefix, 
"snii_abandoned_rowset",
+                           /*seg_id=*/0, InvertedIndexStorageFormatPB::SNII, 
std::move(file_writer),
+                           /*can_use_ram_dir=*/false,
+                           /*tablet_id=*/9913);
+
+    std::vector<std::unique_ptr<AnnIndexColumnWriter>> producers;
+    for (const TabletIndex* meta : {&_meta, &_ivf_on_disk_meta}) {
+        auto producer = std::make_unique<AnnIndexColumnWriter>(&writer, meta);
+        assert_ok(producer->init());
+        Status finish_status = Status::OK();
+        ASSERT_NO_THROW({ finish_status = feed_and_finish(producer.get()); });
+        assert_ok(finish_status);
+        producers.push_back(std::move(producer));
+    }
+    ASSERT_EQ(staged_ann_files().size(), before.size() + 3);
+
+    writer.abandon_snii_staging();
+    // The producers are still alive, as they are in a segment writer whose
+    // finalize failed before clear().
+    EXPECT_EQ(staged_ann_files(), before)
+            << "abandoning the segment left its staged ANN files on the temp 
filesystem";
+}
+
+// The same handover inside ONE container: _indices_dirs holds every staging
+// directory for the whole of finish(), so the per-blob release in
+// SniiCompoundWriter::finish() can only free a sub-file that the directory has
+// already given up. Two ANN indexes, three sub-files (hnsw writes ann.faiss,
+// ivf_on_disk writes ann.faiss and ann.ivfdata).
+TEST_F(SniiAnnContainerTest, EveryAnnIndexInOneContainerIsDrainedBySealing) {
+    const std::set<std::string> before = staged_ann_files();
+    const std::string prefix = std::string(kTestDir) + "/multi_ann_seg";
+    io::FileWriterPtr file_writer;
+    assert_ok(io::global_local_filesystem()->create_file(
+            InvertedIndexDescriptor::get_index_file_path_v2(prefix), 
&file_writer));
+    IndexFileWriter writer(io::global_local_filesystem(), prefix, 
"snii_multi_ann_rowset",
+                           /*seg_id=*/0, InvertedIndexStorageFormatPB::SNII, 
std::move(file_writer),
+                           /*can_use_ram_dir=*/false,
+                           /*tablet_id=*/9911);
+
+    std::vector<std::unique_ptr<AnnIndexColumnWriter>> producers;
+    for (const TabletIndex* meta : {&_meta, &_ivf_on_disk_meta}) {
+        auto producer = std::make_unique<AnnIndexColumnWriter>(&writer, meta);
+        assert_ok(producer->init());
+        Status finish_status = Status::OK();
+        ASSERT_NO_THROW({ finish_status = feed_and_finish(producer.get()); });
+        assert_ok(finish_status);
+        producers.push_back(std::move(producer));
+    }
+    ASSERT_EQ(staged_ann_files().size(), before.size() + 3);
+
+    assert_ok(writer.begin_close());
+    EXPECT_EQ(staged_ann_files(), before)
+            << "one container's seal did not drain every ANN index it sealed";
+    assert_ok(writer.finish_close());
+}
+
 // begin_close() returns Status. Nothing on its SNII path may throw across that
 // boundary -- the non-SNII branch of the same function wraps its directory
 // teardown in try/catch precisely because DorisFSDirectory::deleteDirectory()
diff --git a/be/test/storage/index/snii/snii_bkd_adapter_test.cpp 
b/be/test/storage/index/snii/snii_bkd_adapter_test.cpp
index c6ae01eabc2..3243602176c 100644
--- a/be/test/storage/index/snii/snii_bkd_adapter_test.cpp
+++ b/be/test/storage/index/snii/snii_bkd_adapter_test.cpp
@@ -36,6 +36,7 @@
 #include <cstdint>
 #include <memory>
 #include <optional>
+#include <set>
 #include <string>
 #include <vector>
 
@@ -58,6 +59,7 @@
 #include "storage/index/snii/reader/snii_segment_reader.h"
 #include "storage/index/snii/snii_bkd_index_reader.h"
 #include "storage/index/snii/snii_bkd_index_writer.h"
+#include "storage/index/snii/staged_file_probe.h"
 #include "storage/key_coder.h"
 #include "storage/tablet/tablet_schema.h"
 
@@ -286,6 +288,49 @@ TEST_F(SniiBkdAdapterTest, 
NumericColumnLandsInTheContainerAsAQueryableBkd) {
     }
 }
 
+// The bkd_data staging file must be gone once the container has sealed, even
+// though the producer is still alive. It routinely is: IndexBuilder's SNII ADD
+// INDEX path keeps every writer in _index_column_writers until the whole 
rowset
+// has been closed, and a close error returns before that map is cleared. Since
+// bkd_data is sized by the point count, a producer that keeps holding it pins 
one
+// such file per numeric index per segment on the temp filesystem.
+//
+// write_segment() above already keeps its writer alive across begin_close(), 
so
+// this asserts what that flow was silently doing rather than inventing a 
shape.
+TEST_F(SniiBkdAdapterTest, 
SealingDrainsTheStagedDataWhileTheProducerIsStillAlive) {
+    const std::set<std::string> before = 
doris::snii_test::snii_staged_files("bkd_data");
+    const std::vector<Row> rows = sample_rows(3000, 500);
+    const std::string prefix = test_path("held_producer");
+
+    io::FileWriterPtr file_writer;
+    assert_ok(io::global_local_filesystem()->create_file(
+            InvertedIndexDescriptor::get_index_file_path_v2(prefix), 
&file_writer));
+    IndexFileWriter index_file_writer(io::global_local_filesystem(), prefix, 
"held_rowset",
+                                      /*seg_id=*/0, 
InvertedIndexStorageFormatPB::SNII,
+                                      std::move(file_writer), 
/*can_use_ram_dir=*/true,
+                                      /*tablet_id=*/302);
+
+    SniiBkdIndexColumnWriter writer(&index_file_writer, &_meta, kFieldType);
+    assert_ok(writer.init());
+    std::vector<int64_t> values;
+    for (const Row& row : rows) {
+        if (!row.is_null) {
+            values.push_back(row.value);
+        }
+    }
+    assert_ok(writer.add_values("c1", values.data(), values.size()));
+    assert_ok(writer.finish());
+    // Sanity: its absence below must mean the seal drained it, not that it was
+    // never staged.
+    ASSERT_EQ(doris::snii_test::snii_staged_files("bkd_data").size(), 
before.size() + 1);
+
+    assert_ok(index_file_writer.begin_close());
+    // `writer` is STILL in scope here, exactly as it is in IndexBuilder.
+    EXPECT_EQ(doris::snii_test::snii_staged_files("bkd_data"), before)
+            << "sealing left bkd_data pinned by the producer";
+    assert_ok(index_file_writer.finish_close());
+}
+
 // NULL rows are carried by the SNII null-bitmap POD, not by the point set: a
 // NULL that leaked in as a point would answer `col > x` for a row that has no
 // value at all.
diff --git a/be/test/storage/index/snii/staged_file_probe.h 
b/be/test/storage/index/snii/staged_file_probe.h
new file mode 100644
index 00000000000..d822eaba479
--- /dev/null
+++ b/be/test/storage/index/snii/staged_file_probe.h
@@ -0,0 +1,94 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+#pragma once
+
+#include <gtest/gtest.h>
+
+#include <filesystem>
+#include <set>
+#include <string>
+#include <system_error>
+
+#include "storage/index/snii/writer/temp_dir.h"
+
+// Observing SNII build-time staging files from a test.
+//
+// WHY THIS IS NOT A ONE-LINE DIRECTORY SCAN. TmpFileDirs::get_tmp_file_dir()
+// round-robins over its configured paths with an atomic counter, and every
+// resolve_temp_dir() advances it -- so do IndexFileWriter's constructor and
+// every StagedBlobFile::create. The doris_be_test binary is monolithic and 
some
+// fixtures install two or three StorePath roots without putting the previous
+// TmpFileDirs back, so a test cannot assume there is only one root, nor that 
two
+// scans land on the same one. A before/after count taken through
+// resolve_temp_dir() can therefore compare two different directories and 
report
+// "nothing left behind" while a staging file is still linked.
+namespace doris::snii_test {
+
+// Every configured SNII temp root. A round-robin returns to its first value
+// after exactly one cycle, which is what bounds the probe. Deliberately does 
not
+// install a single-root TmpFileDirs instead: that would pull the temp root out
+// from under whichever test runs next.
+inline std::set<std::string> snii_temp_roots() {
+    std::set<std::string> roots;
+    const std::string first = snii::writer::resolve_temp_dir();
+    roots.insert(first);
+    for (int i = 0; i < 64; ++i) {
+        const std::string next = snii::writer::resolve_temp_dir();
+        if (next == first) {
+            return roots;
+        }
+        roots.insert(next);
+    }
+    ADD_FAILURE() << "resolve_temp_dir() did not cycle within 64 calls, so the 
staged-file "
+                     "probe cannot enumerate the temp roots";
+    return roots;
+}
+
+// Full paths of the staging files named for `tag`, across every root.
+// StagedBlobFile::create names them "snii_bkdstage_<tag>_<pid>_<seq>.stage", 
so
+// the tag selects one producer's sub-files and leaves other suites' staging
+// alone. Compare the returned SETS, not their sizes: equal counts can still 
hide
+// one file leaking while another is created.
+//
+// A filesystem error fails the calling test rather than silently reporting an
+// empty set, which would make "nothing was left behind" unfalsifiable.
+inline std::set<std::string> snii_staged_files(const std::string& tag) {
+    const std::string prefix = "snii_bkdstage_" + tag;
+    std::set<std::string> found;
+    for (const std::string& root : snii_temp_roots()) {
+        std::error_code ec;
+        std::filesystem::directory_iterator it(root, ec);
+        if (ec) {
+            ADD_FAILURE() << "cannot scan SNII temp root " << root << ": " << 
ec.message();
+            continue;
+        }
+        while (it != std::filesystem::directory_iterator()) {
+            if (it->path().filename().string().starts_with(prefix)) {
+                found.insert(it->path().string());
+            }
+            it.increment(ec);
+            if (ec) {
+                ADD_FAILURE() << "cannot walk SNII temp root " << root << ": " 
<< ec.message();
+                break;
+            }
+        }
+    }
+    return found;
+}
+
+} // namespace doris::snii_test
diff --git a/be/test/storage/index/snii/writer/snii_compound_writer_test.cpp 
b/be/test/storage/index/snii/writer/snii_compound_writer_test.cpp
index 9d073835b86..3711b43dc30 100644
--- a/be/test/storage/index/snii/writer/snii_compound_writer_test.cpp
+++ b/be/test/storage/index/snii/writer/snii_compound_writer_test.cpp
@@ -23,7 +23,9 @@
 #include <cstdio>
 #include <cstdlib>
 #include <cstring>
+#include <filesystem>
 #include <functional>
+#include <memory>
 #include <string>
 #include <utility>
 #include <vector>
@@ -32,6 +34,7 @@
 #include "common/status.h"
 #include "gen_cpp/snii.pb.h"
 #include "storage/index/inverted/common_grams/common_grams_segment_metadata.h"
+#include "storage/index/snii/bkd/staged_blob_file.h"
 #include "storage/index/snii/common/slice.h"
 #include "storage/index/snii/encoding/byte_source.h"
 #include "storage/index/snii/encoding/crc32c.h"
@@ -403,6 +406,36 @@ void VerifyAppendFailurePoisonsWriter(size_t 
fail_on_append) {
     EXPECT_EQ(0U, file.finalize_calls());
 }
 
+void VerifyFinishFailureReleasesBlobSource(size_t fail_on_append, size_t 
staged_bytes) {
+    FailOnAppendWriter file(fail_on_append);
+    SniiCompoundWriter writer(&file);
+
+    std::unique_ptr<bkd::StagedBlobFile> created;
+    ASSERT_TRUE(bkd::StagedBlobFile::create("compound_failure", 
&created).ok());
+    const std::string path = created->path();
+    std::vector<uint8_t> bytes(staged_bytes, 0x5A);
+    ASSERT_TRUE(created->append(Slice(bytes)).ok());
+    ASSERT_TRUE(created->finalize().ok());
+
+    auto staged = std::shared_ptr<bkd::StagedBlobFile>(std::move(created));
+    std::vector<BlobFileSource> cold_files;
+    cold_files.push_back(BlobFileSource {
+            .name = "ann.faiss",
+            .length = staged->bytes_written(),
+            .read_fn = [staged](uint64_t offset, size_t len, uint8_t* out) -> 
Status {
+                return staged->read_at(offset, len, out);
+            }});
+    ASSERT_TRUE(
+            writer.add_blob_index(7, "", LogicalIndexKind::kAnn, 
std::move(cold_files), {}).ok());
+    staged.reset();
+    ASSERT_TRUE(std::filesystem::exists(path));
+
+    const Status status = writer.finish();
+    ASSERT_FALSE(status.ok());
+    EXPECT_FALSE(std::filesystem::exists(path))
+            << "a terminal compound failure retained the callback-owned 
staging file";
+}
+
 // A FileReader decorator that counts how many physical reads (single or 
batched)
 // touch a given byte window. Used to assert that the BSBF section is NOT read 
at
 // all on the non-resident (L1) path: with the P1 cold-read fix, open must not
@@ -1026,6 +1059,60 @@ TEST(SniiCompoundWriter, 
BsbfAppendFailureReleasesReservationsBeforeReturn) {
     VerifyAuxiliaryAppendFailureReleasesReservations(AuxiliarySection::kBsbf);
 }
 
+TEST(SniiCompoundWriter, 
FinishFailureReleasesBlobSourcesWhileWriterRemainsAlive) {
+    // Append 1 fails while writing a new container's bootstrap, before the 
blob
+    // is read. Append 3 fails on the second blob chunk, after bootstrap and 
one
+    // complete chunk have reached the output.
+    VerifyFinishFailureReleasesBlobSource(/*fail_on_append=*/1, 
/*staged_bytes=*/1);
+    VerifyFinishFailureReleasesBlobSource(
+            /*fail_on_append=*/3, SniiCompoundWriter::kBlobCopyChunkBytes + 1);
+}
+
+// A poisoned compound can never seal, so every registered blob source is dead 
--
+// and after the producer hands its file over, that source is the file's ONLY
+// owner. Production frequently never calls finish() after a poison:
+// SegmentWriter::_write_inverted_index() returns before 
close_inverted_index(),
+// and SegmentCreator::flush() keeps the failed writer, so a release that waits
+// for finish() never runs and the staging file and its descriptor stay pinned.
+TEST(SniiCompoundWriter, PoisonReleasesBlobSourcesWithoutWaitingForFinish) {
+    // Append 1 is the bootstrap header; append 2 is the first posting byte 
range
+    // of the text index below, which is what poisons the writer.
+    FailOnAppendWriter file(/*fail_on_append=*/2);
+    SniiCompoundWriter writer(&file);
+
+    std::unique_ptr<bkd::StagedBlobFile> created;
+    ASSERT_TRUE(bkd::StagedBlobFile::create("poison_boundary", &created).ok());
+    const std::string path = created->path();
+    const std::vector<uint8_t> bytes(64, 0x31);
+    ASSERT_TRUE(created->append(Slice(bytes)).ok());
+    ASSERT_TRUE(created->finalize().ok());
+
+    auto staged = std::shared_ptr<bkd::StagedBlobFile>(std::move(created));
+    std::vector<BlobFileSource> cold_files;
+    cold_files.push_back(BlobFileSource {
+            .name = "bkd_data",
+            .length = staged->bytes_written(),
+            .read_fn = [staged](uint64_t offset, size_t len, uint8_t* out) -> 
Status {
+                return staged->read_at(offset, len, out);
+            }});
+    ASSERT_TRUE(
+            writer.add_blob_index(5, "", LogicalIndexKind::kBkd, 
std::move(cold_files), {}).ok());
+    // The registered source is now the only owner, which is what a native-BKD
+    // producer leaves behind once it has handed the file over.
+    staged.reset();
+    ASSERT_TRUE(std::filesystem::exists(path));
+
+    // A later text index fails while streaming its physical sections.
+    const Status poisoned = writer.add_logical_index(MakeIndex(6, "body", 30));
+    ASSERT_FALSE(poisoned.ok());
+    ASSERT_NE(poisoned.to_string().find("injected append failure"), 
std::string::npos)
+            << poisoned.to_string();
+
+    // No finish() here on purpose: production does not reach one.
+    EXPECT_FALSE(std::filesystem::exists(path))
+            << "a poisoned compound kept its blob callback, pinning the 
staging file: " << path;
+}
+
 TEST(SniiCompoundWriter, ReopeningLogicalReaderClearsPreviousCommonGramsState) 
{
     auto with_common_grams = EmptyIndex(7, "with");
     with_common_grams.common_grams_metadata = CompleteCommonGramsMetadata();


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

Reply via email to