Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions libs/cluster/Server/Migration/MigrateSessionKeys.cs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,20 @@ private bool MigrateKeysFromMainStore()
logger?.LogCritical("Final flush after Vector Set migration failed");
return false;
}

// Mark VectorSet keys for deletion so DeleteKeys() removes them during the DELETING phase
var sketchKeys = migrateTask.sketch.Keys;
for (var i = 0; i < sketchKeys.Count; i++)
{
if (sketchKeys[i].Item2)
continue;

var spanByte = sketchKeys[i].Item1.SpanByte;
if (indexesToMigrate.ContainsKey(spanByte.ToByteArray()))
{
sketchKeys[i] = (sketchKeys[i].Item1, true);
}
}
}

// Final cleanup, which will also delete Vector Sets
Expand Down
20 changes: 20 additions & 0 deletions libs/server/Resp/AdminCommands.cs
Original file line number Diff line number Diff line change
Expand Up @@ -751,6 +751,23 @@ private bool NetworkDebug()
return true;
}

if (command.EqualsUpperCaseSpanIgnoringCase(CmdStrings.EXISTS))
{
if (parseState.Count != 2)
{
return AbortWithWrongNumberOfArgumentsOrUnknownSubcommand(Encoding.ASCII.GetString(command),
nameof(RespCommand.DEBUG));
}

// Raw store existence check, bypassing cluster routing/EPSM
var key = parseState.GetArgSliceByRef(1);
var status = basicGarnetApi.EXISTS(key, StoreType.Main);

while (!RespWriteUtils.TryWriteInt32(status == GarnetStatus.OK ? 1 : 0, ref dcurr, dend))
SendAndReset();
return true;
}

if (command.EqualsUpperCaseSpanIgnoringCase(CmdStrings.HELP))
{
var help = new string[]
Expand All @@ -759,6 +776,9 @@ private bool NetworkDebug()
"ERROR <string>",
"\tReturn a Redis protocol error with <string> as message. Useful for clients",
"\tunit tests to simulate Redis errors.",
"EXISTS <key>",
"\tCheck if <key> exists in the raw store, bypassing cluster routing.",
"\tReturns 1 if key exists, 0 otherwise.",
"LOG <message>",
"\tWrite <message> to the server log.",
"PANIC",
Expand Down
11 changes: 11 additions & 0 deletions libs/server/Resp/Vector/VectorManager.ContextMetadata.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ private struct HashSlots
[FieldOffset(32)]
private HashSlots slots;

/// <summary>
/// Returns the number of contexts currently marked as in use.
/// </summary>
public readonly int InUseCount => BitOperations.PopCount(inUse);

public readonly bool IsInUse(ulong context)
{
Debug.Assert(context > 0, "Context 0 is reserved, should never queried");
Expand Down Expand Up @@ -330,6 +335,12 @@ public override readonly string ToString()

private ContextMetadata contextMetadata;

/// <summary>
/// Gets the number of DiskANN contexts currently marked as in use.
/// A context stays in use until <see cref="TryDeleteVectorSet"/> completes cleanup.
/// </summary>
internal int InUseContextCount { get { lock (this) { return contextMetadata.InUseCount; } } }

/// <summary>
/// Get a new unique context for a vector set.
///
Expand Down
7 changes: 6 additions & 1 deletion test/Garnet.test.cluster/VectorSets/ClusterVectorSetTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1000,6 +1000,10 @@ public void VectorSetMigrateByKeys()
var migrateKey = toMigrate[migrateSingleIx];
context.clusterTestUtils.MigrateKeys(context.clusterTestUtils.GetEndPoint(sourceNodeIndex), context.clusterTestUtils.GetEndPoint(targetNodeIndex), [migrateKey], NullLogger.Instance);

// Verify the key was deleted from the source store using DEBUG EXISTS (bypasses cluster routing/EPSM)
var rawExists = (int)context.clusterTestUtils.Execute(context.clusterTestUtils.GetEndPoint(sourceNodeIndex), "DEBUG", ["EXISTS", migrateKey]);
ClassicAssert.AreEqual(0, rawExists, $"Key {Encoding.ASCII.GetString(migrateKey)} should not exist in raw store on source after MIGRATE KEYS");

toMigrate.RemoveAt(migrateSingleIx);
}

Expand Down Expand Up @@ -1051,7 +1055,7 @@ public void VectorSetMigrateByKeys()
// Finish migration
context.clusterTestUtils.WaitForMigrationCleanup(NullLogger.Instance);

// Validate vector sets coherent
// Validate vector sets coherent on target
for (var i = 0; i < keys.Count; i++)
{
var _key = keys[i];
Expand All @@ -1063,6 +1067,7 @@ public void VectorSetMigrateByKeys()
ClassicAssert.IsTrue(res[0].SequenceEqual(elem));
ClassicAssert.IsTrue(res[1].SequenceEqual(attrs));
}

}

