Skip to content

[MOD-18863] Reuse hnsw internal id in updateVector - write-in-place mode - #1059

Open
nonirosenfeldredis wants to merge 8 commits into
mainfrom
sharon-MOD-18863
Open

nonirosenfeldredis wants to merge 8 commits into
mainfrom
sharon-MOD-18863

Conversation

@nonirosenfeldredis

@nonirosenfeldredis nonirosenfeldredis commented Sep 23, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

  • updateVector/updateVectors in write-in-place mode used to delete the label's existing vector(s) and re-append fresh ones - unnecessarily shuffling ids via the swap-to-last compaction a real removal uses, even though the label's vectors are being replaced 1:1 (or close to it) anyway.
  • storeNewElement gained an optional elementId to overwrite an existing slot in place instead of appending. The detach half of removeVectorInPlace splits into repairConnectionsInPlace (repair every affected neighbor's connections) and repairEntryPoint (replace the entry point if needed), called together with disposeElementData (free the element's own graph data, no compaction) by both removeVectorInPlace and a new overwriteVectorInPlace, which composes that detach + reuse-store + link under one continuous indexDataGuard lock.
  • Single-value: HNSWIndex_Single::addVector now overwrites an existing label in place via overwriteVectorInPlace instead of delete-then-append. The tiered in-place path required splitting deleteVector's flat-side cleanup into deleteFromFlatAndInsertJobs (so the backend copy is left alone for the reuse to find) and invalidating any repair job still pending against the reused id (found via a real crash under stress testing - a stale job from an earlier, unrelated async deletion would otherwise run against the new element in that slot).
  • Multi-value: updateMultiValueInPlace pairs the label's existing ids with the new blobs one for one and reuses each pair in place. Extra old ids (label shrinking, including all the way to zero - which is functionally a delete) are removed for real, with the same bookkeeping deleteLabelFromHNSWInplace already uses; extra new blobs (label growing) are freshly appended. Each reused/appended blob is preprocessed the same way any other direct-to-backend write is, so quantized/cosine backends store correct values.
  • reuseOnUpdate toggle: setReuseOnUpdate/isReuseOnUpdate let a caller fall back to the original delete-then-append behavior (default is reuse-on, matching the description above).
  • SQ8 accumulation: the in-place reuse fast path is skipped while accumulating, since training writes must stay in FLAT in both write modes - it now falls back to the ordinary delete-then-add path, which already routes through the accumulation-aware addVector/deleteVector.
  • Scope is deliberately write-in-place only; async is unaffected (still deletes + re-inserts as a fresh id, unchanged).
  • Added getLabelSize(label) to the label-lookup interface so updateMultiValueInPlace can read a label's current id count in O(1) instead of building and discarding the whole id vector via getElementIds(label).size().
  • Dropped the unused removeIdFromLabel from the label-lookup interface once popLastIdFromLabel covered every remaining id-removal call site.

Test plan

  • overwriteReusesInternalId (single-value): id stability, other labels' ids untouched, graph integrity, search correctness after overwrite.
  • updateVectorsMultiInPlaceReusesIds (multi-value): same-count (full reuse), shrinking (partial reuse + real removal), growing (partial reuse + fresh append), id/size/integrity/search checks throughout.
  • updateVectorsMultiInPlaceShrinkToZeroRemovesLabel: shrinking a multi-value label to zero vectors removes every id and the label itself.
  • updateVectorsMultiInPlaceInvalidatesPendingRepairJob: a repair job left pending against a reused id from an earlier, unrelated async deletion is invalidated before the id is overwritten, instead of later running against the new vector.
  • updateVectorsMultiInPlaceNormalizesCosine: reused and freshly-appended blobs are both preprocessed correctly for a cosine backend.
  • reuseOnUpdateToggleDisablesReuse: toggling reuse off restores the old delete-then-append id behavior, for both single- and multi-value.
  • updateVectorsMultiInPlaceDuringAccumulationStaysInFlat: an in-place multi-value update during SQ8 accumulation stays entirely in FLAT and keeps the running-sum bookkeeping correct.
  • Updated hnsw_blob_sanity_test, which explicitly encoded the old swap-to-last relocation behavior as its contract.
  • Full test_hnsw suite (374 tests) passes, stress-run 8x back-to-back with no flakes/crashes under real concurrent execution.

