Skip to content

Commit a8e9924

Browse files
fix(hnsw): refresh dead-node set instead of caching it for the index lifetime
removeDeadNodes loaded the tombstoned-vector set (the persisted vecDead posting) into ph.deadNodes exactly once, on the first call, and never refreshed it (the `if ph.deadNodes == nil` guard, with a standing `// TODO add a path to delete deadNodes`). Any vector deleted after that first call stayed invisible to the neighbour filter for the lifetime of the index instance, so dead UIDs leaked back into edge lists during subsequent inserts and neighbour updates. Cache the set as an immutable snapshot tagged with the transaction read timestamp, published via an atomic.Pointer: - Correctness: a transaction with a newer StartTs reloads and observes deletions committed since the previous load. - Snapshot isolation: the shared cache only ever advances in time. If a newer snapshot is already cached, an older-ts caller is served its own ts-scoped set and does not install it, so it never observes deletions newer than its own snapshot. - Performance: a rebuild streams every key at one StartTs, so the JSON is parsed once and reused across the many removeDeadNodes calls per insert. - Concurrency: the index instance is shared across the goroutines that drive a rebuild. Publication is lock-free via atomic.Pointer + CompareAndSwap; the snapshot map is immutable after construction, so the filter reads it without synchronization. Tests cover the cross-timestamp refresh, within-snapshot stability, snapshot-isolation (older caller unaffected by a newer cached snapshot), and concurrent mixed-timestamp loads under -race. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 71f9dae commit a8e9924

3 files changed

Lines changed: 173 additions & 24 deletions

File tree

tok/hnsw/ef_recall_test.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"context"
1010
"encoding/binary"
1111
"math"
12+
"sync"
1213
"testing"
1314