[Test]
Expand Down
80 changes: 80 additions & 0 deletions test/Garnet.test/RespVectorSetTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2342,5 +2342,85 @@ private static GarnetServer CreateGarnetServer(bool tryRecover, bool enableVecto

[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "opts")]
private static extern ref GarnetServerOptions GetOpts(GarnetServer server);

/// <summary>
/// Documents a known limitation: DEL on an evicted VectorSet key does NOT
/// free the native DiskANN index or schedule element data cleanup.
///
/// <para>The only gate that redirects DEL to <c>TryDeleteVectorSet</c> is
/// <c>InPlaceDeleter</c>, which only fires when the record is in memory.
/// For evicted records, Tsavorite writes a blind tombstone — the VectorManager
/// never learns about the deletion, so the DiskANN context stays marked as
/// in-use and the native index is never freed.</para>
/// </summary>
[Test]
public void VectorSetDeleteAfterEvictionLeaksContextTest()
{
// Restart with VectorSets enabled (default memory — VectorSets need
// adequate page sizes for DiskANN element data).
// We force eviction via ShiftHeadAddress instead of lowMemory.
server.Dispose();
TestUtils.DeleteDirectory(TestUtils.MethodTestDir, wait: true);
server = TestUtils.CreateGarnetServer(TestUtils.MethodTestDir,
enableVectorSetPreview: true);
server.Start();

var vectorManager = server.Provider.StoreWrapper.DefaultDatabase.VectorManager;
var store = server.Provider.StoreWrapper.store;

// Record baseline context count before creating any VectorSets
var baselineContexts = vectorManager.InUseContextCount;

using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig());
var db = redis.GetDatabase(0);

// Step 1: Create a VectorSet and add vectors (using XB8 format)
var vec1 = new byte[75];
var vec2 = new byte[75];
vec1[0] = 1;
vec2[0] = 75;
for (var i = 1; i < 75; i++)
{
vec1[i] = (byte)(vec1[i - 1] + 1);
vec2[i] = (byte)(vec2[i - 1] + 1);
}

var addRes = (int)db.Execute("VADD", ["myvectors", "XB8", vec1, new byte[] { 0, 0, 0, 0 }, "XPREQ8"]);
ClassicAssert.AreEqual(1, addRes, "First vector should be inserted");

addRes = (int)db.Execute("VADD", ["myvectors", "XB8", vec2, new byte[] { 0, 0, 0, 1 }, "XPREQ8"]);
ClassicAssert.AreEqual(1, addRes, "Second vector should be inserted");

// Step 2: Verify context was allocated
ClassicAssert.AreEqual(baselineContexts + 1, vectorManager.InUseContextCount,
"One DiskANN context should be allocated after VADD");

// Step 3: Force eviction by shifting HeadAddress past all current records.
// First flush to read-only (required before moving HeadAddress),
// then shift HeadAddress to evict all pages to disk.
// Do NOT read the VectorSet key between eviction and DEL (reads can
// lazily recreate the native index, which would invalidate the test).
var headBefore = store.Log.HeadAddress;
store.Log.ShiftReadOnlyAddress(store.Log.TailAddress, wait: true);
store.Log.ShiftHeadAddress(store.Log.TailAddress, wait: true);

// Write a filler key so TailAddress advances past the evicted region
db.StringSet("filler", "data");

ClassicAssert.IsTrue(store.Log.HeadAddress > headBefore,
"HeadAddress should have advanced (pages were evicted)");

// Step 4: Delete the evicted key — blind tombstone, no VectorSet cleanup
var delResult = db.KeyDelete("myvectors");
// Known limitation: DEL on evicted keys returns false
ClassicAssert.IsFalse(delResult,
"DEL returns false for evicted keys (known limitation)");

// Step 5: The context is STILL in use — proves DropIndex was never called
// and the DiskANN native index was never freed.
ClassicAssert.AreEqual(baselineContexts + 1, vectorManager.InUseContextCount,
"DiskANN context should still be in use after DEL of evicted key (known limitation: " +
"InPlaceDeleter never fires for evicted records, so TryDeleteVectorSet is never called)");
}
}
}
Loading