feat: Waves 3-5 — FileStore, RAFT, JetStream clustering, and concurrency tests

Add comprehensive Go-parity test coverage across 3 subsystems:
- FileStore: basic CRUD, limits, purge, recovery, subjects, encryption,
  compression, MemStore (161 tests, 24 skipped for not-yet-implemented)
- RAFT: core types, wire format, election, log replication, snapshots
  (95 tests)
- JetStream Clustering: meta controller, stream/consumer replica groups,
  concurrency stress tests (90 tests)

Total: ~346 new test annotations across 17 files (+7,557 lines)
Full suite: 2,606 passing, 0 failures, 27 skipped
This commit is contained in:
Joseph Doherty
2026-02-23 22:55:41 -05:00
parent f1353868af
commit 3ff801865a
17 changed files with 7557 additions and 24 deletions

View File

@@ -0,0 +1,180 @@
using System.Text.Json;
using NATS.Server.Raft;
namespace NATS.Server.Tests.Raft;
/// <summary>
/// Tests for core RAFT types: RaftState/RaftRole enum values, RaftLogEntry record,
/// VoteRequest/VoteResponse, AppendResult, RaftTermState, RaftSnapshot construction.
/// Go: server/raft.go core type definitions and server/raft_test.go encoding tests.
/// </summary>
public class RaftCoreTypeTests
{
// Go: State constants in server/raft.go:50-54
[Fact]
public void RaftState_enum_has_correct_values()
{
((byte)RaftState.Follower).ShouldBe((byte)0);
((byte)RaftState.Leader).ShouldBe((byte)1);
((byte)RaftState.Candidate).ShouldBe((byte)2);
((byte)RaftState.Closed).ShouldBe((byte)3);
}
// Go: State constants in server/raft.go:50-54
[Fact]
public void RaftRole_enum_has_follower_candidate_leader()
{
RaftRole.Follower.ShouldBe((RaftRole)0);
RaftRole.Candidate.ShouldBe((RaftRole)1);
RaftRole.Leader.ShouldBe((RaftRole)2);
}
// Go: Entry type in server/raft.go:63-72
[Fact]
public void RaftLogEntry_record_equality()
{
var a = new RaftLogEntry(Index: 1, Term: 1, Command: "test");
var b = new RaftLogEntry(Index: 1, Term: 1, Command: "test");
a.ShouldBe(b);
(a == b).ShouldBeTrue();
}
// Go: Entry type in server/raft.go:63-72
[Fact]
public void RaftLogEntry_record_inequality_on_different_index()
{
var a = new RaftLogEntry(Index: 1, Term: 1, Command: "test");
var b = new RaftLogEntry(Index: 2, Term: 1, Command: "test");
a.ShouldNotBe(b);
(a != b).ShouldBeTrue();
}
// Go: Entry type in server/raft.go:63-72
[Fact]
public void RaftLogEntry_record_inequality_on_different_term()
{
var a = new RaftLogEntry(Index: 1, Term: 1, Command: "test");
var b = new RaftLogEntry(Index: 1, Term: 2, Command: "test");
a.ShouldNotBe(b);
}
// Go: Entry type in server/raft.go:63-72
[Fact]
public void RaftLogEntry_record_inequality_on_different_command()
{
var a = new RaftLogEntry(Index: 1, Term: 1, Command: "alpha");
var b = new RaftLogEntry(Index: 1, Term: 1, Command: "beta");
a.ShouldNotBe(b);
}
// Go: TestNRGAppendEntryEncode server/raft_test.go:82
[Fact]
public void RaftLogEntry_json_round_trip()
{
var original = new RaftLogEntry(Index: 42, Term: 7, Command: "set-key-value");
var json = JsonSerializer.Serialize(original);
json.ShouldNotBeNullOrWhiteSpace();
var decoded = JsonSerializer.Deserialize<RaftLogEntry>(json);
decoded.ShouldNotBeNull();
decoded.ShouldBe(original);
}
// Go: TestNRGAppendEntryEncode server/raft_test.go:82 — nil data case
[Fact]
public void RaftLogEntry_json_round_trip_empty_command()
{
var original = new RaftLogEntry(Index: 1, Term: 1, Command: string.Empty);
var json = JsonSerializer.Serialize(original);
var decoded = JsonSerializer.Deserialize<RaftLogEntry>(json);
decoded.ShouldNotBeNull();
decoded.Command.ShouldBe(string.Empty);
}
// Go: voteRequest struct in server/raft.go
[Fact]
public void VoteRequest_default_values()
{
var vr = new VoteRequest();
vr.Term.ShouldBe(0);
vr.CandidateId.ShouldBe(string.Empty);
}
// Go: voteRequest struct in server/raft.go
[Fact]
public void VoteRequest_init_properties()
{
var vr = new VoteRequest { Term = 5, CandidateId = "node-1" };
vr.Term.ShouldBe(5);
vr.CandidateId.ShouldBe("node-1");
}
// Go: voteResponse struct in server/raft.go
[Fact]
public void VoteResponse_granted_and_denied()
{
var granted = new VoteResponse { Granted = true };
granted.Granted.ShouldBeTrue();
var denied = new VoteResponse { Granted = false };
denied.Granted.ShouldBeFalse();
}
// Go: appendEntryResponse struct in server/raft.go
[Fact]
public void AppendResult_success_and_failure()
{
var success = new AppendResult { FollowerId = "f1", Success = true };
success.FollowerId.ShouldBe("f1");
success.Success.ShouldBeTrue();
var failure = new AppendResult { FollowerId = "f2", Success = false };
failure.Success.ShouldBeFalse();
}
// Go: raft term/vote state in server/raft.go
[Fact]
public void RaftTermState_initial_values()
{
var ts = new RaftTermState();
ts.CurrentTerm.ShouldBe(0);
ts.VotedFor.ShouldBeNull();
}
// Go: raft term/vote state in server/raft.go
[Fact]
public void RaftTermState_term_increment_and_vote()
{
var ts = new RaftTermState();
ts.CurrentTerm = 3;
ts.VotedFor = "candidate-x";
ts.CurrentTerm.ShouldBe(3);
ts.VotedFor.ShouldBe("candidate-x");
}
// Go: snapshot struct in server/raft.go
[Fact]
public void RaftSnapshot_default_values()
{
var snap = new RaftSnapshot();
snap.LastIncludedIndex.ShouldBe(0);
snap.LastIncludedTerm.ShouldBe(0);
snap.Data.ShouldBeEmpty();
}
// Go: snapshot struct in server/raft.go
[Fact]
public void RaftSnapshot_init_properties()
{
var data = new byte[] { 1, 2, 3, 4 };
var snap = new RaftSnapshot
{
LastIncludedIndex = 100,
LastIncludedTerm = 5,
Data = data,
};
snap.LastIncludedIndex.ShouldBe(100);
snap.LastIncludedTerm.ShouldBe(5);
snap.Data.ShouldBe(data);
}
}

View File

