fix(hnsw): correct the vector dead-node lifecycle (record on delete, clear on re-insert)#9757
Open
shaunpatterson wants to merge 1 commit into
Open
Conversation
…clear on re-insert)
The HNSW vector index keeps a per-predicate "dead-node" list (a uid array at
DataKey(<pred>__vector_dead, 1)) of deleted vectors, which removeDeadNodes uses
to strip edges pointing at them. Two correctness bugs:
(A) Deletes were never recorded. addIndexMutations read the deleted value from
pl.AllValues(txn.StartTs), but addMutationHelper has already applied the
delete by then, so the read is empty (len(data)==0) and the dead node is
never added. The value being deleted is already passed in as info.val and
was ignored. Net effect: the dead list stays empty and removeDeadNodes is a
no-op, so edges to deleted vectors are never cleaned up.
(B) The list was append-only: re-inserting (re-embedding) a previously deleted
uid never cleared it. Once (A) is fixed, a re-inserted vector would stay
marked dead, be stripped from every neighbour's edge list, and end up
orphaned — present but unreachable in search. This matters in practice: a
SET that overwrites an existing vector runs addIndexMutations with op=DEL
(old value) then op=SET (new value) in one mutation, so without (B) every
in-place re-embed would orphan its node.
Fix:
- Record on delete using info.val (the value actually being deleted).
- Clear on (re-)insert so a revived uid is no longer treated as dead.
- Both go through updateDeadNodes, an atomic read-modify-write under the
posting's write lock. A multi-edge mutation applies edges in parallel
goroutines sharing one txn (worker/draft.go) and the index rebuilder streams
with 16 goroutines, all touching this single posting; the previous unlocked
delete-path RMW could lose updates under that concurrency. The locked RMW also
reads the txn's own pending writes, so a delete+re-insert within one mutation
nets out correctly.
- recordDeadNode is idempotent (no duplicate growth on repeated deletes / Raft
re-apply).
Tests: delete records the uid; re-insert clears it; a deleted-then-reinserted
vector stays searchable; concurrent records don't lose updates (-race);
record-then-clear within one txn nets out.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The HNSW vector index maintains a per-predicate dead-node list — a uid array stored in a single posting at
DataKey(<pred>__vector_dead, 1)— thatremoveDeadNodesuses to strip edges pointing at deleted vectors. Its lifecycle has two correctness bugs that this PR fixes.Bug A — deletes are never recorded
In
addIndexMutations, the delete branch reads the deleted value frompl.AllValues(txn.StartTs):But
addMutationHelperhas already applied the delete before this runs, soAllValues(txn.StartTs)is empty (len(data)==0) and the dead node is never recorded. The value being deleted is already available asinfo.valand was simply ignored. Net effect: the dead list stays empty,removeDeadNodesis a no-op, and edges to deleted vectors are never cleaned.Bug B — the list is append-only (never cleared on re-insert)
Re-inserting (re-embedding) a previously deleted uid never removed it from the dead list. Once A is fixed, a re-inserted vector would stay marked dead, get stripped from every neighbour's edge list, and end up orphaned — present but unreachable in search.
This is not hypothetical: a
SETthat overwrites an existing vector runsaddIndexMutationswithop=DEL(old value) thenop=SET(new value) in a single mutation. So without B, every in-place re-embed would orphan its own node.Fix
info.val(the value actually being deleted).updateDeadNodes, an atomic read-modify-write under the posting's write lock. A multi-edge mutation applies its edges in parallel goroutines sharing onetxn(worker/draft.go), and the index rebuilder streams with 16 goroutines — all touching this single posting. The previous unlocked delete-path RMW could lose updates under that concurrency; the locked RMW serializes them and reads the txn's own pending writes, so a delete + re-insert within one mutation nets out correctly.recordDeadNodeis idempotent (no duplicate growth on repeated deletes / Raft re-apply).Tests (
posting/index_vector_dead_test.go)All drive the real
AddMutationWithIndex→addIndexMutationspath:TestDeletedVectorRecordedAsDead— a vfloat delete records the uid (bug A).TestReinsertedVectorClearedFromDeadList— re-insert clears it (bug B).TestReinsertedVectorIsSearchable— a deleted-then-reinserted vector is reachable in HNSW search (end-to-end).TestDeadNodeConcurrentRecord— concurrent records don't lose updates (passes under-race).TestDeadNodeSameTxnRecordThenClear— record + clear within one txn nets out (covers the SET-overwrite path).go test ./posting/ ./tok/...passes;gofmt/go vetclean.Note / future work
The dead list is a single JSON-blob posting. This PR makes its lifecycle correct and concurrency-safe, but the blob still grows O(distinct deletes) and is re-marshalled on each change. A more scalable representation (per-uid postings / a uid bitmap, enabling
index.Remove(...)) is left as a follow-up — the existing// TODO ... index.Removeis preserved.🤖 Generated with Claude Code