🤖 Generated with Claude Code


Note

High Risk
Changes core HNSW mutation, graph repair, and tiered async repair invalidation; incorrect reuse could corrupt the graph or apply stale repair jobs to wrong slots.

Overview
Write-in-place updates no longer delete a label’s vector(s) and re-append (which shuffled unrelated internal ids via swap-to-last). The HNSW layer now detaches an element from the graph without compaction (repairConnectionsAndDetach + disposeElementData), reuses the same slot via optional elementId in storeNewElement, and re-links through overwriteVectorInPlace.

Single-value addVector overwrites in place. Tiered write-in-place routes through updateVectorInPlace / updateMultiValueInPlace (pair ids with new blobs, shrink/grow, invalidate stale async repair jobs before reuse). setReuseOnUpdate(false) restores delete-then-append; SQ8 accumulation still uses the old path.

Async write mode is unchanged.

Reviewed by Cursor Bugbot for commit d5415d7. Bugbot is set up for automated code reviews on this repo. Configure here.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread src/VecSim/algorithms/hnsw/hnsw_tiered.h Outdated
@codecov

codecov Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.98496% with 8 lines in your changes missing coverage. Please review.
✅ Project coverage is 97.49%. Comparing base (fcdeb37) to head (dda24b4).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
src/VecSim/algorithms/hnsw/hnsw_single.h 38.46% 8 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1059      +/-   ##
==========================================
- Coverage   97.55%   97.49%   -0.06%     
==========================================
  Files         142      142              
  Lines        9095     9199     +104     
==========================================
+ Hits         8873     8969      +96     
- Misses        222      230       +8     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Base automatically changed from sharon-readd-label-after-delete to main September 23, 2026 13:07
nonirosenfeldredis and others added 3 commits September 23, 2026 16:28
…rnal id

addVector on an existing single-value label used to delete-then-append: the
old id went through the normal removal's swap-to-last compaction, and the
new vector landed on a fresh id - shuffling some unrelated element's id in
the process for no reason, since the label's vectors are about to be
replaced 1:1 anyway.