@@ -0,0 +1,421 @@
using NATS.Server.Raft;
namespace NATS.Server.Tests.Raft;
/// <summary>
/// Election behavior tests covering leader election, vote mechanics, term handling,
/// candidate stepdown, split vote scenarios, and network partition leader stepdown.
/// Go: TestNRGSimple, TestNRGSimpleElection, TestNRGInlineStepdown,
/// TestNRGRecoverFromFollowingNoLeader, TestNRGStepDownOnSameTermDoesntClearVote,
/// TestNRGAssumeHighTermAfterCandidateIsolation in server/raft_test.go.
/// </summary>
public class RaftElectionTests
{
// -- 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;
}
// Go: TestNRGSimple server/raft_test.go:35
[Fact]
public void Single_node_becomes_leader_automatically()
{
var node = new RaftNode("solo");
node.StartElection(clusterSize: 1);
node.IsLeader.ShouldBeTrue();
node.Role.ShouldBe(RaftRole.Leader);
node.Term.ShouldBe(1);
}
// Go: TestNRGSimple server/raft_test.go:35
[Fact]
public void Three_node_cluster_elects_leader()
{
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
leader.IsLeader.ShouldBeTrue();
leader.Role.ShouldBe(RaftRole.Leader);
nodes.Count(n => n.IsLeader).ShouldBe(1);
nodes.Count(n => !n.IsLeader).ShouldBe(2);
}
// Go: TestNRGSimpleElection server/raft_test.go:296
[Fact]
public void Five_node_cluster_elects_leader_with_quorum()
{
var (nodes, _) = CreateCluster(5);
var leader = ElectLeader(nodes);
leader.IsLeader.ShouldBeTrue();
nodes.Count(n => n.IsLeader).ShouldBe(1);
nodes.Count(n => !n.IsLeader).ShouldBe(4);
}
// Go: TestNRGSimpleElection server/raft_test.go:296
[Fact]
public void Election_increments_term()
{
var (nodes, _) = CreateCluster(3);
var candidate = nodes[0];
candidate.Term.ShouldBe(0);
candidate.StartElection(nodes.Length);
candidate.Term.ShouldBe(1);
}
// Go: TestNRGSimpleElection server/raft_test.go:296
[Fact]
public void Candidate_votes_for_self_on_election_start()
{
var node = new RaftNode("n1");
node.StartElection(clusterSize: 3);
node.Role.ShouldBe(RaftRole.Candidate);
node.TermState.VotedFor.ShouldBe("n1");
}
// Go: TestNRGSimpleElection server/raft_test.go:296
[Fact]
public void Candidate_needs_majority_to_become_leader()
{
var (nodes, _) = CreateCluster(3);
var candidate = nodes[0];
candidate.StartElection(nodes.Length);
// Only self-vote, not enough for majority in 3-node cluster
candidate.IsLeader.ShouldBeFalse();
candidate.Role.ShouldBe(RaftRole.Candidate);
// One more vote gives majority (2 out of 3)
var vote = nodes[1].GrantVote(candidate.Term, candidate.Id);
vote.Granted.ShouldBeTrue();
candidate.ReceiveVote(vote, nodes.Length);
candidate.IsLeader.ShouldBeTrue();
}
// Go: TestNRGSimpleElection server/raft_test.go:296
[Fact]
public void Denied_vote_does_not_advance_to_leader()
{
var node = new RaftNode("n1");
node.StartElection(clusterSize: 5);
node.IsLeader.ShouldBeFalse();
// Receive denied votes
node.ReceiveVote(new VoteResponse { Granted = false }, clusterSize: 5);
node.ReceiveVote(new VoteResponse { Granted = false }, clusterSize: 5);
node.IsLeader.ShouldBeFalse();
}
// Go: TestNRGSimpleElection server/raft_test.go:296
[Fact]
public void Vote_granted_for_same_term_and_candidate()
{
var voter = new RaftNode("voter");
var response = voter.GrantVote(term: 1, candidateId: "candidate-a");
response.Granted.ShouldBeTrue();
voter.TermState.VotedFor.ShouldBe("candidate-a");
}
// Go: TestNRGStepDownOnSameTermDoesntClearVote server/raft_test.go:447
[Fact]
public void Vote_denied_for_same_term_different_candidate()
{
var voter = new RaftNode("voter");
// Vote for candidate-a in term 1
voter.GrantVote(term: 1, candidateId: "candidate-a").Granted.ShouldBeTrue();
// Attempt to vote for candidate-b in same term should fail
var response = voter.GrantVote(term: 1, candidateId: "candidate-b");
response.Granted.ShouldBeFalse();
voter.TermState.VotedFor.ShouldBe("candidate-a");
}
// Go: processVoteRequest in server/raft.go — stale term rejection
[Fact]
public void Vote_denied_for_stale_term()
{
var voter = new RaftNode("voter");
voter.TermState.CurrentTerm = 5;
var response = voter.GrantVote(term: 3, candidateId: "candidate");
response.Granted.ShouldBeFalse();
}
// Go: processVoteRequest in server/raft.go — higher term resets vote
[Fact]
public void Vote_granted_for_higher_term_resets_previous_vote()
{
var voter = new RaftNode("voter");
voter.GrantVote(term: 1, candidateId: "candidate-a").Granted.ShouldBeTrue();
voter.TermState.VotedFor.ShouldBe("candidate-a");
// Higher term should clear previous vote and grant new one
var response = voter.GrantVote(term: 2, candidateId: "candidate-b");
response.Granted.ShouldBeTrue();
voter.TermState.VotedFor.ShouldBe("candidate-b");
voter.TermState.CurrentTerm.ShouldBe(2);
}
// Go: TestNRGInlineStepdown server/raft_test.go:194
[Fact]
public void Leader_stepdown_transitions_to_follower()
{
var node = new RaftNode("n1");
node.StartElection(clusterSize: 1);
node.IsLeader.ShouldBeTrue();
node.RequestStepDown();
node.IsLeader.ShouldBeFalse();
node.Role.ShouldBe(RaftRole.Follower);
}
// Go: TestNRGInlineStepdown server/raft_test.go:194
[Fact]
public void Stepdown_clears_votes_received()
{
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
leader.IsLeader.ShouldBeTrue();
leader.RequestStepDown();
leader.Role.ShouldBe(RaftRole.Follower);
leader.TermState.VotedFor.ShouldBeNull();
}
// Go: TestNRGRecoverFromFollowingNoLeader server/raft_test.go:154
[Fact]
public void Candidate_stepdown_on_higher_term_heartbeat()
{
var node = new RaftNode("n1");
node.StartElection(clusterSize: 3);
node.Role.ShouldBe(RaftRole.Candidate);
node.Term.ShouldBe(1);
// Receive heartbeat with higher term
node.ReceiveHeartbeat(term: 5);
node.Role.ShouldBe(RaftRole.Follower);
node.Term.ShouldBe(5);
}
// Go: TestNRGRecoverFromFollowingNoLeader server/raft_test.go:154
[Fact]
public void Leader_stepdown_on_higher_term_heartbeat()
{
var node = new RaftNode("n1");
node.StartElection(clusterSize: 1);
node.IsLeader.ShouldBeTrue();
node.Term.ShouldBe(1);
node.ReceiveHeartbeat(term: 10);
node.Role.ShouldBe(RaftRole.Follower);
node.Term.ShouldBe(10);
}
// Go: TestNRGRecoverFromFollowingNoLeader server/raft_test.go:154
[Fact]
public void Heartbeat_with_lower_term_ignored()
{
var node = new RaftNode("n1");
node.StartElection(clusterSize: 1);
node.IsLeader.ShouldBeTrue();
node.Term.ShouldBe(1);
node.ReceiveHeartbeat(term: 0);
node.IsLeader.ShouldBeTrue();
node.Term.ShouldBe(1);
}
// Go: TestNRGAssumeHighTermAfterCandidateIsolation server/raft_test.go:662
[Fact]
public void Split_vote_forces_reelection_with_higher_term()
{
var (nodes, _) = CreateCluster(3);
// First election: n1 starts but only gets self-vote
nodes[0].StartElection(nodes.Length);
nodes[0].Role.ShouldBe(RaftRole.Candidate);
nodes[0].Term.ShouldBe(1);
// n2 also starts election concurrently (split vote scenario)
nodes[1].StartElection(nodes.Length);
nodes[1].Role.ShouldBe(RaftRole.Candidate);
nodes[1].Term.ShouldBe(1);
// Neither gets majority, so no leader
nodes.Count(n => n.IsLeader).ShouldBe(0);
// n1 starts new election in higher term
nodes[0].StartElection(nodes.Length);
nodes[0].Term.ShouldBe(2);
// Now n2 and n3 grant votes
var v2 = nodes[1].GrantVote(nodes[0].Term, nodes[0].Id);
v2.Granted.ShouldBeTrue();
nodes[0].ReceiveVote(v2, nodes.Length);
nodes[0].IsLeader.ShouldBeTrue();
}
// Go: TestNRGAssumeHighTermAfterCandidateIsolation server/raft_test.go:662
[Fact]
public void Isolated_candidate_with_high_term_forces_term_update()
{
var (nodes, transport) = CreateCluster(3);
var leader = ElectLeader(nodes);
leader.IsLeader.ShouldBeTrue();
// Simulate follower isolation: bump its term high
var follower = nodes.First(n => !n.IsLeader);
follower.TermState.CurrentTerm = 100;
// When the isolated node's vote request reaches others,
// they should update their term even if they don't grant the vote
var voteReq = new VoteRequest { Term = 100, CandidateId = follower.Id };
foreach (var node in nodes.Where(n => n.Id != follower.Id))
{
var resp = node.GrantVote(voteReq.Term, voteReq.CandidateId);
// Term should update to 100 regardless of vote grant
node.TermState.CurrentTerm.ShouldBe(100);
}
}
// Go: TestNRGRecoverFromFollowingNoLeader server/raft_test.go:154
[Fact]
public void Re_election_after_leader_stepdown()
{
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
leader.IsLeader.ShouldBeTrue();
leader.Term.ShouldBe(1);
// Leader steps down
leader.RequestStepDown();
leader.IsLeader.ShouldBeFalse();
// New election with a different candidate — term increments from current
var newCandidate = nodes.First(n => n.Id != leader.Id);
newCandidate.StartElection(nodes.Length);
newCandidate.Term.ShouldBe(2); // was 1 from first election, incremented to 2
foreach (var voter in nodes.Where(n => n.Id != newCandidate.Id))
{
var vote = voter.GrantVote(newCandidate.Term, newCandidate.Id);
newCandidate.ReceiveVote(vote, nodes.Length);
}
newCandidate.IsLeader.ShouldBeTrue();
}
// Go: TestNRGHeartbeatOnLeaderChange server/raft_test.go:708
[Fact]
public void Multiple_sequential_elections_increment_term()
{
var node = new RaftNode("n1");
node.StartElection(clusterSize: 1);
node.Term.ShouldBe(1);
node.RequestStepDown();
node.StartElection(clusterSize: 1);
node.Term.ShouldBe(2);
node.RequestStepDown();
node.StartElection(clusterSize: 1);
node.Term.ShouldBe(3);
}
// Go: TestNRGSimpleElection server/raft_test.go:296 — transport-based vote request
[Fact]
public async Task Transport_based_vote_request()
{
var (nodes, transport) = CreateCluster(3);
var candidate = nodes[0];
candidate.StartElection(nodes.Length);
// Use transport to request votes
var voteReq = new VoteRequest { Term = candidate.Term, CandidateId = candidate.Id };
foreach (var voter in nodes.Skip(1))
{
var resp = await transport.RequestVoteAsync(candidate.Id, voter.Id, voteReq, default);
candidate.ReceiveVote(resp, nodes.Length);
}
candidate.IsLeader.ShouldBeTrue();
}
// Go: TestNRGCandidateDoesntRevertTermAfterOldAE server/raft_test.go:792
[Fact]
public void Candidate_does_not_revert_term_on_stale_heartbeat()
{
var node = new RaftNode("n1");
node.StartElection(clusterSize: 3);
node.Term.ShouldBe(1);
// Start another election to bump term
node.StartElection(clusterSize: 3);
node.Term.ShouldBe(2);
// Receiving heartbeat from older term should not revert
node.ReceiveHeartbeat(term: 1);
node.Term.ShouldBe(2);
}
// Go: TestNRGCandidateDontStepdownDueToLeaderOfPreviousTerm server/raft_test.go:972
[Fact]
public void Candidate_does_not_stepdown_from_old_term_heartbeat()
{
var node = new RaftNode("n1");
node.TermState.CurrentTerm = 10;
node.StartElection(clusterSize: 3);
node.Term.ShouldBe(11);
node.Role.ShouldBe(RaftRole.Candidate);
// Heartbeat from an older term should not cause stepdown
node.ReceiveHeartbeat(term: 5);
node.Role.ShouldBe(RaftRole.Candidate);
node.Term.ShouldBe(11);
}
// Go: TestNRGSimple server/raft_test.go:35 — seven-node quorum
[Theory]
[InlineData(1, 1)] // Single node: quorum = 1
[InlineData(3, 2)] // 3-node: quorum = 2
[InlineData(5, 3)] // 5-node: quorum = 3
[InlineData(7, 4)] // 7-node: quorum = 4
public void Quorum_size_for_various_cluster_sizes(int clusterSize, int expectedQuorum)
{
var node = new RaftNode("n1");
node.StartElection(clusterSize);
// Self-vote = 1, need (expectedQuorum - 1) more
for (int i = 0; i < expectedQuorum - 1; i++)
node.ReceiveVote(new VoteResponse { Granted = true }, clusterSize);
node.IsLeader.ShouldBeTrue();
}
}

