feat(raft): implement joint consensus for safe two-phase membership changes

Adds BeginJointConsensus, CommitJointConsensus, and CalculateJointQuorum to
RaftNode per Raft paper Section 4. During a joint configuration transition
quorum requires majority from BOTH Cold and Cnew; CommitJointConsensus
finalizes Cnew as the sole active configuration. The existing single-phase
ProposeAddPeerAsync/ProposeRemovePeerAsync are unchanged. Includes 16 new
tests covering flag behaviour, quorum boundaries, idempotent commit, and
backward-compatibility with the existing membership API.
This commit is contained in:
Joseph Doherty
2026-02-25 01:40:56 -05:00
parent a0894e7321
commit e0f5fe7150
2 changed files with 361 additions and 0 deletions

View File

@@ -23,6 +23,13 @@ public sealed class RaftNode : IDisposable
// Go reference: raft.go:961-1019 (proposeAddPeer / proposeRemovePeer, single-change invariant)
private long _membershipChangeIndex;
// Joint consensus (two-phase membership change) per Raft paper Section 4.
// During the joint phase both the old config (Cold) and new config (Cnew) are stored.
// A quorum decision requires majority from BOTH configurations simultaneously.
// Go reference: raft.go joint consensus / two-phase membership transitions.
private HashSet<string>? _jointOldMembers;
private HashSet<string>? _jointNewMembers;
// Pre-vote: Go NATS server does not implement pre-vote (RFC 5849 §9.6). Skipped for parity.
public string Id { get; }
@@ -54,6 +61,19 @@ public sealed class RaftNode : IDisposable
// Go reference: raft.go:961-1019 single-change invariant.
public bool MembershipChangeInProgress => Interlocked.Read(ref _membershipChangeIndex) > 0;
/// <summary>
/// True when this node is in the joint consensus phase (transitioning between
/// two membership configurations).
/// Go reference: raft.go joint consensus / two-phase membership transitions.
/// </summary>
public bool InJointConsensus => _jointNewMembers != null;
/// <summary>
/// The new (Cnew) member set stored during a joint configuration transition,
/// or null when not in joint consensus. Exposed for testing.
/// </summary>
public IReadOnlyCollection<string>? JointNewMembers => _jointNewMembers;
public RaftNode(string id, IRaftTransport? transport = null, string? persistDirectory = null)
{
Id = id;
@@ -273,6 +293,62 @@ public sealed class RaftNode : IDisposable
return entry.Index;
}
// Joint consensus (Raft paper Section 4) — two-phase membership transitions.
// Go reference: raft.go joint consensus / two-phase membership transitions.
/// <summary>
/// Enters the joint consensus phase with the given old and new configurations.
/// During this phase quorum decisions require majority from BOTH configurations.
/// The active member set is set to the union of Cold and Cnew so that entries
/// are replicated to all nodes that participate in either configuration.
/// Go reference: raft.go Section 4 (joint consensus).
/// </summary>
public void BeginJointConsensus(IReadOnlyCollection<string> cold, IReadOnlyCollection<string> cnew)
{
_jointOldMembers = new HashSet<string>(cold, StringComparer.Ordinal);
_jointNewMembers = new HashSet<string>(cnew, StringComparer.Ordinal);
// The active member set is the union of both configs
foreach (var member in cnew)
_members.Add(member);
}
/// <summary>
/// Commits the joint configuration by finalizing Cnew as the active member set.
/// Clears both Cold and Cnew, leaving only the new configuration.
/// Call this once the Cnew log entry has reached quorum in both configs.
/// Go reference: raft.go joint consensus commit.
/// </summary>
public void CommitJointConsensus()
{
if (_jointNewMembers == null)
return;
_members.Clear();
foreach (var m in _jointNewMembers)
_members.Add(m);
_jointOldMembers = null;
_jointNewMembers = null;
}
/// <summary>
/// During joint consensus, checks whether a set of acknowledging voters satisfies
/// a majority in BOTH the old configuration (Cold) and the new configuration (Cnew).
/// Returns false when not in joint consensus.
/// Go reference: raft.go Section 4 — joint config quorum calculation.
/// </summary>
public bool CalculateJointQuorum(
IReadOnlyCollection<string> coldVoters,
IReadOnlyCollection<string> cnewVoters)
{
if (_jointOldMembers == null || _jointNewMembers == null)
return false;
var oldQuorum = (_jointOldMembers.Count / 2) + 1;
var newQuorum = (_jointNewMembers.Count / 2) + 1;
return coldVoters.Count >= oldQuorum && cnewVoters.Count >= newQuorum;
}
// B5: Snapshot checkpoints and log compaction
// Go reference: raft.go CreateSnapshotCheckpoint, DrainAndReplaySnapshot

View File