storeNewElement now takes an optional elementId to overwrite an existing
slot instead of appending. removeVectorInPlace splits into
repairConnectionsAndDetach (repair neighbors, replace the entry point if
needed, free the element's own graph data - no compaction) and the
compaction tail, plus a new overwriteVectorInPlace that composes detach +
reuse-store + link under one continuous lock. HNSWIndex_Single::addVector
uses it instead of delete-then-append.

For the tiered in-place path, split deleteVector's flat-side cleanup into
deleteFromFrontAndInsertJobs so addVector can drop the buffered copy without
deleting the backend copy first - which would destroy the very id we want
reused. Also invalidate any repair job still pending against the old id
before overwriting it: a stale job from an earlier, unrelated async
deletion that named this id as a neighbor would otherwise run against the
new element sitting in that slot later - found via a real crash under
stress testing (asserted on a mismatched toplevel), fixed by dropping the
job the same way deleteLabelFromHNSWInplace already does before a real
removal.

Scope is deliberately single-value and in-place only; async and multi-value
are unaffected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
updateVectors on a multi-value label, in write-in-place mode, used to
delete every one of the label's ids and re-append n fresh ones - the same
unnecessary id churn the single-value overwrite fix addressed, just with
several ids instead of one.

updateMultiValueInPlace pairs the label's existing ids with the new blobs
one for one and overwrites each pair with overwriteVectorInPlace, reusing
each id in place. If the label is shrinking, the extra old ids are removed
for real - same invalidateRepairJobs/fixJobsAfterSwap/readySwapJobs
bookkeeping deleteLabelFromHNSWInplace already uses, re-fetching the
current id set on every removal since removing one can relocate another of
the same label's own ids via the swap-to-last compaction. If it's growing,
the extra new blobs are freshly appended. updateVectors now routes
multi-value + VecSim_WriteInPlace here instead of the old delete-everything
path; async is unaffected.

Needed two small supporting changes: storeNewElement now skips
setVectorId when reusing a slot, since a multi-value label lookup appends
rather than assigns - calling it again on a reused id would have recorded
it twice. Added removeIdFromLabel, to drop one specific id from a label's
set without wiping the whole label the way removeLabel does, needed for the
shrink case where the label keeps its other ids.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…d a swap-job accumulation bug

reuseOnUpdate (default true) lets addVector/updateVectors, in write-in-place
mode, fall back to the pre-reuse delete-then-append behavior on demand -
a kill switch, not a tuning knob, following the setEf/setEpsilon precedent
of an unlocked per-index runtime setter (safe under the same "main thread
only" assumption writeMode already relies on).

Fixed a high-severity bug Cursor Bugbot caught in review: updateMultiValueInPlace
wrote raw caller blobs straight into the backend via overwriteVectorInPlace
and addVector, skipping frontendIndex->preprocessForStorage - the step every
other direct-to-backend write already does, since the backend assumes
pre-normalized input for cosine. Reused and freshly-appended blobs alike now
get preprocessed first. Added a regression test that fails without the fix
(confirmed by reverting it locally and observing the stored vector come back
unnormalized).

Also fixes a bug in executeSwapJob, extracted from executeReadySwapJobs's
inline loop during the removeAndSwapMarkDeletedElement/swapDeletedElement
split: idsToRemove was taken by value instead of by reference, so
fixJobsAfterSwap's accumulation into it never reached the caller, and
idToSwapJob was never cleaned up after a GC round - reproduced by a full
suite run (idToSwapJob stuck at 1000 entries, followed by a crash).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread src/VecSim/algorithms/hnsw/hnsw_tiered.h
nonirosenfeldredis and others added 2 commits September 23, 2026 19:38
… multi-value shrink-to-zero branch

The extracted single-value in-place overwrite helper declared a mismatched
template (`template <class, class>` against parameter types instead of
concrete types), which doesn't match the out-of-line `auto`-parameter
definition. Both now use the concrete types the class template already
determines: HNSWIndex<DataType, DistType> * and MemoryUtils::unique_blob.

Also add a regression test for updateVectors shrinking a multi-value label
to zero vectors, the one shrink outcome that exercises removeIdFromLabel's
"drop the label once its last id is gone" branch - flagged as uncovered by
codecov and not previously exercised by any test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…accumulation

updateVectors' write-in-place reuse branch called updateMultiValueInPlace
unconditionally, writing straight into the still-untrained HNSW backend and
skipping the running-sum/insert-job bookkeeping that addVector/deleteVector
maintain during SQ8 accumulation. Training writes must stay in FLAT in both
write modes, so this path is now skipped while sqAccumulationState is set,
falling back to the generic delete-then-add path which is accumulation-safe.

Adds a regression test that fails without the fix (backend gets touched
during accumulation) and passes with it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@nonirosenfeldredis nonirosenfeldredis changed the title [MOD-18863] Reuse the internal id on updateVector, in write-in-place mode [MOD-18863] Reuse hnsw internal id in updateVector, in write-in-place mode Sep 23, 2026

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit cd9bb91. Configure here.

if (state.currMaxLevel < state.elementMaxLevel) {
this->unlockIndexDataGuard();
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Failed overwrite leaves dangling graph slot

Medium Severity

overwriteVectorInPlace detaches and destroys the slot before storeNewElement allocates replacement graph data. That constructor throws on allocator failure, so the label still points at a slot whose others and incoming-edge lists are already freed. A caught low-memory error then leaves the index corrupted: later search, repair, or index teardown can use or double-free that memory. The previous delete-then-append path removed the label and compacted the slot first, so a failed re-insert did not leave a live dangling occupant.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit cd9bb91. Configure here.

@nonirosenfeldredis nonirosenfeldredis changed the title [MOD-18863] Reuse hnsw internal id in updateVector, in write-in-place mode [MOD-18863] Reuse hnsw internal id in updateVector: write-in-place mode Sep 23, 2026
@nonirosenfeldredis nonirosenfeldredis changed the title [MOD-18863] Reuse hnsw internal id in updateVector: write-in-place mode [MOD-18863] Reuse hnsw internal id in updateVector - write-in-place mode Sep 23, 2026

@dor-forer dor-forer left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very good job!
Just a few minor comments

Comment on lines +1280 to +1281
idType id = hnsw_index->getElementIds(label).back();
hnsw_index->removeIdFromLabel(label, id);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can replace these two calls with a popLastIdFromLabel(label) method on the HNSW backend interface. The multi-value implementation could use back() and pop_back() directly on its private ID list, removing the label if it becomes empty. This avoids copying the entire list in getElementIds() and then searching it again in removeIdFromLabel(), while preserving encapsulation and reading the current mapping after each compaction.
Something like:

idType popLastIdFromLabel(labelType label) override {
    auto it = labelLookup.find(label);
    assert(it != labelLookup.end());

    auto &ids = it->second;
    assert(!ids.empty());

    const idType id = ids.back();
    ids.pop_back();
    if (ids.empty()) {
        labelLookup.erase(it);
    }
    return id;
}

If it is too much of a hustle you can leave it

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

its defintly better!

blob + i * this->frontendIndex->getInputBlobSize());
hnsw_index->addVector(storage_blob.get(), label);
}
++this->directHNSWInsertions;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be directHNSWInsertions += n? The counter tracks vectors written directly to HNSW, while this helper processes n vectors. Incrementing once undercounts multi-vector updates and counts an insertion when n == 0 only deletes the label. Please cover both cases in the statistics assertions.

Comment thread tests/unit/test_hnsw_tiered.cpp Outdated
GenerateVector<TEST_DATA_T>(grown + j * dim, dim, 3000 + j);
}
ASSERT_EQ(tiered_index->updateVectors(label, grown, per_label), VecSimUpdate_OK);
ASSERT_EQ(hnsw_index->getElementIds(label).size(), per_label);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we also assert ID reuse during shrink and grow? After shrinking, check that the surviving ID belongs to the original set, then save it and verify that it remains after growing. Currently these sections would also pass with delete-and-append behavior.

