1385 lines
52 KiB
C#
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();
|
|
}
|
|
}
|