View File

@@ -0,0 +1,594 @@
using NATS.Server.Raft;
namespace NATS.Server.Tests.Raft;
/// <summary>
/// Log replication tests covering leader propose, follower append, commit index advance,
/// log compaction, out-of-order rejection, duplicate detection, heartbeat keepalive,
/// persistence round-trips, and replicator backtrack semantics.
/// Go: TestNRGSimple, TestNRGSnapshotAndRestart, TestNRGHeartbeatOnLeaderChange,
/// TestNRGNoResetOnAppendEntryResponse, TestNRGTermNoDecreaseAfterWALReset,
/// TestNRGWALEntryWithoutQuorumMustTruncate in server/raft_test.go.
/// </summary>
public class RaftLogReplicationTests
{
// -- Helpers (self-contained) --
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 node in nodes)
node.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());
}
private static (RaftNode leader, RaftNode[] followers, InMemoryRaftTransport transport) CreateTransportCluster(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);
}
var candidate = nodes[0];
candidate.StartElection(size);
foreach (var voter in nodes.Skip(1))
candidate.ReceiveVote(voter.GrantVote(candidate.Term, candidate.Id), size);
return (candidate, nodes.Skip(1).ToArray(), transport);
}
// Go: TestNRGSimple server/raft_test.go:35 — proposeDelta
[Fact]
public async Task Leader_propose_appends_to_log()
{
var (leader, _) = CreateLeaderWithFollowers(2);
var index = await leader.ProposeAsync("set-x-42", default);
index.ShouldBe(1);
leader.Log.Entries.Count.ShouldBe(1);
leader.Log.Entries[0].Command.ShouldBe("set-x-42");
leader.Log.Entries[0].Term.ShouldBe(leader.Term);
}
// Go: TestNRGSimple server/raft_test.go:35
[Fact]
public async Task Leader_propose_multiple_entries_sequential_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);
leader.Log.Entries.Count.ShouldBe(3);
leader.Log.Entries[0].Index.ShouldBe(1);
leader.Log.Entries[1].Index.ShouldBe(2);
leader.Log.Entries[2].Index.ShouldBe(3);
}
// Go: TestNRGSimple server/raft_test.go:35 — only leader can propose
[Fact]
public async Task Follower_cannot_propose()
{
var (_, followers) = CreateLeaderWithFollowers(2);
var follower = followers[0];
follower.IsLeader.ShouldBeFalse();
await Should.ThrowAsync<InvalidOperationException>(
async () => await follower.ProposeAsync("should-fail", default));
}
// Go: TestNRGSimple server/raft_test.go:35 — state convergence
[Fact]
public async Task Follower_receives_replicated_entry()
{
var (leader, followers) = CreateLeaderWithFollowers(2);
await leader.ProposeAsync("replicated-cmd", default);
// In-process replication: followers should have the entry
foreach (var follower in followers)
{
follower.Log.Entries.Count.ShouldBe(1);
follower.Log.Entries[0].Command.ShouldBe("replicated-cmd");
}
}
// Go: TestNRGSimple server/raft_test.go:35 — commit index advance
[Fact]
public async Task Commit_index_advances_after_quorum()
{
var (leader, followers) = CreateLeaderWithFollowers(2);
await leader.ProposeAsync("committed-entry", default);
// Leader should have advanced applied index
leader.AppliedIndex.ShouldBeGreaterThan(0);
}
// Go: TestNRGSimple server/raft_test.go:35 — all nodes converge
[Fact]
public async Task All_nodes_converge_applied_index()
{
var (leader, followers) = CreateLeaderWithFollowers(2);
var idx = await leader.ProposeAsync("converge-1", default);
await leader.ProposeAsync("converge-2", default);
var finalIdx = await leader.ProposeAsync("converge-3", default);
// All nodes should converge
leader.AppliedIndex.ShouldBeGreaterThanOrEqualTo(finalIdx);
foreach (var follower in followers)
follower.AppliedIndex.ShouldBeGreaterThanOrEqualTo(finalIdx);
}
// Go: appendEntry dedup in server/raft.go
[Fact]
public void Duplicate_replicated_entry_is_deduplicated()
{
var log = new RaftLog();
var entry = new RaftLogEntry(Index: 1, Term: 1, Command: "dedup-test");
log.AppendReplicated(entry);
log.AppendReplicated(entry); // duplicate
log.AppendReplicated(entry); // duplicate
log.Entries.Count.ShouldBe(1);
}
// Go: TestNRGTermNoDecreaseAfterWALReset server/raft_test.go:1156 — stale append rejected
[Fact]
public async Task Stale_term_append_rejected()
{
var node = new RaftNode("n1");
node.StartElection(clusterSize: 1);
node.Term.ShouldBe(1);
var staleEntry = new RaftLogEntry(Index: 1, Term: 0, Command: "stale");
await Should.ThrowAsync<InvalidOperationException>(
async () => await node.TryAppendFromLeaderAsync(staleEntry, default));
}
// Go: TestNRGTermNoDecreaseAfterWALReset server/raft_test.go:1156 — current term accepted
[Fact]
public async Task Current_term_append_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);
node.Log.Entries[0].Command.ShouldBe("valid");
}
// Go: TestNRGTermNoDecreaseAfterWALReset server/raft_test.go:1156 — higher term accepted
[Fact]
public async Task Higher_term_append_accepted()
{
var node = new RaftNode("n1");
node.TermState.CurrentTerm = 1;
var entry = new RaftLogEntry(Index: 1, Term: 5, Command: "future");
await node.TryAppendFromLeaderAsync(entry, default);
node.Log.Entries.Count.ShouldBe(1);
}
// Go: TestNRGHeartbeatOnLeaderChange server/raft_test.go:708 — heartbeat keepalive
[Fact]
public void Heartbeat_updates_follower_term()
{
var follower = new RaftNode("f1");
follower.TermState.CurrentTerm = 1;
follower.ReceiveHeartbeat(term: 3);
follower.Term.ShouldBe(3);
follower.Role.ShouldBe(RaftRole.Follower);
}
// Go: TestNRGHeartbeatOnLeaderChange server/raft_test.go:708
[Fact]
public async Task Heartbeat_via_transport_updates_follower()
{
var transport = new InMemoryRaftTransport();
var leader = new RaftNode("L", transport);
var follower = new RaftNode("F", transport);
transport.Register(leader);
transport.Register(follower);
await transport.AppendHeartbeatAsync("L", ["F"], term: 5, default);
follower.Term.ShouldBe(5);
follower.Role.ShouldBe(RaftRole.Follower);
}
// Go: TestNRGNoResetOnAppendEntryResponse server/raft_test.go:912 — rejection transport
[Fact]
public async Task Propose_without_quorum_does_not_advance_applied_index()
{
var transport = new RejectAllTransport();
var leader = new RaftNode("n1", transport);
var follower1 = new RaftNode("n2", transport);
var follower2 = new RaftNode("n3", transport);
var nodes = new[] { leader, follower1, follower2 };
foreach (var n in nodes)
n.ConfigureCluster(nodes);
leader.StartElection(nodes.Length);
leader.ReceiveVote(new VoteResponse { Granted = true }, nodes.Length);
leader.IsLeader.ShouldBeTrue();
await leader.ProposeAsync("no-quorum-cmd", default);
// No quorum means applied index should not advance
leader.AppliedIndex.ShouldBe(0);
}
// Go: server/raft.go — log append and entries in term
[Fact]
public void Log_entries_preserve_term()
{
var log = new RaftLog();
var e1 = log.Append(term: 1, command: "term1-a");
var e2 = log.Append(term: 1, command: "term1-b");
var e3 = log.Append(term: 2, command: "term2-a");
e1.Term.ShouldBe(1);
e2.Term.ShouldBe(1);
e3.Term.ShouldBe(2);
}
// Go: TestNRGSnapshotAndRestart server/raft_test.go:49 — log persistence
[Fact]
public async Task Log_persist_and_reload()
{
var dir = Path.Combine(Path.GetTempPath(), $"nats-raft-repl-test-{Guid.NewGuid():N}");
var logPath = Path.Combine(dir, "log.json");
try
{
var log = new RaftLog();
log.Append(term: 1, command: "persist-a");
log.Append(term: 2, command: "persist-b");
await log.PersistAsync(logPath, default);
var reloaded = await RaftLog.LoadAsync(logPath, default);
reloaded.Entries.Count.ShouldBe(2);
reloaded.Entries[0].Command.ShouldBe("persist-a");
reloaded.Entries[1].Command.ShouldBe("persist-b");
reloaded.Entries[0].Term.ShouldBe(1);
reloaded.Entries[1].Term.ShouldBe(2);
}
finally
{
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
// Go: TestNRGSnapshotAndRestart server/raft_test.go:49 — node persistence
[Fact]
public async Task Node_persist_and_reload_state()
{
var dir = Path.Combine(Path.GetTempPath(), $"nats-raft-node-test-{Guid.NewGuid():N}");
try
{
var node = new RaftNode("n1", persistDirectory: dir);
node.StartElection(clusterSize: 1);
node.IsLeader.ShouldBeTrue();
node.Log.Append(term: 1, command: "persist-cmd");
node.AppliedIndex = 1;
await node.PersistAsync(default);
// Create new node and reload
var reloaded = new RaftNode("n1", persistDirectory: dir);
await reloaded.LoadPersistedStateAsync(default);
reloaded.Term.ShouldBe(1);
reloaded.AppliedIndex.ShouldBe(1);
reloaded.Log.Entries.Count.ShouldBe(1);
reloaded.Log.Entries[0].Command.ShouldBe("persist-cmd");
}
finally
{
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
// Go: BacktrackNextIndex in server/raft.go
[Fact]
public void Backtrack_next_index_decrements_correctly()
{
RaftReplicator.BacktrackNextIndex(5).ShouldBe(4);
RaftReplicator.BacktrackNextIndex(3).ShouldBe(2);
RaftReplicator.BacktrackNextIndex(2).ShouldBe(1);
}
// Go: BacktrackNextIndex in server/raft.go — floor at 1
[Fact]
public void Backtrack_next_index_floor_at_one()
{
RaftReplicator.BacktrackNextIndex(1).ShouldBe(1);
RaftReplicator.BacktrackNextIndex(0).ShouldBe(1);
}
// Go: RaftReplicator in server/raft.go
[Fact]
public void Replicator_returns_count_of_acknowledged_followers()
{
var replicator = new RaftReplicator();
var follower1 = new RaftNode("f1");
var follower2 = new RaftNode("f2");
var followers = new[] { follower1, follower2 };
var entry = new RaftLogEntry(Index: 1, Term: 1, Command: "replicate-me");
var acks = replicator.Replicate(entry, followers);
acks.ShouldBe(2);
follower1.Log.Entries.Count.ShouldBe(1);
follower2.Log.Entries.Count.ShouldBe(1);
}
// Go: RaftReplicator async via transport
[Fact]
public async Task Replicator_async_via_transport()
{
var (leader, followers, transport) = CreateTransportCluster(3);
var entry = leader.Log.Append(leader.Term, "transport-replicate");
var replicator = new RaftReplicator();
var results = await replicator.ReplicateAsync(leader.Id, entry, followers, transport, default);
results.Count.ShouldBe(2);
results.All(r => r.Success).ShouldBeTrue();
foreach (var follower in followers)
follower.Log.Entries.Count.ShouldBe(1);
}
// Go: RaftReplicator with null transport uses direct replication
[Fact]
public async Task Replicator_async_without_transport_uses_direct()
{
var follower1 = new RaftNode("f1");
var follower2 = new RaftNode("f2");
var followers = new[] { follower1, follower2 };
var entry = new RaftLogEntry(Index: 1, Term: 1, Command: "direct");
var replicator = new RaftReplicator();
var results = await replicator.ReplicateAsync("leader", entry, followers, null, default);
results.Count.ShouldBe(2);
results.All(r => r.Success).ShouldBeTrue();
}
// Go: TestNRGSimple server/raft_test.go:35 — 1000 entries
[Fact]
public async Task Many_entries_replicate_correctly()
{
var (leader, followers) = CreateLeaderWithFollowers(2);
for (int i = 0; i < 100; i++)
await leader.ProposeAsync($"batch-{i}", default);
leader.Log.Entries.Count.ShouldBe(100);
leader.AppliedIndex.ShouldBe(100);
foreach (var follower in followers)
follower.Log.Entries.Count.ShouldBe(100);
}
// Go: Log append after snapshot
[Fact]
public void Log_append_after_snapshot_continues_from_snapshot_index()
{
var log = new RaftLog();
log.Append(term: 1, command: "a");
log.Append(term: 1, command: "b");
log.Append(term: 1, command: "c");
log.ReplaceWithSnapshot(new RaftSnapshot
{
LastIncludedIndex = 3,
LastIncludedTerm = 1,
});
log.Entries.Count.ShouldBe(0);
var e = log.Append(term: 2, command: "post-snap");
e.Index.ShouldBe(4);
}
// Go: Empty log loads from nonexistent path
[Fact]
public async Task Load_from_nonexistent_path_returns_empty_log()
{
var path = Path.Combine(Path.GetTempPath(), $"nats-noexist-{Guid.NewGuid():N}", "log.json");
var log = await RaftLog.LoadAsync(path, default);
log.Entries.Count.ShouldBe(0);
}
// Go: TestNRGWALEntryWithoutQuorumMustTruncate server/raft_test.go:1063
[Fact]
public async Task Propose_with_transport_replicates_to_followers()
{
var (leader, followers, transport) = CreateTransportCluster(3);
var idx = await leader.ProposeAsync("transport-cmd", default);
idx.ShouldBe(1);
leader.Log.Entries.Count.ShouldBe(1);
foreach (var follower in followers)
follower.Log.Entries.Count.ShouldBe(1);
}
// Go: ReceiveReplicatedEntry dedup
[Fact]
public void ReceiveReplicatedEntry_deduplicates()
{
var node = new RaftNode("n1");
var entry = new RaftLogEntry(Index: 1, Term: 1, Command: "once");
node.ReceiveReplicatedEntry(entry);
node.ReceiveReplicatedEntry(entry);
node.Log.Entries.Count.ShouldBe(1);
}
// Go: TestNRGHeartbeatOnLeaderChange server/raft_test.go:708 — repeated proposals
[Fact]
public async Task Multiple_proposals_maintain_sequential_applied_index()
{
var (leader, followers) = CreateLeaderWithFollowers(2);
for (int i = 1; i <= 10; i++)
{
var idx = await leader.ProposeAsync($"seq-{i}", default);
idx.ShouldBe(i);
}
leader.AppliedIndex.ShouldBe(10);
leader.Log.Entries.Count.ShouldBe(10);
}
// Go: TestNRGTermNoDecreaseAfterWALReset server/raft_test.go:1156 — entries carry correct term
[Fact]
public async Task Proposed_entries_carry_leader_term()
{
var (leader, _) = CreateLeaderWithFollowers(2);
leader.Term.ShouldBe(1);
await leader.ProposeAsync("term-check", default);
leader.Log.Entries[0].Term.ShouldBe(1);
}
// Go: TestNRGNoResetOnAppendEntryResponse server/raft_test.go:912 — partial transport
[Fact]
public async Task Partial_replication_still_commits_with_quorum()
{
var transport = new PartialTransport();
var nodes = Enumerable.Range(1, 3)
.Select(i => new RaftNode($"n{i}", transport))
.ToArray();
foreach (var n in nodes)
{
transport.Register(n);
n.ConfigureCluster(nodes);
}
var candidate = nodes[0];
candidate.StartElection(3);
candidate.ReceiveVote(new VoteResponse { Granted = true }, 3);
candidate.IsLeader.ShouldBeTrue();
// With partial transport, 1 follower succeeds (quorum = 2 including leader)
var idx = await candidate.ProposeAsync("partial-cmd", default);
idx.ShouldBe(1);
candidate.AppliedIndex.ShouldBeGreaterThan(0);
}
// Go: TestNRGSimple server/raft_test.go:35 — follower log matches leader
[Fact]
public async Task Follower_log_matches_leader_log_content()
{
var (leader, followers) = CreateLeaderWithFollowers(2);
await leader.ProposeAsync("alpha", default);
await leader.ProposeAsync("beta", default);
await leader.ProposeAsync("gamma", default);
foreach (var follower in followers)
{
follower.Log.Entries.Count.ShouldBe(leader.Log.Entries.Count);
for (int i = 0; i < leader.Log.Entries.Count; i++)
{
follower.Log.Entries[i].Index.ShouldBe(leader.Log.Entries[i].Index);
follower.Log.Entries[i].Term.ShouldBe(leader.Log.Entries[i].Term);
follower.Log.Entries[i].Command.ShouldBe(leader.Log.Entries[i].Command);
}
}
}
// -- Helper transport that rejects all appends --
private sealed class RejectAllTransport : IRaftTransport
{
public Task<IReadOnlyList<AppendResult>> AppendEntriesAsync(
string leaderId, IReadOnlyList<string> followerIds, RaftLogEntry entry, CancellationToken ct)
=> Task.FromResult<IReadOnlyList<AppendResult>>(
followerIds.Select(id => new AppendResult { FollowerId = id, Success = false }).ToArray());
public Task<VoteResponse> RequestVoteAsync(
string candidateId, string voterId, VoteRequest request, CancellationToken ct)
=> Task.FromResult(new VoteResponse { Granted = false });
public Task InstallSnapshotAsync(
string leaderId, string followerId, RaftSnapshot snapshot, CancellationToken ct)
=> Task.CompletedTask;
}
// -- Helper transport that succeeds for first follower, fails for rest --
private sealed class PartialTransport : IRaftTransport
{
private readonly Dictionary<string, RaftNode> _nodes = new(StringComparer.Ordinal);
public void Register(RaftNode node) => _nodes[node.Id] = node;
public Task<IReadOnlyList<AppendResult>> AppendEntriesAsync(
string leaderId, IReadOnlyList<string> followerIds, RaftLogEntry entry, CancellationToken ct)
{
var results = new List<AppendResult>(followerIds.Count);
var first = true;
foreach (var followerId in followerIds)
{
if (first && _nodes.TryGetValue(followerId, out var node))
{
node.ReceiveReplicatedEntry(entry);
results.Add(new AppendResult { FollowerId = followerId, Success = true });
first = false;
}
else
{
results.Add(new AppendResult { FollowerId = followerId, Success = false });
}
}
return Task.FromResult<IReadOnlyList<AppendResult>>(results);
}
public Task<VoteResponse> RequestVoteAsync(
string candidateId, string voterId, VoteRequest request, CancellationToken ct)
=> Task.FromResult(new VoteResponse { Granted = false });
public Task InstallSnapshotAsync(
string leaderId, string followerId, RaftSnapshot snapshot, CancellationToken ct)
=> Task.CompletedTask;
}
}

View File

@@ -0,0 +1,425 @@
using System.Text.Json;
using NATS.Server.Raft;
namespace NATS.Server.Tests.Raft;
/// <summary>
/// Snapshot tests covering creation, restore, transfer, membership changes during
/// snapshot, snapshot store persistence, and leader/follower catchup via snapshots.
/// Go: TestNRGSnapshotAndRestart, TestNRGRemoveLeaderPeerDeadlockBug,
/// TestNRGLeaderTransfer in server/raft_test.go.
/// </summary>
public class RaftSnapshotTests
{
// -- Helpers (self-contained) --
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 node in nodes)
node.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());
}
private static (RaftNode leader, RaftNode[] followers, InMemoryRaftTransport transport) CreateTransportCluster(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);
}
var candidate = nodes[0];
candidate.StartElection(size);
foreach (var voter in nodes.Skip(1))
candidate.ReceiveVote(voter.GrantVote(candidate.Term, candidate.Id), size);
return (candidate, nodes.Skip(1).ToArray(), transport);
}
// Go: TestNRGSnapshotAndRestart server/raft_test.go:49 — snapshot creation
[Fact]
public async Task Create_snapshot_captures_applied_index_and_term()
{
var (leader, _) = CreateLeaderWithFollowers(2);
await leader.ProposeAsync("cmd-1", default);
await leader.ProposeAsync("cmd-2", default);
var snapshot = await leader.CreateSnapshotAsync(default);
snapshot.LastIncludedIndex.ShouldBe(leader.AppliedIndex);
snapshot.LastIncludedTerm.ShouldBe(leader.Term);
}
// Go: TestNRGSnapshotAndRestart server/raft_test.go:49 — install snapshot
[Fact]
public async Task Install_snapshot_updates_applied_index()
{
var (leader, followers) = CreateLeaderWithFollowers(2);
await leader.ProposeAsync("snap-cmd-1", default);
await leader.ProposeAsync("snap-cmd-2", default);
await leader.ProposeAsync("snap-cmd-3", default);
var snapshot = await leader.CreateSnapshotAsync(default);
var newFollower = new RaftNode("new-follower");
await newFollower.InstallSnapshotAsync(snapshot, default);
newFollower.AppliedIndex.ShouldBe(snapshot.LastIncludedIndex);
}
// Go: TestNRGSnapshotAndRestart server/raft_test.go:49 — snapshot clears log
[Fact]
public async Task Install_snapshot_clears_existing_log()
{
var node = new RaftNode("n1");
node.Log.Append(term: 1, command: "old-1");
node.Log.Append(term: 1, command: "old-2");
node.Log.Entries.Count.ShouldBe(2);
var snapshot = new RaftSnapshot
{
LastIncludedIndex = 10,
LastIncludedTerm = 3,
};
await node.InstallSnapshotAsync(snapshot, default);
node.Log.Entries.Count.ShouldBe(0);
node.AppliedIndex.ShouldBe(10);
}
// Go: TestNRGSnapshotAndRestart server/raft_test.go:49 — new entries after snapshot
[Fact]
public async Task Entries_after_snapshot_start_at_correct_index()
{
var node = new RaftNode("n1");
var snapshot = new RaftSnapshot
{
LastIncludedIndex = 50,
LastIncludedTerm = 5,
};
await node.InstallSnapshotAsync(snapshot, default);
var entry = node.Log.Append(term: 6, command: "post-snap");
entry.Index.ShouldBe(51);
}
// Go: TestNRGSnapshotAndRestart server/raft_test.go:49 — snapshot transfer
[Fact]
public async Task Snapshot_transfer_via_transport()
{
var (leader, followers, transport) = CreateTransportCluster(3);
await leader.ProposeAsync("entry-1", default);
await leader.ProposeAsync("entry-2", default);
var snapshot = await leader.CreateSnapshotAsync(default);
// Transfer to a follower
var follower = followers[0];
await transport.InstallSnapshotAsync(leader.Id, follower.Id, snapshot, default);
follower.AppliedIndex.ShouldBe(snapshot.LastIncludedIndex);
}
// Go: TestNRGSnapshotAndRestart server/raft_test.go:49 — lagging follower catchup
[Fact]
public async Task Lagging_follower_catches_up_via_snapshot()
{
var (leader, followers) = CreateLeaderWithFollowers(2);
// Leader has entries, follower is behind
await leader.ProposeAsync("catchup-1", default);
await leader.ProposeAsync("catchup-2", default);
await leader.ProposeAsync("catchup-3", default);
var laggingFollower = new RaftNode("lagging");
laggingFollower.AppliedIndex.ShouldBe(0);
var snapshot = await leader.CreateSnapshotAsync(default);
await laggingFollower.InstallSnapshotAsync(snapshot, default);
laggingFollower.AppliedIndex.ShouldBe(leader.AppliedIndex);
}
// Go: RaftSnapshotStore — in-memory save/load
[Fact]
public async Task Snapshot_store_in_memory_save_and_load()
{
var store = new RaftSnapshotStore();
var snapshot = new RaftSnapshot
{
LastIncludedIndex = 42,
LastIncludedTerm = 7,
Data = [1, 2, 3],
};
await store.SaveAsync(snapshot, default);
var loaded = await store.LoadAsync(default);
loaded.ShouldNotBeNull();
loaded.LastIncludedIndex.ShouldBe(42);
loaded.LastIncludedTerm.ShouldBe(7);
loaded.Data.ShouldBe(new byte[] { 1, 2, 3 });
}
// Go: RaftSnapshotStore — file-based save/load
[Fact]
public async Task Snapshot_store_file_based_persistence()
{
var file = Path.Combine(Path.GetTempPath(), $"nats-raft-snap-{Guid.NewGuid():N}.json");
try
{
var store1 = new RaftSnapshotStore(file);
await store1.SaveAsync(new RaftSnapshot
{
LastIncludedIndex = 100,
LastIncludedTerm = 10,
Data = [99, 88, 77],
}, default);
// New store instance, load from file
var store2 = new RaftSnapshotStore(file);
var loaded = await store2.LoadAsync(default);
loaded.ShouldNotBeNull();
loaded.LastIncludedIndex.ShouldBe(100);
loaded.LastIncludedTerm.ShouldBe(10);
loaded.Data.ShouldBe(new byte[] { 99, 88, 77 });
}
finally
{
if (File.Exists(file))
File.Delete(file);
}
}
// Go: RaftSnapshotStore — load from nonexistent returns null
[Fact]
public async Task Snapshot_store_load_nonexistent_returns_null()
{
var store = new RaftSnapshotStore();
var loaded = await store.LoadAsync(default);
loaded.ShouldBeNull();
}
// Go: TestNRGRemoveLeaderPeerDeadlockBug server/raft_test.go:1040 — membership add
[Fact]
public void Membership_add_member()
{
var node = new RaftNode("n1");
node.Members.ShouldContain("n1"); // self is auto-added
node.AddMember("n2");
node.AddMember("n3");
node.Members.ShouldContain("n2");
node.Members.ShouldContain("n3");
node.Members.Count.ShouldBe(3);
}
// Go: TestNRGRemoveLeaderPeerDeadlockBug server/raft_test.go:1040 — membership remove
[Fact]
public void Membership_remove_member()
{
var node = new RaftNode("n1");
node.AddMember("n2");
node.AddMember("n3");
node.RemoveMember("n2");
node.Members.ShouldNotContain("n2");
node.Members.ShouldContain("n1");
node.Members.ShouldContain("n3");
}
// Go: TestNRGRemoveLeaderPeerDeadlockBug server/raft_test.go:1040
[Fact]
public void Remove_nonexistent_member_is_noop()
{
var node = new RaftNode("n1");
node.RemoveMember("nonexistent"); // should not throw
node.Members.Count.ShouldBe(1); // still just self
}
// Go: ConfigureCluster in RaftNode
[Fact]
public void Configure_cluster_sets_members()
{
var n1 = new RaftNode("n1");
var n2 = new RaftNode("n2");
var n3 = new RaftNode("n3");
var nodes = new[] { n1, n2, n3 };
n1.ConfigureCluster(nodes);
n1.Members.ShouldContain("n1");
n1.Members.ShouldContain("n2");
n1.Members.ShouldContain("n3");
}
// Go: TestNRGLeaderTransfer server/raft_test.go:377 — leadership transfer
[Fact]
public async Task Leadership_transfer_via_stepdown_and_reelection()
{
var (leader, followers) = CreateLeaderWithFollowers(2);
leader.IsLeader.ShouldBeTrue();
var preferredNode = followers[0];
// Leader steps down
leader.RequestStepDown();
leader.IsLeader.ShouldBeFalse();
// Preferred node runs election
var allNodes = new[] { leader }.Concat(followers).ToArray();
preferredNode.StartElection(allNodes.Length);
foreach (var voter in allNodes.Where(n => n.Id != preferredNode.Id))
{
var vote = voter.GrantVote(preferredNode.Term, preferredNode.Id);
preferredNode.ReceiveVote(vote, allNodes.Length);
}
preferredNode.IsLeader.ShouldBeTrue();
}
// Go: TestNRGSnapshotAndRestart server/raft_test.go:49 — snapshot with data payload
[Fact]
public void Snapshot_with_large_data_payload()
{
var data = new byte[1024 * 64]; // 64KB
Random.Shared.NextBytes(data);
var snapshot = new RaftSnapshot
{
LastIncludedIndex = 500,
LastIncludedTerm = 20,
Data = data,
};
snapshot.Data.Length.ShouldBe(1024 * 64);
snapshot.LastIncludedIndex.ShouldBe(500);
}
// Go: TestNRGSnapshotAndRestart server/raft_test.go:49 — snapshot JSON round-trip
[Fact]
public void Snapshot_json_serialization_round_trip()
{
var data = new byte[] { 10, 20, 30, 40, 50 };
var snapshot = new RaftSnapshot
{
LastIncludedIndex = 75,
LastIncludedTerm = 8,
Data = data,
};
var json = JsonSerializer.Serialize(snapshot);
var decoded = JsonSerializer.Deserialize<RaftSnapshot>(json);
decoded.ShouldNotBeNull();
decoded.LastIncludedIndex.ShouldBe(75);
decoded.LastIncludedTerm.ShouldBe(8);
decoded.Data.ShouldBe(data);
}
// Go: TestNRGSnapshotAndRestart server/raft_test.go:49 — full cluster snapshot + restart
[Fact]
public async Task Full_cluster_snapshot_and_follower_restart()
{
var (leader, followers) = CreateLeaderWithFollowers(2);
await leader.ProposeAsync("pre-snap-1", default);
await leader.ProposeAsync("pre-snap-2", default);
await leader.ProposeAsync("pre-snap-3", default);
var snapshot = await leader.CreateSnapshotAsync(default);
// Simulate follower restart by installing snapshot on fresh node
var restartedFollower = new RaftNode("restarted");
await restartedFollower.InstallSnapshotAsync(snapshot, default);
restartedFollower.AppliedIndex.ShouldBe(snapshot.LastIncludedIndex);
restartedFollower.Log.Entries.Count.ShouldBe(0); // log was replaced by snapshot
}
// Go: TestNRGSnapshotAndRestart server/raft_test.go:49 — snapshot replaces stale log
[Fact]
public async Task Snapshot_replaces_stale_log_entries()
{
var node = new RaftNode("n1");
node.Log.Append(term: 1, command: "stale-1");
node.Log.Append(term: 1, command: "stale-2");
node.Log.Append(term: 1, command: "stale-3");
var snapshot = new RaftSnapshot
{
LastIncludedIndex = 100,
LastIncludedTerm = 5,
};
await node.InstallSnapshotAsync(snapshot, default);
node.Log.Entries.Count.ShouldBe(0);
node.AppliedIndex.ShouldBe(100);
// New entries continue from snapshot base
var newEntry = node.Log.Append(term: 6, command: "fresh");
newEntry.Index.ShouldBe(101);
}
// Go: TestNRGSnapshotAndRestart server/raft_test.go:49 — snapshot store overwrites previous
[Fact]
public async Task Snapshot_store_overwrites_previous_snapshot()
{
var store = new RaftSnapshotStore();
await store.SaveAsync(new RaftSnapshot { LastIncludedIndex = 10, LastIncludedTerm = 1 }, default);
await store.SaveAsync(new RaftSnapshot { LastIncludedIndex = 50, LastIncludedTerm = 3 }, default);
var loaded = await store.LoadAsync(default);
loaded.ShouldNotBeNull();
loaded.LastIncludedIndex.ShouldBe(50);
loaded.LastIncludedTerm.ShouldBe(3);
}
// Go: TestNRGSnapshotAndRestart server/raft_test.go:49 — node state after multiple snapshots
[Fact]
public async Task Multiple_snapshot_installs_advance_applied_index()
{
var node = new RaftNode("n1");
await node.InstallSnapshotAsync(new RaftSnapshot
{
LastIncludedIndex = 10,
LastIncludedTerm = 1,
}, default);
node.AppliedIndex.ShouldBe(10);
await node.InstallSnapshotAsync(new RaftSnapshot
{
LastIncludedIndex = 50,
LastIncludedTerm = 3,
}, default);
node.AppliedIndex.ShouldBe(50);
// Entries start after latest snapshot
var entry = node.Log.Append(term: 4, command: "after-second-snap");
entry.Index.ShouldBe(51);
}
}

