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:
180
tests/NATS.Server.Tests/Raft/RaftCoreTypeTests.cs
Normal file
180
tests/NATS.Server.Tests/Raft/RaftCoreTypeTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
421
tests/NATS.Server.Tests/Raft/RaftElectionTests.cs
Normal file
421
tests/NATS.Server.Tests/Raft/RaftElectionTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
594
tests/NATS.Server.Tests/Raft/RaftLogReplicationTests.cs
Normal file
594
tests/NATS.Server.Tests/Raft/RaftLogReplicationTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
425
tests/NATS.Server.Tests/Raft/RaftSnapshotTests.cs
Normal file
425
tests/NATS.Server.Tests/Raft/RaftSnapshotTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
166
tests/NATS.Server.Tests/Raft/RaftWireFormatTests.cs
Normal file
166
tests/NATS.Server.Tests/Raft/RaftWireFormatTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user