Move 43 Raft consensus test files (8 root-level + 35 in Raft/ subfolder) from NATS.Server.Tests into a dedicated NATS.Server.Raft.Tests project. Update namespaces, add InternalsVisibleTo, and fix timing/exception handling issues in moved test files.
286 lines
9.8 KiB
C#
286 lines
9.8 KiB
C#
using NATS.Server.Raft;
|
|
|
|
namespace NATS.Server.Raft.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();
|
|
}
|
|
}
|