Fix root causes behind recurring Garnet .NET CI flakes - #2110
Fix root causes behind recurring Garnet .NET CI flakes#2110Badrish Chandramouli (badrishc) wants to merge 3 commits into
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
Recovery can index beyond rebuilt metadata, waiter release has a lost-release race, and late node disposals may remain unstarted.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Fixes recurring low-core CI flakes across Vector Set recovery/migration, exception injection, and cluster teardown.
Changes:
- Reconciles and remaps Vector Set contexts.
- Hardens shutdown and cluster-test cleanup.
- Isolates affected StackExchange.Redis assertions and reduces expensive tests.
File summaries
| File | Description |
|---|---|
TestUtils.cs |
Adds dedicated-thread Redis exception assertions. |
VectorSetRecoveredContextReservationTests.cs |
Tests context reservation after recovery. |
VectorSetOverwriteTests.cs |
Splits costly SET scenarios. |
RespVectorSetTests.cs |
Uses isolated exception assertions. |
ExceptionInjectionShutdownTests.cs |
Tests disposal with parked injection waiters. |
LuaScriptTests.cs |
Uses isolated exception assertions. |
ClusterTestContext.cs |
Adds bounded per-node disposal handling. |
ClusterReplicationBaseTests.cs |
Waits for slot ownership convergence. |
ClusterMigrateTests.cs |
Waits for bidirectional gossip convergence. |
VectorManager.Replication.cs |
Remaps migrated contexts locally. |
VectorManager.cs |
Restores recovered context reservations. |
VectorManager.ContextMetadata.cs |
Clears remaps during flush. |
GarnetServer.cs |
Releases parked waiters during shutdown. |
ExceptionInjectionHelper.cs |
Adds epoch-based waiter release. |
Review details
- Files reviewed: 14/14 changed files
- Comments generated: 3
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| if (IsEnabled(exceptionType)) | ||
| { | ||
| int epochOnArrival; | ||
| lock (@lock) | ||
| { | ||
| epochOnArrival = releaseEpoch; | ||
| } |
| var (contextIndex, contextValue) = ContextMetadata.DecomposeContext(context); | ||
|
|
||
| // The index record is written before the context metadata that reserves its context, so a | ||
| // recovery boundary between the two leaves a live index record pointing at a free context. | ||
| // Reserving it here keeps the context from being handed to a different Vector Set. | ||
| if (!contextMetadatas[contextIndex].IsInUse(contextIndex != 0, contextValue)) |
| var disposeTask = Task.Run(() => node.Dispose(true)); | ||
| var wait = DisposeClusterBudget - elapsed.Elapsed; | ||
|
|
4a8fa97 to
3b1d993
Compare
|
Worked through all three from first principles. Two were legitimate and are fixed; one is a pre-existing property of 1.
|
| Suite | Result |
|---|---|
Garnet.test.vectorset |
400 / 400 |
Garnet.test.scripting |
619 passed, 30 skipped |
Garnet.test.cluster |
156 / 156 |
Garnet.test.cluster.replication |
107 / 107 |
Garnet.test.cluster.vectorsets |
84 / 84 |
Garnet.test.cluster.replication.vectorsets |
7 / 7 |
Garnet.test.cluster.migrate |
56 / 56 |
Build clean with 0 warnings; dotnet format reports no changes.
3b1d993 to
3482e48
Compare
Triaged the last 60 "Garnet .NET CI" runs on main (39 failed, 68 failed-job
logs) and bucketed every failure by root cause. CI runners have 2 cores, so
each failure was reproduced locally under `taskset -c 0-1`; that is why these
do not reproduce on high-core dev machines. Every change below addresses the
underlying defect rather than relaxing a timeout, adding a retry, or weakening
an assertion.
Product fixes
-------------
Vector Set contexts are allocated independently per node and index creation is
not AOF-logged, so on slot migration the destination reserved a context from
its own free list and stamped it into the AOF migrate records. Replay adopted
that context verbatim, guarding only on IsMigrating and never on IsInUse.
ContextMetadata.MarkInUse only asserted, which is compiled out in Release - the
configuration the failing CI job runs - so two Vector Sets silently shared one
namespace and destroyed each other's data. Migrated records are now remapped
onto a context that is free locally, and the mapping is held for the duration
of the migration. Element records are provably transmitted, applied, and
acknowledged before the index key is sent (Task.WhenAll barrier in
MigrateSessionSlots.CreateAndRunMigrateTasksAsync), so the mapping can be
released when the index key arrives. A/B over 10 iterations: 12 context
collisions, 748 records destroyed and 5 exceptions before, none after.
A Vector Set's index record is written before the context metadata that reserves
its context, and that metadata is only flushed once index creation succeeds. A
checkpoint taken between the two captures a live index record whose context is
not marked in use, so the free list hands the same context to the next Vector
Set created and the two silently share a namespace - the same corruption class
as the migration defect above. ReconcileRecoveredState now restores the
reservation for every recovered index whose context is free. The hash slot the
reservation needs is computed in RecoveredVectorSetIndexKey, which widens
recoveredIndexes from byte to ushort to carry it.
VectorSetRecoveredContextReservationTests covers this: with the fix the new
Vector Set gets its own context, and without it recovery reports the context as
free and the next VADD is handed the identical context, failing the test.
The migration remap above is in-memory state keyed on contextMetadatas, and two
paths rebuild that array underneath it: FLUSHDB/FLUSHALL through
FlushGuard.Dispose, and recovery through ReconcileRecoveredState. Neither
cleared the remap, so a cached entry could steer the remaining records of a
migration into a context that had since been freed and handed to another Vector
Set. Both paths now clear it. Recovery already treats a context still marked
migrating as a failed migration and marks it for cleanup, so forcing the retried
migration to re-resolve is the intended behaviour rather than only the safe one.
Test fixes
----------
A thread parked in ExceptionInjectionHelper.ResetAndWaitAsync is only released
by EnableException, but the cleanup a test runs on its way out is
DisableException. Any test that leaves between a waiter arriving and being
re-enabled - an assertion failing, or a wait for the arrival timing out on a
slow 2-core runner - therefore strands that waiter permanently. The waiter is a
server thread holding a pooled network buffer, so LimitedFixedBufferPool.Dispose
spins forever on a reference that is never returned and the entire test process
hangs: the job produces no results at all rather than one failing test, which is
why these runs show up in CI as a bare timeout with no reportable failure. The
cluster call sites already bound this with WaitAsync(timeout, token); the four
Vector Set call sites use the unbounded synchronous ResetAndWait.
GarnetServer now suspends parking for the duration of InternalDispose, which
releases anyone already parked and stops anyone new from parking. Covering
arrivals matters because disposal closes listeners before it drains handlers, so
a request already in flight can reach a still-armed injection point after
shutdown has begun and strand itself there; releasing only the waiters that were
already parked leaves the identical hang one moment later.
The suspension is a count rather than a flag so concurrent shutdowns are
independent, and it is unwound in a finally so it cannot leak into a later test
sharing this process-wide state - a leaked suspension would silently stop every
subsequent injection point from pausing anything. No timeout is introduced. Both
the helper methods and their call site are [Conditional("DEBUG")] and compile
away in Release.
ExceptionInjectionShutdownTests covers all three properties, and each fails when
the corresponding behaviour is removed: disposal completes while a waiter is
parked, a caller arriving after shutdown began does not park, and parking still
pauses normally once the shutdown that suspended it has finished.
StackExchange.Redis 2.12.8 pools SimpleResultBox in a [ThreadStatic] field and
sets the exception outside the lock that ActivateContinuations pulses. When a
server error faults the box before the caller reaches Monitor.Wait, the caller
skips the wait and recycles the box while the reader thread still has a
PulseAll outstanding. The next synchronous call on that thread consumes the
stale pulse and returns with neither result nor exception, shifting every
subsequent reply on that thread by one - a permanent lag that survives new
multiplexers and new servers, because the box is thread-static and NUnit runs a
fixture on one thread. The CI logs show this exactly: LuaScriptTests.Issue1079
loses its exception and Issue1235 later receives Issue1079's error, and
ScriptLoadErrors pairs with Struct the same way, with counts matching 1:1.
RespVectorSetTests.VADDErrors fails identically. Adds
TestUtils.ThrowsRedisException, which runs the failing call on a dedicated
thread so the poisoned box never lands on the shared test thread, and applies
it to the two fixtures CI proves are affected. Garnet's wire output was
confirmed byte-exact with a command/reply balance counter, so this corrects the
client-side defect without hiding any server behaviour.
DatabaseManagerBase.TakeCheckpointAsync runs compaction inline, and with the
compactionMaxSegments: 1 used by VectorSetOverwriteTests the computed
compactLength is zero, so untilAddress equals readOnlyAddress and every SAVE
compacts the entire log. SETAsync was the only test in the fixture invoking the
~120 MB helper twice against one server; splitting the KEEPTTL case into its
own test takes it from 13 s to 2 s. The 3-minute AsyncTimeout added previously
is left in place as defence in depth.
DisposeCluster disposed nodes in a bare loop, so one node throwing or stalling
left the rest of the cluster alive and holding its ports; the next four tests
then failed with "Failed to connect within 30 seconds" even though the test
that actually broke had passed. Every node's dispose is now started before any
of them is waited on, and each is then waited on under a bounded share of one
budget. Starting them inside the wait loop would leave a single node that
exhausts the budget with every later node still undisposed, which is the same
abandoned port by another route. Each dispose gets a dedicated thread rather
than a pool thread, because on a two-core runner the pool injects threads slowly
enough that a queued dispose could otherwise sit unstarted behind the ones
already blocked. Task.Wait(TimeSpan) rethrows a faulted dispose as an
AggregateException rather than returning, so it is caught per node; without that
the throw escaped mid-loop and abandoned every remaining node, which is the
cascade this loop exists to prevent. A standalone probe confirmed it: 1 of 3
healthy nodes disposed before, 3 of 3 after. The wait is bounded only by the
shared budget, with no per-node cap, so a merely slow node cannot fail a
teardown that the pre-existing outer guard would have allowed.
CLUSTER MEET returns before gossip propagates, and MIGRATE and SETSLOT are
issued against the source node, so the source must know the target or it
replies "ERR Unknown endpoint". Adds the missing convergence waits in
ClusterMigrateTests, including one that waited in the wrong direction and one
that had no wait at all. Slot re-assignment likewise settles asynchronously
after CLUSTER FAILOVER FORCE, so ClusterDivergentReplicasTest now waits for the
new primary to observe its own slot ownership before writing to it.
Verification
------------
All runs pinned to 2 cores with `taskset -c 0-1`, Debug, net8.0:
Garnet.test.vectorset 400/400
Garnet.test.scripting 619 passed, 30 skipped, 0 failed
Garnet.test.cluster.vectorsets 84/84
Garnet.test.cluster.repl.vectorsets 7/7
Garnet.test.cluster 156/156
Garnet.test.cluster.replication 107/107
Garnet.test.cluster.migrate 56/56
Build is clean with 0 warnings, and `dotnet format` reports no changes for both
Garnet.slnx and Tsavorite.slnx.
Superseded upstream during rebase
---------------------------------
Fixes from the original version of this branch were landed independently on
main and have been dropped in favour of the upstream versions:
- The missing VADDSetFlagsArg branch in HandleVectorSetAddReplication, and
the drain that has to precede it, are both in #2080. That version is a
superset: it also reads the index back and fails loudly rather than
silently applying flags to a Vector Set that is not there.
- UpgradeReplicasAsync racing a promoted primary that is still recovering is
fixed in main with WaitForFailoverCompleted, which polls the failover state
machine directly instead of inferring readiness from slot coverage. The
WaitForAllSlotsServedAsync helper written for that call site is dropped
with it rather than left unused.
Deliberately unchanged
----------------------
ClusterSRAddReplicaAfterPrimaryCheckpoint and
RespMemoryWriterOverflowTests.SortedSetAsync did not reproduce (3/3 and 6/6
locally), so no speculative change was made rather than risk masking a cause
that has not been identified.
Vector Set cleanup reclaims a whole context but decides a context is abandoned
from the single key QueueCleanups happened to observe during compaction, so a
context that lives on under a different key - after RENAME, or after being
handed to the next Vector Set created - can be marked while live. Fixing that
requires an allocation generation on the context so a stale request can be
recognised, which is a design change to context ownership rather than a CI
issue, and is being handled separately. Compensating guards were prototyped
here and removed: they cannot cover the window in which a context is reserved
before its index record is written, and detecting the damage after the fact
only converts silent data loss into a different symptom.
ClusterVectorSetTests.VectorSetMigrateManyBySlot: poll on any failed read
-------------------------------------------------------------------------
This test failed three times across recent CI runs (windows net8.0 Debug and
windows net10.0 Debug) with "Expected: 3, But was: 1".
ClusterTestUtils.Execute catches every exception and returns the message as a
single-element reply, so a failed read is indistinguishable from data by type.
The retry loop that reads the migrated keys back from the new replica treated a
reply as transient only when its text began with "Key has MOVED to ", and hit a
hard ClassicAssert.AreEqual(3, length) for anything else - on the very first
poll, before the replica had converged.
Probing that window directly (reading the new replica immediately after
MigrateSlots) shows three distinct transient replies, only the first of which
the old guard recognised:
"Key has MOVED to Endpoint ... but CommandFlags.NoRedirect was specified"
"CLUSTERDOWN Hash slot not served"
a nil reply, which casts to a null array and throws NullReferenceException
Replaying the original guard in that window fails 2/2 tests with exactly the CI
error; the new guard passes 5 runs of 2/2.
A successful VSIM ... WITHSCORES WITHATTRIBS hit is always three elements, so a
null or single-element reply always means the read did not land. The loop now
treats all of them as "poll again", captures the last response for the failure
message, and no longer asserts inside the loop - a caught NUnit assertion is
still recorded against the test result even when the next poll would resolve it,
which the single-slot variant of this test already documents.
The element/score/attribute assertions are unchanged; they now run once after
the loop, so a genuinely wrong reply still fails with the full NUnit diff, and a
persistent error still fails with the response text instead of a bare length
mismatch. The polling window matches the 15s the single-slot variant already
uses for the same convergence event, and the previously absent sleep between
polls stops the loop from spinning on the two cores it shares with the nodes it
is waiting for.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 30f31d5b-9e53-4111-91a3-e16c94510a77
3482e48 to
ea90744
Compare
… fixtures AofSyncDriver.Dispose() disposed each AOF sync task's GarnetClientSession while its worker task was still running. GarnetClientSession documents that it "expects mono-threaded client access, i.e., no concurrent invocations of API by client", so this is a contract violation, and it is the dominant source of flakiness in the cluster suites. When the disposing thread wins the race, the worker has just published its send frame (SendResponse nulls responseObject before sending) and has not yet reacquired one. The disposer's ReturnResponseObject() therefore returns nothing, and the worker then calls GetResponseObject(): LightConcurrentStack.TryPop samples disposed==false on an empty stack, releases the latch, and the caller allocates a fresh GarnetSaeaBuffer renting from the replication pool. The disposer disposes the stack in that gap, the worker dies at its next Throttle() check without returning the frame, and its second Dispose() returns immediately on the disposed counter. The buffer is stranded. LimitedFixedBufferPool.Dispose() then spin-waits forever for totalReferences to reach zero, so ReplicationManager.Dispose() never returns, the node never releases its port, and every later test in the fixture fails on "Failed to connect within 30 seconds". CI shows exactly this: one unreturned 64 MB Replication SaeaSendBuffer (2 << AofPageSizeBits, i.e. an AOF sync buffer) logged immediately before "Timed out waiting for DisposeCluster", followed by 26 consecutive failures in the same job. Fix the ordering rather than making the sender thread-safe, which would paper over the contract violation and add synchronization to the send path: - AofSyncTask.RunAofSyncTaskAsync disposes its client before leaving the active worker monitor, so a drained monitor implies every client was torn down by the one thread that used it and its buffer is back in the pool. - AofSyncDriver.Dispose() breaks the connections instead of disposing the sessions, waits for the workers to quiesce, and only then disposes the tasks. The task dispose is kept so a client connected without a running worker still returns its buffer, and it also makes the iterator disposal race-free. - GarnetClientSession.CloseConnection() closes only the socket, which is what unblocks a worker parked on a send without touching the buffer it owns. This is confined to teardown; no hot path changes. Verified by modelling the acquire-then-abandon sequence directly against GarnetTcpNetworkSender: when a foreign thread disposes the sender while the worker holds a rented frame, LimitedFixedBufferPool.Dispose() never completes; when the owning thread returns its own frame, the pool drains immediately. Also fix RespTests.ClientKillTestAsync, which asserted IsConnected was false after a single fire-and-forget Ping. Socket.Connected reflects the last completed I/O, and TCP guarantees the first send after a remote FIN succeeds into the kernel buffer, so the assertion could only pass by winning a race with the client's receive loop. It now pings until the disconnect surfaces, bounded at 10s, so a CLIENT KILL that genuinely failed still fails the test. Validated on 2 cores (taskset -c 0-1) to match the CI runners: Garnet.test.cluster.multilog 105 passed / 12 skipped / 0 failed, with no DisposeCluster timeout, no buffer pool dispose diagnostic and no leaked epochs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 30f31d5b-9e53-4111-91a3-e16c94510a77
AofSyncDriver.Dispose() relies on CloseConnection() to fail a worker's pending network I/O so the worker exits and the driver's wait for it completes. That signal could be lost: CloseConnection() reads the socket field, and a worker inside ConnectAsync has not published it yet. The window is not narrow. The field is written in the continuation of `socket = await ConnectSendSocketAsync(...)`, which on a loaded 2-core runner can be delayed arbitrarily. ConnectSendSocketAsync honours the token, but nothing after it does on the non-TLS path: NetworkHandler.StartAsync returns immediately when TLS is off, and the AUTH, CLIENT SETINFO and CLIENT SETNAME exchanges that follow await a reply with no cancellation token. A worker that lost the break therefore rents its send buffer and parks forever on a peer that is also shutting down, so the driver's wait never completes and the pool dispose spins. Make it a handshake: CloseConnection() records the request before closing, and the connecting thread rechecks it immediately after publishing the socket, so either the closer observes the socket or the connector observes the request. The recheck sits before the network handler is constructed and before the send buffer is rented, so the throw path allocates nothing. Both sides need a StoreLoad fence for the handshake to hold. Publishing the socket and reading the request are a store then a load of a different location, which the acquire load does not order, so without the barrier both sides can read stale values and the break is lost anyway. Verified on 2 cores (taskset -c 0-1): Garnet.test.cluster.multilog 105 passed / 12 skipped / 0 failed, Garnet.test.cluster.replication 107/107, with no DisposeCluster timeout and no buffer pool dispose diagnostic. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 30f31d5b-9e53-4111-91a3-e16c94510a77
Triaged the last 60
Garnet .NET CIruns onmain(39 failed, 68 failed-job logs) and bucketed every failure by root cause.CI runners have 2 cores, so each failure was reproduced locally under
taskset -c 0-1— that is why none of these reproduce on a high-core dev machine. Every change addresses the underlying defect rather than relaxing a timeout, adding a retry, or weakening an assertion.Product fixes
Replication teardown disposes a client session that a worker is still using
This is the single largest source of CI flakiness: across the failed-job logs it accounts for the great majority of cluster failures, and each occurrence takes down up to 26 consecutive tests in the same job.
GarnetClientSessiondocuments that it "expects mono-threaded client access, i.e., no concurrent invocations of API by client".AofSyncDriver.Dispose()violated this: it disposed each AOF sync task's session before waiting for that task's worker to quiesce.When the disposing thread wins the race, the worker has just published its send frame (
SendResponsenullsresponseObjectbefore sending) and has not yet reacquired one, so the disposer'sReturnResponseObject()returns nothing. The worker then callsGetResponseObject():LightConcurrentStack.TryPopsamplesdisposed == falseon an empty stack, releases the latch, and the caller allocates a freshGarnetSaeaBufferrenting from the replication pool. The disposer disposes the stack in that gap, the worker dies at its nextThrottle()check without returning the frame, and its ownDispose()returns immediately because the disposed counter is already past 1. The buffer is stranded.LimitedFixedBufferPool.Dispose()then spin-waits forever fortotalReferencesto reach zero, soReplicationManager.Dispose()never returns and the node never releases its port. Every later test in the fixture fails onFailed to connect within 30 seconds. The CI logs show precisely this, in order:67108864is2 << AofPageSizeBitsat the default 32 MB AOF page size, i.e.aofSyncSendBufferSize— the stranded frame is an AOF sync buffer, which is why these failures concentrate in the sharded-log fixture.The fix is to correct the ordering, not to make the sender thread-safe (that would paper over the contract violation and put synchronization on the send path):
AofSyncTask.RunAofSyncTaskAsyncdisposes its client before leaving the active worker monitor, so a drained monitor implies every client was torn down by the one thread that used it and its buffer is back in the pool.AofSyncDriver.Dispose()breaks the connections, waits for the workers, and only then disposes the tasks. The task dispose is kept so a client that was connected without a running worker still returns its buffer exactly as before, and moving it after the wait also makes the AOF iterator disposal race-free.GarnetClientSession.CloseConnection()closes only the socket — enough to fail a pending send and unblock a parked worker, without touching the buffer that worker owns.Confined to teardown; no hot-path changes.
CloseConnection()is what makes the wait terminate, so it must not be possible to lose it. It could be: it reads thesocketfield, and a worker insideConnectAsynchas not published that field yet. The window is not narrow — the field is written in the continuation ofsocket = await ConnectSendSocketAsync(...), which on a loaded 2-core runner can be delayed arbitrarily.ConnectSendSocketAsynchonours the token, but nothing after it does on the non-TLS path:NetworkHandler.StartAsyncreturns immediately when TLS is off, and theAUTH/CLIENT SETINFO/CLIENT SETNAMEexchanges that follow await a reply with no cancellation token. A worker that lost the break rents its send buffer and then parks forever on a peer that is itself shutting down — the same hang, one step earlier.So it is a handshake:
CloseConnection()records the request before closing, and the connecting thread rechecks it immediately after publishing the socket. Either the closer observes the socket or the connector observes the request. The recheck sits before the network handler is constructed and before the send buffer is rented, so the throw path allocates nothing. Both sides carry a StoreLoad fence — publishing the socket and reading the request are a store then a load of a different location, which an acquire load does not order, so a fence on only one side would still lose the break.Validated on 2 cores (
taskset -c 0-1):Garnet.test.cluster.multilog105 passed / 12 skipped / 0 failed, with noDisposeClustertimeout, no buffer-pool dispose diagnostic and no leaked epochs.Slot migration hands a Vector Set a context that is already in use locally
Vector Set contexts are allocated independently per node and index creation is not AOF-logged. On slot migration the destination reserved a context from its own free list and stamped it into the AOF migrate records. Replay adopted that context verbatim, guarding only on
IsMigratingand never onIsInUse.ContextMetadata.MarkInUseonly asserted — compiled out in Release, which is the configuration the failing CI job runs — so two Vector Sets silently shared one namespace and destroyed each other's data.Migrated records are now remapped onto a context that is free locally, and the mapping is held for the duration of the migration. Element records are provably transmitted, applied, and acknowledged before the index key is sent (the
Task.WhenAllbarrier inMigrateSessionSlots.CreateAndRunMigrateTasksAsync), so the mapping can be released when the index key arrives.A checkpoint between an index record and its context metadata leaks the context
A Vector Set's index record is written before the context metadata that reserves its context, and that metadata is only flushed once index creation succeeds. A checkpoint taken between the two captures a live index record whose context is not marked in use, so the free list hands the same context to the next Vector Set created — the same corruption class as the migration defect above.
ReconcileRecoveredStatenow restores the reservation for every recovered index whose context is free. The hash slot the reservation needs is computed inRecoveredVectorSetIndexKey, which widensrecoveredIndexesfrombytetoushortto carry it.VectorSetRecoveredContextReservationTestscovers this: with the fix the new Vector Set gets its own context; without it recovery reports the context as free and the nextVADDis handed the identical context, failing the test.The migration remap outlived rebuilds of the array it is keyed on
The remap above is in-memory state keyed on
contextMetadatas, and two paths rebuild that array underneath it:FLUSHDB/FLUSHALLthroughFlushGuard.Dispose, and recovery throughReconcileRecoveredState. Neither cleared the remap, so a cached entry could steer the remaining records of a migration into a context that had since been freed and handed to another Vector Set.Both paths now clear it. Recovery already treats a context still marked migrating as a failed migration and marks it for cleanup, so forcing the retried migration to re-resolve is the intended behaviour rather than merely the safe one.
Test fixes
ClientKillTestAsyncasserts an asynchronous disconnect synchronouslyThe test issued one fire-and-forget
Pingand immediately assertedIsConnectedwas false.GarnetClient.IsConnectedreadsSocket.Connected, which .NET defines as the state as of the last completed I/O, and TCP guarantees the first send after a remote FIN still succeeds into the kernel buffer — only the next operation observes the reset. The assertion could therefore only pass by winning a race against the client's receive loop asynchronously marking the session disposed, which it loses on a loaded 2-core runner.A standalone socket experiment confirms the mechanism deterministically: 200 ms after the peer closes,
Connectedis stillTrue; the first send succeeds; the second throws and only then doesConnectedgo false.It now pings until the disconnect surfaces, bounded at 10 s. The test keeps its full power: if
CLIENT KILLgenuinely failed to kill the connection the pings keep succeeding,IsConnectedstays true, and the assertion still fires.A stranded injection point hangs the entire test process
A thread parked in
ExceptionInjectionHelper.ResetAndWaitAsyncis only released byEnableException, but the cleanup a test runs on its way out isDisableException. Any test that leaves between a waiter arriving and being re-enabled — an assertion failing, or a wait for the arrival timing out on a slow 2-core runner — strands that waiter permanently.The waiter is a server thread holding a pooled network buffer, so
LimitedFixedBufferPool.Disposespins forever on a reference that is never returned and the entire test process hangs: the job produces no results at all rather than one failing test. That is why these runs appear in CI as a bare timeout with no reportable failure.The cluster call sites already bound this with
WaitAsync(timeout, token); the four Vector Set call sites use the unbounded synchronousResetAndWait.GarnetServernow suspends parking for the duration ofInternalDispose, which releases anyone already parked and stops anyone new from parking. Covering arrivals matters because disposal closes listeners before it drains handlers, so a request already in flight can reach a still-armed injection point after shutdown has begun and strand itself there — releasing only the waiters already parked leaves the identical hang one moment later.The suspension is a count rather than a flag, so concurrent shutdowns are independent, and it is unwound in a
finallyso it cannot leak into a later test sharing this process-wide state (a leaked suspension would silently stop every subsequent injection point from pausing anything). No timeout is introduced. Both the helper methods and their call site are[Conditional("DEBUG")]and compile away in Release.ExceptionInjectionShutdownTestscovers all three properties, and each fails when the corresponding behaviour is removed: disposal completes while a waiter is parked, a caller arriving after shutdown began does not park, and parking still pauses normally once the shutdown that suspended it has finished.StackExchange.Redis recycles a result box while a pulse is still outstanding
SE.Redis 2.12.8 pools
SimpleResultBoxin a[ThreadStatic]field and sets the exception outside the lock thatActivateContinuationspulses. When a server error faults the box before the caller reachesMonitor.Wait, the caller skips the wait and recycles the box while the reader thread still has aPulseAlloutstanding. The next synchronous call on that thread consumes the stale pulse and returns with neither result nor exception, shifting every subsequent reply on that thread by one — a permanent lag that survives new multiplexers and new servers, because the box is thread-static and NUnit runs a fixture on one thread.The CI logs show this exactly:
LuaScriptTests.Issue1079loses its exception andIssue1235later receivesIssue1079's error;ScriptLoadErrorspairs withStructthe same way, with counts matching 1:1.RespVectorSetTests.VADDErrorsfails identically.Adds
TestUtils.ThrowsRedisException, which runs the failing call on a dedicated thread so the poisoned box never lands on the shared test thread, applied to the two fixtures CI proves are affected. Garnet's wire output was confirmed byte-exact with a command/reply balance counter, so this corrects the client-side defect without hiding any server behaviour.VectorSetOverwriteTests.SETAsynccompacts the whole log twiceDatabaseManagerBase.TakeCheckpointAsyncruns compaction inline, and with thecompactionMaxSegments: 1used by this fixture the computedcompactLengthis zero, sountilAddressequalsreadOnlyAddressand everySAVEcompacts the entire log.SETAsyncwas the only test invoking the ~120 MB helper twice against one server; splitting theKEEPTTLcase into its own test takes it from 13 s to 2 s.One stalled node in
DisposeClusterfailed the next four testsDisposeClusterdisposed nodes in a bare loop, so one node throwing or stalling left the rest of the cluster alive holding its ports; the next four tests then failed withFailed to connect within 30 secondseven though the test that actually broke had passed.Every node's dispose is now started before any of them is waited on, and each is then waited on under a bounded share of one budget. Starting them inside the wait loop would leave a single node that exhausts the budget with every later node still undisposed — the same abandoned port by another route. Each dispose gets a dedicated thread (
TaskCreationOptions.LongRunning) rather than a pool thread, because on a two-core runner the pool injects threads slowly enough that a queued dispose could otherwise sit unstarted behind the ones already blocked.Task.Wait(TimeSpan)rethrows a faulted dispose as anAggregateExceptionrather than returning, so it is caught per node; without that the throw escaped mid-loop and abandoned every remaining node — the exact cascade this loop exists to prevent.The wait is bounded only by the shared budget, with no per-node cap, so a merely slow node cannot fail a teardown that the pre-existing outer guard would have allowed.
Missing gossip-convergence waits
CLUSTER MEETreturns before gossip propagates, andMIGRATE/SETSLOTare issued against the source node, so the source must know the target or it repliesERR Unknown endpoint. Adds the missing convergence waits inClusterMigrateTests, including one that waited in the wrong direction and one that had no wait at all.Slot re-assignment likewise settles asynchronously after
CLUSTER FAILOVER FORCE, soClusterDivergentReplicasTestnow waits for the new primary to observe its own slot ownership before writing to it.Verification
All runs pinned to 2 cores with
taskset -c 0-1, Debug, net8.0:Garnet.test.vectorsetGarnet.test.scriptingGarnet.test.clusterGarnet.test.cluster.replicationGarnet.test.cluster.vectorsetsGarnet.test.cluster.replication.vectorsetsGarnet.test.cluster.migrateBuild is clean with 0 warnings, and
dotnet formatreports no changes for bothGarnet.slnxandTsavorite.slnx.Superseded upstream during rebase
Fixes from the original version of this branch landed independently on
mainand were dropped in favour of the upstream versions:VADDSetFlagsArgbranch inHandleVectorSetAddReplication, and the drain that has to precede it, are both in Fixes for Vector SetRENAMEs in AOF replay #2080. That version is a superset: it also reads the index back and fails loudly rather than silently applying flags to a Vector Set that is not there.UpgradeReplicasAsyncracing a promoted primary that is still recovering is fixed onmainwithWaitForFailoverCompleted, which polls the failover state machine directly instead of inferring readiness from slot coverage. TheWaitForAllSlotsServedAsynchelper written for that call site is dropped with it rather than left unused.Deliberately unchanged
AofSyncDriverStoreholds_lock.WriteLock()acrosssyncDriver.Dispose()inTryRemove,TryAddReplicationDriverandTryAddReplicationDrivers, whileAofSyncTask.Throttle()takes_lock.ReadLock()viaPublishShippedAddress— whose own doc comment says it "must not be called while holding the store lock". That is a lock inversion, but it is strictly pre-existing (the monitor wait was already insideDispose()before this PR, and the trigger is decided beforeDispose()is entered, so the reordering here does not make it more likely). It is also unreachable in these suites: it needsbackpressure.Enabled, andAofSyncMaxLagBytesdefaults to-1with nothing in the cluster tests setting it. Filed rather than fixed here — the fix is to hoist the dispose out of the write-locked region, whichAofSyncDriverStore.Dispose()already models.ClusterSRAddReplicaAfterPrimaryCheckpointandRespMemoryWriterOverflowTests.SortedSetAsyncdid not reproduce (3/3 and 6/6 locally), so no speculative change was made rather than risk masking a cause that has not been identified.Vector Set cleanup reclaims a whole context but decides a context is abandoned from the single key
QueueCleanupshappened to observe during compaction, so a context that lives on under a different key — afterRENAME, or after being handed to the next Vector Set created — can be marked while live. Fixing that requires an allocation generation on the context so a stale request can be recognised, which is a design change to context ownership rather than a CI issue, and should be handled separately. Compensating guards were prototyped here and removed: they cannot cover the window in which a context is reserved before its index record is written, and detecting the damage after the fact only converts silent data loss into a different symptom.