Files
natsdotnet/tests/NATS.Server.Tests/Raft/RaftGoParityTests.cs

1385 lines
52 KiB
C#

// Go parity: golang/nats-server/server/raft_test.go
// Covers the behavioral intent of the Go NRG (NATS RAFT Group) tests,
// ported to the .NET RaftNode / RaftLog / RaftSnapshot infrastructure.
// Each test cites the corresponding Go function and approximate line.
using NATS.Server.Raft;
namespace NATS.Server.Tests.Raft;
/// <summary>
/// Go-parity tests for the NATS RAFT implementation. Tests cover election,
/// log replication, snapshot/catchup, membership changes, quorum accounting,
/// observer mode semantics, and peer tracking. Each test cites the Go test
/// function it maps to in server/raft_test.go.
/// </summary>
public class RaftGoParityTests
{
// ---------------------------------------------------------------
// 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;
}
private static (RaftNode leader, RaftNode[] followers) CreateLeaderWithFollowers(int followerCount)
{
var total = followerCount + 1;
var nodes = Enumerable.Range(1, total)
.Select(i => new RaftNode($"n{i}"))
.ToArray();
foreach (var n in nodes)
n.ConfigureCluster(nodes);
var candidate = nodes[0];
candidate.StartElection(total);
foreach (var voter in nodes.Skip(1))
candidate.ReceiveVote(voter.GrantVote(candidate.Term, candidate.Id), total);
return (candidate, nodes.Skip(1).ToArray());
}
// ---------------------------------------------------------------
// Go: TestNRGSimple server/raft_test.go:35
// ---------------------------------------------------------------
// Go reference: TestNRGSimple — basic single-node leader election
[Fact]
public void Single_node_becomes_leader_on_election()
{
var node = new RaftNode("solo");
node.StartElection(clusterSize: 1);
node.IsLeader.ShouldBeTrue();
node.Term.ShouldBe(1);
node.Role.ShouldBe(RaftRole.Leader);
}
// Go reference: TestNRGSimple — three-node cluster elects one leader
[Fact]
public void Three_node_cluster_elects_single_leader()
{
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
leader.IsLeader.ShouldBeTrue();
nodes.Count(n => n.IsLeader).ShouldBe(1);
nodes.Count(n => n.Role == RaftRole.Follower).ShouldBe(2);
}
// Go reference: TestNRGSimple — term increments on election start
[Fact]
public void Term_increments_on_each_election()
{
var node = new RaftNode("n1");
node.Term.ShouldBe(0);
node.StartElection(1);
node.Term.ShouldBe(1);
node.RequestStepDown();
node.StartElection(1);
node.Term.ShouldBe(2);
}
// ---------------------------------------------------------------
// Go: TestNRGSimpleElection server/raft_test.go:296
// ---------------------------------------------------------------
// Go reference: TestNRGSimpleElection — five-node election
[Fact]
public void Five_node_cluster_elects_leader_with_three_vote_quorum()
{
var (nodes, _) = CreateCluster(5);
var leader = ElectLeader(nodes);
leader.IsLeader.ShouldBeTrue();
}
// Go reference: TestNRGSimpleElection — candidate self-votes
[Fact]
public void Candidate_records_self_vote_on_start()
{
var node = new RaftNode("n1");
node.StartElection(clusterSize: 3);
node.Role.ShouldBe(RaftRole.Candidate);
node.TermState.VotedFor.ShouldBe("n1");
}
// Go reference: TestNRGSimpleElection — two votes out of three wins
[Fact]
public void Majority_vote_wins_three_node_election()
{
var (nodes, _) = CreateCluster(3);
var candidate = nodes[0];
candidate.StartElection(nodes.Length);
candidate.IsLeader.ShouldBeFalse(); // only self-vote so far
var vote = nodes[1].GrantVote(candidate.Term, candidate.Id);
vote.Granted.ShouldBeTrue();
candidate.ReceiveVote(vote, nodes.Length);
candidate.IsLeader.ShouldBeTrue();
}
// ---------------------------------------------------------------
// Go: TestNRGSingleNodeElection server/raft_test.go:?
// ---------------------------------------------------------------
// Go reference: TestNRGSingleNodeElection — single node after peers removed elects itself
[Fact]
public void Single_remaining_node_can_elect_itself()
{
var node = new RaftNode("solo2");
node.StartElection(1);
node.IsLeader.ShouldBeTrue();
}
// ---------------------------------------------------------------
// Go: TestNRGInlineStepdown server/raft_test.go:194
// ---------------------------------------------------------------
// Go reference: TestNRGInlineStepdown — leader transitions to follower on stepdown
[Fact]
public void Leader_becomes_follower_on_stepdown()
{
var node = new RaftNode("n1");
node.StartElection(1);
node.IsLeader.ShouldBeTrue();
node.RequestStepDown();
node.IsLeader.ShouldBeFalse();
node.Role.ShouldBe(RaftRole.Follower);
}
// Go reference: TestNRGInlineStepdown — stepdown clears voted-for
[Fact]
public void Stepdown_clears_voted_for_state()
{
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
leader.IsLeader.ShouldBeTrue();
leader.RequestStepDown();
leader.TermState.VotedFor.ShouldBeNull();
}
// ---------------------------------------------------------------
// Go: TestNRGRecoverFromFollowingNoLeader server/raft_test.go:154
// ---------------------------------------------------------------
// Go reference: TestNRGRecoverFromFollowingNoLeader — higher-term heartbeat causes stepdown
[Fact]
public void Higher_term_heartbeat_causes_candidate_to_become_follower()
{
var node = new RaftNode("n1");
node.StartElection(3);
node.Role.ShouldBe(RaftRole.Candidate);
node.ReceiveHeartbeat(term: 5);
node.Role.ShouldBe(RaftRole.Follower);
node.Term.ShouldBe(5);
}
// Go reference: TestNRGRecoverFromFollowingNoLeader — leader steps down on higher-term HB
[Fact]
public void Leader_steps_down_on_higher_term_heartbeat()
{
var node = new RaftNode("n1");
node.StartElection(1);
node.IsLeader.ShouldBeTrue();
node.ReceiveHeartbeat(term: 10);
node.Role.ShouldBe(RaftRole.Follower);
node.Term.ShouldBe(10);
}
// Go reference: TestNRGRecoverFromFollowingNoLeader — lower-term heartbeat is ignored
[Fact]
public void Stale_heartbeat_is_ignored()
{
var node = new RaftNode("n1");
node.StartElection(1);
node.IsLeader.ShouldBeTrue();
node.Term.ShouldBe(1);
node.ReceiveHeartbeat(term: 0);
node.IsLeader.ShouldBeTrue();
node.Term.ShouldBe(1);
}
// ---------------------------------------------------------------
// Go: TestNRGStepDownOnSameTermDoesntClearVote server/raft_test.go:447
// ---------------------------------------------------------------
// Go reference: TestNRGStepDownOnSameTermDoesntClearVote — same term vote denied to second candidate
[Fact]
public void Vote_denied_to_second_candidate_in_same_term()
{
var voter = new RaftNode("voter");
voter.GrantVote(term: 1, candidateId: "candidate-a").Granted.ShouldBeTrue();
voter.GrantVote(term: 1, candidateId: "candidate-b").Granted.ShouldBeFalse();
voter.TermState.VotedFor.ShouldBe("candidate-a");
}
// ---------------------------------------------------------------
// Go: TestNRGAssumeHighTermAfterCandidateIsolation server/raft_test.go:662
// ---------------------------------------------------------------
// Go reference: TestNRGAssumeHighTermAfterCandidateIsolation — isolated candidate bumps term high
[Fact]
public void Vote_request_with_high_term_updates_receiver_term()
{
var voter = new RaftNode("voter");
voter.TermState.CurrentTerm = 5;
var resp = voter.GrantVote(term: 100, candidateId: "isolated");
voter.TermState.CurrentTerm.ShouldBe(100);
}
// ---------------------------------------------------------------
// Go: TestNRGCandidateDoesntRevertTermAfterOldAE server/raft_test.go:792
// ---------------------------------------------------------------
// Go reference: TestNRGCandidateDoesntRevertTermAfterOldAE — stale heartbeat does not revert term
[Fact]
public void Stale_heartbeat_does_not_revert_candidate_term()
{
var node = new RaftNode("n1");
node.StartElection(3); // term = 1
node.StartElection(3); // term = 2
node.Term.ShouldBe(2);
node.ReceiveHeartbeat(term: 1); // stale
node.Term.ShouldBe(2);
}
// ---------------------------------------------------------------
// Go: TestNRGCandidateDontStepdownDueToLeaderOfPreviousTerm server/raft_test.go:972
// ---------------------------------------------------------------
// Go reference: TestNRGCandidateDontStepdownDueToLeaderOfPreviousTerm
[Fact]
public void Candidate_ignores_heartbeat_from_previous_term_leader()
{
var node = new RaftNode("n1");
node.TermState.CurrentTerm = 10;
node.StartElection(3); // term = 11
node.Role.ShouldBe(RaftRole.Candidate);
node.ReceiveHeartbeat(term: 5);
node.Role.ShouldBe(RaftRole.Candidate);
node.Term.ShouldBe(11);
}
// ---------------------------------------------------------------
// Go: TestNRGHeartbeatOnLeaderChange server/raft_test.go:708
// ---------------------------------------------------------------
// Go reference: TestNRGHeartbeatOnLeaderChange — heartbeat updates follower term
[Fact]
public void Heartbeat_updates_follower_to_new_term()
{
var follower = new RaftNode("f1");
follower.TermState.CurrentTerm = 2;
follower.ReceiveHeartbeat(term: 7);
follower.Term.ShouldBe(7);
follower.Role.ShouldBe(RaftRole.Follower);
}
// ---------------------------------------------------------------
// Go: TestNRGLeaderTransfer server/raft_test.go:?
// ---------------------------------------------------------------
// Go reference: TestNRGLeaderTransfer — leadership transfers via stepdown and re-election
[Fact]
public void Leadership_transfer_via_stepdown_and_reelection()
{
var (nodes, _) = CreateCluster(3);
var firstLeader = ElectLeader(nodes);
firstLeader.IsLeader.ShouldBeTrue();
firstLeader.RequestStepDown();
firstLeader.IsLeader.ShouldBeFalse();
// Elect a different node
var newCandidate = nodes.First(n => n.Id != firstLeader.Id);
newCandidate.StartElection(nodes.Length);
foreach (var voter in nodes.Where(n => n.Id != newCandidate.Id))
newCandidate.ReceiveVote(voter.GrantVote(newCandidate.Term, newCandidate.Id), nodes.Length);
newCandidate.IsLeader.ShouldBeTrue();
newCandidate.Id.ShouldNotBe(firstLeader.Id);
}
// ---------------------------------------------------------------
// Go: TestNRGMustNotResetVoteOnStepDownOrLeaderTransfer server/raft_test.go:?
// ---------------------------------------------------------------
// Go reference: TestNRGMustNotResetVoteOnStepDownOrLeaderTransfer — vote not reset on same-term stepdown
[Fact]
public void Stepdown_preserves_vote_state_until_new_term()
{
var voter = new RaftNode("voter");
voter.GrantVote(term: 1, candidateId: "a").Granted.ShouldBeTrue();
voter.TermState.VotedFor.ShouldBe("a");
// Receiving a same-term heartbeat (stepdown) from a leader should NOT clear the vote
voter.ReceiveHeartbeat(term: 1);
// Vote should remain — same term heartbeat does not clear votedFor
voter.TermState.VotedFor.ShouldBe("a");
}
// ---------------------------------------------------------------
// Go: TestNRGVoteResponseEncoding server/raft_test.go:?
// ---------------------------------------------------------------
// Go reference: TestNRGVoteResponseEncoding — vote response round-trip
[Fact]
public void Vote_response_carries_granted_true_on_success()
{
var voter = new RaftNode("voter");
var resp = voter.GrantVote(term: 3, candidateId: "cand");
resp.Granted.ShouldBeTrue();
}
// Go reference: TestNRGVoteResponseEncoding — denied vote carries granted=false
[Fact]
public void Vote_response_carries_granted_false_on_denial()
{
var voter = new RaftNode("voter");
voter.GrantVote(term: 1, candidateId: "a"); // vote for a in term 1
var denied = voter.GrantVote(term: 1, candidateId: "b"); // denied
denied.Granted.ShouldBeFalse();
}
// ---------------------------------------------------------------
// Go: TestNRGSimple server/raft_test.go:35 — log replication
// ---------------------------------------------------------------
// Go reference: TestNRGSimple — propose adds entry to leader log
[Fact]
public async Task Leader_propose_adds_entry_to_log()
{
var (leader, _) = CreateLeaderWithFollowers(2);
var idx = await leader.ProposeAsync("set-x=1", default);
idx.ShouldBe(1);
leader.Log.Entries.Count.ShouldBe(1);
leader.Log.Entries[0].Command.ShouldBe("set-x=1");
}
// Go reference: TestNRGSimple — follower receives replicated entry
[Fact]
public async Task Followers_receive_replicated_entries()
{
var (leader, followers) = CreateLeaderWithFollowers(2);
await leader.ProposeAsync("replicated-cmd", default);
foreach (var f in followers)
{
f.Log.Entries.Count.ShouldBe(1);
f.Log.Entries[0].Command.ShouldBe("replicated-cmd");
}
}
// Go reference: TestNRGSimple — commit index advances after quorum
[Fact]
public async Task Commit_index_advances_after_quorum_replication()
{
var (leader, _) = CreateLeaderWithFollowers(2);
await leader.ProposeAsync("committed", default);
leader.AppliedIndex.ShouldBeGreaterThan(0);
}
// Go reference: TestNRGSimple — sequential indices preserved
[Fact]
public async Task Sequential_proposals_use_monotonically_increasing_indices()
{
var (leader, _) = CreateLeaderWithFollowers(2);
var i1 = await leader.ProposeAsync("cmd-1", default);
var i2 = await leader.ProposeAsync("cmd-2", default);
var i3 = await leader.ProposeAsync("cmd-3", default);
i1.ShouldBe(1);
i2.ShouldBe(2);
i3.ShouldBe(3);
}
// ---------------------------------------------------------------
// Go: TestNRGWALEntryWithoutQuorumMustTruncate server/raft_test.go:1063
// ---------------------------------------------------------------
// Go reference: TestNRGWALEntryWithoutQuorumMustTruncate — follower cannot propose
[Fact]
public async Task Follower_throws_on_propose()
{
var (_, followers) = CreateLeaderWithFollowers(2);
await Should.ThrowAsync<InvalidOperationException>(
async () => await followers[0].ProposeAsync("should-fail", default));
}
// ---------------------------------------------------------------
// Go: TestNRGTermNoDecreaseAfterWALReset server/raft_test.go:1156
// ---------------------------------------------------------------
// Go reference: TestNRGTermNoDecreaseAfterWALReset — stale-term append rejected
[Fact]
public async Task Stale_term_append_entry_is_rejected()
{
var node = new RaftNode("n1");
node.StartElection(1); // term = 1
var stale = new RaftLogEntry(Index: 1, Term: 0, Command: "stale");
await Should.ThrowAsync<InvalidOperationException>(
async () => await node.TryAppendFromLeaderAsync(stale, default));
}
// Go reference: TestNRGTermNoDecreaseAfterWALReset — current-term append accepted
[Fact]
public async Task Current_term_append_entry_is_accepted()
{
var node = new RaftNode("n1");
node.TermState.CurrentTerm = 3;
var entry = new RaftLogEntry(Index: 1, Term: 3, Command: "valid");
await node.TryAppendFromLeaderAsync(entry, default);
node.Log.Entries.Count.ShouldBe(1);
}
// ---------------------------------------------------------------
// Go: TestNRGNoResetOnAppendEntryResponse server/raft_test.go:912
// ---------------------------------------------------------------
// Go reference: TestNRGNoResetOnAppendEntryResponse — no quorum means applied stays 0
[Fact]
public async Task Propose_without_follower_quorum_does_not_advance_applied()
{
// Single node is its own quorum, so use a special test node
var node = new RaftNode("n1");
node.StartElection(5); // needs 3 votes but only has 1
node.IsLeader.ShouldBeFalse(); // candidate, not leader
// Only leader can propose — this tests that the gate works
await Should.ThrowAsync<InvalidOperationException>(
async () => await node.ProposeAsync("no-quorum", default));
}
// ---------------------------------------------------------------
// Go: TestNRGSnapshotAndRestart server/raft_test.go:49
// ---------------------------------------------------------------
// Go reference: TestNRGSnapshotAndRestart — snapshot creation captures index and term
[Fact]
public async Task Snapshot_creation_records_applied_index_and_term()
{
var (leader, _) = CreateLeaderWithFollowers(2);
await leader.ProposeAsync("cmd-a", default);
await leader.ProposeAsync("cmd-b", default);
var snap = await leader.CreateSnapshotAsync(default);
snap.LastIncludedIndex.ShouldBe(leader.AppliedIndex);
snap.LastIncludedTerm.ShouldBe(leader.Term);
}
// Go reference: TestNRGSnapshotAndRestart — installing snapshot updates applied index
[Fact]
public async Task Installing_snapshot_updates_applied_index_on_follower()
{
var (leader, _) = CreateLeaderWithFollowers(2);
await leader.ProposeAsync("snap-1", default);
await leader.ProposeAsync("snap-2", default);
var snap = await leader.CreateSnapshotAsync(default);
var newNode = new RaftNode("latecomer");
await newNode.InstallSnapshotAsync(snap, default);
newNode.AppliedIndex.ShouldBe(snap.LastIncludedIndex);
}
// Go reference: TestNRGSnapshotAndRestart — log is cleared after snapshot install
[Fact]
public async Task Log_is_cleared_when_snapshot_is_installed()
{
var (leader, _) = CreateLeaderWithFollowers(2);
await leader.ProposeAsync("pre-snap", default);
var snap = await leader.CreateSnapshotAsync(default);
var follower = new RaftNode("f-snap");
await follower.InstallSnapshotAsync(snap, default);
follower.Log.Entries.Count.ShouldBe(0);
}
// ---------------------------------------------------------------
// Go: TestNRGSnapshotCatchup / TestNRGSimpleCatchup server/raft_test.go
// ---------------------------------------------------------------
// Go reference: TestNRGSimpleCatchup — lagging follower catches up via log entries
[Fact]
public async Task Lagging_follower_catches_up_via_replicated_entries()
{
var (leader, followers) = CreateLeaderWithFollowers(2);
await leader.ProposeAsync("e1", default);
await leader.ProposeAsync("e2", default);
await leader.ProposeAsync("e3", default);
followers[0].Log.Entries.Count.ShouldBe(3);
}
// Go reference: TestNRGSnapshotCatchup — snapshot + subsequent entries applied correctly
[Fact]
public async Task Snapshot_install_followed_by_new_entries_uses_correct_base_index()
{
var (leader, _) = CreateLeaderWithFollowers(2);
await leader.ProposeAsync("early", default);
var snap = await leader.CreateSnapshotAsync(default);
var newNode = new RaftNode("catchup");
await newNode.InstallSnapshotAsync(snap, default);
// After snapshot, new log entries should continue from snapshot index
var postEntry = newNode.Log.Append(term: 1, command: "post-snap");
postEntry.Index.ShouldBe(snap.LastIncludedIndex + 1);
}
// ---------------------------------------------------------------
// Go: TestNRGDrainAndReplaySnapshot server/raft_test.go
// ---------------------------------------------------------------
// Go reference: TestNRGDrainAndReplaySnapshot — DrainAndReplaySnapshot resets commit queue
[Fact]
public async Task DrainAndReplaySnapshot_advances_applied_and_commit_indices()
{
var (leader, _) = CreateLeaderWithFollowers(2);
await leader.ProposeAsync("pre", default);
var snap = new RaftSnapshot { LastIncludedIndex = 50, LastIncludedTerm = 2 };
await leader.DrainAndReplaySnapshotAsync(snap, default);
leader.AppliedIndex.ShouldBe(50);
leader.CommitIndex.ShouldBe(50);
}
// Go reference: TestNRGDrainAndReplaySnapshot — log is replaced by snapshot
[Fact]
public async Task DrainAndReplaySnapshot_replaces_log()
{
var (leader, _) = CreateLeaderWithFollowers(2);
await leader.ProposeAsync("a", default);
await leader.ProposeAsync("b", default);
var snap = new RaftSnapshot { LastIncludedIndex = 5, LastIncludedTerm = 1 };
await leader.DrainAndReplaySnapshotAsync(snap, default);
leader.Log.Entries.Count.ShouldBe(0);
leader.Log.BaseIndex.ShouldBe(5);
}
// ---------------------------------------------------------------
// Go: TestNRGSnapshotAndTruncateToApplied server/raft_test.go
// ---------------------------------------------------------------
// Go reference: TestNRGSnapshotAndTruncateToApplied — checkpoint compacts log
[Fact]
public async Task Snapshot_checkpoint_compacts_log_to_applied_index()
{
var (leader, _) = CreateLeaderWithFollowers(2);
await leader.ProposeAsync("a", default);
await leader.ProposeAsync("b", default);
await leader.ProposeAsync("c", default);
leader.Log.Entries.Count.ShouldBe(3);
await leader.CreateSnapshotCheckpointAsync(default);
leader.Log.Entries.Count.ShouldBe(0);
}
// Go reference: TestNRGSnapshotAndTruncateToApplied — base index matches snapshot
[Fact]
public async Task Snapshot_checkpoint_sets_base_index_to_applied()
{
var (leader, _) = CreateLeaderWithFollowers(2);
await leader.ProposeAsync("x", default);
await leader.ProposeAsync("y", default);
var applied = leader.AppliedIndex;
await leader.CreateSnapshotCheckpointAsync(default);
leader.Log.BaseIndex.ShouldBe(applied);
}
// ---------------------------------------------------------------
// Go: TestNRGIgnoreDoubleSnapshot server/raft_test.go
// ---------------------------------------------------------------
// Go reference: TestNRGIgnoreDoubleSnapshot — installing same snapshot twice is idempotent
[Fact]
public async Task Installing_same_snapshot_twice_is_idempotent()
{
var node = new RaftNode("n1");
var snap = new RaftSnapshot { LastIncludedIndex = 10, LastIncludedTerm = 1 };
await node.InstallSnapshotAsync(snap, default);
await node.InstallSnapshotAsync(snap, default);
node.AppliedIndex.ShouldBe(10);
node.Log.Entries.Count.ShouldBe(0);
}
// ---------------------------------------------------------------
// Go: TestNRGProposeRemovePeer server/raft_test.go
// ---------------------------------------------------------------
// Go reference: TestNRGProposeRemovePeer — remove follower peer succeeds
[Fact]
public async Task Remove_peer_removes_member_from_cluster()
{
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
leader.Members.ShouldContain("n2");
await leader.ProposeRemovePeerAsync("n2", default);
leader.Members.ShouldNotContain("n2");
}
// Go reference: TestNRGProposeRemovePeer — remove creates log entry
[Fact]
public async Task Remove_peer_creates_log_entry()
{
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
var before = leader.Log.Entries.Count;
await leader.ProposeRemovePeerAsync("n2", default);
leader.Log.Entries.Count.ShouldBe(before + 1);
}
// Go reference: TestNRGProposeRemovePeerLeader — leader cannot remove itself
[Fact]
public async Task Leader_cannot_remove_itself()
{
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
await Should.ThrowAsync<InvalidOperationException>(
async () => await leader.ProposeRemovePeerAsync(leader.Id, default));
}
// ---------------------------------------------------------------
// Go: TestNRGAddPeers server/raft_test.go
// ---------------------------------------------------------------
// Go reference: TestNRGAddPeers — add peer adds to member set
[Fact]
public async Task Add_peer_adds_to_member_set()
{
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
leader.Members.ShouldNotContain("n4");
await leader.ProposeAddPeerAsync("n4", default);
leader.Members.ShouldContain("n4");
}
// Go reference: TestNRGAddPeers — add peer creates log entry
[Fact]
public async Task Add_peer_creates_log_entry()
{
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
var before = leader.Log.Entries.Count;
await leader.ProposeAddPeerAsync("n4", default);
leader.Log.Entries.Count.ShouldBe(before + 1);
}
// Go reference: TestNRGAddPeers — add peer tracks peer state
[Fact]
public async Task Add_peer_initializes_peer_state_tracking()
{
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
await leader.ProposeAddPeerAsync("n4", default);
leader.GetPeerStates().ShouldContainKey("n4");
}
// ---------------------------------------------------------------
// Go: TestNRGProposeRemovePeerAll server/raft_test.go
// ---------------------------------------------------------------
// Go reference: TestNRGProposeRemovePeerAll — removing all followers leaves single node
[Fact]
public async Task Removing_all_followers_leaves_single_leader_node()
{
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
await leader.ProposeRemovePeerAsync("n2", default);
await leader.ProposeRemovePeerAsync("n3", default);
leader.Members.Count.ShouldBe(1);
leader.Members.ShouldContain(leader.Id);
}
// ---------------------------------------------------------------
// Go: TestNRGLeaderResurrectsRemovedPeers server/raft_test.go
// ---------------------------------------------------------------
// Go reference: TestNRGLeaderResurrectsRemovedPeers — can re-add a previously removed peer
[Fact]
public async Task Previously_removed_peer_can_be_re_added()
{
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
await leader.ProposeRemovePeerAsync("n2", default);
leader.Members.ShouldNotContain("n2");
await leader.ProposeAddPeerAsync("n2", default);
leader.Members.ShouldContain("n2");
}
// ---------------------------------------------------------------
// Go: TestNRGUncommittedMembershipChangeGetsTruncated server/raft_test.go
// ---------------------------------------------------------------
// Go reference: TestNRGUncommittedMembershipChangeGetsTruncated — membership change in-progress flag clears
[Fact]
public async Task Membership_change_in_progress_flag_clears_after_completion()
{
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
await leader.ProposeAddPeerAsync("n4", default);
leader.MembershipChangeInProgress.ShouldBeFalse();
}
// ---------------------------------------------------------------
// Go: TestNRGProposeRemovePeerQuorum server/raft_test.go
// ---------------------------------------------------------------
// Go reference: TestNRGProposeRemovePeerQuorum — remove/add sequence maintains quorum
[Fact]
public async Task Sequential_add_and_remove_maintains_consistent_member_count()
{
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
var before = leader.Members.Count;
await leader.ProposeAddPeerAsync("n4", default);
leader.Members.Count.ShouldBe(before + 1);
await leader.ProposeRemovePeerAsync("n4", default);
leader.Members.Count.ShouldBe(before);
}
// ---------------------------------------------------------------
// Go: TestNRGReplayAddPeerKeepsClusterSize server/raft_test.go
// ---------------------------------------------------------------
// Go reference: TestNRGReplayAddPeerKeepsClusterSize — cluster size accurate after membership change
[Fact]
public async Task Cluster_size_reflects_membership_changes_correctly()
{
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
leader.Members.Count.ShouldBe(3);
await leader.ProposeAddPeerAsync("n4", default);
leader.Members.Count.ShouldBe(4);
await leader.ProposeRemovePeerAsync("n4", default);
leader.Members.Count.ShouldBe(3);
}
// ---------------------------------------------------------------
// Go: TestNRGInitSingleMemRaftNodeDefaults server/raft_test.go
// ---------------------------------------------------------------
// Go reference: TestNRGInitSingleMemRaftNodeDefaults — fresh node has expected defaults
[Fact]
public void New_node_has_zero_term_and_follower_role()
{
var node = new RaftNode("defaults-test");
node.Term.ShouldBe(0);
node.Role.ShouldBe(RaftRole.Follower);
node.IsLeader.ShouldBeFalse();
node.AppliedIndex.ShouldBe(0);
}
// Go reference: TestNRGInitSingleMemRaftNodeDefaults — fresh node has itself as sole member
[Fact]
public void New_node_contains_itself_as_initial_member()
{
var node = new RaftNode("solo-member");
node.Members.ShouldContain("solo-member");
}
// Go reference: TestNRGInitSingleMemRaftNodeDefaults — fresh node has empty log
[Fact]
public void New_node_has_empty_log()
{
var node = new RaftNode("empty-log");
node.Log.Entries.Count.ShouldBe(0);
node.Log.BaseIndex.ShouldBe(0);
}
// ---------------------------------------------------------------
// Go: TestNRGProcessed server/raft_test.go
// ---------------------------------------------------------------
// Go reference: TestNRGProcessed — MarkProcessed advances processed index
[Fact]
public void MarkProcessed_advances_processed_index()
{
var node = new RaftNode("proc-test");
node.ProcessedIndex.ShouldBe(0);
node.MarkProcessed(5);
node.ProcessedIndex.ShouldBe(5);
node.MarkProcessed(3); // lower value should not regress
node.ProcessedIndex.ShouldBe(5);
}
// Go reference: TestNRGProcessed — processed index does not regress
[Fact]
public void MarkProcessed_does_not_allow_regression()
{
var node = new RaftNode("proc-floor");
node.MarkProcessed(10);
node.MarkProcessed(5);
node.ProcessedIndex.ShouldBe(10);
}
// ---------------------------------------------------------------
// Go: TestNRGSizeAndApplied server/raft_test.go
// ---------------------------------------------------------------
// Go reference: TestNRGSizeAndApplied — applied index matches number of committed entries
[Fact]
public async Task Applied_index_matches_committed_entry_count()
{
var (leader, _) = CreateLeaderWithFollowers(2);
await leader.ProposeAsync("e1", default);
await leader.ProposeAsync("e2", default);
await leader.ProposeAsync("e3", default);
leader.AppliedIndex.ShouldBe(3);
}
// ---------------------------------------------------------------
// Go: TestNRGForwardProposalResponse server/raft_test.go
// ---------------------------------------------------------------
// Go reference: TestNRGForwardProposalResponse — follower can receive entries from leader
[Fact]
public async Task Follower_can_receive_entries_forwarded_from_leader()
{
var follower = new RaftNode("follower");
follower.TermState.CurrentTerm = 2;
var entry = new RaftLogEntry(Index: 1, Term: 2, Command: "forwarded");
await follower.TryAppendFromLeaderAsync(entry, default);
follower.Log.Entries.Count.ShouldBe(1);
follower.Log.Entries[0].Command.ShouldBe("forwarded");
}
// ---------------------------------------------------------------
// Go: TestNRGQuorumAccounting server/raft_test.go
// ---------------------------------------------------------------
// Go reference: TestNRGQuorumAccounting — correct quorum sizes for various cluster sizes
[Theory]
[InlineData(3, 2)]
[InlineData(5, 3)]
[InlineData(7, 4)]
public void Cluster_quorum_requires_majority_votes(int clusterSize, int neededVotes)
{
var node = new RaftNode("qtest");
node.StartElection(clusterSize);
node.IsLeader.ShouldBeFalse(); // only self-vote so far (2+ node cluster)
for (int i = 1; i < neededVotes; i++)
node.ReceiveVote(new VoteResponse { Granted = true }, clusterSize);
node.IsLeader.ShouldBeTrue();
}
// Go reference: TestNRGQuorumAccounting — single node cluster immediately becomes leader
[Fact]
public void Single_node_cluster_reaches_quorum_with_self_vote()
{
var node = new RaftNode("solo");
node.StartElection(clusterSize: 1);
node.IsLeader.ShouldBeTrue(); // single-node: self-vote is quorum
}
// ---------------------------------------------------------------
// Go: TestNRGTrackPeerActive server/raft_test.go
// ---------------------------------------------------------------
// Go reference: TestNRGTrackPeerActive — leader tracks peer states after cluster formation
[Fact]
public void Leader_tracks_peer_state_for_all_followers()
{
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
var peers = leader.GetPeerStates();
peers.ShouldContainKey("n2");
peers.ShouldContainKey("n3");
peers.ShouldNotContainKey("n1"); // self is not in peer states
}
// Go reference: TestNRGTrackPeerActive — peer state contains correct peer ID
[Fact]
public void Peer_state_contains_correct_peer_id()
{
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
var peers = leader.GetPeerStates();
peers["n2"].PeerId.ShouldBe("n2");
peers["n3"].PeerId.ShouldBe("n3");
}
// ---------------------------------------------------------------
// Go: TestNRGRevalidateQuorumAfterLeaderChange server/raft_test.go
// ---------------------------------------------------------------
// Go reference: TestNRGRevalidateQuorumAfterLeaderChange — new leader commits after re-election
[Fact]
public async Task New_leader_can_commit_entries_after_re_election()
{
var (nodes, _) = CreateCluster(3);
var firstLeader = ElectLeader(nodes);
await firstLeader.ProposeAsync("pre-stepdown", default);
firstLeader.RequestStepDown();
// Elect a new leader
var newLeader = nodes.First(n => n.Id != firstLeader.Id);
newLeader.StartElection(nodes.Length);
foreach (var v in nodes.Where(n => n.Id != newLeader.Id))
newLeader.ReceiveVote(v.GrantVote(newLeader.Term, newLeader.Id), nodes.Length);
newLeader.IsLeader.ShouldBeTrue();
var idx = await newLeader.ProposeAsync("post-election", default);
idx.ShouldBeGreaterThan(0);
newLeader.AppliedIndex.ShouldBeGreaterThan(0);
}
// ---------------------------------------------------------------
// Go: TestNRGQuorumAfterLeaderStepdown server/raft_test.go
// ---------------------------------------------------------------
// Go reference: TestNRGQuorumAfterLeaderStepdown — quorum maintained after leader stepdown
[Fact]
public void Cluster_maintains_quorum_after_leader_stepdown()
{
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
leader.IsLeader.ShouldBeTrue();
leader.RequestStepDown();
leader.IsLeader.ShouldBeFalse();
// The cluster can still elect a new leader
var newCandidate = nodes.First(n => n.Id != leader.Id);
newCandidate.StartElection(nodes.Length);
foreach (var v in nodes.Where(n => n.Id != newCandidate.Id))
newCandidate.ReceiveVote(v.GrantVote(newCandidate.Term, newCandidate.Id), nodes.Length);
newCandidate.IsLeader.ShouldBeTrue();
}
// ---------------------------------------------------------------
// Go: TestNRGSendAppendEntryNotLeader server/raft_test.go
// ---------------------------------------------------------------
// Go reference: TestNRGSendAppendEntryNotLeader — non-leader cannot propose
[Fact]
public async Task Non_leader_cannot_send_append_entries()
{
var node = new RaftNode("follower-node");
// node stays as follower, never elected
await Should.ThrowAsync<InvalidOperationException>(
async () => await node.ProposeAsync("should-reject", default));
}
// ---------------------------------------------------------------
// Go: TestNRGInstallSnapshotFromCheckpoint server/raft_test.go
// ---------------------------------------------------------------
// Go reference: TestNRGInstallSnapshotFromCheckpoint — chunked snapshot assembly
[Fact]
public async Task Chunked_snapshot_assembles_correctly()
{
var node = new RaftNode("n1");
var chunk1 = new byte[] { 0x01, 0x02, 0x03 };
var chunk2 = new byte[] { 0x04, 0x05, 0x06 };
await node.InstallSnapshotFromChunksAsync([chunk1, chunk2], snapshotIndex: 20, snapshotTerm: 3, default);
node.AppliedIndex.ShouldBe(20);
node.CommitIndex.ShouldBe(20);
}
// Go reference: TestNRGInstallSnapshotFromCheckpoint — snapshot clears log
[Fact]
public async Task Chunked_snapshot_clears_log()
{
var (leader, _) = CreateLeaderWithFollowers(2);
await leader.ProposeAsync("a", default);
await leader.InstallSnapshotFromChunksAsync([[0x01]], snapshotIndex: 10, snapshotTerm: 1, default);
leader.Log.Entries.Count.ShouldBe(0);
leader.Log.BaseIndex.ShouldBe(10);
}
// ---------------------------------------------------------------
// Go: TestNRGInstallSnapshotForce server/raft_test.go
// ---------------------------------------------------------------
// Go reference: TestNRGInstallSnapshotForce — forced snapshot installation overwrites state
[Fact]
public async Task Force_snapshot_install_overrides_higher_applied_index()
{
var node = new RaftNode("n1");
node.AppliedIndex = 100; // simulate advanced state
// Installing an older snapshot should reset to snapshot index
var snap = new RaftSnapshot { LastIncludedIndex = 50, LastIncludedTerm = 1 };
await node.InstallSnapshotAsync(snap, default);
node.AppliedIndex.ShouldBe(50);
}
// ---------------------------------------------------------------
// Go: TestNRGMultipleStopsDontPanic server/raft_test.go
// ---------------------------------------------------------------
// Go reference: TestNRGMultipleStopsDontPanic — multiple disposals do not throw
[Fact]
public void Multiple_disposals_do_not_throw()
{
var node = new RaftNode("n1");
Should.NotThrow(() => node.Dispose());
Should.NotThrow(() => node.Dispose());
}
// ---------------------------------------------------------------
// Go: TestNRGMemoryWALEmptiesSnapshotsDir server/raft_test.go
// ---------------------------------------------------------------
// Go reference: TestNRGMemoryWALEmptiesSnapshotsDir — log compaction empties entries
[Fact]
public async Task Log_compaction_removes_entries_below_snapshot_index()
{
var (leader, _) = CreateLeaderWithFollowers(2);
await leader.ProposeAsync("e1", default);
await leader.ProposeAsync("e2", default);
await leader.ProposeAsync("e3", default);
leader.Log.Entries.Count.ShouldBe(3);
leader.Log.Compact(2);
leader.Log.Entries.Count.ShouldBe(1); // only e3 remains
}
// ---------------------------------------------------------------
// Go: TestNRGDisjointMajorities server/raft_test.go
// ---------------------------------------------------------------
// Go reference: TestNRGDisjointMajorities — split cluster — two candidates, neither reaches quorum
[Fact]
public void Split_cluster_produces_no_leader_without_quorum()
{
var (nodes, _) = CreateCluster(5);
// n1 gets 2 votes (including self) out of 5 — not enough
nodes[0].StartElection(5);
nodes[0].ReceiveVote(new VoteResponse { Granted = true }, 5);
nodes[0].IsLeader.ShouldBeFalse(); // 2/5, needs 3
// n2 gets 2 votes (including self) out of 5 — not enough
nodes[1].StartElection(5);
nodes[1].ReceiveVote(new VoteResponse { Granted = true }, 5);
nodes[1].IsLeader.ShouldBeFalse(); // 2/5, needs 3
}
// ---------------------------------------------------------------
// Go: TestNRGAppendEntryResurrectsLeader server/raft_test.go
// ---------------------------------------------------------------
// Go reference: TestNRGAppendEntryResurrectsLeader — higher-term AE makes follower follow new leader
[Fact]
public void Higher_term_append_entry_switches_follower_to_new_term()
{
var follower = new RaftNode("f1");
follower.TermState.CurrentTerm = 2;
// Append entry from higher-term leader
follower.ReceiveHeartbeat(term: 5);
follower.Term.ShouldBe(5);
follower.Role.ShouldBe(RaftRole.Follower);
}
// ---------------------------------------------------------------
// Go: TestNRGObserverMode server/raft_test.go
// ---------------------------------------------------------------
// Go reference: TestNRGObserverMode — observer receives entries but does not campaign
[Fact]
public async Task Observer_node_receives_replicated_entries_without_campaigning()
{
// Observer = a follower that is told not to campaign
var observer = new RaftNode("observer");
observer.PreVoteEnabled = false; // disable pre-vote to prevent auto-campaigning
var entry = new RaftLogEntry(Index: 1, Term: 1, Command: "observed");
observer.ReceiveReplicatedEntry(entry);
observer.Log.Entries.Count.ShouldBe(1);
observer.Role.ShouldBe(RaftRole.Follower);
}
// ---------------------------------------------------------------
// Go: TestNRGAEFromOldLeader server/raft_test.go
// ---------------------------------------------------------------
// Go reference: TestNRGAEFromOldLeader — stale AE from old leader term rejected
[Fact]
public async Task Append_entry_from_stale_term_leader_is_rejected()
{
var node = new RaftNode("n1");
node.TermState.CurrentTerm = 5;
var staleEntry = new RaftLogEntry(Index: 1, Term: 2, Command: "stale-ae");
await Should.ThrowAsync<InvalidOperationException>(
async () => await node.TryAppendFromLeaderAsync(staleEntry, default));
}
// ---------------------------------------------------------------
// Go: TestNRGElectionTimerAfterObserver server/raft_test.go
// ---------------------------------------------------------------
// Go reference: TestNRGElectionTimerAfterObserver — election timer can be started and stopped
[Fact]
public void Election_timer_can_be_started_and_stopped_without_throwing()
{
var node = new RaftNode("timer-test");
Should.NotThrow(() => node.StartElectionTimer());
Should.NotThrow(() => node.StopElectionTimer());
}
// ---------------------------------------------------------------
// Go: TestNRGSnapshotRecovery server/raft_test.go
// ---------------------------------------------------------------
// Go reference: TestNRGSnapshotRecovery — snapshot followed by new entries produces correct index sequence
[Fact]
public async Task After_snapshot_new_entries_have_sequential_indices()
{
var node = new RaftNode("n1");
var snap = new RaftSnapshot { LastIncludedIndex = 10, LastIncludedTerm = 1 };
await node.InstallSnapshotAsync(snap, default);
var e1 = node.Log.Append(term: 2, command: "after-snap-1");
var e2 = node.Log.Append(term: 2, command: "after-snap-2");
e1.Index.ShouldBe(11);
e2.Index.ShouldBe(12);
}
// ---------------------------------------------------------------
// Go: TestNRGReplayOnSnapshotSameTerm / TestNRGReplayOnSnapshotDifferentTerm
// ---------------------------------------------------------------
// Go reference: TestNRGReplayOnSnapshotSameTerm — entries in same term as snapshot are handled
[Fact]
public async Task Entry_in_same_term_as_snapshot_is_accepted_after_install()
{
var node = new RaftNode("n1");
var snap = new RaftSnapshot { LastIncludedIndex = 5, LastIncludedTerm = 2 };
await node.InstallSnapshotAsync(snap, default);
node.TermState.CurrentTerm = 2;
var entry = new RaftLogEntry(Index: 6, Term: 2, Command: "same-term");
await node.TryAppendFromLeaderAsync(entry, default);
node.Log.Entries.Count.ShouldBe(1);
}
// Go reference: TestNRGReplayOnSnapshotDifferentTerm — entries in new term after snapshot
[Fact]
public async Task Entry_in_different_term_after_snapshot_is_accepted()
{
var node = new RaftNode("n1");
var snap = new RaftSnapshot { LastIncludedIndex = 5, LastIncludedTerm = 1 };
await node.InstallSnapshotAsync(snap, default);
node.TermState.CurrentTerm = 3;
var entry = new RaftLogEntry(Index: 6, Term: 3, Command: "new-term");
await node.TryAppendFromLeaderAsync(entry, default);
node.Log.Entries.Count.ShouldBe(1);
}
// ---------------------------------------------------------------
// Go: TestNRGTruncateDownToCommitted server/raft_test.go
// ---------------------------------------------------------------
// Go reference: TestNRGTruncateDownToCommitted — Compact removes entries up to committed index
[Fact]
public void Compact_removes_entries_up_to_given_index()
{
var log = new RaftLog();
log.Append(1, "a");
log.Append(1, "b");
log.Append(1, "c");
log.Append(1, "d");
log.Compact(2);
log.Entries.Count.ShouldBe(2);
log.Entries[0].Command.ShouldBe("c");
log.Entries[1].Command.ShouldBe("d");
}
// Go reference: TestNRGTruncateDownToCommitted — base index set to compact point
[Fact]
public void Compact_sets_base_index_correctly()
{
var log = new RaftLog();
log.Append(1, "a");
log.Append(1, "b");
log.Append(1, "c");
log.Compact(2);
log.BaseIndex.ShouldBe(2);
}
// ---------------------------------------------------------------
// Go: TestNRGPendingAppendEntryCacheInvalidation server/raft_test.go
// ---------------------------------------------------------------
// Go reference: TestNRGPendingAppendEntryCacheInvalidation — duplicate entries deduplicated
[Fact]
public void Duplicate_replicated_entries_are_deduplicated_by_index()
{
var log = new RaftLog();
var entry = new RaftLogEntry(Index: 1, Term: 1, Command: "once");
log.AppendReplicated(entry);
log.AppendReplicated(entry);
log.AppendReplicated(entry);
log.Entries.Count.ShouldBe(1);
}
// ---------------------------------------------------------------
// Go: TestNRGDontRemoveSnapshotIfTruncateToApplied server/raft_test.go
// ---------------------------------------------------------------
// Go reference: TestNRGDontRemoveSnapshotIfTruncateToApplied — snapshot data preserved
[Fact]
public async Task Snapshot_data_is_preserved_after_install()
{
var node = new RaftNode("n1");
var snap = new RaftSnapshot
{
LastIncludedIndex = 7,
LastIncludedTerm = 2,
Data = [0xAB, 0xCD]
};
await node.InstallSnapshotAsync(snap, default);
node.AppliedIndex.ShouldBe(7);
}
// ---------------------------------------------------------------
// Log base index continuity after repeated compactions
// ---------------------------------------------------------------
// Go reference: multiple compaction rounds produce correct running base index
[Fact]
public void Multiple_compaction_rounds_maintain_correct_base_index()
{
var log = new RaftLog();
for (int i = 0; i < 10; i++)
log.Append(1, $"cmd-{i}");
log.Compact(3);
log.BaseIndex.ShouldBe(3);
log.Entries.Count.ShouldBe(7);
log.Compact(7);
log.BaseIndex.ShouldBe(7);
log.Entries.Count.ShouldBe(3);
}
// ---------------------------------------------------------------
// Go: TestNRGHealthCheckWaitForCatchup (via peer state)
// ---------------------------------------------------------------
// Go reference: TestNRGHealthCheckWaitForCatchup — peer state reflects last contact
[Fact]
public void Peer_state_last_contact_updated_when_peer_responds()
{
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
var peerState = leader.GetPeerStates()["n2"];
// MatchIndex is 0 before any replication
peerState.MatchIndex.ShouldBe(0);
}
// Go reference: TestNRGHealthCheckWaitForCatchup — match index updates after proposal
[Fact]
public async Task Peer_match_index_updates_after_successful_replication()
{
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
await leader.ProposeAsync("sync-check", default);
var peerState = leader.GetPeerStates()["n2"];
peerState.MatchIndex.ShouldBeGreaterThan(0);
}
// ---------------------------------------------------------------
// Go: TestNRGSignalLeadChangeFalseIfCampaignImmediately server/raft_test.go
// ---------------------------------------------------------------
// Go reference: TestNRGSignalLeadChangeFalseIfCampaignImmediately — CampaignImmediately fires election
[Fact]
public void CampaignImmediately_triggers_election()
{
var (nodes, _) = CreateCluster(3);
// Disable pre-vote for direct testing
nodes[0].PreVoteEnabled = false;
nodes[0].ConfigureCluster(nodes);
nodes[0].CampaignImmediately();
// After campaign-immediate, node is at least a candidate
(nodes[0].Role == RaftRole.Candidate || nodes[0].Role == RaftRole.Leader).ShouldBeTrue();
}
}