Conversation
This comment was marked as outdated.
This comment was marked as outdated.
Greptile SummaryThis PR replaces the old The implementation resolves several prior issues:
Critical remaining issues:
Confidence Score: 2/5
Important Files Changed
|
This comment was marked as resolved.
This comment was marked as resolved.
Co-authored-by: yinzefeng.yzf <yinzefeng.yzf@alibaba-inc.com>
Changed 'your-org' placeholder to 'alibaba' in the git clone command to reflect the correct repository URL.
…BUTING (#150) - README.md: remove spurious space in align=" center" → align="center" (logo was not centered on GitHub due to invalid HTML attribute value) - CONTRIBUTING.md: correct Python prerequisite from '>= 3.9' to '3.10 - 3.12' to match pyproject.toml classifiers and CI matrix (cp310, cp312)
|
@greptile |
|
@greptile |
|
@greptile |
| void acquire_one(block_id_t block_id); | ||
|
|
||
| private: | ||
| VecBufferPool &pool; |
There was a problem hiding this comment.
use pool_ as member variable
| class VecBufferPoolHandle { | ||
| public: | ||
| VecBufferPoolHandle(VecBufferPool &pool) : pool(pool) {} | ||
| VecBufferPoolHandle(VecBufferPoolHandle &&other) : pool(other.pool) {} |
There was a problem hiding this comment.
use std::move to avoid copy
There was a problem hiding this comment.
pool_ is a reference member.
src/core/utility/buffer_storage.cc
Outdated
| IndexFormat::MetaHeader header_{}; | ||
| IndexFormat::MetaFooter footer_{}; | ||
| std::map<std::string, IndexMapping::SegmentInfo> segments_{}; | ||
| std::map<std::string, size_t> id_hash_{}; |
There was a problem hiding this comment.
try unordered hashmap?
src/core/utility/buffer_storage.cc
Outdated
| get_buffer_handle(offset, sizeof(footer_)); | ||
| void *buffer = footer_handle.pin_vector_data(); | ||
| int ParseFooter(size_t offset) { | ||
| char *buffer = new char[sizeof(footer_)]; |
There was a problem hiding this comment.
std::unique_ptr<char[]> buffer(new char[sizeof(footer_)]);
src/core/utility/buffer_storage.cc
Outdated
| get_buffer_handle(offset, sizeof(header_)); | ||
| void *buffer = header_handle.pin_vector_data(); | ||
| int ParseHeader(size_t offset) { | ||
| char *buffer = new char[sizeof(header_)]; |
There was a problem hiding this comment.
std::unique_ptr<char[]> buffer(new char[sizeof(footer_)]);
src/ailego/buffer/buffer_pool.cc
Outdated
| char *LPMap::set_block_acquired(block_id_t block_id, char *buffer) { | ||
| assert(block_id < entry_num_); | ||
| Entry &entry = entries_[block_id]; | ||
| if (entry.ref_count.load(std::memory_order_relaxed) >= 0) { |
There was a problem hiding this comment.
judge the return value of fetch_add?
|
@greptile |
|
@greptile |
|
@greptile |
1 similar comment
|
@greptile |
resolve #64
Greptile Summary
This PR replaces the
BufferManager-based storage backend with a new purpose-builtVecBufferPool— a file-backed, LRU-evicting, ref-counted buffer pool using a lock-free concurrent queue (moodycamel::ConcurrentQueue). It also wires in propercontext->reset()calls afterAdd()andSearch()operations, fixes result-clearing inFlatStreamerContextandHnswContext, and adaptsNeighborsto use copy semantics for the newMemoryBlocktype.Key changes and findings:
open()fails for empty indices: When the index file has no segments,max_segment_size_remains0, causingbuffer_pool_->init(buffer_size_, 0, 0)to return-1on theblock_size == 0guard, breakingopen()entirely for newly-created indices.fetch()/read(const void**): Both methods return0when the buffer pool fails to supply a buffer, making it impossible for callers to distinguish an I/O error from a legitimate zero-byte read.MemoryBlockholds a dangling raw pointer:data.reset(owner_->buffer_pool_handle_.get(), ...)stores a raw pointer from ashared_ptr. Ifclose_index()resetsbuffer_pool_handle_while aMemoryBlockis still alive, the stored pointer becomes dangling and subsequentrelease_one()calls in theMemoryBlockdestructor are undefined behaviour.fstatfailure, hardcoded pool size, wrong param key,release()vsreset(), null-pointer arithmetic) have been correctly addressed in this revision.Confidence Score: 2/5
open()can fail unconditionally for empty indices, and theMemoryBlockdangling pointer is a latent use-after-free.shared_ptr<VecBufferPoolHandle>and the raw pointer stored inMemoryBlock. The last one is a use-after-free waiting to happen in any shutdown-under-load scenario. The PR has addressed many prior review findings, but these new issues need to be fixed before merging.src/core/utility/buffer_storage.ccrequires the most attention (all three new issues originate here);src/include/zvec/core/framework/index_storage.hshould be updated to store ashared_ptrrather than a raw pointer inMemoryBlock.Important Files Changed
recycle()is still called without holdingmutex_inacquire_buffer().block_size_issize_twith default initializer).VecBufferPool::lp_map_ispublic, exposing internal LPMap state directly.open()fails for empty indices whenmax_segment_size_==0;fetch()andread()silently return0on buffer pool failure;MemoryBlockstores a raw pointer fromshared_ptr::get()that can become dangling afterclose_index().MemoryBlockstruct with MBT_MMAP and MBT_BUFFERPOOL variants, correct copy/move semantics and RAII ref-count management. The raw pointer stored frombuffer_pool_handle_.get()can dangle if the storage is closed while a MemoryBlock is live (see buffer_storage.cc comment).Neighborsconstructor from move-semanticMemoryBlock&&to const-referenceconst MemoryBlock&, necessary for the new ref-counted MemoryBlock model.context->reset()calls on all exit paths ofAdd()andSearch()to properly clear per-context state and release any held MemoryBlocks.Sequence Diagram
sequenceDiagram participant C as Caller participant BS as BufferStorage participant WS as WrappedSegment participant BPH as VecBufferPoolHandle participant BP as VecBufferPool participant LPM as LPMap C->>BS: open(path) BS->>BP: new VecBufferPool(path) BS->>BPH: get_handle() BS->>BS: ParseToMapping() → fills segments_, max_segment_size_ BS->>BP: init(buffer_size_, max_segment_size_, segments_.size()) BP->>LPM: init(segment_count+10) C->>BS: get(segment_id) BS-->>C: WrappedSegment C->>WS: read(offset, MemoryBlock, len) WS->>BPH: get_block(buffer_offset, capacity_, segment_id_) BPH->>BP: acquire_buffer(block_id, offset, size, 5) BP->>LPM: acquire_block(block_id) alt block cached LPM-->>BP: char* (ref_count++) else block not cached BP->>BP: free_buffers_.try_dequeue() BP->>BP: pread(fd_, buffer, size, offset) BP->>LPM: set_block_acquired(block_id, buffer) LPM-->>BP: placed_buffer end BP-->>BPH: char* BPH-->>WS: char* WS->>WS: data.reset(handle.get(), segment_id_, raw+offset) WS-->>C: MemoryBlock (holds ref) C->>C: use MemoryBlock C->>C: ~MemoryBlock() note over C: destructor calls release_one() C->>BPH: release_one(block_id) BPH->>LPM: release_block(block_id) note over LPM: ref_count-- → if 0, enqueue to LRU cacheComments Outside Diff (11)
src/core/utility/buffer_storage.cc, line 75-93 (link)Reference count leaked on every
fetch()callget_buffer()callsacquire_buffer()→acquire_block(), which unconditionally performs aref_count.fetch_add(1). Thefetch()function copies the data and returns, but never callsrelease_block()to decrement the reference. Every call tofetch()permanently increments the block's reference count by one.Consequences:
release_blockadds it to the LRU only when ref_count drops to 0).ref_countwill grow unboundedly until signed-integer overflow, which is undefined behaviour in C++.free_buffers_, so the pool gradually starves.The fix is to call
release_blockafter copying the data. The simplest approach is to mirror the RAII pattern already used inread(MemoryBlock &):Or use an explicit scope guard that calls
release_block. The same problem affectsread(size_t, const void**, size_t)(line 96–113): it also acquires a reference viaget_buffer()and returns a raw pointer without ever releasing the reference.src/core/utility/buffer_storage.cc, line 96-113 (link)Reference count leaked in
read(void**)pathSame issue as
fetch():get_buffer()increments the block's reference count throughacquire_block(), but thisreadoverload returns a raw*datapointer and never callsrelease_block().The pointer returned to the caller remains valid indefinitely (the block cannot be evicted because ref_count ≥ 1 forever), which accidentally provides safety for the raw pointer access. However:
Consider using the
MemoryBlock-based overload internally, or add an explicitrelease_blockcall after using the pointer.src/include/zvec/core/framework/index_storage.h, line 54-57 (link)acquire_oneignores its return value — silent use-after-free risk in copy assignmentVecBufferPoolHandle::acquire_one()forwards toLPMap::acquire_block(), which returnsnullptrif the block has been concurrently evicted (i.e. itsref_counthas been set toINT_MIN).acquire_onediscards this return value entirely:This is exercised in both the copy constructor (line 56) and copy-assignment operator (line 88) of
MemoryBlock. In the copy-assignment path,reset()releases the old reference first (decrementing ref_count), potentially allowing another thread to evict the block between therelease_oneandacquire_onecalls. If the eviction succeeds,acquire_blockreturnsnullptr— butacquire_oneignores this. TheMemoryBlocknow stores a pointer to a buffer that may have been recycled and overwritten, causing a silent use-after-free whendata()is dereferenced.At minimum,
acquire_oneshould check the return value and propagate failure to the caller.src/core/utility/buffer_storage.cc, line 199-202 (link)max_segment_size_of zero causesbuffer_pool_->init()to allocate only 10 buffersmax_segment_size_is computed insideParseSegment. If the index file contains no segments (e.g. a freshly-created index),max_segment_size_remains0and is passed asblock_sizetobuffer_pool_->init():ret = buffer_pool_->init(buffer_size_, max_segment_size_, segments_.size());Inside
init():This causes
open()to return a failure for any empty index, making it impossible to open a freshly-initialised index viaBufferStorage. This was not a problem with the previousMMapFilestorage path because it did not require a pre-computed block size at open time.src/core/utility/buffer_storage.cc, line 75-82 (link)fetch()is a non-functional stub — silently discards all datafetch()has been replaced withreturn 0, meaning it never copies anything intobuf. This is a critical regression:memory_read_storage.cc:78callsblock_->fetch(...)expecting data to be written intobuf, and will now silently receive zero bytes. Any code path that reads segment data viafetch()will operate on uninitialised memory.The full implementation that bounds-checks
offset + lenand reads from the buffer pool (analogous to theread(MemoryBlock&...)overload below) must be restored.src/core/utility/buffer_storage.cc, line 80-83 (link)read(size_t, const void**, size_t)is a non-functional stub — callers receive stale/null pointersThis overload now unconditionally returns 0 without writing to
*data. Every call site that uses the pointer-return variant is now broken:src/core/algorithm/flat/flat_searcher.cc:52,142—mapping/keys_pointers left uninitialisedsrc/core/algorithm/hnsw/hnsw_searcher_entity.cc:247,259—dataleft uninitialised, leading to UB on dereferencesrc/core/algorithm/hnsw_sparse/hnsw_sparse_searcher_entity.cc:258,270— same issuesrc/core/framework/index_helper.cc:83— version data will be garbageAll of these callers check the return value (
!= len) and proceed to dereference*datawhen they believe the read succeeded. Since this stub always returns 0 (≠len), the callers will at minimum log an error and bail — but any caller that does not strictly check might proceed with a stale or null pointer, causing undefined behaviour.A concrete implementation that reads from the buffer pool — mirroring the
read(MemoryBlock&...)overload — must be provided, or callers must be migrated to theMemoryBlockvariant.src/include/zvec/core/framework/index_storage.h, line 154-155 (link)Dangling pointer when
close_index()races with liveMemoryBlockdestructorsbuffer_pool_handle_is stored as a raw non-owning pointer. It points to theVecBufferPoolHandleobject whose lifetime is controlled bystd::shared_ptr<VecBufferPoolHandle> buffer_pool_handle_inBufferStorage.When
close_index()is called (e.g., fromclose()or the destructor), it callsbuffer_pool_handle_.reset(), which destroys theVecBufferPoolHandle. If anyMemoryBlockinstance (e.g., inside aNeighborsstruct still held by a caller) outlives that reset, its storedbuffer_pool_handle_raw pointer is now dangling. TheMemoryBlockdestructor then callsbuffer_pool_handle_->release_one(buffer_block_id_)on that dangling pointer — undefined behaviour that may crash or silently corrupt memory.Consider having
MemoryBlockhold astd::shared_ptr<VecBufferPoolHandle>instead of a raw pointer to prevent the handle from being destroyed while any block is still pinned.src/core/utility/buffer_storage.cc, line 164-174 (link)buffer_pool_->init()called withmax_segment_size_which can be 0max_segment_size_is only updated inside theParseSegmentloop, so it remains0if the index file contains no data-carrying segments (e.g., a freshly created index with only the version segment, or an empty index). Passing0asblock_sizetoVecBufferPool::init()hits the guardif (block_size == 0) { return -1; }, causingopen()to return a hard error and making the index unopenable.Additionally, even for valid indices, the
max_segment_size_is only the size of the largest segment. Segments smaller than the max will waste an entiremax_segment_size_-sized buffer slot per read. Consider validatingmax_segment_size_ > 0before callinginit()and returning a clear error if the file has no usable segments.src/core/utility/buffer_storage.cc, line 199-201 (link)open()fails for empty or newly-created indicesmax_segment_size_is initialized to0and is only updated inside theforloop inParseSegment(). If the index file contains no segments (e.g., a freshly written index before any data is added),max_segment_size_remains0afterParseToMapping()returns. This causesbuffer_pool_->init()to be called withblock_size == 0, which hits the explicit guard atbuffer_pool.cc:187and returns-1, makingopen()fail entirely.A guard (or a fallback minimum block size) is needed before calling
init():Alternatively,
init()could fall back to a sensible minimum block size instead of hard-failing whenblock_size == 0.src/core/utility/buffer_storage.cc, line 87-89 (link)fetch()andread(const void**)silently return0on buffer pool failureWhen
get_buffer()returnsnullptr(pool exhausted or I/O error), bothfetch()(line 88) andread(const void**, size_t)(line 109) return0. Since these methods have asize_treturn type, callers that use the patternif (returned_len != requested_len)to detect errors will see0 != lenand may interpret this as a partial/truncated read rather than a hard I/O error — potentially producing silent data corruption downstream.The same
return 0pattern also appears at line 109 inread(size_t, const void**, size_t).These methods should propagate a clear failure signal. For
fetch(), returning0can be misread as "zero bytes available". Consider returning a sentinel like(size_t)-1on error, consistent with POSIXread()semantics, or changing the return type toint/ssize_tfor error-aware callers. At minimum, add aLOG_ERRORat these early-exit paths so the failure is visible.src/core/utility/buffer_storage.cc, line 131 (link)MemoryBlockstores a dangling raw pointer when storage is closedowner_->buffer_pool_handle_.get()returns a rawVecBufferPoolHandle*from theshared_ptr. TheMemoryBlockstores this raw pointer and callsbuffer_pool_handle_->release_one(buffer_block_id_)in its destructor.In
close_index()(line 468),buffer_pool_handle_.reset()destroys theVecBufferPoolHandleobject. If anyMemoryBlockreturned byread()is still alive at that point (e.g., held by a caller), the raw pointer stored in theMemoryBlockbecomes dangling. When theMemoryBlockdestructor subsequently callsrelease_one(), this is undefined behaviour — a use-after-free.To fix this,
MemoryBlockshould share ownership of the handle's lifetime. For example, store ashared_ptr<VecBufferPoolHandle>instead of a raw pointer, and adjust theMemoryBlockconstructors andreset()overloads accordingly. This guarantees the handle stays alive at least as long as anyMemoryBlockreferencing it.Last reviewed commit: 4231a53