- ProposeAddPeerAsync/ProposeRemovePeerAsync: single-change-at-a-time membership changes through RAFT consensus (Go ref: raft.go:961-1019) - RaftLog.Compact: removes entries up to given index for log compaction - CreateSnapshotCheckpointAsync: creates snapshot and compacts log in one operation - DrainAndReplaySnapshotAsync: drains commit queue, installs snapshot, resets indices - Pre-vote protocol skipped (Go NATS doesn't implement it either) - 23 new tests in RaftMembershipAndSnapshotTests
227 lines
7.4 KiB
C#
227 lines
7.4 KiB
C#
using NATS.Server.Raft;
|
|
|
|
namespace NATS.Server.Tests.Raft;
|
|
|
|
/// <summary>
|
|
/// Tests for B4: Membership Changes (Add/Remove Peer).
|
|
/// Go reference: raft.go:2500-2600 (ProposeAddPeer/RemovePeer), raft.go:961-1019.
|
|
/// </summary>
|
|
public class RaftMembershipTests
|
|
{
|
|
// -- Helpers --
|
|
|
|
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;
|
|
}
|
|
|
|
// -- RaftMembershipChange type tests --
|
|
|
|
[Fact]
|
|
public void MembershipChange_ToCommand_encodes_add_peer()
|
|
{
|
|
var change = new RaftMembershipChange(RaftMembershipChangeType.AddPeer, "n4");
|
|
change.ToCommand().ShouldBe("AddPeer:n4");
|
|
}
|
|
|
|
[Fact]
|
|
public void MembershipChange_ToCommand_encodes_remove_peer()
|
|
{
|
|
var change = new RaftMembershipChange(RaftMembershipChangeType.RemovePeer, "n2");
|
|
change.ToCommand().ShouldBe("RemovePeer:n2");
|
|
}
|
|
|
|
[Fact]
|
|
public void MembershipChange_TryParse_roundtrips_add_peer()
|
|
{
|
|
var original = new RaftMembershipChange(RaftMembershipChangeType.AddPeer, "n4");
|
|
var parsed = RaftMembershipChange.TryParse(original.ToCommand());
|
|
parsed.ShouldNotBeNull();
|
|
parsed.Value.ShouldBe(original);
|
|
}
|
|
|
|
[Fact]
|
|
public void MembershipChange_TryParse_roundtrips_remove_peer()
|
|
{
|
|
var original = new RaftMembershipChange(RaftMembershipChangeType.RemovePeer, "n2");
|
|
var parsed = RaftMembershipChange.TryParse(original.ToCommand());
|
|
parsed.ShouldNotBeNull();
|
|
parsed.Value.ShouldBe(original);
|
|
}
|
|
|
|
[Fact]
|
|
public void MembershipChange_TryParse_returns_null_for_invalid_command()
|
|
{
|
|
RaftMembershipChange.TryParse("some-random-command").ShouldBeNull();
|
|
RaftMembershipChange.TryParse("UnknownType:n1").ShouldBeNull();
|
|
RaftMembershipChange.TryParse("AddPeer:").ShouldBeNull();
|
|
}
|
|
|
|
// -- ProposeAddPeerAsync tests --
|
|
|
|
[Fact]
|
|
public async Task Add_peer_succeeds_as_leader()
|
|
{
|
|
// Go reference: raft.go:961-990 (proposeAddPeer succeeds when leader)
|
|
var (nodes, _) = CreateCluster(3);
|
|
var leader = ElectLeader(nodes);
|
|
|
|
var index = await leader.ProposeAddPeerAsync("n4", default);
|
|
index.ShouldBeGreaterThan(0);
|
|
leader.Members.ShouldContain("n4");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Add_peer_fails_when_not_leader()
|
|
{
|
|
// Go reference: raft.go:961 (leader check)
|
|
var node = new RaftNode("follower");
|
|
|
|
await Should.ThrowAsync<InvalidOperationException>(
|
|
async () => await node.ProposeAddPeerAsync("n2", default));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Add_peer_updates_peer_state_tracking()
|
|
{
|
|
// After adding a peer, the leader should track its replication state
|
|
var (nodes, _) = CreateCluster(3);
|
|
var leader = ElectLeader(nodes);
|
|
|
|
await leader.ProposeAddPeerAsync("n4", default);
|
|
|
|
var peerStates = leader.GetPeerStates();
|
|
peerStates.ShouldContainKey("n4");
|
|
peerStates["n4"].PeerId.ShouldBe("n4");
|
|
}
|
|
|
|
// -- ProposeRemovePeerAsync tests --
|
|
|
|
[Fact]
|
|
public async Task Remove_peer_succeeds()
|
|
{
|
|
// Go reference: raft.go:992-1019 (proposeRemovePeer succeeds)
|
|
var (nodes, _) = CreateCluster(3);
|
|
var leader = ElectLeader(nodes);
|
|
|
|
// n2 is a follower, should be removable
|
|
leader.Members.ShouldContain("n2");
|
|
var index = await leader.ProposeRemovePeerAsync("n2", default);
|
|
index.ShouldBeGreaterThan(0);
|
|
leader.Members.ShouldNotContain("n2");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Remove_peer_fails_for_self_while_leader()
|
|
{
|
|
// Go reference: leader must step down before removing itself
|
|
var (nodes, _) = CreateCluster(3);
|
|
var leader = ElectLeader(nodes);
|
|
|
|
await Should.ThrowAsync<InvalidOperationException>(
|
|
async () => await leader.ProposeRemovePeerAsync(leader.Id, default));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Remove_peer_fails_when_not_leader()
|
|
{
|
|
var node = new RaftNode("follower");
|
|
|
|
await Should.ThrowAsync<InvalidOperationException>(
|
|
async () => await node.ProposeRemovePeerAsync("n2", default));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Remove_peer_removes_from_peer_state_tracking()
|
|
{
|
|
// After removing a peer, its state should be cleaned up
|
|
var (nodes, _) = CreateCluster(3);
|
|
var leader = ElectLeader(nodes);
|
|
|
|
leader.GetPeerStates().ShouldContainKey("n2");
|
|
await leader.ProposeRemovePeerAsync("n2", default);
|
|
leader.GetPeerStates().ShouldNotContainKey("n2");
|
|
}
|
|
|
|
// -- Concurrent membership change rejection --
|
|
|
|
[Fact]
|
|
public async Task Concurrent_membership_changes_rejected()
|
|
{
|
|
// Go reference: raft.go single-change invariant — only one in-flight at a time
|
|
var (nodes, _) = CreateCluster(3);
|
|
var leader = ElectLeader(nodes);
|
|
|
|
// The first add should succeed
|
|
await leader.ProposeAddPeerAsync("n4", default);
|
|
|
|
// Since the first completed synchronously via in-memory transport,
|
|
// the in-flight flag is cleared. Verify the flag mechanism works by
|
|
// checking the property is false after completion.
|
|
leader.MembershipChangeInProgress.ShouldBeFalse();
|
|
}
|
|
|
|
// -- Membership change updates member list on commit --
|
|
|
|
[Fact]
|
|
public async Task Membership_change_updates_member_list_on_commit()
|
|
{
|
|
// Go reference: membership applied after quorum commit
|
|
var (nodes, _) = CreateCluster(3);
|
|
var leader = ElectLeader(nodes);
|
|
|
|
var membersBefore = leader.Members.Count;
|
|
await leader.ProposeAddPeerAsync("n4", default);
|
|
leader.Members.Count.ShouldBe(membersBefore + 1);
|
|
leader.Members.ShouldContain("n4");
|
|
|
|
await leader.ProposeRemovePeerAsync("n4", default);
|
|
leader.Members.Count.ShouldBe(membersBefore);
|
|
leader.Members.ShouldNotContain("n4");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Add_peer_creates_log_entry()
|
|
{
|
|
// The membership change should appear in the RAFT log
|
|
var (nodes, _) = CreateCluster(3);
|
|
var leader = ElectLeader(nodes);
|
|
|
|
var logCountBefore = leader.Log.Entries.Count;
|
|
await leader.ProposeAddPeerAsync("n4", default);
|
|
leader.Log.Entries.Count.ShouldBe(logCountBefore + 1);
|
|
leader.Log.Entries[^1].Command.ShouldContain("n4");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Remove_peer_creates_log_entry()
|
|
{
|
|
var (nodes, _) = CreateCluster(3);
|
|
var leader = ElectLeader(nodes);
|
|
|
|
var logCountBefore = leader.Log.Entries.Count;
|
|
await leader.ProposeRemovePeerAsync("n2", default);
|
|
leader.Log.Entries.Count.ShouldBe(logCountBefore + 1);
|
|
leader.Log.Entries[^1].Command.ShouldContain("n2");
|
|
}
|
|
}
|