1415
"github.com/stretchr/testify/require"
@@ -209,3 +210,94 @@ func TestHNSWDistanceThreshold_Cosine(t *testing.T) {
209210
require.NoError(t, err)
210211
require.Equal(t, []uint64{1}, res)
211212
}
213+
214+
// deadNodesTxn is a minimal index.Txn for exercising removeDeadNodes: Get reads
215+
// from an in-memory map and StartTs is fixed. The rest are unused no-ops.
216+
type deadNodesTxn struct {
217+
startTs uint64
218+
data map[string][]byte
219+
}
220+
221+
func (t *deadNodesTxn) StartTs() uint64 { return t.startTs }
222+
func (t *deadNodesTxn) Get(key []byte) ([]byte, error) { return t.data[string(key)], nil }
223+
func (t *deadNodesTxn) GetWithLockHeld(key []byte) ([]byte, error) { return t.data[string(key)], nil }
224+
func (t *deadNodesTxn) Find([]byte, func([]byte) bool) (uint64, error) { return 0, nil }
225+
func (t *deadNodesTxn) AddMutation(context.Context, []byte, *index.KeyValue) error {
226+
return nil
227+
}
228+
func (t *deadNodesTxn) AddMutationWithLockHeld(context.Context, []byte, *index.KeyValue) error {
229+
return nil
230+
}
231+
func (t *deadNodesTxn) LockKey([]byte) {}
232+
func (t *deadNodesTxn) UnlockKey([]byte) {}
233+
234+
// TestRemoveDeadNodesRefreshesAcrossTimestamps guards the fix for the
235+
// load-once-never-refresh bug: the dead-node set must be re-read when the
236+
// transaction timestamp advances, while staying stable within a single snapshot.
237+
func TestRemoveDeadNodesRefreshesAcrossTimestamps(t *testing.T) {
238+
ph := &persistentHNSW[float64]{vecDead: ConcatStrings("0-dead", VecDead)}
239+
deadKey := string(DataKey(ph.vecDead, 1))
240+
store := map[string][]byte{}
241+
242+
// ts=10: nothing is dead yet, so nothing is filtered.
243+
tc1 := NewTxnCache(&deadNodesTxn{startTs: 10, data: store}, 10)
244+
out, err := ph.removeDeadNodes([]uint64{1, 2, 3}, tc1)
245+
require.NoError(t, err)
246+
require.Equal(t, []uint64{1, 2, 3}, out)
247+
248+
// A delete makes uid 2 dead. Reusing the same snapshot (ts=10) must NOT see
249+
// it — reads at a fixed StartTs are snapshot-consistent.
250+
store[deadKey] = []byte("[2]")
251+
out, err = ph.removeDeadNodes([]uint64{1, 2, 3}, tc1)
252+
require.NoError(t, err)
253+
require.Equal(t, []uint64{1, 2, 3}, out)
254+
255+
// A newer transaction (ts=20) MUST observe the deletion. Before the fix the
256+
// cache was loaded once and this still returned {1,2,3}.
257+
tc2 := NewTxnCache(&deadNodesTxn{startTs: 20, data: store}, 20)
258+
out, err = ph.removeDeadNodes([]uint64{1, 2, 3}, tc2)
259+
require.NoError(t, err)
260+
require.Equal(t, []uint64{1, 3}, out)
261+
}
262+
263+
// TestRemoveDeadNodesSnapshotIsolation verifies the shared cache only advances
264+
// in time: once a newer snapshot is cached, an older-ts caller must still see
265+
// its own (older) view, not the newer set of deletions.
266+
func TestRemoveDeadNodesSnapshotIsolation(t *testing.T) {
267+
ph := &persistentHNSW[float64]{vecDead: ConcatStrings("0-dead", VecDead)}
268+
deadKey := string(DataKey(ph.vecDead, 1))
269+
270+
// Newer txn (ts=20) sees uid 2 as dead and installs the cache at ts=20.
271+
tcNew := NewTxnCache(&deadNodesTxn{startTs: 20, data: map[string][]byte{deadKey: []byte("[2]")}}, 20)
272+
out, err := ph.removeDeadNodes([]uint64{1, 2, 3}, tcNew)
273+
require.NoError(t, err)
274+
require.Equal(t, []uint64{1, 3}, out)
275+
276+
// Older txn (ts=10), at whose snapshot uid 2 is NOT yet dead, must not be
277+
// affected by the newer cached snapshot.
278+
tcOld := NewTxnCache(&deadNodesTxn{startTs: 10, data: map[string][]byte{}}, 10)
279+
out, err = ph.removeDeadNodes([]uint64{1, 2, 3}, tcOld)
280+
require.NoError(t, err)
281+
require.Equal(t, []uint64{1, 2, 3}, out)
282+
}
283+
284+
// TestLoadDeadNodesConcurrent exercises the lock-free publication path under the
285+
// race detector: many goroutines at mixed timestamps loading concurrently.
286+
func TestLoadDeadNodesConcurrent(t *testing.T) {
287+
ph := &persistentHNSW[float64]{vecDead: ConcatStrings("0-dead", VecDead)}
288+
deadKey := string(DataKey(ph.vecDead, 1))
289+
data := map[string][]byte{deadKey: []byte("[2]")}
290+
291+
var wg sync.WaitGroup
292+
for g := range 32 {
293+
wg.Add(1)
294+
go func(ts uint64) {
295+
defer wg.Done()
296+
tc := NewTxnCache(&deadNodesTxn{startTs: ts, data: data}, ts)
297+
out, err := ph.removeDeadNodes([]uint64{1, 2, 3}, tc)
298+
require.NoError(t, err)
299+
require.Equal(t, []uint64{1, 3}, out)
300+
}(uint64(10 + g%4)) // timestamps 10..13
301+
}
302+
wg.Wait()
303+
}

tok/hnsw/helper.go