View File

@@ -0,0 +1,166 @@
using System.Text.Json;
using NATS.Server.Raft;
namespace NATS.Server.Tests.Raft;
/// <summary>
/// Wire format encoding/decoding tests for RAFT RPC contracts.
/// Go: TestNRGAppendEntryEncode, TestNRGAppendEntryDecode in server/raft_test.go:82-152.
/// The .NET implementation uses JSON serialization rather than binary encoding,
/// so these tests validate JSON round-trip fidelity for all RPC types.
/// </summary>
public class RaftWireFormatTests
{
// Go: TestNRGAppendEntryEncode server/raft_test.go:82
[Fact]
public void VoteRequest_json_round_trip()
{
var original = new VoteRequest { Term = 5, CandidateId = "node-alpha" };
var json = JsonSerializer.Serialize(original);
json.ShouldNotBeNullOrWhiteSpace();
var decoded = JsonSerializer.Deserialize<VoteRequest>(json);
decoded.ShouldNotBeNull();
decoded.Term.ShouldBe(5);
decoded.CandidateId.ShouldBe("node-alpha");
}
// Go: TestNRGAppendEntryEncode server/raft_test.go:82
[Fact]
public void VoteResponse_json_round_trip()
{
var granted = new VoteResponse { Granted = true };
var json = JsonSerializer.Serialize(granted);
var decoded = JsonSerializer.Deserialize<VoteResponse>(json);
decoded.ShouldNotBeNull();
decoded.Granted.ShouldBeTrue();
var denied = new VoteResponse { Granted = false };
var json2 = JsonSerializer.Serialize(denied);
var decoded2 = JsonSerializer.Deserialize<VoteResponse>(json2);
decoded2.ShouldNotBeNull();
decoded2.Granted.ShouldBeFalse();
}
// Go: TestNRGAppendEntryEncode server/raft_test.go:82
[Fact]
public void AppendResult_json_round_trip()
{
var original = new AppendResult { FollowerId = "f1", Success = true };
var json = JsonSerializer.Serialize(original);
var decoded = JsonSerializer.Deserialize<AppendResult>(json);
decoded.ShouldNotBeNull();
decoded.FollowerId.ShouldBe("f1");
decoded.Success.ShouldBeTrue();
}
// Go: TestNRGAppendEntryEncode server/raft_test.go:82 — multiple entries
[Fact]
public void RaftLogEntry_batch_json_round_trip_preserves_order()
{
var entries = Enumerable.Range(1, 50)
.Select(i => new RaftLogEntry(Index: i, Term: (i % 3) + 1, Command: $"op-{i}"))
.ToList();
var json = JsonSerializer.Serialize(entries);
var decoded = JsonSerializer.Deserialize<List<RaftLogEntry>>(json);
decoded.ShouldNotBeNull();
decoded.Count.ShouldBe(50);
for (var i = 0; i < 50; i++)
{
decoded[i].Index.ShouldBe(i + 1);
decoded[i].Term.ShouldBe((i + 1) % 3 + 1);
decoded[i].Command.ShouldBe($"op-{i + 1}");
}
}
// Go: TestNRGAppendEntryEncode server/raft_test.go:82 — large payload
[Fact]
public void RaftLogEntry_large_command_round_trips()
{
var largeCommand = new string('x', 65536);
var entry = new RaftLogEntry(Index: 1, Term: 1, Command: largeCommand);
var json = JsonSerializer.Serialize(entry);
var decoded = JsonSerializer.Deserialize<RaftLogEntry>(json);
decoded.ShouldNotBeNull();
decoded.Command.Length.ShouldBe(65536);
decoded.Command.ShouldBe(largeCommand);
}
// Go: TestNRGAppendEntryEncode server/raft_test.go:82 — snapshot marker
[Fact]
public void RaftSnapshot_json_round_trip()
{
var data = new byte[256];
Random.Shared.NextBytes(data);
var snapshot = new RaftSnapshot
{
LastIncludedIndex = 999,
LastIncludedTerm = 42,
Data = data,
};
var json = JsonSerializer.Serialize(snapshot);
var decoded = JsonSerializer.Deserialize<RaftSnapshot>(json);
decoded.ShouldNotBeNull();
decoded.LastIncludedIndex.ShouldBe(999);
decoded.LastIncludedTerm.ShouldBe(42);
decoded.Data.ShouldBe(data);
}
// Go: TestNRGAppendEntryEncode server/raft_test.go:82 — empty snapshot data
[Fact]
public void RaftSnapshot_empty_data_round_trips()
{
var snapshot = new RaftSnapshot
{
LastIncludedIndex = 10,
LastIncludedTerm = 2,
Data = [],
};
var json = JsonSerializer.Serialize(snapshot);
var decoded = JsonSerializer.Deserialize<RaftSnapshot>(json);
decoded.ShouldNotBeNull();
decoded.Data.ShouldBeEmpty();
}
// Go: TestNRGAppendEntryEncode server/raft_test.go:82 — special characters
[Fact]
public void RaftLogEntry_special_characters_in_command_round_trips()
{
var commands = new[]
{
"hello\nworld",
"tab\there",
"quote\"inside",
"backslash\\path",
"unicode-\u00e9\u00e0\u00fc",
"{\"nested\":\"json\"}",
};
foreach (var cmd in commands)
{
var entry = new RaftLogEntry(Index: 1, Term: 1, Command: cmd);
var json = JsonSerializer.Serialize(entry);
var decoded = JsonSerializer.Deserialize<RaftLogEntry>(json);
decoded.ShouldNotBeNull();
decoded.Command.ShouldBe(cmd);
}
}
// Go: TestNRGAppendEntryDecode server/raft_test.go:125 — deserialization of malformed input
[Fact]
public void Malformed_json_returns_null_or_throws()
{
var badJson = "not-json-at-all";
Should.Throw<JsonException>(() => JsonSerializer.Deserialize<RaftLogEntry>(badJson));
}
}