[Cluster] Prevent MergeSlotMap from crediting a slot to a replica sender - #2089
Open
jiajunpeng-msft wants to merge 1 commit into
Open
[Cluster] Prevent MergeSlotMap from crediting a slot to a replica sender#2089jiajunpeng-msft wants to merge 1 commit into
jiajunpeng-msft wants to merge 1 commit into
Conversation
MergeSlotMap could credit a hash slot to a node that never claimed it. When the sender is a replica and the receiver holds the slot unowned, the `else if (currentOwnerId != RESERVED_WORKER_ID)` guard is false, so the block is skipped entirely. assignToWorkerId therefore keeps its default of the sender and is written in as the owner, while the correction that would have redirected it to the sender's primary sits inside the branch that was skipped. The attribution then propagates rather than settling. On the next gossip from that same replica the receiver does credit the slot to the sender, so the planned-failover branch is entered and the slot is handed to the replica's primary, a node generally unrelated to the slot and with no epoch gate on that path. The slot ends up owned by a node that never claimed it, and the true owner's claim is rejected for as long as the bogus owner's epoch is greater. It does drain, since the bogus owner is a primary that does not claim the slot and its own gossip triggers the stale-ownership reset. But unowned is exactly this bug's precondition, so the reset hands the slot back into the race, and the loop gates only on the sender's slot state, so the receiver holding the slot OFFLINE does not protect it. Whichever gossip arrives first decides: a primary repairs the slot, a replica puts it back to a wrong owner. Convergence therefore depends on the true owner repeatedly winning that race against every replica that gossips. The same window exists for any node that joins with an empty slot map. On a large cluster this took about a day to settle, and until it did, the affected nodes answered with MOVED redirects to nodes that did not own the slot. The guard was added by microsoft#1435 to fix a NullReferenceException, since workers[RESERVED_WORKER_ID].Nodeid is null. Skipping the block avoided the dereference but turned a throw into silently crediting the replica. Restore the pre-microsoft#1435 invariant that a replica sender can never introduce ownership, while keeping the dereference guarded: check for the reserved owner first and skip the slot. An unowned slot then becomes a safe resting state, claimable only by a primary that actually claims it, so the stale-ownership reset settles in one round instead of feeding the race. Adds four regression tests. The unowned-slot and correction-leak tests fail without this change; the ownership-preservation and planned-failover hand-off tests guard against regressing behavior that already worked. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Contributor
There was a problem hiding this comment.
Pull request overview
Prevents replica gossip from claiming unowned cluster slots while preserving planned-failover handoffs.
Changes:
- Rejects replica ownership updates for unowned slots.
- Adds regression coverage for ownership and handoff scenarios.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
libs/cluster/Server/ClusterConfig.cs |
Guards unowned slots during replica merges. |
test/cluster/Garnet.test.cluster/ClusterConfigTests.cs |
Adds four replica merge regression tests. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Comment on lines
+1206
to
+1209
| // above; a replica must never introduce ownership. Crediting the replica here would be | ||
| // permanent, because the true owner is afterwards rejected by the config epoch comparison. | ||
| // NOTE: this check must precede the node-id comparison, since | ||
| // workers[RESERVED_WORKER_ID].Nodeid is null and dereferencing it was the failure fixed by #1435. |
Comment on lines
+407
to
+409
| /// A replica sender must not be credited with a slot the receiver holds unowned. Doing so is permanent: | ||
| /// the true owner is afterwards rejected by the config epoch comparison against the bogus owner. | ||
| /// Also guards the null dereference of workers[RESERVED_WORKER_ID].Nodeid fixed by #1435. |
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.
Root Cause
ClusterConfig.MergeSlotMapcan credit a hash slot to a node that never claimed it.When the sender is a replica and the receiver holds the slot unowned, the
else if (currentOwnerId != RESERVED_WORKER_ID)guard is false, so the block is skipped:assignToWorkerIdkeeps its default from (1), the replica sender, and (3) writes it in as owner. The correction that would have redirected it to the sender's primary lives inside the branch that is skipped in precisely the case where it is needed. A replica sender reaches this path at all because the stale-ownership reset above it is gated on&& senderConfig.IsPrimary.The attribution then propagates rather than settling. On the next gossip from that same replica the receiver now does credit the slot to the sender, so the planned-failover branch is entered and the slot is handed to the replica's primary — a node generally unrelated to the slot, and with no epoch gate on that path. The slot ends up owned by a node that never claimed it, carrying an arbitrary config epoch, and the true owner's claim is rejected for as long as that epoch is the greater one.
It does drain: the bogus owner is a primary that does not claim the slot, so its own gossip triggers the stale-ownership reset, returning the slot to unowned, after which a genuine primary claim wins unconditionally against
ConfigEpoch == 0. But unowned is exactly this bug's precondition, so the reset hands the slot straight back into the race — and note the loop gates only onsenderSlotMap[i]._state, the sender's view, so the receiver holding the slotOFFLINEdoes not protect it. Whichever gossip arrives first decides: a primary's claim repairs the slot, a replica's puts it back to a wrong owner. Repair and corruption alternate, and convergence depends on the true owner repeatedly winning that race against every replica that gossips. The same window exists for any node that joins with an empty slot map. On a large cluster this took about a day to settle, and until it did, affected nodes answered withMOVEDredirects to nodes that did not own the slot.This is a regression from #1435 (
54286ac0f1), which added thecurrentOwnerId != RESERVED_WORKER_IDguard. Before it the branch was a plainelse, so every replica sender was forced through the node-id check and the correction could not be bypassed — but on an unowned slotworkers[RESERVED_WORKER_ID].Nodeidisnull, so.Equals(...)threw. Guarding that dereference by skipping the block also skipped the correction, turning a throw into silently crediting the replica.Description of Change
libs/cluster/Server/ClusterConfig.cs— restore the plainelseand check for the reserved owner first, so an unowned slot is skipped rather than assigned:A replica can still hand off a slot the receiver already credits to that replica, which is the planned-failover case the branch exists for and where its safety comes from the node-id check. An unowned slot offers no such basis, so it is left for its real owner, whose claim always succeeds because
workers[RESERVED_WORKER_ID].ConfigEpochis0. With that path closed, an unowned slot can only ever be taken by a primary that actually claims it, so the stale-ownership reset settles in one round instead of feeding the race described above. The null dereference #1435 fixed stays guarded, since the reserved check precedes it.No public API, configuration, or wire-format change.
test/cluster/Garnet.test.cluster/ClusterConfigTests.cs— four regression tests:...ReplicaSenderCannotClaimUnownedSlotTest...ReplicaHandoffDoesNotLeakToLaterSlotsTest...ReplicaSenderCannotStealOwnedSlotTest...ReplicaSenderHandsOffOwnedSlotToPrimaryTestThe first also asserts
Assert.DoesNotThrow, pinning the #1435 NRE so the guard cannot be lost again.Validation
Garnet.test.cluster160/160,Garnet.test.cluster.replication107/107 andGarnet.test.cluster.migrate56/56 pass on net8.0 Debug. Release build ofGarnet.clusteris warning-clean anddotnet format Garnet.slnx --verify-no-changesis clean.Both new failing tests were confirmed to fail against unpatched
mainand pass with the change; the two hand-off tests pass in both states.Issues Fixed
No existing issue — filed directly as a PR. Happy to open a tracking issue if preferred.
Assisted-By: GitHub Copilot