@@ -0,0 +1,285 @@
using NATS.Server.Raft;
namespace NATS.Server.Tests.Raft;
/// <summary>
/// Tests for joint consensus membership changes per Raft paper Section 4.
/// During a joint configuration transition a quorum requires majority from BOTH
/// the old configuration (Cold) and the new configuration (Cnew).
/// Go reference: raft.go joint consensus / two-phase membership transitions.
/// </summary>
public class RaftJointConsensusTests
{
// -- Helpers (self-contained, no shared TestHelpers class) --
private static (RaftNode[] nodes, InMemoryRaftTransport transport) CreateCluster(int size)
{
var transport = new InMemoryRaftTransport();
var nodes = Enumerable.Range(1, size)
.Select(i => new RaftNode($"n{i}", transport))
.ToArray();
foreach (var node in nodes)
{
transport.Register(node);
node.ConfigureCluster(nodes);
}
return (nodes, transport);
}
private static RaftNode ElectLeader(RaftNode[] nodes)
{
var candidate = nodes[0];
candidate.StartElection(nodes.Length);
foreach (var voter in nodes.Skip(1))
candidate.ReceiveVote(voter.GrantVote(candidate.Term, candidate.Id), nodes.Length);
return candidate;
}
// -- BeginJointConsensus / InJointConsensus / JointNewMembers --
[Fact]
public void BeginJointConsensus_sets_InJointConsensus_flag()
{
// Go reference: raft.go Section 4 — begin joint config
var node = new RaftNode("n1");
node.AddMember("n2");
node.AddMember("n3");
node.InJointConsensus.ShouldBeFalse();
node.BeginJointConsensus(["n1", "n2", "n3"], ["n1", "n2", "n3", "n4"]);
node.InJointConsensus.ShouldBeTrue();
}
[Fact]
public void BeginJointConsensus_exposes_JointNewMembers()
{
// Go reference: raft.go Section 4 — Cnew accessible during joint phase
var node = new RaftNode("n1");
node.AddMember("n2");
node.AddMember("n3");
node.BeginJointConsensus(["n1", "n2", "n3"], ["n1", "n2", "n3", "n4"]);
node.JointNewMembers.ShouldNotBeNull();
node.JointNewMembers!.ShouldContain("n4");
node.JointNewMembers.Count.ShouldBe(4);
}
[Fact]
public void BeginJointConsensus_adds_new_members_to_active_set()
{
// During joint consensus the active member set is the union of Cold and Cnew
// so that entries are replicated to all participating nodes.
// Go reference: raft.go Section 4 — joint config is union of Cold and Cnew.
var node = new RaftNode("n1");
node.AddMember("n2");
node.AddMember("n3");
node.BeginJointConsensus(["n1", "n2", "n3"], ["n1", "n2", "n3", "n4"]);
node.Members.ShouldContain("n4");
}
// -- CommitJointConsensus --
[Fact]
public void CommitJointConsensus_clears_InJointConsensus_flag()
{
// Go reference: raft.go joint consensus commit finalizes Cnew
var node = new RaftNode("n1");
node.AddMember("n2");
node.AddMember("n3");
node.BeginJointConsensus(["n1", "n2", "n3"], ["n1", "n2", "n3", "n4"]);
node.CommitJointConsensus();
node.InJointConsensus.ShouldBeFalse();
}
[Fact]
public void CommitJointConsensus_finalizes_new_configuration_when_adding_peer()
{
// After commit, Members should exactly equal Cnew.
// Go reference: raft.go joint consensus commit.
var node = new RaftNode("n1");
node.AddMember("n2");
node.AddMember("n3");
node.BeginJointConsensus(["n1", "n2", "n3"], ["n1", "n2", "n3", "n4"]);
node.CommitJointConsensus();
node.Members.Count.ShouldBe(4);
node.Members.ShouldContain("n1");
node.Members.ShouldContain("n2");
node.Members.ShouldContain("n3");
node.Members.ShouldContain("n4");
}
[Fact]
public void CommitJointConsensus_removes_old_only_members_when_removing_peer()
{
// Removing a peer: Cold={n1,n2,n3}, Cnew={n1,n2}.
// After commit, n3 must be gone.
// Go reference: raft.go joint consensus commit removes Cold-only members.
var node = new RaftNode("n1");
node.AddMember("n2");
node.AddMember("n3");
node.BeginJointConsensus(["n1", "n2", "n3"], ["n1", "n2"]);
node.CommitJointConsensus();
node.Members.Count.ShouldBe(2);
node.Members.ShouldContain("n1");
node.Members.ShouldContain("n2");
node.Members.ShouldNotContain("n3");
}
[Fact]
public void CommitJointConsensus_is_idempotent_when_not_in_joint()
{
// Calling commit when not in joint consensus must be a no-op.
var node = new RaftNode("n1");
node.AddMember("n2");
node.CommitJointConsensus(); // should not throw
node.Members.Count.ShouldBe(2);
node.InJointConsensus.ShouldBeFalse();
}
// -- CalculateJointQuorum --
[Fact]
public void CalculateJointQuorum_returns_false_when_not_in_joint_consensus()
{
// Outside joint consensus the method has no defined result and returns false.
// Go reference: raft.go Section 4.
var node = new RaftNode("n1");
node.CalculateJointQuorum(["n1"], ["n1"]).ShouldBeFalse();
}
[Fact]
public void Joint_quorum_requires_majority_from_both_old_and_new_configurations()
{
// Cold={n1,n2,n3} (size 3, quorum=2), Cnew={n1,n2,n3,n4} (size 4, quorum=3).
// 2/3 old AND 3/4 new — both majorities satisfied.
// Go reference: raft.go Section 4 — joint config quorum calculation.
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
leader.BeginJointConsensus(["n1", "n2", "n3"], ["n1", "n2", "n3", "n4"]);
leader.CalculateJointQuorum(["n1", "n2"], ["n1", "n2", "n3"]).ShouldBeTrue();
}
[Fact]
public void Joint_quorum_fails_when_old_majority_not_met()
{
// Cold={n1,n2,n3} (quorum=2): only 1 old voter — fails old quorum.
// Cnew={n1,n2,n3,n4} (quorum=3): 2 new voters — also fails new quorum.
// Go reference: raft.go Section 4 — must satisfy BOTH majorities.
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
leader.BeginJointConsensus(["n1", "n2", "n3"], ["n1", "n2", "n3", "n4"]);
leader.CalculateJointQuorum(["n1"], ["n1", "n2"]).ShouldBeFalse();
}
[Fact]
public void Joint_quorum_fails_when_new_majority_not_met()
{
// Cold={n1,n2,n3} (quorum=2): 2 old voters — passes old quorum.
// Cnew={n1,n2,n3,n4} (quorum=3): only 2 new voters — fails new quorum.
// Go reference: raft.go Section 4 — both are required.
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
leader.BeginJointConsensus(["n1", "n2", "n3"], ["n1", "n2", "n3", "n4"]);
leader.CalculateJointQuorum(["n1", "n2"], ["n1", "n2"]).ShouldBeFalse();
}
[Fact]
public void Joint_quorum_exact_majority_boundary_old_config()
{
// Cold={n1,n2,n3,n4,n5} (size 5, quorum=3).
// Exactly 3 old voters meets old quorum boundary.
var node = new RaftNode("n1");
foreach (var m in new[] { "n2", "n3", "n4", "n5" })
node.AddMember(m);
node.BeginJointConsensus(
["n1", "n2", "n3", "n4", "n5"],
["n1", "n2", "n3", "n4", "n5", "n6"]);
// 3/5 old (exact quorum=3) and 4/6 new (quorum=4) — both satisfied
node.CalculateJointQuorum(["n1", "n2", "n3"], ["n1", "n2", "n3", "n4"]).ShouldBeTrue();
}
[Fact]
public void Joint_quorum_just_below_boundary_old_config_fails()
{
// Cold={n1,n2,n3,n4,n5} (size 5, quorum=3): 2 voters fails.
var node = new RaftNode("n1");
foreach (var m in new[] { "n2", "n3", "n4", "n5" })
node.AddMember(m);
node.BeginJointConsensus(
["n1", "n2", "n3", "n4", "n5"],
["n1", "n2", "n3", "n4", "n5", "n6"]);
// 2/5 old < quorum=3 — must fail
node.CalculateJointQuorum(["n1", "n2"], ["n1", "n2", "n3", "n4"]).ShouldBeFalse();
}
// -- Integration: existing ProposeAddPeerAsync/ProposeRemovePeerAsync unchanged --
[Fact]
public async Task ProposeAddPeerAsync_still_works_after_joint_consensus_fields_added()
{
// Verify that adding joint consensus fields does not break the existing
// single-phase ProposeAddPeerAsync behaviour.
// Go reference: raft.go:961-990 (proposeAddPeer).
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
await leader.ProposeAddPeerAsync("n4", default);
leader.Members.ShouldContain("n4");
leader.Members.Count.ShouldBe(4);
leader.MembershipChangeInProgress.ShouldBeFalse();
}
[Fact]
public async Task ProposeRemovePeerAsync_still_works_after_joint_consensus_fields_added()
{
// Verify that adding joint consensus fields does not break the existing
// single-phase ProposeRemovePeerAsync behaviour.
// Go reference: raft.go:992-1019 (proposeRemovePeer).
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
await leader.ProposeRemovePeerAsync("n3", default);
leader.Members.ShouldNotContain("n3");
leader.Members.Count.ShouldBe(2);
leader.MembershipChangeInProgress.ShouldBeFalse();
}
[Fact]
public async Task MembershipChangeInProgress_is_false_after_completed_add()
{
// The single-change invariant must still hold: flag is cleared after completion.
// Go reference: raft.go:961-1019 single-change invariant.
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
await leader.ProposeAddPeerAsync("n4", default);
leader.MembershipChangeInProgress.ShouldBeFalse();
}
}