Lines changed: 65 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -743,40 +743,82 @@ func (ph *persistentHNSW[T]) addNeighbors(ctx context.Context, tc *TxnCache,
743743

744744
// removeDeadNodes(nnEdges, tc) removes dead nodes from nnEdges and returns the new nnEdges
745745
func (ph *persistentHNSW[T]) removeDeadNodes(nnEdges []uint64, tc *TxnCache) ([]uint64, error) {
746-
// TODO add a path to delete deadNodes
747-
if ph.deadNodes == nil {
748-
data, err := getDataFromKeyWithCacheType(ph.vecDead, 1, tc)
749-
if err != nil && !errors.Is(err, errFetchingPostingList) {
750-
return []uint64{}, err
751-
}
752-
753-
var deadNodes []uint64
754-
if data != nil { // if dead nodes exist, convert to []uint64
755-
deadNodes, err = ParseEdges(string(data))
756-
if err != nil {
757-
return []uint64{}, err
758-
}
759-
}
760-
761-
ph.deadNodes = make(map[uint64]struct{})
762-
for _, n := range deadNodes {
763-
ph.deadNodes[n] = struct{}{}
764-
}
746+
deadNodes, err := ph.loadDeadNodes(tc)
747+
if err != nil {
748+
return []uint64{}, err
765749
}
766-
if len(ph.deadNodes) == 0 {
750+
if len(deadNodes) == 0 {
767751
return nnEdges, nil
768752
}
769753

770-
var diff []uint64
754+
diff := make([]uint64, 0, len(nnEdges))
771755
for _, s := range nnEdges {
772-
if _, ok := ph.deadNodes[s]; !ok {
756+
if _, ok := deadNodes[s]; !ok {
773757
diff = append(diff, s)
774-
continue
775758
}
776759
}
777760
return diff, nil
778761
}
779762

763+
// loadDeadNodes returns the set of tombstoned (deleted) vector UIDs visible at
764+
// the cache's read timestamp.
765+
//
766+
// The dead set is persisted as a single posting (DataKey(vecDead, 1)) that grows
767+
// as vectors are deleted. It used to be loaded once and never refreshed, so any
768+
// vector deleted after the first call stayed invisible to the neighbour filter
769+
// for the lifetime of the index instance — dead UIDs leaked back into edge
770+
// lists. We instead cache it as an immutable snapshot tagged with its read
771+
// timestamp: a rebuild streams every key at a single StartTs, so the JSON is
772+
// parsed once and reused across the many removeDeadNodes calls per insert, while
773+
// later transactions (a newer StartTs) reload and observe new deletions.
774+
//
775+
// The shared cache only ever advances in time. A transaction never observes
776+
// deletions newer than its own snapshot: if a newer snapshot is already cached,
777+
// the caller gets its own freshly-loaded set without overwriting the cache. The
778+
// returned map is immutable, so callers read it without locking.
779+
func (ph *persistentHNSW[T]) loadDeadNodes(tc *TxnCache) (map[uint64]struct{}, error) {
780+
ts := tc.Ts()
781+
if cur := ph.deadNodes.Load(); cur != nil && cur.ts == ts {
782+
return cur.set, nil
783+
}
784+
785+
data, err := getDataFromKeyWithCacheType(ph.vecDead, 1, tc)
786+
if err != nil && !errors.Is(err, errFetchingPostingList) {
787+
return nil, err
788+
}
789+
790+
var deadNodes []uint64
791+
if data != nil { // if dead nodes exist, convert to []uint64
792+
deadNodes, err = ParseEdges(string(data))
793+
if err != nil {
794+
return nil, err
795+
}
796+
}
797+
798+
loaded := make(map[uint64]struct{}, len(deadNodes))
799+
for _, n := range deadNodes {
800+
loaded[n] = struct{}{}
801+
}
802+
snap := &deadSnapshot{ts: ts, set: loaded}
803+
804+
for {
805+
cur := ph.deadNodes.Load()
806+
switch {
807+
case cur != nil && cur.ts == ts:
808+
// A concurrent loader installed our snapshot first; reuse it.
809+
return cur.set, nil
810+
case cur != nil && cur.ts > ts:
811+
// A newer snapshot is cached. Serve our own ts-scoped set without
812+
// installing it, so older readers keep snapshot isolation.
813+
return loaded, nil
814+
default:
815+
if ph.deadNodes.CompareAndSwap(cur, snap) {
816+
return loaded, nil
817+
}
818+
}
819+
}
820+
}
821+
780822
func Uint64ToBytes(key uint64) []byte {
781823
b := make([]byte, 8)
782824
binary.BigEndian.PutUint64(b, key)

tok/hnsw/persistent_hnsw.go

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"context"
1010
"fmt"
1111
"strings"
12+
"sync/atomic"
1213
"time"
1314

1415
c "github.com/dgraph-io/dgraph/v25/tok/constraints"
@@ -31,7 +32,21 @@ type persistentHNSW[T c.Float] struct {
3132
// nodeAllEdges[65443][1][3] indicates the 3rd neighbor in the first
3233
// layer for UUID 65443. The result will be a neighboring UUID.
3334
nodeAllEdges map[uint64][][]uint64
34-
deadNodes map[uint64]struct{}
35+
36+
// deadNodes caches the tombstoned (deleted) vector set — the persisted
37+
// vecDead posting — as an immutable snapshot tagged with the read timestamp
38+
// it was loaded at, so it is refreshed when the snapshot advances (it used to
39+
// be loaded once and never refreshed; see loadDeadNodes). Published
40+
// atomically because the index instance is shared across the goroutines that
41+
// drive an index rebuild.
42+
deadNodes atomic.Pointer[deadSnapshot]
43+
}
44+
45+
// deadSnapshot is an immutable view of the dead-node set as of a read timestamp.
46+
// It is never mutated after construction, so readers use set without locking.
47+
type deadSnapshot struct {
48+
ts uint64
49+
set map[uint64]struct{}
3550
}
3651

3752
func GetPersistantOptions[T c.Float](o opt.Options) string {

0 commit comments

Comments
 (0)