feat: phase B distributed substrate test parity — 39 new tests across 5 subsystems
FileStore basics (4), MemStore/retention (10), RAFT election/append (16), config reload parity (3), monitoring endpoints varz/connz/healthz (6). 972 total tests passing, 0 failures.
This commit is contained in:
188
tests/NATS.Server.Tests/Raft/RaftAppendEntryTests.cs
Normal file
188
tests/NATS.Server.Tests/Raft/RaftAppendEntryTests.cs
Normal file
@@ -0,0 +1,188 @@
|
||||
using System.Text.Json;
|
||||
using NATS.Server.Raft;
|
||||
|
||||
namespace NATS.Server.Tests.Raft;
|
||||
|
||||
/// <summary>
|
||||
/// Ported from Go: TestNRGAppendEntryEncode in golang/nats-server/server/raft_test.go
|
||||
/// Tests append entry serialization/deserialization and log entry mechanics.
|
||||
/// The Go test validates binary encode/decode of appendEntry; the .NET equivalent
|
||||
/// validates JSON round-trip of RaftLogEntry and log persistence.
|
||||
/// </summary>
|
||||
public class RaftAppendEntryTests
|
||||
{
|
||||
[Fact]
|
||||
public void Append_entry_encode_decode_round_trips()
|
||||
{
|
||||
// Reference: TestNRGAppendEntryEncode — test entry serialization.
|
||||
// In .NET the RaftLogEntry is a sealed record serialized via JSON.
|
||||
var original = new RaftLogEntry(Index: 1, Term: 1, Command: "test-command");
|
||||
|
||||
var json = JsonSerializer.Serialize(original);
|
||||
json.ShouldNotBeNullOrWhiteSpace();
|
||||
|
||||
var decoded = JsonSerializer.Deserialize<RaftLogEntry>(json);
|
||||
decoded.ShouldNotBeNull();
|
||||
decoded.Index.ShouldBe(original.Index);
|
||||
decoded.Term.ShouldBe(original.Term);
|
||||
decoded.Command.ShouldBe(original.Command);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Append_entry_with_empty_command_round_trips()
|
||||
{
|
||||
// Reference: TestNRGAppendEntryEncode — Go test encodes entry with nil data.
|
||||
var original = new RaftLogEntry(Index: 5, Term: 2, Command: string.Empty);
|
||||
|
||||
var json = JsonSerializer.Serialize(original);
|
||||
var decoded = JsonSerializer.Deserialize<RaftLogEntry>(json);
|
||||
decoded.ShouldNotBeNull();
|
||||
decoded.Index.ShouldBe(5);
|
||||
decoded.Term.ShouldBe(2);
|
||||
decoded.Command.ShouldBe(string.Empty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Multiple_entries_encode_decode_preserves_order()
|
||||
{
|
||||
// Reference: TestNRGAppendEntryEncode — Go test encodes multiple entries.
|
||||
var entries = Enumerable.Range(0, 100)
|
||||
.Select(i => new RaftLogEntry(Index: i + 1, Term: 1, Command: $"cmd-{i}"))
|
||||
.ToList();
|
||||
|
||||
var json = JsonSerializer.Serialize(entries);
|
||||
var decoded = JsonSerializer.Deserialize<List<RaftLogEntry>>(json);
|
||||
|
||||
decoded.ShouldNotBeNull();
|
||||
decoded.Count.ShouldBe(100);
|
||||
|
||||
for (var i = 0; i < 100; i++)
|
||||
{
|
||||
decoded[i].Index.ShouldBe(i + 1);
|
||||
decoded[i].Term.ShouldBe(1);
|
||||
decoded[i].Command.ShouldBe($"cmd-{i}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Log_append_assigns_sequential_indices()
|
||||
{
|
||||
var log = new RaftLog();
|
||||
|
||||
var e1 = log.Append(term: 1, command: "first");
|
||||
var e2 = log.Append(term: 1, command: "second");
|
||||
var e3 = log.Append(term: 2, command: "third");
|
||||
|
||||
e1.Index.ShouldBe(1);
|
||||
e2.Index.ShouldBe(2);
|
||||
e3.Index.ShouldBe(3);
|
||||
|
||||
log.Entries.Count.ShouldBe(3);
|
||||
log.Entries[0].Command.ShouldBe("first");
|
||||
log.Entries[1].Command.ShouldBe("second");
|
||||
log.Entries[2].Command.ShouldBe("third");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Log_append_replicated_deduplicates_by_index()
|
||||
{
|
||||
var log = new RaftLog();
|
||||
var entry = new RaftLogEntry(Index: 1, Term: 1, Command: "cmd");
|
||||
|
||||
log.AppendReplicated(entry);
|
||||
log.AppendReplicated(entry); // duplicate should be ignored
|
||||
|
||||
log.Entries.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Log_replace_with_snapshot_clears_entries_and_resets_base()
|
||||
{
|
||||
// Reference: TestNRGSnapshotAndRestart — snapshot replaces log.
|
||||
var log = new RaftLog();
|
||||
log.Append(term: 1, command: "a");
|
||||
log.Append(term: 1, command: "b");
|
||||
log.Append(term: 1, command: "c");
|
||||
log.Entries.Count.ShouldBe(3);
|
||||
|
||||
var snapshot = new RaftSnapshot
|
||||
{
|
||||
LastIncludedIndex = 3,
|
||||
LastIncludedTerm = 1,
|
||||
};
|
||||
|
||||
log.ReplaceWithSnapshot(snapshot);
|
||||
log.Entries.Count.ShouldBe(0);
|
||||
|
||||
// After snapshot, new entries should start at index 4.
|
||||
var e = log.Append(term: 2, command: "post-snapshot");
|
||||
e.Index.ShouldBe(4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Log_persist_and_reload_round_trips()
|
||||
{
|
||||
// Reference: TestNRGSnapshotAndRestart — persistence round-trip.
|
||||
var dir = Path.Combine(Path.GetTempPath(), $"nats-raft-log-test-{Guid.NewGuid():N}");
|
||||
var logPath = Path.Combine(dir, "log.json");
|
||||
|
||||
try
|
||||
{
|
||||
var log = new RaftLog();
|
||||
log.Append(term: 1, command: "alpha");
|
||||
log.Append(term: 1, command: "beta");
|
||||
log.Append(term: 2, command: "gamma");
|
||||
|
||||
await log.PersistAsync(logPath, CancellationToken.None);
|
||||
File.Exists(logPath).ShouldBeTrue();
|
||||
|
||||
var reloaded = await RaftLog.LoadAsync(logPath, CancellationToken.None);
|
||||
reloaded.Entries.Count.ShouldBe(3);
|
||||
reloaded.Entries[0].Index.ShouldBe(1);
|
||||
reloaded.Entries[0].Term.ShouldBe(1);
|
||||
reloaded.Entries[0].Command.ShouldBe("alpha");
|
||||
reloaded.Entries[1].Command.ShouldBe("beta");
|
||||
reloaded.Entries[2].Command.ShouldBe("gamma");
|
||||
reloaded.Entries[2].Term.ShouldBe(2);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(dir))
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Log_load_returns_empty_for_nonexistent_path()
|
||||
{
|
||||
var logPath = Path.Combine(Path.GetTempPath(), $"nats-raft-noexist-{Guid.NewGuid():N}", "log.json");
|
||||
|
||||
var log = await RaftLog.LoadAsync(logPath, CancellationToken.None);
|
||||
log.Entries.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Entry_record_equality_holds_for_identical_values()
|
||||
{
|
||||
// RaftLogEntry is a sealed record — structural equality should work.
|
||||
var a = new RaftLogEntry(Index: 1, Term: 1, Command: "cmd");
|
||||
var b = new RaftLogEntry(Index: 1, Term: 1, Command: "cmd");
|
||||
a.ShouldBe(b);
|
||||
|
||||
var c = new RaftLogEntry(Index: 2, Term: 1, Command: "cmd");
|
||||
a.ShouldNotBe(c);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Entry_term_is_preserved_through_append()
|
||||
{
|
||||
var log = new RaftLog();
|
||||
var e1 = log.Append(term: 3, command: "term3-entry");
|
||||
var e2 = log.Append(term: 5, command: "term5-entry");
|
||||
|
||||
e1.Term.ShouldBe(3);
|
||||
e2.Term.ShouldBe(5);
|
||||
log.Entries[0].Term.ShouldBe(3);
|
||||
log.Entries[1].Term.ShouldBe(5);
|
||||
}
|
||||
}
|
||||
139
tests/NATS.Server.Tests/Raft/RaftElectionBasicTests.cs
Normal file
139
tests/NATS.Server.Tests/Raft/RaftElectionBasicTests.cs
Normal file
@@ -0,0 +1,139 @@
|
||||
using NATS.Server.Raft;
|
||||
|
||||
namespace NATS.Server.Tests.Raft;
|
||||
|
||||
/// <summary>
|
||||
/// Ported from Go: TestNRGSimple in golang/nats-server/server/raft_test.go
|
||||
/// Validates basic RAFT election mechanics and state convergence after proposals.
|
||||
/// </summary>
|
||||
public class RaftElectionBasicTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Three_node_group_elects_leader()
|
||||
{
|
||||
// Reference: TestNRGSimple — create 3-node RAFT group, wait for leader election.
|
||||
var cluster = RaftTestCluster.Create(3);
|
||||
var leader = await cluster.ElectLeaderAsync();
|
||||
|
||||
// Verify exactly 1 leader among the 3 nodes.
|
||||
leader.IsLeader.ShouldBeTrue();
|
||||
leader.Role.ShouldBe(RaftRole.Leader);
|
||||
leader.Term.ShouldBe(1);
|
||||
|
||||
// The other 2 nodes should not be leaders.
|
||||
var followers = cluster.Nodes.Where(n => n.Id != leader.Id).ToList();
|
||||
followers.Count.ShouldBe(2);
|
||||
foreach (var follower in followers)
|
||||
{
|
||||
follower.IsLeader.ShouldBeFalse();
|
||||
}
|
||||
|
||||
// Verify the cluster has exactly 1 leader total.
|
||||
cluster.Nodes.Count(n => n.IsLeader).ShouldBe(1);
|
||||
cluster.Nodes.Count(n => !n.IsLeader).ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task State_converges_after_proposals()
|
||||
{
|
||||
// Reference: TestNRGSimple — propose entries and verify all nodes converge.
|
||||
var cluster = RaftTestCluster.Create(3);
|
||||
var leader = await cluster.ElectLeaderAsync();
|
||||
|
||||
// Propose multiple entries like the Go test does with proposeDelta.
|
||||
var index1 = await leader.ProposeAsync("delta-22", default);
|
||||
var index2 = await leader.ProposeAsync("delta-minus-11", default);
|
||||
var index3 = await leader.ProposeAsync("delta-minus-10", default);
|
||||
|
||||
// Wait for all members to have applied the entries.
|
||||
await cluster.WaitForAppliedAsync(index3);
|
||||
|
||||
// All nodes should have converged to the same applied index.
|
||||
cluster.Nodes.All(n => n.AppliedIndex >= index3).ShouldBeTrue();
|
||||
|
||||
// The leader's log should contain all 3 entries.
|
||||
leader.Log.Entries.Count.ShouldBe(3);
|
||||
leader.Log.Entries[0].Command.ShouldBe("delta-22");
|
||||
leader.Log.Entries[1].Command.ShouldBe("delta-minus-11");
|
||||
leader.Log.Entries[2].Command.ShouldBe("delta-minus-10");
|
||||
|
||||
// Verify log indices are sequential.
|
||||
leader.Log.Entries[0].Index.ShouldBe(1);
|
||||
leader.Log.Entries[1].Index.ShouldBe(2);
|
||||
leader.Log.Entries[2].Index.ShouldBe(3);
|
||||
|
||||
// All entries should carry the current term.
|
||||
foreach (var entry in leader.Log.Entries)
|
||||
{
|
||||
entry.Term.ShouldBe(leader.Term);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Candidate_receives_majority_to_become_leader()
|
||||
{
|
||||
// Validates the vote-counting mechanics in detail.
|
||||
var node1 = new RaftNode("n1");
|
||||
var node2 = new RaftNode("n2");
|
||||
var node3 = new RaftNode("n3");
|
||||
var allNodes = new[] { node1, node2, node3 };
|
||||
foreach (var n in allNodes)
|
||||
n.ConfigureCluster(allNodes);
|
||||
|
||||
// n1 starts an election.
|
||||
node1.StartElection(clusterSize: 3);
|
||||
node1.Role.ShouldBe(RaftRole.Candidate);
|
||||
node1.Term.ShouldBe(1);
|
||||
node1.TermState.VotedFor.ShouldBe("n1");
|
||||
|
||||
// With only 1 vote (self), not yet leader.
|
||||
node1.IsLeader.ShouldBeFalse();
|
||||
|
||||
// n2 grants vote.
|
||||
var voteFromN2 = node2.GrantVote(node1.Term, "n1");
|
||||
voteFromN2.Granted.ShouldBeTrue();
|
||||
node1.ReceiveVote(voteFromN2, clusterSize: 3);
|
||||
|
||||
// With 2 out of 3 votes (majority), should now be leader.
|
||||
node1.IsLeader.ShouldBeTrue();
|
||||
node1.Role.ShouldBe(RaftRole.Leader);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Leader_steps_down_on_request()
|
||||
{
|
||||
var cluster = RaftTestCluster.Create(3);
|
||||
var leader = await cluster.ElectLeaderAsync();
|
||||
leader.IsLeader.ShouldBeTrue();
|
||||
|
||||
leader.RequestStepDown();
|
||||
leader.IsLeader.ShouldBeFalse();
|
||||
leader.Role.ShouldBe(RaftRole.Follower);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Follower_steps_down_to_higher_term_on_heartbeat()
|
||||
{
|
||||
// When a follower receives a heartbeat with a higher term, it updates its term.
|
||||
var node = new RaftNode("n1");
|
||||
node.StartElection(clusterSize: 1);
|
||||
node.IsLeader.ShouldBeTrue();
|
||||
node.Term.ShouldBe(1);
|
||||
|
||||
// Receiving heartbeat with higher term causes step-down.
|
||||
node.ReceiveHeartbeat(term: 5);
|
||||
node.Role.ShouldBe(RaftRole.Follower);
|
||||
node.Term.ShouldBe(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Five_node_group_elects_leader_with_quorum()
|
||||
{
|
||||
var cluster = RaftTestCluster.Create(5);
|
||||
var leader = await cluster.ElectLeaderAsync();
|
||||
|
||||
leader.IsLeader.ShouldBeTrue();
|
||||
cluster.Nodes.Count(n => n.IsLeader).ShouldBe(1);
|
||||
cluster.Nodes.Count(n => !n.IsLeader).ShouldBe(4);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user