Files
natsdotnet/tests/NATS.Server.Tests/Raft/RaftMembershipTests.cs
Joseph Doherty 824e0b3607 feat(raft): add membership proposals, snapshot checkpoints, and log compaction (B4+B5+B6)
- 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
2026-02-24 17:08:59 -05:00

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");
}
}