idType id = old_ids[i];
// Same reasoning as the single-value overwrite in `addVector`: a repair job filed by some
// earlier, unrelated async deletion may still be pending against this id.
this->invalidateRepairJobs(id);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we add a regression test that queues a repair job for an existing multi-value ID, overwrites that ID in write-in-place mode before the job runs, then drains the queue and runs GC? Please verify that the old job is invalidated, swap bookkeeping completes, and the replacement vectors and graph remain valid.

@nonirosenfeldredis

Copy link
Copy Markdown
Collaborator Author

@dor-forer very good comments!

removeIdFromLabel had no remaining callers after popLastIdFromLabel took
over id removal during in-place updates; remove it from the interface and
both label-lookup implementations. Also fix
updateVectorsMultiInPlaceInvalidatesPendingRepairJob, which asserted a
repair job would be filed while the index was already in in-place write
mode (where deletes repair synchronously and never leave one); it now
deletes asynchronously before switching to in-place for the update, with a
third anchor label so the swap that follows doesn't disturb the id being
tested.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
nonirosenfeldredis and others added 2 commits September 29, 2026 15:12
…Place + repairEntryPoint

Callers now explicitly sequence link-repair, entry-point replacement, and
freeing the element's own graph data, instead of one function doing all
three - overwriteVectorInPlace and removeVectorInPlace each call the three
steps directly. Also updates the doc comments that referenced the old
function name and fixes a column-limit/pointer-style formatting slip in
the new definition.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…them

updateMultiValueInPlace only needed the count of a label's current ids to
compute how many to shrink by, but getElementIds(label).size() built and
threw away the whole id vector (a real copy for multi-value) to get it.
getLabelSize returns the count directly - O(1) for single-value, a size()
read with no copy for multi-value.

Also fixes a stray line-wrap/pointer-style formatting slip and clarifies
a comment in repairConnectionsInPlace from the previous commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants