Skip to content

Clear the Vector Set index pointer on the diskless replication stream-in path - #1993

Closed
tiagonapoli wants to merge 5 commits into
mainfrom
tiagonapoli/vector-set-fixes
Closed

Clear the Vector Set index pointer on the diskless replication stream-in path#1993
tiagonapoli wants to merge 5 commits into
mainfrom
tiagonapoli/vector-set-fixes

Conversation

@tiagonapoli

Copy link
Copy Markdown
Collaborator

Problem

A Vector Set index record persists the native DiskANN handle IndexPtr in its value (offset 8 of the 56-byte VectorManager.Index). Diskless replication streams raw Tsavorite log records verbatim (ReplicationSnapshotIteratorDiskLogRecord.Serialize), so a replica taking a full sync adopts the primary's handle.

Nothing clears it on that path:

  • GarnetRecordTriggers.OnDiskRead is the only hook that zeroes IndexPtr, and it only fires for records faulted in from disk. Streamed records land directly in memory, so it never runs.
  • VectorManager.NeedsRecreate is exactly indexPtr == 0, so a foreign non-zero pointer is indistinguishable from a healthy local one and the lazy Service.RecreateIndex rebuild is skipped.
  • The raw persisted value then reaches the P/Invoke unvalidated.

In production this faults the replica with SIGSEGV inside NativeDiskANNMethods.card, and the replica crash-loops so it never converges. Where the address happens to resolve to another live index instead of unmapped memory, the read succeeds and silently answers out of the wrong vector set.

This only affects the replication path. VADD/VREM replicate vector payloads, not native bytes, so a replica that is attached before the writes rebuilds its own index locally and is fine. It takes a full sync of an already-populated Vector Set to hit this.

Fix

Clear the pointer as the record enters the replica's store, in NetworkClusterSync — the single point where streamed records are ingested:

if (diskLogRecord.RecordType == VectorManager.RecordType)
    VectorManager.ClearIndexPointer(diskLogRecord.ValueSpan);

The index is then recreated on first touch. This is the same thing OnDiskRead already does for disk-faulted records, and SetContextForMigration already does for slot migration — diskless replication was the one path that carried the handle across nodes untouched.

VectorManager.ClearIndexPointer becomes public for this; Garnet.cluster already consumes the public ReadIndex / SetContextForMigration on the migration path in the same way.

Tests

New test/cluster/Garnet.test.cluster.vectorsets/VectorSets/ClusterVectorSetDisklessSyncTests.cs:

Test Before fix After fix
VectorSetReadableOnReplicaAfterDisklessFullSync FAIL PASS
VectorSetsStayPartitionedAcrossDisklessFullSync FAIL PASS
VectorSetReadableOnReplicaWithoutFullSync (control) PASS PASS

Existing cluster Vector Set coverage cannot catch this defect: those tests go through SimpleSetupClusterAsync, which never passes enableDisklessSync, and they attach replicas before writing, so each replica builds its own index from replicated VADD payloads.

Why the assertion is structural rather than behavioural. Cluster tests run every node inside a single process, so the primary's handle is still mapped and valid when the replica dereferences it. The replica quietly answers out of the primary's live index and every black-box read returns the correct answer — the SIGSEGV cannot be reproduced in-process, it needs two address spaces. The tests therefore assert the invariant that is actually violated: a node may only dereference a handle it allocated itself. Against unfixed main the replica holds the byte-identical pointer:

replica persisted the primary's DiskANN handle for '{vsdisk}solo' (0x2c28269b490)
Expected: not equal to 3034434876560
But was:  3034434876560

The record is read via Read_MainStore rather than VectorManager.ReadVectorIndex, because the latter consults NeedsRecreate and would rebuild the index, rewriting the field under test.

The tests also assert recoverFullSync:True from the sync metadata, so they cannot pass vacuously by silently degrading into an incremental AOF replay. The no-full-sync control passes throughout and pins the full sync down as the cause.

Garnet.test.cluster.vectorsets is added to InternalsVisibleTo so the test can read the persisted record.

Validation

  • 3/3 new tests fail on unfixed main and pass with the fix.
  • Garnet.test.cluster.vectorsets — 85/85 pass.
  • Garnet.test.cluster.replication.disklesssync — 29/29 pass.
  • Clean Release build across both target frameworks, 0 warnings.

Background

Found while reproducing a customer report on a live 1-shard / 3-replica cluster, where replicas were terminating with status=11/SEGV and crash-looping. The faulting managed stack from a captured minidump was NativeDiskANNMethods.cardStorageSession.VectorSetInfoRespServerSession.NetworkVINFO.

The lifetime of a Vector Set index across primary, replica, AOF and full sync was then modelled in TLA+ to confirm the mechanism and to choose between candidate fixes. TLC reproduced the production crash as a 6-step counterexample (SyncResetPrimaryCreateSyncSnapshotBeginReadDeref) and, across seven scenarios, showed that:

  • sanitizing on the stream-in path (this PR) is sufficient on its own;
  • validating the pointer at the call site is also sufficient, but strictly more invasive;
  • quiescing readers around the reset is not sufficient — the handle was never valid in this address space, so this is not a locking race. The per-key vectorSetLocks are already correct.

The three tests above are the executable form of those counterexamples.

Tiago Martins Napoli added 2 commits July 29, 2026 11:06
Diskless replication streams raw Tsavorite log records verbatim, and a
Vector Set's index record persists the native DiskANN handle IndexPtr in
its value. A replica taking a diskless full sync therefore ends up with
the primary's handle in its own copy of the record.

Nothing clears it on that path. GarnetRecordTriggers.OnDiskRead is the
only hook that zeroes IndexPtr and it only fires for records faulted in
from disk, never for records streamed straight into memory. Since
VectorManager.NeedsRecreate is exactly indexPtr == 0, a foreign non-zero
pointer is indistinguishable from a healthy local one, so the lazy
recreate is skipped and the raw value reaches the P/Invoke.

Existing cluster Vector Set coverage cannot catch this. Those tests go
through SimpleSetupClusterAsync, which never enables diskless sync, and
they attach replicas before writing, so each replica builds its own index
from replicated VADD payloads.

The assertion is structural rather than behavioural because cluster tests
run every node in one process, where the primary's handle is still mapped
and valid. The replica silently answers out of the primary's live index
and every black-box read looks correct; the same aliasing only becomes
the observed SIGSEGV once the nodes are separate processes.

Tests assert that the replica took an actual streaming full sync, so they
cannot pass vacuously by degrading to an incremental AOF replay. The
no-full-sync control passes and pins the full sync down as the cause.

Grants Garnet.test.cluster.vectorsets access to Garnet.server internals so
the persisted record can be read without going through ReadVectorIndex,
which would lazily rebuild the index and rewrite the field under test.
…-in path

A Vector Set index record persists the native DiskANN handle IndexPtr in
its value, and diskless replication streams raw Tsavorite log records
verbatim. A replica taking a full sync therefore adopted the primary's
handle and, because VectorManager.NeedsRecreate is exactly indexPtr == 0,
treated it as a healthy local index: the lazy rebuild was skipped and the
foreign address was handed to DiskANN. In production that faults the
replica with SIGSEGV; where the address happens to resolve to another live
index it silently answers out of the wrong one.

Clear the pointer as the record enters the replica's store, so the index is
recreated on first touch. This is the same thing OnDiskRead already does
for records faulted in from disk, and SetContextForMigration already does
for slot migration. Diskless replication was the one path that carried the
handle across nodes untouched.

ClearIndexPointer becomes public for this; Garnet.cluster already consumes
the public ReadIndex and SetContextForMigration on the migration path.

Verified against the TLA+ model in the companion investigation: sanitizing
the stream-in path is the FixSanitize scenario, which TLC showed to be
sufficient on its own. Validating the pointer at the call site was also
sufficient but strictly more invasive, and quiescing readers was NOT
sufficient, since the handle was never valid in this address space.

Turns the three ClusterVectorSetDisklessSyncTests green. Full
Garnet.test.cluster.vectorsets (85) and
Garnet.test.cluster.replication.disklesssync (29) suites pass.
/// <c>MC_Vec_QuiesceOnly_Buggy</c> scenario still failed, which is why none of these tests need to
/// race anything: a quiet, fully serialized read after the sync completes is enough.
/// </para>
/// </summary>

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.

reduce verbosity - too many comments
no need to say that was a regression test

…efects

New test project covering Vector Set replication end to end: diskless full
sync, disk-based checkpoint full sync, restart/recovery, failover and async
replay. Assertions verify the replica actually holds its own DiskANN index
(not the primary's raw handle) and that every VADD'd element and vector
payload is present, via VINFO/VDIM/VEMB/VSIM rather than index pointers alone.

Fixes two product defects the new tests uncovered:

1. AofProcessor.Dispose() permanently shut down VectorManager's replication
   replay channel. AofProcessor is transient during startup AOF replay but
   VectorManager is owned by the database, so after a --recover start every
   replicated VADD was silently discarded. Dispose now only drains.

2. VectorManager's recovery state (recoveredIndexes/recoveredMetadata) was
   released after the startup recovery, but a replica re-recovers the store on
   every disk-based full sync. The second recovery threw NullReferenceException
   inside the record-trigger walk, aborting it after the first record, so the
   remaining Vector Set records kept the primary's raw DiskANN handle. State is
   now cleared instead of released, and ResumePostRecovery runs after a
   replica's disk-based recovery.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9cdb19eb-7ffa-44cb-ab05-566f44d79d70
// clears it on this path: GarnetRecordTriggers.OnDiskRead only fires for records
// faulted in from disk. Left alone, VectorManager.NeedsRecreate would see a
// non-zero pointer, skip the rebuild, and hand a foreign address to DiskANN.
if (diskLogRecord.RecordType == VectorManager.RecordType)

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.

Add RangeIndex management for this as well

Tiago Martins Napoli and others added 2 commits July 29, 2026 15:04
- ClusterVectorSetAsyncReplayTests: 150/4 truncates to 37, so the interleaved
  loop wrote 148 elements while the assertion expected 150. Per-round counts
  are now the constants and the totals are derived from them.

- AssertSearchesAgree demanded identical VSIM result lists. A replica that
  rebuilt its index has a DiskANN graph built in a different insertion order,
  so an approximate search legitimately diverges in the tail. It now checks
  what must actually hold: an exact query for an element's own embedding
  returns that element, every neighbour is a real member of the set, and at
  least half of a random query's neighbourhood agrees with the primary.

- PrimaryRebuildsVectorSetIndexAfterRestart waited for a replica whose
  replication link died with the old primary process. The post-restart
  replication leg now uses a spare node that attaches to the recovered
  primary, which is the case the test is actually about.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9cdb19eb-7ffa-44cb-ab05-566f44d79d70
Collapse the multi-paragraph doc comments across the Vector Set replication
suite and the accompanying product fixes down to one to three lines each,
keeping only the constraints that are not evident from the code.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9cdb19eb-7ffa-44cb-ab05-566f44d79d70
@tiagonapoli

Copy link
Copy Markdown
Collaborator Author

Split into three stacked PRs, one per defect, each with its own tests: #1995 (diskless stream-in), #1996 (recovery re-entrancy), #1997 (AOF replay channel).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant