refactor: extract NATS.Server.Raft.Tests project

Move 43 Raft consensus test files (8 root-level + 35 in Raft/ subfolder)
from NATS.Server.Tests into a dedicated NATS.Server.Raft.Tests project.
Update namespaces, add InternalsVisibleTo, and fix timing/exception
handling issues in moved test files.
This commit is contained in:
Joseph Doherty
2026-03-12 15:36:02 -04:00
parent 615752cdc2
commit edf9ed770e
47 changed files with 94 additions and 58 deletions

View File

@@ -1,488 +0,0 @@
using NATS.Server;
using NATS.Server.Auth;
using NATS.Server.Raft;
namespace NATS.Server.Tests.Raft;
/// <summary>
/// Tests for NatsRaftTransport — verifies subject routing, wire encoding,
/// and that the transport can be constructed with an InternalClient.
///
/// Go reference: golang/nats-server/server/raft.go:2192-2230 (subject setup),
/// 2854-2970 (send helpers), 2161-2169 (subject constants).
/// </summary>
public class NatsRaftTransportTests
{
// ---------------------------------------------------------------------------
// Construction
// ---------------------------------------------------------------------------
// Go: server/raft.go:2210 — n.vsubj, n.vreply = fmt.Sprintf(raftVoteSubj, n.group)...
[Fact]
public void Transport_can_be_constructed_with_internal_client()
{
var account = new Account("$G");
var client = new InternalClient(1UL, ClientKind.System, account);
var transport = new NatsRaftTransport(client, "meta",
(subject, reply, payload) => { });
transport.ShouldNotBeNull();
transport.GroupId.ShouldBe("meta");
transport.Client.ShouldBeSameAs(client);
}
[Fact]
public void Transport_exposes_group_id()
{
var account = new Account("$G");
var client = new InternalClient(2UL, ClientKind.System, account);
var transport = new NatsRaftTransport(client, "stream-A",
(_, _, _) => { });
transport.GroupId.ShouldBe("stream-A");
}
[Fact]
public void Transport_throws_when_client_is_null()
{
Should.Throw<ArgumentNullException>(
() => new NatsRaftTransport(null!, "meta", (_, _, _) => { }));
}
[Fact]
public void Transport_throws_when_groupId_is_empty()
{
var account = new Account("$G");
var client = new InternalClient(3UL, ClientKind.System, account);
Should.Throw<ArgumentException>(
() => new NatsRaftTransport(client, "", (_, _, _) => { }));
}
[Fact]
public void Transport_throws_when_publish_is_null()
{
var account = new Account("$G");
var client = new InternalClient(4UL, ClientKind.System, account);
Should.Throw<ArgumentNullException>(
() => new NatsRaftTransport(client, "meta", null!));
}
// ---------------------------------------------------------------------------
// AppendEntries — subject routing
// ---------------------------------------------------------------------------
// Go: server/raft.go:2164 — n.asubj = fmt.Sprintf(raftAppendSubj, n.group)
[Fact]
public async Task AppendEntries_publishes_to_NRG_AE_subject()
{
var capturedSubject = string.Empty;
var account = new Account("$G");
var client = new InternalClient(10UL, ClientKind.System, account);
var transport = new NatsRaftTransport(client, "meta",
(subject, _, _) => capturedSubject = subject);
var entry = new RaftLogEntry(Index: 1, Term: 1, Command: "op");
await transport.AppendEntriesAsync("leader1", ["peer1"], entry, CancellationToken.None);
capturedSubject.ShouldBe("$NRG.AE.meta");
}
// Go: server/raft.go:2164 — subject varies by group name
[Fact]
public async Task AppendEntries_subject_includes_group_name()
{
var capturedSubject = string.Empty;
var account = new Account("$G");
var client = new InternalClient(11UL, ClientKind.System, account);
var transport = new NatsRaftTransport(client, "stream-orders",
(subject, _, _) => capturedSubject = subject);
var entry = new RaftLogEntry(Index: 1, Term: 1, Command: "op");
await transport.AppendEntriesAsync("leader1", ["peer1"], entry, CancellationToken.None);
capturedSubject.ShouldBe("$NRG.AE.stream-orders");
}
// Go: server/raft.go:2167 — reply inbox set to raftReply format
[Fact]
public async Task AppendEntries_includes_NRG_R_reply_subject()
{
var capturedReply = string.Empty;
var account = new Account("$G");
var client = new InternalClient(12UL, ClientKind.System, account);
var transport = new NatsRaftTransport(client, "meta",
(_, reply, _) => capturedReply = reply ?? string.Empty);
var entry = new RaftLogEntry(Index: 1, Term: 1, Command: "op");
await transport.AppendEntriesAsync("leader1", ["peer1"], entry, CancellationToken.None);
capturedReply.ShouldStartWith("$NRG.R.");
}
// ---------------------------------------------------------------------------
// AppendEntries — wire encoding
// ---------------------------------------------------------------------------
// Go: server/raft.go:2662-2711 — appendEntry.encode()
[Fact]
public async Task AppendEntries_encodes_leader_id_in_wire_payload()
{
ReadOnlyMemory<byte> capturedPayload = default;
var account = new Account("$G");
var client = new InternalClient(13UL, ClientKind.System, account);
var transport = new NatsRaftTransport(client, "meta",
(_, _, payload) => capturedPayload = payload);
var entry = new RaftLogEntry(Index: 3, Term: 2, Command: "x");
await transport.AppendEntriesAsync("leader1", ["peer1"], entry, CancellationToken.None);
capturedPayload.IsEmpty.ShouldBeFalse();
var decoded = RaftAppendEntryWire.Decode(capturedPayload.Span);
decoded.LeaderId.ShouldBe("leader1");
}
// Go: server/raft.go:2694 — ae.term written to wire
[Fact]
public async Task AppendEntries_encodes_term_in_wire_payload()
{
ReadOnlyMemory<byte> capturedPayload = default;
var account = new Account("$G");
var client = new InternalClient(14UL, ClientKind.System, account);
var transport = new NatsRaftTransport(client, "meta",
(_, _, payload) => capturedPayload = payload);
var entry = new RaftLogEntry(Index: 5, Term: 7, Command: "cmd");
await transport.AppendEntriesAsync("L", ["peer1"], entry, CancellationToken.None);
var decoded = RaftAppendEntryWire.Decode(capturedPayload.Span);
decoded.Term.ShouldBe(7UL);
}
// Go: server/raft.go:2699-2705 — entry data encoded in payload
[Fact]
public async Task AppendEntries_encodes_command_as_normal_entry()
{
ReadOnlyMemory<byte> capturedPayload = default;
var account = new Account("$G");
var client = new InternalClient(15UL, ClientKind.System, account);
var transport = new NatsRaftTransport(client, "meta",
(_, _, payload) => capturedPayload = payload);
var entry = new RaftLogEntry(Index: 1, Term: 1, Command: "hello");
await transport.AppendEntriesAsync("L", ["peer1"], entry, CancellationToken.None);
var decoded = RaftAppendEntryWire.Decode(capturedPayload.Span);
decoded.Entries.Count.ShouldBe(1);
decoded.Entries[0].Type.ShouldBe(RaftEntryType.Normal);
System.Text.Encoding.UTF8.GetString(decoded.Entries[0].Data).ShouldBe("hello");
}
// AppendEntries returns one result per follower
[Fact]
public async Task AppendEntries_returns_result_per_follower()
{
var account = new Account("$G");
var client = new InternalClient(16UL, ClientKind.System, account);
var transport = new NatsRaftTransport(client, "meta", (_, _, _) => { });
var entry = new RaftLogEntry(Index: 1, Term: 1, Command: "op");
var results = await transport.AppendEntriesAsync("L", ["peer1", "peer2", "peer3"],
entry, CancellationToken.None);
results.Count.ShouldBe(3);
results.Select(r => r.FollowerId).ShouldBe(["peer1", "peer2", "peer3"], ignoreOrder: false);
}
// ---------------------------------------------------------------------------
// RequestVote — subject routing
// ---------------------------------------------------------------------------
// Go: server/raft.go:2163 — n.vsubj = fmt.Sprintf(raftVoteSubj, n.group)
[Fact]
public async Task RequestVote_publishes_to_NRG_V_subject()
{
var capturedSubject = string.Empty;
var account = new Account("$G");
var client = new InternalClient(20UL, ClientKind.System, account);
var transport = new NatsRaftTransport(client, "meta",
(subject, _, _) => capturedSubject = subject);
var req = new VoteRequest { Term = 3, CandidateId = "cand1" };
await transport.RequestVoteAsync("cand1", "voter1", req, CancellationToken.None);
capturedSubject.ShouldBe("$NRG.V.meta");
}
// Go: server/raft.go:2163 — subject varies by group name
[Fact]
public async Task RequestVote_subject_includes_group_name()
{
var capturedSubject = string.Empty;
var account = new Account("$G");
var client = new InternalClient(21UL, ClientKind.System, account);
var transport = new NatsRaftTransport(client, "stream-events",
(subject, _, _) => capturedSubject = subject);
var req = new VoteRequest { Term = 1, CandidateId = "c" };
await transport.RequestVoteAsync("c", "v", req, CancellationToken.None);
capturedSubject.ShouldBe("$NRG.V.stream-events");
}
// Go: server/raft.go:2167 — n.vreply = n.newInbox() → "$NRG.R.{suffix}"
[Fact]
public async Task RequestVote_includes_NRG_R_reply_subject()
{
var capturedReply = string.Empty;
var account = new Account("$G");
var client = new InternalClient(22UL, ClientKind.System, account);
var transport = new NatsRaftTransport(client, "meta",
(_, reply, _) => capturedReply = reply ?? string.Empty);
var req = new VoteRequest { Term = 1, CandidateId = "c" };
await transport.RequestVoteAsync("c", "v", req, CancellationToken.None);
capturedReply.ShouldStartWith("$NRG.R.");
}
// ---------------------------------------------------------------------------
// RequestVote — wire encoding
// ---------------------------------------------------------------------------
// Go: server/raft.go:4560-4568 — voteRequest.encode()
[Fact]
public async Task RequestVote_encodes_term_in_wire_payload()
{
ReadOnlyMemory<byte> capturedPayload = default;
var account = new Account("$G");
var client = new InternalClient(23UL, ClientKind.System, account);
var transport = new NatsRaftTransport(client, "meta",
(_, _, payload) => capturedPayload = payload);
var req = new VoteRequest { Term = 9, CandidateId = "cand1" };
await transport.RequestVoteAsync("cand1", "voter1", req, CancellationToken.None);
capturedPayload.Length.ShouldBe(RaftWireConstants.VoteRequestLen); // 32 bytes
var decoded = RaftVoteRequestWire.Decode(capturedPayload.Span);
decoded.Term.ShouldBe(9UL);
}
// Go: server/raft.go:4567 — candidateId written to wire
[Fact]
public async Task RequestVote_uses_candidate_id_from_request_when_set()
{
ReadOnlyMemory<byte> capturedPayload = default;
var account = new Account("$G");
var client = new InternalClient(24UL, ClientKind.System, account);
var transport = new NatsRaftTransport(client, "meta",
(_, _, payload) => capturedPayload = payload);
var req = new VoteRequest { Term = 2, CandidateId = "cand99" };
await transport.RequestVoteAsync("fallback", "voter1", req, CancellationToken.None);
var decoded = RaftVoteRequestWire.Decode(capturedPayload.Span);
// CandidateId from request takes precedence, truncated to 8 chars (idLen)
decoded.CandidateId.ShouldBe("cand99");
}
// Go: server/raft.go:4567 — candidateId falls back to candidateId param when request id is empty
[Fact]
public async Task RequestVote_uses_caller_candidate_id_when_request_id_empty()
{
ReadOnlyMemory<byte> capturedPayload = default;
var account = new Account("$G");
var client = new InternalClient(25UL, ClientKind.System, account);
var transport = new NatsRaftTransport(client, "meta",
(_, _, payload) => capturedPayload = payload);
var req = new VoteRequest { Term = 1, CandidateId = "" };
await transport.RequestVoteAsync("fallbk", "voter1", req, CancellationToken.None);
var decoded = RaftVoteRequestWire.Decode(capturedPayload.Span);
decoded.CandidateId.ShouldBe("fallbk");
}
// ---------------------------------------------------------------------------
// InstallSnapshot — subject routing
// ---------------------------------------------------------------------------
// Go: server/raft.go:2168 — raftCatchupReply = "$NRG.CR.%s"
[Fact]
public async Task InstallSnapshot_publishes_to_NRG_CR_subject()
{
var capturedSubject = string.Empty;
var account = new Account("$G");
var client = new InternalClient(30UL, ClientKind.System, account);
var transport = new NatsRaftTransport(client, "meta",
(subject, _, _) => capturedSubject = subject);
var snapshot = new RaftSnapshot { LastIncludedIndex = 10, LastIncludedTerm = 2, Data = [1, 2, 3] };
await transport.InstallSnapshotAsync("leader1", "peer1", snapshot, CancellationToken.None);
capturedSubject.ShouldStartWith("$NRG.CR.");
}
// Go: server/raft.go:2168 — no reply-to for catchup transfers
[Fact]
public async Task InstallSnapshot_has_no_reply_subject()
{
string? capturedReply = "not-null";
var account = new Account("$G");
var client = new InternalClient(31UL, ClientKind.System, account);
var transport = new NatsRaftTransport(client, "meta",
(_, reply, _) => capturedReply = reply);
var snapshot = new RaftSnapshot { LastIncludedIndex = 5, LastIncludedTerm = 1, Data = [] };
await transport.InstallSnapshotAsync("L", "P", snapshot, CancellationToken.None);
capturedReply.ShouldBeNull();
}
// ---------------------------------------------------------------------------
// InstallSnapshot — wire encoding
// ---------------------------------------------------------------------------
// Go: server/raft.go:3247 — snapshot encoded as EntryOldSnapshot AppendEntry
[Fact]
public async Task InstallSnapshot_encodes_data_as_old_snapshot_entry()
{
ReadOnlyMemory<byte> capturedPayload = default;
var account = new Account("$G");
var client = new InternalClient(32UL, ClientKind.System, account);
var transport = new NatsRaftTransport(client, "meta",
(_, _, payload) => capturedPayload = payload);
var snapshotData = new byte[] { 0xDE, 0xAD, 0xBE, 0xEF };
var snapshot = new RaftSnapshot { LastIncludedIndex = 100, LastIncludedTerm = 5, Data = snapshotData };
await transport.InstallSnapshotAsync("L", "P", snapshot, CancellationToken.None);
capturedPayload.IsEmpty.ShouldBeFalse();
var decoded = RaftAppendEntryWire.Decode(capturedPayload.Span);
decoded.Entries.Count.ShouldBe(1);
decoded.Entries[0].Type.ShouldBe(RaftEntryType.OldSnapshot);
decoded.Entries[0].Data.ShouldBe(snapshotData);
}
// ---------------------------------------------------------------------------
// ForwardProposal — subject routing
// ---------------------------------------------------------------------------
// Go: server/raft.go:2165 — n.psubj = fmt.Sprintf(raftPropSubj, n.group)
[Fact]
public void ForwardProposal_publishes_to_NRG_P_subject()
{
var capturedSubject = string.Empty;
var account = new Account("$G");
var client = new InternalClient(40UL, ClientKind.System, account);
var transport = new NatsRaftTransport(client, "meta",
(subject, _, _) => capturedSubject = subject);
transport.ForwardProposal(new byte[] { 1, 2, 3 });
capturedSubject.ShouldBe("$NRG.P.meta");
}
// Go: server/raft.go:2165 — subject varies by group name
[Fact]
public void ForwardProposal_subject_includes_group_name()
{
var capturedSubject = string.Empty;
var account = new Account("$G");
var client = new InternalClient(41UL, ClientKind.System, account);
var transport = new NatsRaftTransport(client, "stream-inventory",
(subject, _, _) => capturedSubject = subject);
transport.ForwardProposal(System.Text.Encoding.UTF8.GetBytes("entry"));
capturedSubject.ShouldBe("$NRG.P.stream-inventory");
}
// Go: server/raft.go:949 — payload forwarded verbatim
[Fact]
public void ForwardProposal_sends_payload_verbatim()
{
ReadOnlyMemory<byte> capturedPayload = default;
var account = new Account("$G");
var client = new InternalClient(42UL, ClientKind.System, account);
var transport = new NatsRaftTransport(client, "meta",
(_, _, payload) => capturedPayload = payload);
var data = new byte[] { 10, 20, 30, 40 };
transport.ForwardProposal(data);
capturedPayload.ToArray().ShouldBe(data);
}
// ---------------------------------------------------------------------------
// ProposeRemovePeer — subject routing
// ---------------------------------------------------------------------------
// Go: server/raft.go:2166 — n.rpsubj = fmt.Sprintf(raftRemovePeerSubj, n.group)
[Fact]
public void ProposeRemovePeer_publishes_to_NRG_RP_subject()
{
var capturedSubject = string.Empty;
var account = new Account("$G");
var client = new InternalClient(50UL, ClientKind.System, account);
var transport = new NatsRaftTransport(client, "meta",
(subject, _, _) => capturedSubject = subject);
transport.ProposeRemovePeer("peer-x");
capturedSubject.ShouldBe("$NRG.RP.meta");
}
// Go: server/raft.go:986 — peer name encoded as UTF-8 bytes
[Fact]
public void ProposeRemovePeer_encodes_peer_name_as_utf8()
{
ReadOnlyMemory<byte> capturedPayload = default;
var account = new Account("$G");
var client = new InternalClient(51UL, ClientKind.System, account);
var transport = new NatsRaftTransport(client, "meta",
(_, _, payload) => capturedPayload = payload);
transport.ProposeRemovePeer("peer-abc");
System.Text.Encoding.UTF8.GetString(capturedPayload.Span).ShouldBe("peer-abc");
}
// ---------------------------------------------------------------------------
// IRaftTransport implementation
// ---------------------------------------------------------------------------
// NatsRaftTransport must implement IRaftTransport
[Fact]
public void NatsRaftTransport_implements_IRaftTransport()
{
var account = new Account("$G");
var client = new InternalClient(60UL, ClientKind.System, account);
var transport = new NatsRaftTransport(client, "meta", (_, _, _) => { });
(transport as IRaftTransport).ShouldNotBeNull();
}
}

View File

@@ -1,14 +0,0 @@
namespace NATS.Server.Tests;
public class RaftAppendCommitParityTests
{
[Fact]
public async Task Leader_commits_only_after_quorum_and_rejects_conflicting_log_index_term_sequences()
{
var safety = new RaftSafetyContractTests();
await safety.Follower_rejects_stale_term_vote_and_append();
var runtime = new RaftConsensusRuntimeParityTests();
await runtime.Raft_cluster_commits_with_next_index_backtracking_semantics();
}
}

View File

@@ -1,188 +0,0 @@
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);
}
}

View File

@@ -1,256 +0,0 @@
using NATS.Server.Raft;
namespace NATS.Server.Tests.Raft;
/// <summary>
/// Tests for CommitQueue and commit/processed index tracking in RaftNode.
/// Go reference: raft.go:150-160 (applied/processed fields), raft.go:2100-2150 (ApplyQ).
/// </summary>
public class RaftApplyQueueTests
{
// -- Helpers --
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;
}
// -- CommitQueue<T> unit tests --
[Fact]
public async Task Enqueue_and_dequeue_lifecycle()
{
var queue = new CommitQueue<RaftLogEntry>();
var entry = new RaftLogEntry(1, 1, "cmd-1");
await queue.EnqueueAsync(entry);
queue.Count.ShouldBe(1);
var dequeued = await queue.DequeueAsync();
dequeued.ShouldBe(entry);
queue.Count.ShouldBe(0);
}
[Fact]
public async Task Multiple_items_dequeue_in_fifo_order()
{
var queue = new CommitQueue<RaftLogEntry>();
var entry1 = new RaftLogEntry(1, 1, "cmd-1");
var entry2 = new RaftLogEntry(2, 1, "cmd-2");
var entry3 = new RaftLogEntry(3, 1, "cmd-3");
await queue.EnqueueAsync(entry1);
await queue.EnqueueAsync(entry2);
await queue.EnqueueAsync(entry3);
queue.Count.ShouldBe(3);
(await queue.DequeueAsync()).ShouldBe(entry1);
(await queue.DequeueAsync()).ShouldBe(entry2);
(await queue.DequeueAsync()).ShouldBe(entry3);
queue.Count.ShouldBe(0);
}
[Fact]
public void TryDequeue_returns_false_when_empty()
{
var queue = new CommitQueue<RaftLogEntry>();
queue.TryDequeue(out var item).ShouldBeFalse();
item.ShouldBeNull();
}
[Fact]
public async Task TryDequeue_returns_true_when_item_available()
{
var queue = new CommitQueue<RaftLogEntry>();
var entry = new RaftLogEntry(1, 1, "cmd-1");
await queue.EnqueueAsync(entry);
queue.TryDequeue(out var item).ShouldBeTrue();
item.ShouldBe(entry);
}
[Fact]
public async Task Complete_prevents_further_enqueue()
{
var queue = new CommitQueue<RaftLogEntry>();
await queue.EnqueueAsync(new RaftLogEntry(1, 1, "cmd-1"));
queue.Complete();
// After completion, writing should throw ChannelClosedException
await Should.ThrowAsync<System.Threading.Channels.ChannelClosedException>(
async () => await queue.EnqueueAsync(new RaftLogEntry(2, 1, "cmd-2")));
}
[Fact]
public async Task Complete_allows_draining_remaining_items()
{
var queue = new CommitQueue<RaftLogEntry>();
var entry = new RaftLogEntry(1, 1, "cmd-1");
await queue.EnqueueAsync(entry);
queue.Complete();
// Should still be able to read remaining items
var dequeued = await queue.DequeueAsync();
dequeued.ShouldBe(entry);
}
[Fact]
public void Count_reflects_current_queue_depth()
{
var queue = new CommitQueue<RaftLogEntry>();
queue.Count.ShouldBe(0);
}
// -- RaftNode CommitIndex tracking tests --
[Fact]
public async Task CommitIndex_advances_when_proposal_succeeds_quorum()
{
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
leader.CommitIndex.ShouldBe(0);
var index1 = await leader.ProposeAsync("cmd-1", default);
leader.CommitIndex.ShouldBe(index1);
var index2 = await leader.ProposeAsync("cmd-2", default);
leader.CommitIndex.ShouldBe(index2);
index2.ShouldBeGreaterThan(index1);
}
[Fact]
public async Task CommitIndex_starts_at_zero()
{
var node = new RaftNode("n1");
node.CommitIndex.ShouldBe(0);
await Task.CompletedTask;
}
// -- RaftNode ProcessedIndex tracking tests --
[Fact]
public void ProcessedIndex_starts_at_zero()
{
var node = new RaftNode("n1");
node.ProcessedIndex.ShouldBe(0);
}
[Fact]
public void MarkProcessed_advances_ProcessedIndex()
{
var node = new RaftNode("n1");
node.MarkProcessed(5);
node.ProcessedIndex.ShouldBe(5);
}
[Fact]
public void MarkProcessed_does_not_go_backward()
{
var node = new RaftNode("n1");
node.MarkProcessed(10);
node.MarkProcessed(5);
node.ProcessedIndex.ShouldBe(10);
}
[Fact]
public async Task ProcessedIndex_tracks_separately_from_CommitIndex()
{
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
var index1 = await leader.ProposeAsync("cmd-1", default);
var index2 = await leader.ProposeAsync("cmd-2", default);
// CommitIndex should have advanced
leader.CommitIndex.ShouldBe(index2);
// ProcessedIndex stays at 0 until explicitly marked
leader.ProcessedIndex.ShouldBe(0);
// Simulate state machine processing one entry
leader.MarkProcessed(index1);
leader.ProcessedIndex.ShouldBe(index1);
// CommitIndex is still ahead of ProcessedIndex
leader.CommitIndex.ShouldBeGreaterThan(leader.ProcessedIndex);
// Process the second entry
leader.MarkProcessed(index2);
leader.ProcessedIndex.ShouldBe(index2);
leader.ProcessedIndex.ShouldBe(leader.CommitIndex);
}
// -- CommitQueue integration with RaftNode --
[Fact]
public async Task CommitQueue_receives_entries_after_successful_quorum()
{
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
var index1 = await leader.ProposeAsync("cmd-1", default);
var index2 = await leader.ProposeAsync("cmd-2", default);
// CommitQueue should have 2 entries
leader.CommitQueue.Count.ShouldBe(2);
// Dequeue and verify order
var entry1 = await leader.CommitQueue.DequeueAsync();
entry1.Index.ShouldBe(index1);
entry1.Command.ShouldBe("cmd-1");
var entry2 = await leader.CommitQueue.DequeueAsync();
entry2.Index.ShouldBe(index2);
entry2.Command.ShouldBe("cmd-2");
}
[Fact]
public async Task CommitQueue_entries_match_committed_log_entries()
{
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
await leader.ProposeAsync("alpha", default);
await leader.ProposeAsync("beta", default);
await leader.ProposeAsync("gamma", default);
// Drain the commit queue and verify entries match log
for (int i = 0; i < 3; i++)
{
var committed = await leader.CommitQueue.DequeueAsync();
committed.ShouldBe(leader.Log.Entries[i]);
}
}
[Fact]
public async Task Non_leader_proposal_throws_and_does_not_affect_commit_queue()
{
var node = new RaftNode("follower");
node.CommitQueue.Count.ShouldBe(0);
await Should.ThrowAsync<InvalidOperationException>(
async () => await node.ProposeAsync("cmd", default));
node.CommitQueue.Count.ShouldBe(0);
node.CommitIndex.ShouldBe(0);
}
}

View File

@@ -1,519 +0,0 @@
using NATS.Server.Raft;
namespace NATS.Server.Tests.Raft;
/// <summary>
/// Binary wire format encoding/decoding tests for all RAFT RPC types.
/// These validate exact byte-for-byte fidelity with Go's raft.go encoding.
/// Go reference: golang/nats-server/server/raft.go lines 2662-2796 (AppendEntry),
/// 4560-4768 (vote types).
/// </summary>
public class RaftBinaryWireFormatTests
{
// ---------------------------------------------------------------------------
// VoteRequest
// ---------------------------------------------------------------------------
// Go: server/raft.go:4560-4568 — voteRequest.encode()
// Go: server/raft.go:4571-4583 — decodeVoteRequest()
[Fact]
public void VoteRequest_round_trip_encode_decode()
{
var original = new RaftVoteRequestWire(
Term: 7,
LastTerm: 3,
LastIndex: 42,
CandidateId: "peer0001");
var encoded = original.Encode();
encoded.Length.ShouldBe(RaftWireConstants.VoteRequestLen); // 32 bytes
var decoded = RaftVoteRequestWire.Decode(encoded);
decoded.Term.ShouldBe(7UL);
decoded.LastTerm.ShouldBe(3UL);
decoded.LastIndex.ShouldBe(42UL);
decoded.CandidateId.ShouldBe("peer0001");
}
[Fact]
public void VoteRequest_bytes_are_little_endian()
{
var req = new RaftVoteRequestWire(Term: 1, LastTerm: 0, LastIndex: 0, CandidateId: "");
var bytes = req.Encode();
// term = 1 in little-endian: [1, 0, 0, 0, 0, 0, 0, 0]
// Go: server/raft.go:4563 — le.PutUint64(buf[0:], vr.term)
bytes[0].ShouldBe((byte)1);
bytes[1].ShouldBe((byte)0);
}
[Fact]
public void VoteRequest_zero_values_encode_to_zeroed_buffer()
{
var req = new RaftVoteRequestWire(Term: 0, LastTerm: 0, LastIndex: 0, CandidateId: "");
var bytes = req.Encode();
bytes.Length.ShouldBe(32);
bytes.ShouldAllBe(b => b == 0);
}
[Fact]
public void VoteRequest_large_term_round_trips()
{
var req = new RaftVoteRequestWire(
Term: ulong.MaxValue,
LastTerm: ulong.MaxValue - 1,
LastIndex: ulong.MaxValue - 2,
CandidateId: "node1234");
var decoded = RaftVoteRequestWire.Decode(req.Encode());
decoded.Term.ShouldBe(ulong.MaxValue);
decoded.LastTerm.ShouldBe(ulong.MaxValue - 1);
decoded.LastIndex.ShouldBe(ulong.MaxValue - 2);
decoded.CandidateId.ShouldBe("node1234");
}
[Fact]
public void VoteRequest_short_buffer_throws_ArgumentException()
{
var shortBuffer = new byte[RaftWireConstants.VoteRequestLen - 1];
Should.Throw<ArgumentException>(() => RaftVoteRequestWire.Decode(shortBuffer));
}
[Fact]
public void VoteRequest_long_buffer_throws_ArgumentException()
{
var longBuffer = new byte[RaftWireConstants.VoteRequestLen + 1];
Should.Throw<ArgumentException>(() => RaftVoteRequestWire.Decode(longBuffer));
}
[Fact]
public void VoteRequest_candidate_id_truncated_to_8_bytes()
{
// IDs longer than 8 chars are silently truncated (Go copy semantics).
// Go: server/raft.go:4566 — copy(buf[24:24+idLen], vr.candidate)
var req = new RaftVoteRequestWire(
Term: 1, LastTerm: 0, LastIndex: 0,
CandidateId: "abcdefghXXXXXXXX"); // 16 chars; only first 8 kept
var bytes = req.Encode();
// Check that the ID field contains only the first 8 chars.
var idBytes = bytes[24..32];
System.Text.Encoding.ASCII.GetString(idBytes).ShouldBe("abcdefgh");
}
[Fact]
public void VoteRequest_short_candidate_id_zero_padded()
{
var req = new RaftVoteRequestWire(
Term: 1, LastTerm: 0, LastIndex: 0, CandidateId: "abc");
var bytes = req.Encode();
bytes[27].ShouldBe((byte)0); // byte 3..7 should be zero
bytes[28].ShouldBe((byte)0);
// Decode should recover the original 3-char ID.
var decoded = RaftVoteRequestWire.Decode(bytes);
decoded.CandidateId.ShouldBe("abc");
}
// ---------------------------------------------------------------------------
// VoteResponse
// ---------------------------------------------------------------------------
// Go: server/raft.go:4739-4751 — voteResponse.encode()
// Go: server/raft.go:4753-4762 — decodeVoteResponse()
[Fact]
public void VoteResponse_granted_true_round_trip()
{
var resp = new RaftVoteResponseWire(Term: 5, PeerId: "peer0002", Granted: true);
var decoded = RaftVoteResponseWire.Decode(resp.Encode());
decoded.Term.ShouldBe(5UL);
decoded.PeerId.ShouldBe("peer0002");
decoded.Granted.ShouldBeTrue();
decoded.Empty.ShouldBeFalse();
}
[Fact]
public void VoteResponse_granted_false_round_trip()
{
var resp = new RaftVoteResponseWire(Term: 3, PeerId: "peer0003", Granted: false);
var decoded = RaftVoteResponseWire.Decode(resp.Encode());
decoded.Granted.ShouldBeFalse();
decoded.PeerId.ShouldBe("peer0003");
}
[Fact]
public void VoteResponse_empty_flag_round_trip()
{
// Go: server/raft.go:4746-4748 — buf[16] |= 2 when empty
var resp = new RaftVoteResponseWire(Term: 1, PeerId: "p1", Granted: false, Empty: true);
var decoded = RaftVoteResponseWire.Decode(resp.Encode());
decoded.Empty.ShouldBeTrue();
decoded.Granted.ShouldBeFalse();
}
[Fact]
public void VoteResponse_both_flags_set()
{
var resp = new RaftVoteResponseWire(Term: 1, PeerId: "p1", Granted: true, Empty: true);
var bytes = resp.Encode();
// Go: server/raft.go:4744-4748 — bit 0 = granted, bit 1 = empty
(bytes[16] & 1).ShouldBe(1); // granted
(bytes[16] & 2).ShouldBe(2); // empty
var decoded = RaftVoteResponseWire.Decode(bytes);
decoded.Granted.ShouldBeTrue();
decoded.Empty.ShouldBeTrue();
}
[Fact]
public void VoteResponse_fixed_17_bytes()
{
var resp = new RaftVoteResponseWire(Term: 10, PeerId: "peer0001", Granted: true);
resp.Encode().Length.ShouldBe(RaftWireConstants.VoteResponseLen); // 17
}
[Fact]
public void VoteResponse_short_buffer_throws_ArgumentException()
{
var shortBuffer = new byte[RaftWireConstants.VoteResponseLen - 1];
Should.Throw<ArgumentException>(() => RaftVoteResponseWire.Decode(shortBuffer));
}
[Fact]
public void VoteResponse_peer_id_truncated_to_8_bytes()
{
// Go: server/raft.go:4743 — copy(buf[8:], vr.peer)
var resp = new RaftVoteResponseWire(
Term: 1, PeerId: "longpeernamethatexceeds8chars", Granted: true);
var bytes = resp.Encode();
// Bytes [8..15] hold the peer ID — only first 8 chars fit.
var idBytes = bytes[8..16];
System.Text.Encoding.ASCII.GetString(idBytes).ShouldBe("longpeer");
}
// ---------------------------------------------------------------------------
// AppendEntry — zero entries
// ---------------------------------------------------------------------------
// Go: server/raft.go:2662-2711 — appendEntry.encode()
// Go: server/raft.go:2714-2746 — decodeAppendEntry()
[Fact]
public void AppendEntry_zero_entries_round_trip()
{
var ae = new RaftAppendEntryWire(
LeaderId: "lead0001",
Term: 10,
Commit: 8,
PrevTerm: 9,
PrevIndex: 7,
Entries: [],
LeaderTerm: 0);
var encoded = ae.Encode();
// Base length + 1-byte uvarint(0) for lterm.
// Go: server/raft.go:2681-2683 — lterm uvarint always appended
encoded.Length.ShouldBe(RaftWireConstants.AppendEntryBaseLen + 1);
var decoded = RaftAppendEntryWire.Decode(encoded);
decoded.LeaderId.ShouldBe("lead0001");
decoded.Term.ShouldBe(10UL);
decoded.Commit.ShouldBe(8UL);
decoded.PrevTerm.ShouldBe(9UL);
decoded.PrevIndex.ShouldBe(7UL);
decoded.Entries.Count.ShouldBe(0);
decoded.LeaderTerm.ShouldBe(0UL);
}
[Fact]
public void AppendEntry_base_layout_at_correct_offsets()
{
// Go: server/raft.go:2693-2698 — exact layout:
// [0..7]=leader [8..15]=term [16..23]=commit [24..31]=pterm [32..39]=pindex [40..41]=entryCount
var ae = new RaftAppendEntryWire(
LeaderId: "AAAAAAAA", // 0x41 x 8
Term: 1,
Commit: 2,
PrevTerm: 3,
PrevIndex: 4,
Entries: []);
var bytes = ae.Encode();
// leader bytes
bytes[0].ShouldBe((byte)'A');
bytes[7].ShouldBe((byte)'A');
// term = 1 LE
bytes[8].ShouldBe((byte)1);
bytes[9].ShouldBe((byte)0);
// commit = 2 LE
bytes[16].ShouldBe((byte)2);
// entryCount = 0
bytes[40].ShouldBe((byte)0);
bytes[41].ShouldBe((byte)0);
}
// ---------------------------------------------------------------------------
// AppendEntry — single entry
// ---------------------------------------------------------------------------
[Fact]
public void AppendEntry_single_entry_round_trip()
{
var data = "hello world"u8.ToArray();
var entry = new RaftEntryWire(RaftEntryType.Normal, data);
var ae = new RaftAppendEntryWire(
LeaderId: "leader01",
Term: 5,
Commit: 3,
PrevTerm: 4,
PrevIndex: 2,
Entries: [entry]);
var decoded = RaftAppendEntryWire.Decode(ae.Encode());
decoded.Entries.Count.ShouldBe(1);
decoded.Entries[0].Type.ShouldBe(RaftEntryType.Normal);
decoded.Entries[0].Data.ShouldBe(data);
}
[Fact]
public void AppendEntry_entry_size_field_equals_1_plus_data_length()
{
// Go: server/raft.go:2702 — le.AppendUint32(buf, uint32(1+len(e.Data)))
var data = new byte[10];
var entry = new RaftEntryWire(RaftEntryType.PeerState, data);
var ae = new RaftAppendEntryWire(
LeaderId: "ld", Term: 1, Commit: 0, PrevTerm: 0, PrevIndex: 0,
Entries: [entry]);
var bytes = ae.Encode();
// Entry starts at offset 42 (appendEntryBaseLen).
// First 4 bytes are the uint32 size = 1 + 10 = 11.
var sizeField = System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(42));
sizeField.ShouldBe(11u);
// Byte at offset 46 is the entry type.
bytes[46].ShouldBe((byte)RaftEntryType.PeerState);
}
// ---------------------------------------------------------------------------
// AppendEntry — multiple entries
// ---------------------------------------------------------------------------
[Fact]
public void AppendEntry_multiple_entries_round_trip()
{
var entries = new RaftEntryWire[]
{
new(RaftEntryType.Normal, "first"u8.ToArray()),
new(RaftEntryType.AddPeer, "second"u8.ToArray()),
new(RaftEntryType.RemovePeer, "third"u8.ToArray()),
};
var ae = new RaftAppendEntryWire(
LeaderId: "lead0001",
Term: 20,
Commit: 15,
PrevTerm: 19,
PrevIndex: 14,
Entries: entries);
var decoded = RaftAppendEntryWire.Decode(ae.Encode());
decoded.Entries.Count.ShouldBe(3);
decoded.Entries[0].Type.ShouldBe(RaftEntryType.Normal);
decoded.Entries[0].Data.ShouldBe("first"u8.ToArray());
decoded.Entries[1].Type.ShouldBe(RaftEntryType.AddPeer);
decoded.Entries[1].Data.ShouldBe("second"u8.ToArray());
decoded.Entries[2].Type.ShouldBe(RaftEntryType.RemovePeer);
decoded.Entries[2].Data.ShouldBe("third"u8.ToArray());
}
[Fact]
public void AppendEntry_50_entries_preserve_order()
{
var entries = Enumerable.Range(0, 50)
.Select(i => new RaftEntryWire(RaftEntryType.Normal, [(byte)i]))
.ToArray();
var ae = new RaftAppendEntryWire(
LeaderId: "lead0001", Term: 1, Commit: 0, PrevTerm: 0, PrevIndex: 0,
Entries: entries);
var decoded = RaftAppendEntryWire.Decode(ae.Encode());
decoded.Entries.Count.ShouldBe(50);
for (var i = 0; i < 50; i++)
decoded.Entries[i].Data[0].ShouldBe((byte)i);
}
[Fact]
public void AppendEntry_entry_with_empty_data_round_trips()
{
var entry = new RaftEntryWire(RaftEntryType.LeaderTransfer, []);
var ae = new RaftAppendEntryWire(
LeaderId: "ld", Term: 1, Commit: 0, PrevTerm: 0, PrevIndex: 0,
Entries: [entry]);
var decoded = RaftAppendEntryWire.Decode(ae.Encode());
decoded.Entries.Count.ShouldBe(1);
decoded.Entries[0].Data.Length.ShouldBe(0);
decoded.Entries[0].Type.ShouldBe(RaftEntryType.LeaderTransfer);
}
// ---------------------------------------------------------------------------
// AppendEntry — leaderTerm (uvarint tail)
// ---------------------------------------------------------------------------
// Go: server/raft.go:2709 — buf = append(buf, lterm...)
// Go: server/raft.go:2740-2743 — if lterm, n := binary.Uvarint(msg[ri:]); n > 0 ...
[Theory]
[InlineData(0UL)]
[InlineData(1UL)]
[InlineData(127UL)]
[InlineData(128UL)]
[InlineData(ulong.MaxValue)]
public void AppendEntry_leader_term_uvarint_round_trips(ulong lterm)
{
var ae = new RaftAppendEntryWire(
LeaderId: "lead0001", Term: 5, Commit: 3, PrevTerm: 4, PrevIndex: 2,
Entries: [],
LeaderTerm: lterm);
var decoded = RaftAppendEntryWire.Decode(ae.Encode());
decoded.LeaderTerm.ShouldBe(lterm);
}
// ---------------------------------------------------------------------------
// AppendEntry — error cases
// ---------------------------------------------------------------------------
[Fact]
public void AppendEntry_short_buffer_throws_ArgumentException()
{
// Buffer smaller than appendEntryBaseLen (42 bytes).
var shortBuffer = new byte[RaftWireConstants.AppendEntryBaseLen - 1];
Should.Throw<ArgumentException>(() => RaftAppendEntryWire.Decode(shortBuffer));
}
// ---------------------------------------------------------------------------
// AppendEntryResponse
// ---------------------------------------------------------------------------
// Go: server/raft.go:2777-2794 — appendEntryResponse.encode()
// Go: server/raft.go:2799-2817 — decodeAppendEntryResponse()
[Fact]
public void AppendEntryResponse_success_true_round_trip()
{
var resp = new RaftAppendEntryResponseWire(
Term: 12, Index: 99, PeerId: "follwr01", Success: true);
var encoded = resp.Encode();
encoded.Length.ShouldBe(RaftWireConstants.AppendEntryResponseLen); // 25
var decoded = RaftAppendEntryResponseWire.Decode(encoded);
decoded.Term.ShouldBe(12UL);
decoded.Index.ShouldBe(99UL);
decoded.PeerId.ShouldBe("follwr01");
decoded.Success.ShouldBeTrue();
}
[Fact]
public void AppendEntryResponse_success_false_round_trip()
{
var resp = new RaftAppendEntryResponseWire(
Term: 3, Index: 1, PeerId: "follwr02", Success: false);
var decoded = RaftAppendEntryResponseWire.Decode(resp.Encode());
decoded.Success.ShouldBeFalse();
decoded.PeerId.ShouldBe("follwr02");
}
[Fact]
public void AppendEntryResponse_success_byte_is_0_or_1()
{
// Go: server/raft.go:2815 — ar.success = msg[24] == 1
var yes = new RaftAppendEntryResponseWire(Term: 1, Index: 0, PeerId: "p", Success: true);
var no = new RaftAppendEntryResponseWire(Term: 1, Index: 0, PeerId: "p", Success: false);
yes.Encode()[24].ShouldBe((byte)1);
no.Encode()[24].ShouldBe((byte)0);
}
[Fact]
public void AppendEntryResponse_layout_at_correct_offsets()
{
// Go: server/raft.go:2784-2792 — exact layout:
// [0..7]=term [8..15]=index [16..23]=peer [24]=success
var resp = new RaftAppendEntryResponseWire(
Term: 1, Index: 2, PeerId: "BBBBBBBB", Success: true);
var bytes = resp.Encode();
bytes[0].ShouldBe((byte)1); // term LE
bytes[8].ShouldBe((byte)2); // index LE
bytes[16].ShouldBe((byte)'B'); // peer[0]
bytes[24].ShouldBe((byte)1); // success = 1
}
[Fact]
public void AppendEntryResponse_short_buffer_throws_ArgumentException()
{
var shortBuffer = new byte[RaftWireConstants.AppendEntryResponseLen - 1];
Should.Throw<ArgumentException>(() => RaftAppendEntryResponseWire.Decode(shortBuffer));
}
[Fact]
public void AppendEntryResponse_long_buffer_throws_ArgumentException()
{
var longBuffer = new byte[RaftWireConstants.AppendEntryResponseLen + 1];
Should.Throw<ArgumentException>(() => RaftAppendEntryResponseWire.Decode(longBuffer));
}
[Fact]
public void AppendEntryResponse_peer_id_truncated_to_8_bytes()
{
// Go: server/raft.go:2787 — copy(buf[16:16+idLen], ar.peer)
var resp = new RaftAppendEntryResponseWire(
Term: 1, Index: 0,
PeerId: "verylongpeeridthatexceeds8", Success: false);
var bytes = resp.Encode();
var idBytes = bytes[16..24];
System.Text.Encoding.ASCII.GetString(idBytes).ShouldBe("verylong");
}
// ---------------------------------------------------------------------------
// Wire constant values
// ---------------------------------------------------------------------------
[Fact]
public void Wire_constants_match_go_definitions()
{
// Go: server/raft.go:4558 — voteRequestLen = 24 + idLen = 32
RaftWireConstants.VoteRequestLen.ShouldBe(32);
// Go: server/raft.go:4737 — voteResponseLen = 8 + 8 + 1 = 17
RaftWireConstants.VoteResponseLen.ShouldBe(17);
// Go: server/raft.go:2660 — appendEntryBaseLen = idLen + 4*8 + 2 = 42
RaftWireConstants.AppendEntryBaseLen.ShouldBe(42);
// Go: server/raft.go:2757 — appendEntryResponseLen = 24 + 1 = 25
RaftWireConstants.AppendEntryResponseLen.ShouldBe(25);
// Go: server/raft.go:2756 — idLen = 8
RaftWireConstants.IdLen.ShouldBe(8);
}
}

View File

@@ -1,344 +0,0 @@
using NATS.Server.Raft;
namespace NATS.Server.Tests.Raft;
/// <summary>
/// Tests for configurable log compaction policies (Gap 8.5).
///
/// Covers <see cref="CompactionPolicy"/> enum values, <see cref="CompactionOptions"/> defaults
/// and threshold logic, and the integration between <see cref="RaftNode.CompactLogAsync"/>
/// and the policy engine.
///
/// Go reference: raft.go compactLog / WAL compact threshold checks.
/// </summary>
public class RaftCompactionPolicyTests
{
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// <summary>
/// Creates a leader node with <paramref name="entryCount"/> proposed entries all applied.
/// Each entry command is "cmd-N" so UTF-8 command size is predictable (~6 bytes each).
/// </summary>
private static async Task<RaftNode> CreateLeaderWithEntriesAsync(int entryCount,
CompactionOptions? compactionOptions = null)
{
var node = new RaftNode("leader", compactionOptions: compactionOptions);
node.ConfigureCluster([node]);
node.StartElection(1);
for (var i = 1; i <= entryCount; i++)
await node.ProposeAsync($"cmd-{i}", default);
return node;
}
/// <summary>
/// Creates a log entry at an explicit index with an optional custom timestamp.
/// Used for ByAge tests where we need to control entry timestamps.
/// </summary>
private static RaftLogEntry MakeEntry(long index, int term, string command, DateTime? timestamp = null)
{
var entry = new RaftLogEntry(index, term, command);
if (timestamp.HasValue)
entry = entry with { Timestamp = timestamp.Value };
return entry;
}
// ---------------------------------------------------------------------------
// CompactionOptions default values
// ---------------------------------------------------------------------------
// Go reference: raft.go compactLog threshold defaults.
[Fact]
public void CompactionOptions_default_values()
{
var opts = new CompactionOptions();
opts.Policy.ShouldBe(CompactionPolicy.None);
opts.MaxEntries.ShouldBe(10_000);
opts.MaxSizeBytes.ShouldBe(100L * 1024 * 1024);
opts.MaxAge.ShouldBe(TimeSpan.FromHours(1));
}
// ---------------------------------------------------------------------------
// None policy — no-op
// ---------------------------------------------------------------------------
// Go reference: raft.go compactLog — no compaction when policy is None.
[Fact]
public async Task None_policy_does_not_compact()
{
var opts = new CompactionOptions { Policy = CompactionPolicy.None };
var node = await CreateLeaderWithEntriesAsync(50, opts);
node.Log.Entries.Count.ShouldBe(50);
await node.CompactLogAsync(default);
// With None policy, nothing should be removed.
node.Log.Entries.Count.ShouldBe(50);
}
// ---------------------------------------------------------------------------
// ByCount policy
// ---------------------------------------------------------------------------
// Go reference: raft.go compactLog — keep at most MaxEntries.
[Fact]
public async Task ByCount_compacts_when_log_exceeds_max_entries()
{
const int total = 100;
const int keep = 50;
var opts = new CompactionOptions
{
Policy = CompactionPolicy.ByCount,
MaxEntries = keep,
};
var node = await CreateLeaderWithEntriesAsync(total, opts);
// Before compaction: all 100 entries are present.
node.Log.Entries.Count.ShouldBe(total);
await node.CompactLogAsync(default);
// After compaction: only the newest `keep` entries remain.
node.Log.Entries.Count.ShouldBe(keep);
// The oldest entry's index should be total - keep + 1
node.Log.Entries[0].Index.ShouldBe(total - keep + 1);
}
// Go reference: raft.go compactLog — do not compact below applied index (safety guard).
[Fact]
public async Task ByCount_does_not_compact_below_applied_index()
{
// Build a node but manually set a lower applied index so the safety guard kicks in.
const int total = 100;
const int keep = 50;
var opts = new CompactionOptions
{
Policy = CompactionPolicy.ByCount,
MaxEntries = keep,
};
var node = await CreateLeaderWithEntriesAsync(total, opts);
// Artificially lower the applied index so the safety guard prevents full compaction.
// With applied = 30 the policy wants to compact to index 50 but the guard limits it to 30.
node.AppliedIndex = 30;
await node.CompactLogAsync(default);
// Only entries up to index 30 may be removed.
// BaseIndex should be 30, entries with index > 30 remain.
node.Log.BaseIndex.ShouldBe(30);
node.Log.Entries[0].Index.ShouldBe(31);
}
// Go reference: raft.go compactLog — no-op when count is within threshold.
[Fact]
public async Task ByCount_does_not_compact_when_within_threshold()
{
const int total = 20;
const int maxEntries = 50; // more than total — nothing to compact
var opts = new CompactionOptions
{
Policy = CompactionPolicy.ByCount,
MaxEntries = maxEntries,
};
var node = await CreateLeaderWithEntriesAsync(total, opts);
await node.CompactLogAsync(default);
node.Log.Entries.Count.ShouldBe(total);
}
// ---------------------------------------------------------------------------
// BySize policy
// ---------------------------------------------------------------------------
// Go reference: raft.go compactLog — compact oldest entries until under size threshold.
[Fact]
public void BySize_compacts_when_total_size_exceeds_threshold()
{
// Construct a log manually so we control sizes precisely.
// Each entry: index(8) + term(4) + command(N bytes).
// Use a 100-byte command to make size maths easy.
var log = new RaftLog();
var largeCommand = new string('x', 100); // 100 UTF-8 bytes
// Append 10 entries — each ~112 bytes → total ~1,120 bytes.
for (var i = 1; i <= 10; i++)
log.Append(term: 1, command: largeCommand);
// Set threshold at ~600 bytes — roughly half the total — so the oldest ~5 entries
// should be compacted.
var opts = new CompactionOptions
{
Policy = CompactionPolicy.BySize,
MaxSizeBytes = 600,
};
// appliedIndex = last entry index (all applied)
var appliedIndex = log.Entries[^1].Index;
var cutoff = opts.ComputeCompactionIndex(log, appliedIndex);
// Should return a positive index, meaning some entries must be compacted.
cutoff.ShouldBeGreaterThan(0);
cutoff.ShouldBeLessThanOrEqualTo(appliedIndex);
log.Compact(cutoff);
// After compaction fewer entries should remain.
log.Entries.Count.ShouldBeLessThan(10);
log.Entries.Count.ShouldBeGreaterThan(0);
}
// Go reference: raft.go compactLog — no-op when total size is under threshold.
[Fact]
public void BySize_does_not_compact_when_under_threshold()
{
var log = new RaftLog();
for (var i = 1; i <= 5; i++)
log.Append(term: 1, command: "tiny");
// Threshold large enough to fit everything.
var opts = new CompactionOptions
{
Policy = CompactionPolicy.BySize,
MaxSizeBytes = 10_000,
};
var cutoff = opts.ComputeCompactionIndex(log, log.Entries[^1].Index);
cutoff.ShouldBe(-1); // no compaction
}
// ---------------------------------------------------------------------------
// ByAge policy
// ---------------------------------------------------------------------------
// Go reference: raft.go compactLog — compact entries older than MaxAge.
[Fact]
public void ByAge_compacts_old_entries()
{
// Build a log manually with some old and some fresh entries.
var log = new RaftLog();
var old = DateTime.UtcNow - TimeSpan.FromHours(2);
var fresh = DateTime.UtcNow - TimeSpan.FromMinutes(1);
// Entries 15: old (2 hours ago)
for (var i = 1; i <= 5; i++)
log.AppendWithTimestamp(term: 1, command: $"old-{i}", timestamp: old);
// Entries 610: fresh (1 minute ago)
for (var i = 6; i <= 10; i++)
log.AppendWithTimestamp(term: 1, command: $"fresh-{i}", timestamp: fresh);
var opts = new CompactionOptions
{
Policy = CompactionPolicy.ByAge,
MaxAge = TimeSpan.FromHours(1), // default; entries older than 1h are eligible
};
var appliedIndex = log.Entries[^1].Index;
var cutoff = opts.ComputeCompactionIndex(log, appliedIndex);
// Should compact through index 5 (the last old entry).
cutoff.ShouldBe(5);
log.Compact(cutoff);
log.Entries.Count.ShouldBe(5); // only the fresh entries remain
log.Entries[0].Index.ShouldBe(6);
}
// Go reference: raft.go compactLog — no-op when all entries are still fresh.
[Fact]
public void ByAge_does_not_compact_fresh_entries()
{
var log = new RaftLog();
var fresh = DateTime.UtcNow - TimeSpan.FromMinutes(5);
for (var i = 1; i <= 10; i++)
log.AppendWithTimestamp(term: 1, command: $"cmd-{i}", timestamp: fresh);
var opts = new CompactionOptions
{
Policy = CompactionPolicy.ByAge,
MaxAge = TimeSpan.FromHours(1),
};
var cutoff = opts.ComputeCompactionIndex(log, log.Entries[^1].Index);
cutoff.ShouldBe(-1); // nothing old enough
}
// ---------------------------------------------------------------------------
// CompactLogAsync integration tests
// ---------------------------------------------------------------------------
// Go reference: raft.go CompactLog — safety guard: never exceed applied index.
[Fact]
public async Task CompactLogAsync_with_policy_respects_applied_index()
{
// Node with 50 entries. Policy wants to compact to index 40, but applied is only 25.
const int total = 50;
var opts = new CompactionOptions
{
Policy = CompactionPolicy.ByCount,
MaxEntries = 10, // wants to compact to index 40
};
var node = await CreateLeaderWithEntriesAsync(total, opts);
// Override applied index to a lower value than the policy cutoff.
node.AppliedIndex = 25;
await node.CompactLogAsync(default);
// Compaction must not go past applied index = 25.
node.Log.BaseIndex.ShouldBeLessThanOrEqualTo(25);
// All entries above index 25 must still be present.
node.Log.Entries.ShouldAllBe(e => e.Index > 25);
}
// Go reference: raft.go CompactLog — when no policy is set, compacts to applied index.
[Fact]
public async Task CompactLogAsync_without_policy_compacts_to_applied_index()
{
var node = await CreateLeaderWithEntriesAsync(30);
// No CompactionOptions set.
await node.CompactLogAsync(default);
// All entries compacted to the applied index.
node.Log.Entries.Count.ShouldBe(0);
node.Log.BaseIndex.ShouldBe(node.AppliedIndex);
}
// Go reference: raft.go CompactLog — per-call options override node-level options.
[Fact]
public async Task CompactLogAsync_call_level_options_override_node_level_options()
{
// Node has None policy (no auto-compaction).
var nodeOpts = new CompactionOptions { Policy = CompactionPolicy.None };
var node = await CreateLeaderWithEntriesAsync(50, nodeOpts);
// But the call-level override uses ByCount with MaxEntries = 25.
var callOpts = new CompactionOptions
{
Policy = CompactionPolicy.ByCount,
MaxEntries = 25,
};
await node.CompactLogAsync(default, callOpts);
// Call-level option must win: 25 entries kept.
node.Log.Entries.Count.ShouldBe(25);
}
}

View File

@@ -1,63 +0,0 @@
using NATS.Server.Raft;
namespace NATS.Server.Tests.Raft;
public class RaftConfigAndStateParityBatch1Tests
{
[Fact]
public void RaftState_string_matches_go_labels()
{
RaftState.Follower.String().ShouldBe("Follower");
RaftState.Leader.String().ShouldBe("Leader");
RaftState.Candidate.String().ShouldBe("Candidate");
RaftState.Closed.String().ShouldBe("Closed");
}
[Fact]
public void RaftConfig_exposes_go_shape_fields()
{
var cfg = new RaftConfig
{
Name = "META",
Store = new object(),
Log = new object(),
Track = true,
Observer = true,
Recovering = true,
ScaleUp = true,
};
cfg.Name.ShouldBe("META");
cfg.Store.ShouldNotBeNull();
cfg.Log.ShouldNotBeNull();
cfg.Track.ShouldBeTrue();
cfg.Observer.ShouldBeTrue();
cfg.Recovering.ShouldBeTrue();
cfg.ScaleUp.ShouldBeTrue();
}
[Fact]
public void RaftNode_group_defaults_to_id_when_not_supplied()
{
using var node = new RaftNode("N1");
node.GroupName.ShouldBe("N1");
}
[Fact]
public void RaftNode_group_uses_explicit_value_when_supplied()
{
using var node = new RaftNode("N1", group: "G1");
node.GroupName.ShouldBe("G1");
}
[Fact]
public void RaftNode_created_utc_is_set_on_construction()
{
var before = DateTime.UtcNow;
using var node = new RaftNode("N1");
var after = DateTime.UtcNow;
node.CreatedUtc.ShouldBeGreaterThanOrEqualTo(before);
node.CreatedUtc.ShouldBeLessThanOrEqualTo(after);
}
}

View File

@@ -1,16 +0,0 @@
using NATS.Server.Raft;
namespace NATS.Server.Tests;
public class RaftConsensusRuntimeParityTests
{
[Fact]
public async Task Raft_cluster_commits_with_next_index_backtracking_semantics()
{
var cluster = RaftTestCluster.Create(3);
await cluster.GenerateCommittedEntriesAsync(5);
await cluster.WaitForAppliedAsync(5);
cluster.Nodes.All(n => n.AppliedIndex >= 5).ShouldBeTrue();
}
}

View File

@@ -1,180 +0,0 @@
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

@@ -1,139 +0,0 @@
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);
}
}

View File

@@ -1,157 +0,0 @@
using NATS.Server.Raft;
namespace NATS.Server.Tests.Raft;
/// <summary>
/// Tests for randomized election timeout jitter in RaftNode.
/// Jitter prevents synchronized elections after network partitions (split-vote avoidance).
/// Go reference: raft.go resetElectionTimeout — uses rand.Int63n to jitter election timeout.
/// </summary>
public class RaftElectionJitterTests : IDisposable
{
private readonly List<RaftNode> _nodesToDispose = [];
public void Dispose()
{
foreach (var node in _nodesToDispose)
node.Dispose();
}
private RaftNode CreateTrackedNode(string id, Random? random = null)
{
var node = new RaftNode(id, random: random);
_nodesToDispose.Add(node);
return node;
}
[Fact]
public void RandomizedElectionTimeout_within_range()
{
// Go reference: raft.go resetElectionTimeout — timeout is always within [min, max).
var node = CreateTrackedNode("n1");
node.ElectionTimeoutMinMs = 150;
node.ElectionTimeoutMaxMs = 300;
for (var i = 0; i < 100; i++)
{
var timeout = node.RandomizedElectionTimeout();
timeout.TotalMilliseconds.ShouldBeGreaterThanOrEqualTo(150);
timeout.TotalMilliseconds.ShouldBeLessThan(300);
}
}
[Fact]
public void RandomizedElectionTimeout_varies()
{
// Multiple calls must produce at least 2 distinct values in 10 samples,
// confirming that the timeout is not fixed.
// Go reference: raft.go resetElectionTimeout — jitter ensures nodes don't all
// timeout simultaneously after a partition.
var node = CreateTrackedNode("n1");
node.ElectionTimeoutMinMs = 150;
node.ElectionTimeoutMaxMs = 300;
var samples = Enumerable.Range(0, 10)
.Select(_ => node.RandomizedElectionTimeout().TotalMilliseconds)
.ToList();
var distinct = samples.Distinct().Count();
distinct.ShouldBeGreaterThanOrEqualTo(2);
}
[Fact]
public void RandomizedElectionTimeout_uses_total_milliseconds()
{
// Verifies that TotalMilliseconds (not .Milliseconds component) gives the full
// value. For timeouts >= 1000 ms, .Milliseconds would return only the sub-second
// component (0-999), while TotalMilliseconds returns the complete value.
// This test uses a range that straddles 1000 ms to expose the bug if present.
// Go reference: raft.go resetElectionTimeout uses full duration, not sub-second part.
var node = CreateTrackedNode("n1");
node.ElectionTimeoutMinMs = 1200;
node.ElectionTimeoutMaxMs = 1500;
for (var i = 0; i < 50; i++)
{
var timeout = node.RandomizedElectionTimeout();
// TotalMilliseconds must be in [1200, 1500)
timeout.TotalMilliseconds.ShouldBeGreaterThanOrEqualTo(1200);
timeout.TotalMilliseconds.ShouldBeLessThan(1500);
// The .Milliseconds property would return only the 0-999 sub-second
// component. If the implementation incorrectly used .Milliseconds,
// these values would be wrong (< 1200). Verify TotalMilliseconds is >= 1200.
((int)timeout.TotalMilliseconds).ShouldBeGreaterThanOrEqualTo(1200);
}
}
[Fact]
public void ResetElectionTimeout_randomizes_each_time()
{
// Successive calls to ResetElectionTimeout should obtain fresh random intervals.
// We observe the timer change indirectly by injecting a deterministic Random
// and confirming it gets called on each reset.
// Go reference: raft.go resetElectionTimeout — called on every heartbeat and
// leader append to re-arm with a fresh random deadline.
var callCount = 0;
var deterministicRandom = new CountingRandom(() => callCount++);
var node = CreateTrackedNode("n1", deterministicRandom);
node.ElectionTimeoutMinMs = 150;
node.ElectionTimeoutMaxMs = 300;
node.StartElectionTimer();
var countAfterStart = callCount;
node.ResetElectionTimeout();
node.ResetElectionTimeout();
node.ResetElectionTimeout();
// Each ResetElectionTimeout + StartElectionTimer must have called Next() at least once each
callCount.ShouldBeGreaterThan(countAfterStart);
node.StopElectionTimer();
}
[Fact]
public void Different_nodes_get_different_timeouts()
{
// Two nodes created with the default shared Random should produce at least some
// distinct timeout values across multiple samples (probabilistic).
// Go reference: raft.go — each server picks an independent random timeout so
// they do not all call elections at exactly the same moment.
var node1 = CreateTrackedNode("n1");
var node2 = CreateTrackedNode("n2");
node1.ElectionTimeoutMinMs = node2.ElectionTimeoutMinMs = 150;
node1.ElectionTimeoutMaxMs = node2.ElectionTimeoutMaxMs = 300;
var samples1 = Enumerable.Range(0, 20)
.Select(_ => node1.RandomizedElectionTimeout().TotalMilliseconds)
.ToList();
var samples2 = Enumerable.Range(0, 20)
.Select(_ => node2.RandomizedElectionTimeout().TotalMilliseconds)
.ToList();
// The combined set of 40 values must contain at least 2 distinct values
// (if both nodes returned the exact same value every time, that would
// indicate no jitter at all).
var allValues = samples1.Concat(samples2).Distinct().Count();
allValues.ShouldBeGreaterThanOrEqualTo(2);
}
/// <summary>
/// A <see cref="Random"/> subclass that invokes a callback on every call to
/// <see cref="Next(int, int)"/>, allowing tests to count how many times the
/// RaftNode requests a new random value.
/// </summary>
private sealed class CountingRandom(Action onNext) : Random
{
public override int Next(int minValue, int maxValue)
{
onNext();
return base.Next(minValue, maxValue);
}
}
}

View File

@@ -1,421 +0,0 @@
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

@@ -1,263 +0,0 @@
using NATS.Server.Raft;
namespace NATS.Server.Tests.Raft;
/// <summary>
/// Tests for election timeout management and campaign triggering in RaftNode.
/// Go reference: raft.go:1400-1450 (resetElectionTimeout), raft.go:1500-1550 (campaign logic).
/// </summary>
public class RaftElectionTimerTests : IDisposable
{
private readonly List<RaftNode> _nodesToDispose = [];
public void Dispose()
{
foreach (var node in _nodesToDispose)
node.Dispose();
}
private RaftNode CreateTrackedNode(string id)
{
var node = new RaftNode(id);
_nodesToDispose.Add(node);
return node;
}
private RaftNode[] CreateTrackedCluster(int size)
{
var nodes = Enumerable.Range(1, size)
.Select(i => CreateTrackedNode($"n{i}"))
.ToArray();
foreach (var node in nodes)
node.ConfigureCluster(nodes);
return nodes;
}
[Fact]
public void ResetElectionTimeout_prevents_election_while_receiving_heartbeats()
{
// Node with very short timeout for testing
var nodes = CreateTrackedCluster(3);
var node = nodes[0];
node.ElectionTimeoutMinMs = 50;
node.ElectionTimeoutMaxMs = 80;
node.StartElectionTimer();
// Keep resetting to prevent election
for (int i = 0; i < 5; i++)
{
Thread.Sleep(30);
node.ResetElectionTimeout();
}
// Node should still be a follower since we kept resetting the timer
node.Role.ShouldBe(RaftRole.Follower);
node.StopElectionTimer();
}
[Fact]
public void CampaignImmediately_triggers_election_without_timer()
{
var nodes = CreateTrackedCluster(3);
var candidate = nodes[0];
candidate.Role.ShouldBe(RaftRole.Follower);
candidate.Term.ShouldBe(0);
candidate.CampaignImmediately();
// Should have started an election
candidate.Role.ShouldBe(RaftRole.Candidate);
candidate.Term.ShouldBe(1);
candidate.TermState.VotedFor.ShouldBe(candidate.Id);
}
[Fact]
public void CampaignImmediately_single_node_becomes_leader()
{
var node = CreateTrackedNode("solo");
node.AddMember("solo");
node.CampaignImmediately();
node.IsLeader.ShouldBeTrue();
node.Role.ShouldBe(RaftRole.Leader);
}
[Fact]
public async Task Expired_timer_triggers_campaign_when_follower()
{
var nodes = CreateTrackedCluster(3);
var node = nodes[0];
// Use very short timeouts for testing
node.ElectionTimeoutMinMs = 30;
node.ElectionTimeoutMaxMs = 50;
node.Role.ShouldBe(RaftRole.Follower);
node.StartElectionTimer();
// Wait long enough for the timer to fire
await Task.Delay(200);
// The timer callback should have triggered an election
node.Role.ShouldBe(RaftRole.Candidate);
node.Term.ShouldBeGreaterThan(0);
node.TermState.VotedFor.ShouldBe(node.Id);
node.StopElectionTimer();
}
[Fact]
public async Task Timer_does_not_trigger_campaign_when_leader()
{
var nodes = CreateTrackedCluster(3);
var node = nodes[0];
// Make this node the leader first
node.StartElection(nodes.Length);
foreach (var voter in nodes.Skip(1))
node.ReceiveVote(voter.GrantVote(node.Term, node.Id), nodes.Length);
node.IsLeader.ShouldBeTrue();
var termBefore = node.Term;
// Use very short timeouts
node.ElectionTimeoutMinMs = 30;
node.ElectionTimeoutMaxMs = 50;
node.StartElectionTimer();
// Wait for timer to fire
await Task.Delay(200);
// Should still be leader, no new election started
node.IsLeader.ShouldBeTrue();
// Term may have incremented if re-election happened, but role stays leader
// The key assertion is the node didn't transition to Candidate
node.Role.ShouldBe(RaftRole.Leader);
node.StopElectionTimer();
}
[Fact]
public async Task Timer_does_not_trigger_campaign_when_candidate()
{
var node = CreateTrackedNode("n1");
node.AddMember("n1");
node.AddMember("n2");
node.AddMember("n3");
// Start an election manually (becomes Candidate but not Leader since no quorum)
node.StartElection(clusterSize: 3);
node.Role.ShouldBe(RaftRole.Candidate);
var termAfterElection = node.Term;
// Use very short timeouts
node.ElectionTimeoutMinMs = 30;
node.ElectionTimeoutMaxMs = 50;
node.StartElectionTimer();
// Wait for timer to fire
await Task.Delay(200);
// Timer should not trigger additional campaigns when already candidate
// (the callback only triggers for Follower state)
node.Role.ShouldNotBe(RaftRole.Follower);
node.StopElectionTimer();
}
[Fact]
public void Election_timeout_range_is_configurable()
{
var node = CreateTrackedNode("n1");
node.ElectionTimeoutMinMs.ShouldBe(150);
node.ElectionTimeoutMaxMs.ShouldBe(300);
node.ElectionTimeoutMinMs = 500;
node.ElectionTimeoutMaxMs = 1000;
node.ElectionTimeoutMinMs.ShouldBe(500);
node.ElectionTimeoutMaxMs.ShouldBe(1000);
}
[Fact]
public void StopElectionTimer_is_safe_when_no_timer_started()
{
var node = CreateTrackedNode("n1");
// Should not throw
node.StopElectionTimer();
}
[Fact]
public void StopElectionTimer_can_be_called_multiple_times()
{
var node = CreateTrackedNode("n1");
node.StartElectionTimer();
node.StopElectionTimer();
node.StopElectionTimer(); // Should not throw
}
[Fact]
public void ReceiveHeartbeat_resets_election_timeout()
{
var nodes = CreateTrackedCluster(3);
var node = nodes[0];
node.ElectionTimeoutMinMs = 50;
node.ElectionTimeoutMaxMs = 80;
node.StartElectionTimer();
// Simulate heartbeats coming in regularly, preventing election
for (int i = 0; i < 8; i++)
{
Thread.Sleep(30);
node.ReceiveHeartbeat(term: 1);
}
// Should still be follower since heartbeats kept resetting the timer
node.Role.ShouldBe(RaftRole.Follower);
node.StopElectionTimer();
}
[Fact]
public async Task Timer_fires_after_heartbeats_stop()
{
var nodes = CreateTrackedCluster(3);
var node = nodes[0];
node.ElectionTimeoutMinMs = 40;
node.ElectionTimeoutMaxMs = 60;
node.StartElectionTimer();
// Send a few heartbeats
for (int i = 0; i < 3; i++)
{
Thread.Sleep(20);
node.ReceiveHeartbeat(term: 1);
}
node.Role.ShouldBe(RaftRole.Follower);
// Stop sending heartbeats and wait for timer to fire
await Task.Delay(200);
// Should have started an election
node.Role.ShouldBe(RaftRole.Candidate);
node.StopElectionTimer();
}
[Fact]
public void Dispose_stops_election_timer()
{
var node = new RaftNode("n1");
node.ElectionTimeoutMinMs = 30;
node.ElectionTimeoutMaxMs = 50;
node.StartElectionTimer();
// Dispose should stop the timer cleanly
node.Dispose();
// Calling dispose again should be safe
node.Dispose();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,342 +0,0 @@
using NATS.Server.Raft;
namespace NATS.Server.Tests.Raft;
/// <summary>
/// Tests for RaftPeerState health classification and peer tracking in RaftNode.
/// Go reference: raft.go peer tracking (nextIndex, matchIndex, last contact, isCurrent).
/// </summary>
public class RaftHealthTests
{
// -- Helpers --
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;
}
// -- RaftPeerState unit tests --
[Fact]
public void PeerState_defaults_are_correct()
{
var peer = new RaftPeerState { PeerId = "n2" };
peer.PeerId.ShouldBe("n2");
peer.NextIndex.ShouldBe(1);
peer.MatchIndex.ShouldBe(0);
peer.Active.ShouldBeTrue();
}
[Fact]
public void IsCurrent_returns_true_when_within_timeout()
{
var peer = new RaftPeerState { PeerId = "n2" };
peer.LastContact = DateTime.UtcNow;
peer.IsCurrent(TimeSpan.FromSeconds(5)).ShouldBeTrue();
}
[Fact]
public void IsCurrent_returns_false_when_stale()
{
var peer = new RaftPeerState { PeerId = "n2" };
peer.LastContact = DateTime.UtcNow.AddSeconds(-10);
peer.IsCurrent(TimeSpan.FromSeconds(5)).ShouldBeFalse();
}
[Fact]
public void IsHealthy_returns_true_for_active_recent_peer()
{
var peer = new RaftPeerState { PeerId = "n2", Active = true };
peer.LastContact = DateTime.UtcNow;
peer.IsHealthy(TimeSpan.FromSeconds(5)).ShouldBeTrue();
}
[Fact]
public void IsHealthy_returns_false_for_inactive_peer()
{
var peer = new RaftPeerState { PeerId = "n2", Active = false };
peer.LastContact = DateTime.UtcNow;
peer.IsHealthy(TimeSpan.FromSeconds(5)).ShouldBeFalse();
}
[Fact]
public void IsHealthy_returns_false_for_stale_active_peer()
{
var peer = new RaftPeerState { PeerId = "n2", Active = true };
peer.LastContact = DateTime.UtcNow.AddSeconds(-10);
peer.IsHealthy(TimeSpan.FromSeconds(5)).ShouldBeFalse();
}
// -- Peer state initialization via ConfigureCluster --
[Fact]
public void ConfigureCluster_initializes_peer_states()
{
var (nodes, _) = CreateCluster(3);
var node = nodes[0];
var peerStates = node.GetPeerStates();
peerStates.Count.ShouldBe(2); // 2 peers, not counting self
peerStates.ContainsKey("n2").ShouldBeTrue();
peerStates.ContainsKey("n3").ShouldBeTrue();
peerStates.ContainsKey("n1").ShouldBeFalse(); // Self excluded
}
[Fact]
public void ConfigureCluster_sets_initial_peer_state_values()
{
var (nodes, _) = CreateCluster(3);
var peerStates = nodes[0].GetPeerStates();
foreach (var (peerId, state) in peerStates)
{
state.NextIndex.ShouldBe(1);
state.MatchIndex.ShouldBe(0);
state.Active.ShouldBeTrue();
}
}
[Fact]
public void ConfigureCluster_five_node_has_four_peers()
{
var (nodes, _) = CreateCluster(5);
nodes[0].GetPeerStates().Count.ShouldBe(4);
}
// -- LastContact updates on heartbeat --
[Fact]
public void LastContact_updates_on_heartbeat_from_known_peer()
{
var (nodes, _) = CreateCluster(3);
var node = nodes[0];
// Set contact time in the past
var peerStates = node.GetPeerStates();
var oldTime = DateTime.UtcNow.AddMinutes(-5);
peerStates["n2"].LastContact = oldTime;
// Receive heartbeat from n2
node.ReceiveHeartbeat(term: 1, fromPeerId: "n2");
peerStates["n2"].LastContact.ShouldBeGreaterThan(oldTime);
}
[Fact]
public void LastContact_not_updated_for_unknown_peer()
{
var (nodes, _) = CreateCluster(3);
var node = nodes[0];
// Heartbeat from unknown peer should not crash
node.ReceiveHeartbeat(term: 1, fromPeerId: "unknown-node");
// Existing peers should be unchanged
var peerStates = node.GetPeerStates();
peerStates.ContainsKey("unknown-node").ShouldBeFalse();
}
[Fact]
public void LastContact_not_updated_when_fromPeerId_null()
{
var (nodes, _) = CreateCluster(3);
var node = nodes[0];
var oldContact = DateTime.UtcNow.AddMinutes(-5);
node.GetPeerStates()["n2"].LastContact = oldContact;
// Heartbeat without peer ID
node.ReceiveHeartbeat(term: 1);
// Should not update any peer contact times (no peer specified)
node.GetPeerStates()["n2"].LastContact.ShouldBe(oldContact);
}
// -- IsCurrent on RaftNode --
[Fact]
public void Leader_is_always_current()
{
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
leader.IsCurrent(TimeSpan.FromSeconds(1)).ShouldBeTrue();
}
[Fact]
public void Follower_is_current_when_peer_recently_contacted()
{
var (nodes, _) = CreateCluster(3);
var follower = nodes[1];
// Peer states are initialized with current time by ConfigureCluster
follower.IsCurrent(TimeSpan.FromSeconds(5)).ShouldBeTrue();
}
[Fact]
public void Follower_is_not_current_when_all_peers_stale()
{
var (nodes, _) = CreateCluster(3);
var follower = nodes[1];
// Make all peers stale
foreach (var (_, state) in follower.GetPeerStates())
state.LastContact = DateTime.UtcNow.AddMinutes(-10);
follower.IsCurrent(TimeSpan.FromSeconds(5)).ShouldBeFalse();
}
// -- IsHealthy on RaftNode --
[Fact]
public void Leader_is_healthy_when_majority_peers_responsive()
{
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
// All peers recently contacted
leader.IsHealthy(TimeSpan.FromSeconds(5)).ShouldBeTrue();
}
[Fact]
public void Leader_is_unhealthy_when_majority_peers_unresponsive()
{
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
// Make all peers stale
foreach (var (_, state) in leader.GetPeerStates())
state.LastContact = DateTime.UtcNow.AddMinutes(-10);
leader.IsHealthy(TimeSpan.FromSeconds(5)).ShouldBeFalse();
}
[Fact]
public void Follower_is_healthy_when_leader_peer_responsive()
{
var (nodes, _) = CreateCluster(3);
var follower = nodes[1];
// At least one peer (simulating leader) is recent
follower.IsHealthy(TimeSpan.FromSeconds(5)).ShouldBeTrue();
}
[Fact]
public void Follower_is_unhealthy_when_no_peers_responsive()
{
var (nodes, _) = CreateCluster(3);
var follower = nodes[1];
// Make all peers stale
foreach (var (_, state) in follower.GetPeerStates())
state.LastContact = DateTime.UtcNow.AddMinutes(-10);
follower.IsHealthy(TimeSpan.FromSeconds(5)).ShouldBeFalse();
}
// -- MatchIndex / NextIndex tracking during replication --
[Fact]
public async Task MatchIndex_and_NextIndex_update_during_replication()
{
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
var index = await leader.ProposeAsync("cmd-1", default);
var peerStates = leader.GetPeerStates();
// Both followers should have updated match/next indices
foreach (var (_, state) in peerStates)
{
state.MatchIndex.ShouldBe(index);
state.NextIndex.ShouldBe(index + 1);
}
}
[Fact]
public async Task MatchIndex_advances_monotonically_with_proposals()
{
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
var index1 = await leader.ProposeAsync("cmd-1", default);
var index2 = await leader.ProposeAsync("cmd-2", default);
var index3 = await leader.ProposeAsync("cmd-3", default);
var peerStates = leader.GetPeerStates();
foreach (var (_, state) in peerStates)
{
state.MatchIndex.ShouldBe(index3);
state.NextIndex.ShouldBe(index3 + 1);
}
}
[Fact]
public async Task LastContact_updates_on_successful_replication()
{
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
// Record timestamps just before proposing (peers are fresh from ConfigureCluster).
var beforePropose = DateTime.UtcNow;
await leader.ProposeAsync("cmd-1", default);
// Successful replication should update LastContact to at least the time we
// recorded before the propose call.
foreach (var (_, state) in leader.GetPeerStates())
state.LastContact.ShouldBeGreaterThanOrEqualTo(beforePropose);
}
[Fact]
public void Peer_states_empty_before_cluster_configuration()
{
var node = new RaftNode("n1");
node.GetPeerStates().Count.ShouldBe(0);
}
[Fact]
public void ConfigureCluster_clears_previous_peer_states()
{
var (nodes, transport) = CreateCluster(3);
var node = nodes[0];
node.GetPeerStates().Count.ShouldBe(2);
// Reconfigure with 5 nodes
var moreNodes = Enumerable.Range(1, 5)
.Select(i => new RaftNode($"m{i}", transport))
.ToArray();
foreach (var n in moreNodes)
transport.Register(n);
node.ConfigureCluster(moreNodes);
// Should now have 4 peers (5 nodes minus self)
// Note: the node's ID is "n1" but cluster members are "m1"-"m5"
// So all 5 are peers since none match "n1"
node.GetPeerStates().Count.ShouldBe(5);
}
}

View File

@@ -1,285 +0,0 @@
using NATS.Server.Raft;
namespace NATS.Server.Tests.Raft;
/// <summary>
/// Tests for joint consensus membership changes per Raft paper Section 4.
/// During a joint configuration transition a quorum requires majority from BOTH
/// the old configuration (Cold) and the new configuration (Cnew).
/// Go reference: raft.go joint consensus / two-phase membership transitions.
/// </summary>
public class RaftJointConsensusTests
{
// -- 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;
}
// -- BeginJointConsensus / InJointConsensus / JointNewMembers --
[Fact]
public void BeginJointConsensus_sets_InJointConsensus_flag()
{
// Go reference: raft.go Section 4 — begin joint config
var node = new RaftNode("n1");
node.AddMember("n2");
node.AddMember("n3");
node.InJointConsensus.ShouldBeFalse();
node.BeginJointConsensus(["n1", "n2", "n3"], ["n1", "n2", "n3", "n4"]);
node.InJointConsensus.ShouldBeTrue();
}
[Fact]
public void BeginJointConsensus_exposes_JointNewMembers()
{
// Go reference: raft.go Section 4 — Cnew accessible during joint phase
var node = new RaftNode("n1");
node.AddMember("n2");
node.AddMember("n3");
node.BeginJointConsensus(["n1", "n2", "n3"], ["n1", "n2", "n3", "n4"]);
node.JointNewMembers.ShouldNotBeNull();
node.JointNewMembers!.ShouldContain("n4");
node.JointNewMembers.Count.ShouldBe(4);
}
[Fact]
public void BeginJointConsensus_adds_new_members_to_active_set()
{
// During joint consensus the active member set is the union of Cold and Cnew
// so that entries are replicated to all participating nodes.
// Go reference: raft.go Section 4 — joint config is union of Cold and Cnew.
var node = new RaftNode("n1");
node.AddMember("n2");
node.AddMember("n3");
node.BeginJointConsensus(["n1", "n2", "n3"], ["n1", "n2", "n3", "n4"]);
node.Members.ShouldContain("n4");
}
// -- CommitJointConsensus --
[Fact]
public void CommitJointConsensus_clears_InJointConsensus_flag()
{
// Go reference: raft.go joint consensus commit finalizes Cnew
var node = new RaftNode("n1");
node.AddMember("n2");
node.AddMember("n3");
node.BeginJointConsensus(["n1", "n2", "n3"], ["n1", "n2", "n3", "n4"]);
node.CommitJointConsensus();
node.InJointConsensus.ShouldBeFalse();
}
[Fact]
public void CommitJointConsensus_finalizes_new_configuration_when_adding_peer()
{
// After commit, Members should exactly equal Cnew.
// Go reference: raft.go joint consensus commit.
var node = new RaftNode("n1");
node.AddMember("n2");
node.AddMember("n3");
node.BeginJointConsensus(["n1", "n2", "n3"], ["n1", "n2", "n3", "n4"]);
node.CommitJointConsensus();
node.Members.Count.ShouldBe(4);
node.Members.ShouldContain("n1");
node.Members.ShouldContain("n2");
node.Members.ShouldContain("n3");
node.Members.ShouldContain("n4");
}
[Fact]
public void CommitJointConsensus_removes_old_only_members_when_removing_peer()
{
// Removing a peer: Cold={n1,n2,n3}, Cnew={n1,n2}.
// After commit, n3 must be gone.
// Go reference: raft.go joint consensus commit removes Cold-only members.
var node = new RaftNode("n1");
node.AddMember("n2");
node.AddMember("n3");
node.BeginJointConsensus(["n1", "n2", "n3"], ["n1", "n2"]);
node.CommitJointConsensus();
node.Members.Count.ShouldBe(2);
node.Members.ShouldContain("n1");
node.Members.ShouldContain("n2");
node.Members.ShouldNotContain("n3");
}
[Fact]
public void CommitJointConsensus_is_idempotent_when_not_in_joint()
{
// Calling commit when not in joint consensus must be a no-op.
var node = new RaftNode("n1");
node.AddMember("n2");
node.CommitJointConsensus(); // should not throw
node.Members.Count.ShouldBe(2);
node.InJointConsensus.ShouldBeFalse();
}
// -- CalculateJointQuorum --
[Fact]
public void CalculateJointQuorum_returns_false_when_not_in_joint_consensus()
{
// Outside joint consensus the method has no defined result and returns false.
// Go reference: raft.go Section 4.
var node = new RaftNode("n1");
node.CalculateJointQuorum(["n1"], ["n1"]).ShouldBeFalse();
}
[Fact]
public void Joint_quorum_requires_majority_from_both_old_and_new_configurations()
{
// Cold={n1,n2,n3} (size 3, quorum=2), Cnew={n1,n2,n3,n4} (size 4, quorum=3).
// 2/3 old AND 3/4 new — both majorities satisfied.
// Go reference: raft.go Section 4 — joint config quorum calculation.
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
leader.BeginJointConsensus(["n1", "n2", "n3"], ["n1", "n2", "n3", "n4"]);
leader.CalculateJointQuorum(["n1", "n2"], ["n1", "n2", "n3"]).ShouldBeTrue();
}
[Fact]
public void Joint_quorum_fails_when_old_majority_not_met()
{
// Cold={n1,n2,n3} (quorum=2): only 1 old voter — fails old quorum.
// Cnew={n1,n2,n3,n4} (quorum=3): 2 new voters — also fails new quorum.
// Go reference: raft.go Section 4 — must satisfy BOTH majorities.
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
leader.BeginJointConsensus(["n1", "n2", "n3"], ["n1", "n2", "n3", "n4"]);
leader.CalculateJointQuorum(["n1"], ["n1", "n2"]).ShouldBeFalse();
}
[Fact]
public void Joint_quorum_fails_when_new_majority_not_met()
{
// Cold={n1,n2,n3} (quorum=2): 2 old voters — passes old quorum.
// Cnew={n1,n2,n3,n4} (quorum=3): only 2 new voters — fails new quorum.
// Go reference: raft.go Section 4 — both are required.
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
leader.BeginJointConsensus(["n1", "n2", "n3"], ["n1", "n2", "n3", "n4"]);
leader.CalculateJointQuorum(["n1", "n2"], ["n1", "n2"]).ShouldBeFalse();
}
[Fact]
public void Joint_quorum_exact_majority_boundary_old_config()
{
// Cold={n1,n2,n3,n4,n5} (size 5, quorum=3).
// Exactly 3 old voters meets old quorum boundary.
var node = new RaftNode("n1");
foreach (var m in new[] { "n2", "n3", "n4", "n5" })
node.AddMember(m);
node.BeginJointConsensus(
["n1", "n2", "n3", "n4", "n5"],
["n1", "n2", "n3", "n4", "n5", "n6"]);
// 3/5 old (exact quorum=3) and 4/6 new (quorum=4) — both satisfied
node.CalculateJointQuorum(["n1", "n2", "n3"], ["n1", "n2", "n3", "n4"]).ShouldBeTrue();
}
[Fact]
public void Joint_quorum_just_below_boundary_old_config_fails()
{
// Cold={n1,n2,n3,n4,n5} (size 5, quorum=3): 2 voters fails.
var node = new RaftNode("n1");
foreach (var m in new[] { "n2", "n3", "n4", "n5" })
node.AddMember(m);
node.BeginJointConsensus(
["n1", "n2", "n3", "n4", "n5"],
["n1", "n2", "n3", "n4", "n5", "n6"]);
// 2/5 old < quorum=3 — must fail
node.CalculateJointQuorum(["n1", "n2"], ["n1", "n2", "n3", "n4"]).ShouldBeFalse();
}
// -- Integration: existing ProposeAddPeerAsync/ProposeRemovePeerAsync unchanged --
[Fact]
public async Task ProposeAddPeerAsync_still_works_after_joint_consensus_fields_added()
{
// Verify that adding joint consensus fields does not break the existing
// single-phase ProposeAddPeerAsync behaviour.
// Go reference: raft.go:961-990 (proposeAddPeer).
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
await leader.ProposeAddPeerAsync("n4", default);
leader.Members.ShouldContain("n4");
leader.Members.Count.ShouldBe(4);
leader.MembershipChangeInProgress.ShouldBeFalse();
}
[Fact]
public async Task ProposeRemovePeerAsync_still_works_after_joint_consensus_fields_added()
{
// Verify that adding joint consensus fields does not break the existing
// single-phase ProposeRemovePeerAsync behaviour.
// Go reference: raft.go:992-1019 (proposeRemovePeer).
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
await leader.ProposeRemovePeerAsync("n3", default);
leader.Members.ShouldNotContain("n3");
leader.Members.Count.ShouldBe(2);
leader.MembershipChangeInProgress.ShouldBeFalse();
}
[Fact]
public async Task MembershipChangeInProgress_is_false_after_completed_add()
{
// The single-change invariant must still hold: flag is cleared after completion.
// Go reference: raft.go:961-1019 single-change invariant.
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
await leader.ProposeAddPeerAsync("n4", default);
leader.MembershipChangeInProgress.ShouldBeFalse();
}
}

View File

@@ -1,417 +0,0 @@
using NATS.Server.Raft;
namespace NATS.Server.Tests.Raft;
/// <summary>
/// Tests for RAFT leadership transfer via TimeoutNow RPC (Gap 8.4).
/// The leader sends a TimeoutNow message to a target follower, which immediately
/// starts an election. The leader blocks proposals while the transfer is in flight.
/// Go reference: raft.go sendTimeoutNow / processTimeoutNow
/// </summary>
public class RaftLeadershipTransferTests
{
// -- Helpers --
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);
// Use short election timeouts so polling in TransferLeadershipAsync
// converges quickly in tests without requiring real async delays.
node.ElectionTimeoutMinMs = 5;
node.ElectionTimeoutMaxMs = 10;
}
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;
}
// -- Wire format tests --
// Go reference: raft.go TimeoutNow wire encoding
[Fact]
public void TimeoutNowRpc_wire_format_roundtrip()
{
var wire = new RaftTimeoutNowWire(Term: 7UL, LeaderId: "n1");
var encoded = wire.Encode();
encoded.Length.ShouldBe(RaftTimeoutNowWire.MessageLen); // 16 bytes
var decoded = RaftTimeoutNowWire.Decode(encoded);
decoded.Term.ShouldBe(7UL);
decoded.LeaderId.ShouldBe("n1");
}
[Fact]
public void TimeoutNowRpc_wire_format_preserves_term_and_leader_id()
{
var wire = new RaftTimeoutNowWire(Term: 42UL, LeaderId: "node5");
var decoded = RaftTimeoutNowWire.Decode(wire.Encode());
decoded.Term.ShouldBe(42UL);
decoded.LeaderId.ShouldBe("node5");
}
[Fact]
public void TimeoutNowRpc_decode_throws_on_wrong_length()
{
Should.Throw<ArgumentException>(() =>
RaftTimeoutNowWire.Decode(new byte[10]));
}
[Fact]
public void TimeoutNowRpc_message_len_is_16_bytes()
{
RaftTimeoutNowWire.MessageLen.ShouldBe(16);
}
// -- ReceiveTimeoutNow logic tests --
// Go reference: raft.go processTimeoutNow -- follower starts election immediately
[Fact]
public void ReceiveTimeoutNow_triggers_immediate_election_on_follower()
{
var (nodes, _) = CreateCluster(3);
var follower = nodes[1]; // starts as follower
follower.Role.ShouldBe(RaftRole.Follower);
follower.ReceiveTimeoutNow(term: 0);
// Node should now be a candidate (or leader if it self-voted quorum)
follower.Role.ShouldBeOneOf(RaftRole.Candidate, RaftRole.Leader);
}
[Fact]
public void ReceiveTimeoutNow_updates_term_when_sender_term_is_higher()
{
var node = new RaftNode("follower");
node.TermState.CurrentTerm = 3;
node.ReceiveTimeoutNow(term: 10);
// ReceiveTimeoutNow sets term to 10, then StartElection increments to 11
node.TermState.CurrentTerm.ShouldBe(11);
}
[Fact]
public void ReceiveTimeoutNow_increments_term_and_starts_campaign()
{
var node = new RaftNode("n1");
node.TermState.CurrentTerm = 2;
var termBefore = node.Term;
node.ReceiveTimeoutNow(term: 0);
// StartElection increments the term regardless of whether the node wins.
node.Term.ShouldBe(termBefore + 1);
// With no cluster configured, quorum = 1 (self-vote), so the node becomes leader.
node.Role.ShouldBeOneOf(RaftRole.Candidate, RaftRole.Leader);
}
[Fact]
public void ReceiveTimeoutNow_on_single_node_makes_it_leader()
{
// Single-node cluster: quorum = 1, so self-vote is sufficient.
var node = new RaftNode("solo");
node.ConfigureCluster([node]);
node.ReceiveTimeoutNow(term: 0);
node.IsLeader.ShouldBeTrue();
}
// -- Proposal blocking during transfer --
// Go reference: raft.go -- leader rejects new entries while transfer is in progress.
// BlockingTimeoutNowTransport signals via SemaphoreSlim when SendTimeoutNowAsync is
// entered, letting the test observe the _transferInProgress flag without timing deps.
[Fact]
public async Task TransferLeadership_leader_blocks_proposals_during_transfer()
{
var blockingTransport = new BlockingTimeoutNowTransport();
var node = new RaftNode("leader", blockingTransport);
node.ConfigureCluster([node]);
node.StartElection(1); // become leader
node.IsLeader.ShouldBeTrue();
using var cts = new CancellationTokenSource();
var transferTask = node.TransferLeadershipAsync("n2", cts.Token);
// Wait until SendTimeoutNowAsync is entered -- transfer flag is guaranteed set.
await blockingTransport.WaitUntilBlockingAsync();
// ProposeAsync must throw because the transfer flag is set.
var ex = await Should.ThrowAsync<InvalidOperationException>(
() => node.ProposeAsync("cmd", CancellationToken.None).AsTask());
ex.Message.ShouldContain("Leadership transfer in progress");
// Cancel and await proper completion to avoid test resource leaks.
await cts.CancelAsync();
await Should.ThrowAsync<OperationCanceledException>(() => transferTask);
}
// Go reference: raft.go -- only leader can initiate leadership transfer
[Fact]
public async Task TransferLeadership_only_leader_can_transfer()
{
var transport = new InMemoryRaftTransport();
var follower = new RaftNode("follower", transport);
follower.Role.ShouldBe(RaftRole.Follower);
var ex = await Should.ThrowAsync<InvalidOperationException>(
() => follower.TransferLeadershipAsync("n2", CancellationToken.None));
ex.Message.ShouldContain("Only the leader");
}
// Go reference: raft.go -- TransferLeadershipAsync requires a configured transport
[Fact]
public async Task TransferLeadership_throws_when_no_transport_configured()
{
// No transport injected.
var node = new RaftNode("leader");
node.StartElection(1); // become leader (single node, quorum = 1)
node.IsLeader.ShouldBeTrue();
var ex = await Should.ThrowAsync<InvalidOperationException>(
() => node.TransferLeadershipAsync("n2", CancellationToken.None));
ex.Message.ShouldContain("No transport configured");
}
// Go reference: raft.go sendTimeoutNow -- target becomes leader after receiving TimeoutNow.
// VoteGrantingTransport delivers TimeoutNow and immediately grants votes so the target
// is already leader before the polling loop runs -- no Task.Delay required.
[Fact]
public async Task TransferLeadership_target_becomes_leader()
{
var transport = new VoteGrantingTransport();
var nodes = Enumerable.Range(1, 3)
.Select(i => new RaftNode($"n{i}", transport))
.ToArray();
foreach (var node in nodes)
{
transport.Register(node);
node.ConfigureCluster(nodes);
node.ElectionTimeoutMinMs = 5;
node.ElectionTimeoutMaxMs = 10;
}
var leader = nodes[0];
leader.StartElection(nodes.Length);
foreach (var voter in nodes.Skip(1))
leader.ReceiveVote(voter.GrantVote(leader.Term, leader.Id), nodes.Length);
leader.IsLeader.ShouldBeTrue();
var target = nodes[1];
// VoteGrantingTransport makes the target a leader synchronously during TimeoutNow
// delivery, so the first poll iteration in TransferLeadershipAsync succeeds.
var result = await leader.TransferLeadershipAsync(target.Id, CancellationToken.None);
result.ShouldBeTrue();
target.IsLeader.ShouldBeTrue();
}
// Go reference: raft.go sendTimeoutNow -- returns false when target doesn't respond.
// "ghost" is not registered in the transport so TimeoutNow is a no-op and the
// polling loop times out after 2x election timeout.
[Fact]
public async Task TransferLeadership_timeout_on_unreachable_target()
{
var transport = new InMemoryRaftTransport();
var leader = new RaftNode("leader", transport);
leader.ConfigureCluster([leader]);
transport.Register(leader);
leader.StartElection(1);
// Very short timeouts so the poll deadline is reached quickly.
leader.ElectionTimeoutMinMs = 5;
leader.ElectionTimeoutMaxMs = 10;
// "ghost" is not registered -- TimeoutNow is a no-op; target never becomes leader.
var result = await leader.TransferLeadershipAsync("ghost", CancellationToken.None);
result.ShouldBeFalse();
}
// -- Integration: flag lifecycle --
[Fact]
public async Task TransferLeadership_clears_transfer_flag_after_success()
{
var transport = new VoteGrantingTransport();
var nodes = Enumerable.Range(1, 3)
.Select(i => new RaftNode($"n{i}", transport))
.ToArray();
foreach (var node in nodes)
{
transport.Register(node);
node.ConfigureCluster(nodes);
node.ElectionTimeoutMinMs = 5;
node.ElectionTimeoutMaxMs = 10;
}
var leader = nodes[0];
leader.StartElection(nodes.Length);
foreach (var voter in nodes.Skip(1))
leader.ReceiveVote(voter.GrantVote(leader.Term, leader.Id), nodes.Length);
var target = nodes[1];
var success = await leader.TransferLeadershipAsync(target.Id, CancellationToken.None);
success.ShouldBeTrue();
// After transfer completes the flag must be cleared.
leader.TransferInProgress.ShouldBeFalse();
}
[Fact]
public async Task TransferLeadership_clears_transfer_flag_after_timeout()
{
var transport = new InMemoryRaftTransport();
var leader = new RaftNode("leader", transport);
leader.ConfigureCluster([leader]);
transport.Register(leader);
leader.StartElection(1);
leader.ElectionTimeoutMinMs = 5;
leader.ElectionTimeoutMaxMs = 10;
// "ghost" is not registered -- transfer times out.
await leader.TransferLeadershipAsync("ghost", CancellationToken.None);
// Flag must be cleared regardless of outcome.
leader.TransferInProgress.ShouldBeFalse();
}
}
/// <summary>
/// A transport that blocks inside <see cref="SendTimeoutNowAsync"/> until the
/// provided <see cref="CancellationToken"/> is cancelled. Exposes a semaphore
/// so the test can synchronize on when the leader transfer flag is set.
/// </summary>
file sealed class BlockingTimeoutNowTransport : IRaftTransport
{
private readonly SemaphoreSlim _entered = new(0, 1);
/// <summary>
/// Returns a task that completes once <see cref="SendTimeoutNowAsync"/> has been
/// entered and the leader's transfer flag is guaranteed to be set.
/// </summary>
public Task WaitUntilBlockingAsync() => _entered.WaitAsync();
public Task<IReadOnlyList<AppendResult>> AppendEntriesAsync(
string leaderId, IReadOnlyList<string> followerIds, RaftLogEntry entry, CancellationToken ct)
=> Task.FromResult<IReadOnlyList<AppendResult>>([]);
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;
public async Task SendTimeoutNowAsync(string leaderId, string targetId, ulong term, CancellationToken ct)
{
// Signal that the transfer flag is set -- the test can now probe ProposeAsync.
_entered.Release();
// Block until the test cancels the token.
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
await using var reg = ct.Register(() => tcs.TrySetCanceled(ct));
await tcs.Task;
}
public Task SendHeartbeatAsync(string leaderId, IReadOnlyList<string> followerIds, int term, Action<string> onAck, CancellationToken ct)
=> Task.CompletedTask;
}
/// <summary>
/// A transport that, when delivering a TimeoutNow RPC, also immediately grants
/// votes to the target candidate so it reaches quorum synchronously. This makes
/// the target become leader before TransferLeadershipAsync starts polling, removing
/// any need for Task.Delay waits in the test.
/// </summary>
file sealed class VoteGrantingTransport : 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);
foreach (var followerId in followerIds)
{
if (_nodes.TryGetValue(followerId, out var node))
{
node.ReceiveReplicatedEntry(entry);
results.Add(new AppendResult { FollowerId = followerId, Success = true });
}
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)
{
if (_nodes.TryGetValue(voterId, out var node))
return Task.FromResult(node.GrantVote(request.Term, candidateId));
return Task.FromResult(new VoteResponse { Granted = false });
}
public Task InstallSnapshotAsync(
string leaderId, string followerId, RaftSnapshot snapshot, CancellationToken ct)
=> Task.CompletedTask;
/// <summary>
/// Delivers TimeoutNow to the target (triggering an immediate election), then
/// grants votes from every other peer so the target reaches quorum synchronously.
/// This ensures the target is already leader before TransferLeadershipAsync polls,
/// removing any timing dependency between delivery and vote propagation.
/// </summary>
public Task SendTimeoutNowAsync(string leaderId, string targetId, ulong term, CancellationToken ct)
{
if (!_nodes.TryGetValue(targetId, out var target))
return Task.CompletedTask;
// Trigger immediate election on the target node.
target.ReceiveTimeoutNow(term);
// Grant peer votes so the target reaches quorum immediately.
if (target.Role == RaftRole.Candidate)
{
var clusterSize = _nodes.Count;
foreach (var (peerId, peer) in _nodes)
{
if (string.Equals(peerId, targetId, StringComparison.Ordinal))
continue;
var vote = peer.GrantVote(target.Term, targetId);
target.ReceiveVote(vote, clusterSize);
if (target.IsLeader)
break;
}
}
return Task.CompletedTask;
}
public Task SendHeartbeatAsync(string leaderId, IReadOnlyList<string> followerIds, int term, Action<string> onAck, CancellationToken ct)
=> Task.CompletedTask;
}

View File

@@ -1,606 +0,0 @@
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;
public Task SendTimeoutNowAsync(string leaderId, string targetId, ulong term, CancellationToken ct)
=> Task.CompletedTask;
public Task SendHeartbeatAsync(string leaderId, IReadOnlyList<string> followerIds, int term, Action<string> onAck, 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;
public Task SendTimeoutNowAsync(string leaderId, string targetId, ulong term, CancellationToken ct)
=> Task.CompletedTask;
public Task SendHeartbeatAsync(string leaderId, IReadOnlyList<string> followerIds, int term, Action<string> onAck, CancellationToken ct)
=> Task.CompletedTask;
}
}

View File

@@ -1,393 +0,0 @@
using NATS.Server.Raft;
namespace NATS.Server.Tests.Raft;
/// <summary>
/// Tests for B4 (membership change proposals), B5 (snapshot checkpoints and log compaction),
/// and verifying the pre-vote absence (B6).
/// Go reference: raft.go:961-1019 (proposeAddPeer/proposeRemovePeer),
/// raft.go CreateSnapshotCheckpoint, raft.go DrainAndReplaySnapshot.
/// </summary>
public class RaftMembershipAndSnapshotTests
{
// -- Helpers (self-contained) --
private static (RaftNode leader, RaftNode[] followers) CreateCluster(int size)
{
var nodes = Enumerable.Range(1, size)
.Select(i => new RaftNode($"n{i}"))
.ToArray();
foreach (var node in nodes)
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());
}
// =====================================================================
// B4: ProposeAddPeerAsync
// Go reference: raft.go:961-990 (proposeAddPeer)
// =====================================================================
// Go: raft.go proposeAddPeer — adds member after quorum confirmation
[Fact]
public async Task ProposeAddPeerAsync_adds_member_after_quorum()
{
var (leader, _) = CreateCluster(3);
leader.Members.ShouldNotContain("n4");
await leader.ProposeAddPeerAsync("n4", default);
leader.Members.ShouldContain("n4");
}
// Go: raft.go proposeAddPeer — log entry has correct command format
[Fact]
public async Task ProposeAddPeerAsync_appends_entry_with_plus_peer_command()
{
var (leader, _) = CreateCluster(3);
var initialLogCount = leader.Log.Entries.Count;
await leader.ProposeAddPeerAsync("n4", default);
leader.Log.Entries.Count.ShouldBe(initialLogCount + 1);
leader.Log.Entries[^1].Command.ShouldBe("+peer:n4");
}
// Go: raft.go proposeAddPeer — commit index advances
[Fact]
public async Task ProposeAddPeerAsync_advances_commit_and_applied_index()
{
var (leader, _) = CreateCluster(3);
var index = await leader.ProposeAddPeerAsync("n4", default);
leader.CommitIndex.ShouldBe(index);
leader.AppliedIndex.ShouldBe(index);
}
// Go: raft.go proposeAddPeer — commit queue receives the entry
[Fact]
public async Task ProposeAddPeerAsync_enqueues_entry_to_commit_queue()
{
var (leader, _) = CreateCluster(3);
await leader.ProposeAddPeerAsync("n4", default);
// The commit queue should contain the membership change entry
leader.CommitQueue.Count.ShouldBeGreaterThanOrEqualTo(1);
}
// =====================================================================
// B4: ProposeRemovePeerAsync
// Go reference: raft.go:992-1019 (proposeRemovePeer)
// =====================================================================
// Go: raft.go proposeRemovePeer — removes member after quorum
[Fact]
public async Task ProposeRemovePeerAsync_removes_member_after_quorum()
{
var (leader, _) = CreateCluster(3);
leader.Members.ShouldContain("n2");
await leader.ProposeRemovePeerAsync("n2", default);
leader.Members.ShouldNotContain("n2");
}
// Go: raft.go proposeRemovePeer — log entry has correct command format
[Fact]
public async Task ProposeRemovePeerAsync_appends_entry_with_minus_peer_command()
{
var (leader, _) = CreateCluster(3);
var initialLogCount = leader.Log.Entries.Count;
await leader.ProposeRemovePeerAsync("n2", default);
leader.Log.Entries.Count.ShouldBe(initialLogCount + 1);
leader.Log.Entries[^1].Command.ShouldBe("-peer:n2");
}
// Go: raft.go proposeRemovePeer — commit index advances
[Fact]
public async Task ProposeRemovePeerAsync_advances_commit_and_applied_index()
{
var (leader, _) = CreateCluster(3);
var index = await leader.ProposeRemovePeerAsync("n2", default);
leader.CommitIndex.ShouldBe(index);
leader.AppliedIndex.ShouldBe(index);
}
// =====================================================================
// B4: MembershipChangeInProgress guard
// Go reference: raft.go:961-1019 single-change invariant
// =====================================================================
// Go: raft.go single-change invariant — cannot remove the last member
[Fact]
public async Task ProposeRemovePeerAsync_throws_when_only_one_member_remains()
{
// Create a lone leader (not in a cluster — self is the only member)
var lone = new RaftNode("solo");
// Manually make it leader by running election against itself
lone.StartElection(1);
lone.Members.Count.ShouldBe(1);
await Should.ThrowAsync<InvalidOperationException>(
() => lone.ProposeRemovePeerAsync("solo", default).AsTask());
}
// Go: raft.go proposeAddPeer — only leader can propose
[Fact]
public async Task ProposeAddPeerAsync_throws_when_node_is_not_leader()
{
var (_, followers) = CreateCluster(3);
var follower = followers[0];
follower.IsLeader.ShouldBeFalse();
await Should.ThrowAsync<InvalidOperationException>(
() => follower.ProposeAddPeerAsync("n4", default).AsTask());
}
// Go: raft.go proposeRemovePeer — only leader can propose
[Fact]
public async Task ProposeRemovePeerAsync_throws_when_node_is_not_leader()
{
var (_, followers) = CreateCluster(3);
var follower = followers[0];
follower.IsLeader.ShouldBeFalse();
await Should.ThrowAsync<InvalidOperationException>(
() => follower.ProposeRemovePeerAsync("n1", default).AsTask());
}
// Go: raft.go single-change invariant — MembershipChangeInProgress cleared after proposal
[Fact]
public async Task MembershipChangeInProgress_is_false_after_proposal_completes()
{
var (leader, _) = CreateCluster(3);
await leader.ProposeAddPeerAsync("n4", default);
// After the proposal completes the flag must be cleared
leader.MembershipChangeInProgress.ShouldBeFalse();
}
// Go: raft.go single-change invariant — two sequential proposals both succeed
[Fact]
public async Task Two_sequential_membership_changes_both_succeed()
{
var (leader, _) = CreateCluster(3);
await leader.ProposeAddPeerAsync("n4", default);
// First change must be cleared before second can proceed
leader.MembershipChangeInProgress.ShouldBeFalse();
await leader.ProposeAddPeerAsync("n5", default);
leader.Members.ShouldContain("n4");
leader.Members.ShouldContain("n5");
}
// =====================================================================
// B5: RaftLog.Compact
// Go reference: raft.go WAL compact / compactLog
// =====================================================================
// Go: raft.go compactLog — removes entries up to given index
[Fact]
public void Log_Compact_removes_entries_up_to_index()
{
var log = new RaftLog();
log.Append(term: 1, command: "a"); // index 1
log.Append(term: 1, command: "b"); // index 2
log.Append(term: 1, command: "c"); // index 3
log.Append(term: 1, command: "d"); // index 4
log.Compact(upToIndex: 2);
log.Entries.Count.ShouldBe(2);
log.Entries[0].Index.ShouldBe(3);
log.Entries[1].Index.ShouldBe(4);
}
// Go: raft.go compactLog — base index advances after compact
[Fact]
public void Log_Compact_advances_base_index()
{
var log = new RaftLog();
log.Append(term: 1, command: "a"); // index 1
log.Append(term: 1, command: "b"); // index 2
log.Append(term: 1, command: "c"); // index 3
log.Compact(upToIndex: 2);
// New entries should be indexed from the new base
var next = log.Append(term: 1, command: "d");
next.Index.ShouldBe(4);
}
// Go: raft.go compactLog — compact all entries yields empty log
[Fact]
public void Log_Compact_all_entries_leaves_empty_log()
{
var log = new RaftLog();
log.Append(term: 1, command: "x"); // index 1
log.Append(term: 1, command: "y"); // index 2
log.Compact(upToIndex: 2);
log.Entries.Count.ShouldBe(0);
}
// Go: raft.go compactLog — compact with index beyond all entries is safe
[Fact]
public void Log_Compact_beyond_all_entries_removes_everything()
{
var log = new RaftLog();
log.Append(term: 1, command: "p"); // index 1
log.Append(term: 1, command: "q"); // index 2
log.Compact(upToIndex: 999);
log.Entries.Count.ShouldBe(0);
}
// Go: raft.go compactLog — compact with index 0 is a no-op
[Fact]
public void Log_Compact_index_zero_is_noop()
{
var log = new RaftLog();
log.Append(term: 1, command: "r"); // index 1
log.Append(term: 1, command: "s"); // index 2
log.Compact(upToIndex: 0);
log.Entries.Count.ShouldBe(2);
}
// =====================================================================
// B5: CreateSnapshotCheckpointAsync
// Go reference: raft.go CreateSnapshotCheckpoint
// =====================================================================
// Go: raft.go CreateSnapshotCheckpoint — captures applied index and compacts log
[Fact]
public async Task CreateSnapshotCheckpointAsync_creates_snapshot_and_compacts_log()
{
var (leader, _) = CreateCluster(3);
await leader.ProposeAsync("cmd-1", default);
await leader.ProposeAsync("cmd-2", default);
await leader.ProposeAsync("cmd-3", default);
var logCountBefore = leader.Log.Entries.Count;
var snapshot = await leader.CreateSnapshotCheckpointAsync(default);
snapshot.LastIncludedIndex.ShouldBe(leader.AppliedIndex);
snapshot.LastIncludedTerm.ShouldBe(leader.Term);
// The log should have been compacted — entries up to applied index removed
leader.Log.Entries.Count.ShouldBeLessThan(logCountBefore);
}
// Go: raft.go CreateSnapshotCheckpoint — log is empty after compacting all entries
[Fact]
public async Task CreateSnapshotCheckpointAsync_with_all_entries_applied_empties_log()
{
var (leader, _) = CreateCluster(3);
await leader.ProposeAsync("alpha", default);
await leader.ProposeAsync("beta", default);
// AppliedIndex should equal the last entry's index after ProposeAsync
var snapshot = await leader.CreateSnapshotCheckpointAsync(default);
snapshot.LastIncludedIndex.ShouldBeGreaterThan(0);
leader.Log.Entries.Count.ShouldBe(0);
}
// Go: raft.go CreateSnapshotCheckpoint — new entries continue from correct index after checkpoint
[Fact]
public async Task CreateSnapshotCheckpointAsync_new_entries_start_after_snapshot()
{
var (leader, _) = CreateCluster(3);
await leader.ProposeAsync("first", default);
await leader.ProposeAsync("second", default);
var snapshot = await leader.CreateSnapshotCheckpointAsync(default);
var snapshotIndex = snapshot.LastIncludedIndex;
// Append directly to the log (bypasses quorum for index continuity test)
var nextEntry = leader.Log.Append(term: leader.Term, command: "third");
nextEntry.Index.ShouldBe(snapshotIndex + 1);
}
// =====================================================================
// B5: DrainAndReplaySnapshotAsync
// Go reference: raft.go DrainAndReplaySnapshot
// =====================================================================
// Go: raft.go DrainAndReplaySnapshot — installs snapshot, updates commit and applied index
[Fact]
public async Task DrainAndReplaySnapshotAsync_installs_snapshot_and_updates_indices()
{
var (leader, followers) = CreateCluster(3);
await leader.ProposeAsync("entry-1", default);
await leader.ProposeAsync("entry-2", default);
var snapshot = new RaftSnapshot
{
LastIncludedIndex = 100,
LastIncludedTerm = 5,
};
var follower = followers[0];
await follower.DrainAndReplaySnapshotAsync(snapshot, default);
follower.AppliedIndex.ShouldBe(100);
follower.CommitIndex.ShouldBe(100);
}
// Go: raft.go DrainAndReplaySnapshot — drains pending commit queue entries
[Fact]
public async Task DrainAndReplaySnapshotAsync_drains_commit_queue()
{
var node = new RaftNode("n1");
// Manually stuff some entries into the commit queue to simulate pending work
var fakeEntry1 = new RaftLogEntry(1, 1, "fake-1");
var fakeEntry2 = new RaftLogEntry(2, 1, "fake-2");
await node.CommitQueue.EnqueueAsync(fakeEntry1, default);
await node.CommitQueue.EnqueueAsync(fakeEntry2, default);
node.CommitQueue.Count.ShouldBe(2);
var snapshot = new RaftSnapshot { LastIncludedIndex = 50, LastIncludedTerm = 3 };
await node.DrainAndReplaySnapshotAsync(snapshot, default);
// Queue should be empty after drain
node.CommitQueue.Count.ShouldBe(0);
}
// Go: raft.go DrainAndReplaySnapshot — log is replaced with snapshot baseline
[Fact]
public async Task DrainAndReplaySnapshotAsync_replaces_log_with_snapshot_baseline()
{
var node = new RaftNode("n1");
node.Log.Append(term: 1, command: "stale-a");
node.Log.Append(term: 1, command: "stale-b");
node.Log.Entries.Count.ShouldBe(2);
var snapshot = new RaftSnapshot { LastIncludedIndex = 77, LastIncludedTerm = 4 };
await node.DrainAndReplaySnapshotAsync(snapshot, default);
node.Log.Entries.Count.ShouldBe(0);
// New entries should start from the snapshot base
var next = node.Log.Append(term: 5, command: "fresh");
next.Index.ShouldBe(78);
}
}

View File

@@ -1,19 +0,0 @@
using NATS.Server.Raft;
namespace NATS.Server.Tests;
public class RaftMembershipRuntimeParityTests
{
[Fact]
public void Raft_membership_add_remove_round_trips()
{
var node = new RaftNode("N1");
node.AddMember("N2");
node.AddMember("N3");
node.Members.ShouldContain("N2");
node.Members.ShouldContain("N3");
node.RemoveMember("N2");
node.Members.ShouldNotContain("N2");
}
}

View File

@@ -1,226 +0,0 @@
using NATS.Server.Raft;
namespace NATS.Server.Tests.Raft;
/// <summary>
/// Tests for B4: Membership Changes (Add/Remove Peer).
/// Go reference: raft.go:2500-2600 (ProposeAddPeer/RemovePeer), raft.go:961-1019.
/// </summary>
public class RaftMembershipTests
{
// -- Helpers --
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;
}
// -- RaftMembershipChange type tests --
[Fact]
public void MembershipChange_ToCommand_encodes_add_peer()
{
var change = new RaftMembershipChange(RaftMembershipChangeType.AddPeer, "n4");
change.ToCommand().ShouldBe("AddPeer:n4");
}
[Fact]
public void MembershipChange_ToCommand_encodes_remove_peer()
{
var change = new RaftMembershipChange(RaftMembershipChangeType.RemovePeer, "n2");
change.ToCommand().ShouldBe("RemovePeer:n2");
}
[Fact]
public void MembershipChange_TryParse_roundtrips_add_peer()
{
var original = new RaftMembershipChange(RaftMembershipChangeType.AddPeer, "n4");
var parsed = RaftMembershipChange.TryParse(original.ToCommand());
parsed.ShouldNotBeNull();
parsed.Value.ShouldBe(original);
}
[Fact]
public void MembershipChange_TryParse_roundtrips_remove_peer()
{
var original = new RaftMembershipChange(RaftMembershipChangeType.RemovePeer, "n2");
var parsed = RaftMembershipChange.TryParse(original.ToCommand());
parsed.ShouldNotBeNull();
parsed.Value.ShouldBe(original);
}
[Fact]
public void MembershipChange_TryParse_returns_null_for_invalid_command()
{
RaftMembershipChange.TryParse("some-random-command").ShouldBeNull();
RaftMembershipChange.TryParse("UnknownType:n1").ShouldBeNull();
RaftMembershipChange.TryParse("AddPeer:").ShouldBeNull();
}
// -- ProposeAddPeerAsync tests --
[Fact]
public async Task Add_peer_succeeds_as_leader()
{
// Go reference: raft.go:961-990 (proposeAddPeer succeeds when leader)
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
var index = await leader.ProposeAddPeerAsync("n4", default);
index.ShouldBeGreaterThan(0);
leader.Members.ShouldContain("n4");
}
[Fact]
public async Task Add_peer_fails_when_not_leader()
{
// Go reference: raft.go:961 (leader check)
var node = new RaftNode("follower");
await Should.ThrowAsync<InvalidOperationException>(
async () => await node.ProposeAddPeerAsync("n2", default));
}
[Fact]
public async Task Add_peer_updates_peer_state_tracking()
{
// After adding a peer, the leader should track its replication state
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
await leader.ProposeAddPeerAsync("n4", default);
var peerStates = leader.GetPeerStates();
peerStates.ShouldContainKey("n4");
peerStates["n4"].PeerId.ShouldBe("n4");
}
// -- ProposeRemovePeerAsync tests --
[Fact]
public async Task Remove_peer_succeeds()
{
// Go reference: raft.go:992-1019 (proposeRemovePeer succeeds)
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
// n2 is a follower, should be removable
leader.Members.ShouldContain("n2");
var index = await leader.ProposeRemovePeerAsync("n2", default);
index.ShouldBeGreaterThan(0);
leader.Members.ShouldNotContain("n2");
}
[Fact]
public async Task Remove_peer_fails_for_self_while_leader()
{
// Go reference: leader must step down before removing itself
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
await Should.ThrowAsync<InvalidOperationException>(
async () => await leader.ProposeRemovePeerAsync(leader.Id, default));
}
[Fact]
public async Task Remove_peer_fails_when_not_leader()
{
var node = new RaftNode("follower");
await Should.ThrowAsync<InvalidOperationException>(
async () => await node.ProposeRemovePeerAsync("n2", default));
}
[Fact]
public async Task Remove_peer_removes_from_peer_state_tracking()
{
// After removing a peer, its state should be cleaned up
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
leader.GetPeerStates().ShouldContainKey("n2");
await leader.ProposeRemovePeerAsync("n2", default);
leader.GetPeerStates().ShouldNotContainKey("n2");
}
// -- Concurrent membership change rejection --
[Fact]
public async Task Concurrent_membership_changes_rejected()
{
// Go reference: raft.go single-change invariant — only one in-flight at a time
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
// The first add should succeed
await leader.ProposeAddPeerAsync("n4", default);
// Since the first completed synchronously via in-memory transport,
// the in-flight flag is cleared. Verify the flag mechanism works by
// checking the property is false after completion.
leader.MembershipChangeInProgress.ShouldBeFalse();
}
// -- Membership change updates member list on commit --
[Fact]
public async Task Membership_change_updates_member_list_on_commit()
{
// Go reference: membership applied after quorum commit
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
var membersBefore = leader.Members.Count;
await leader.ProposeAddPeerAsync("n4", default);
leader.Members.Count.ShouldBe(membersBefore + 1);
leader.Members.ShouldContain("n4");
await leader.ProposeRemovePeerAsync("n4", default);
leader.Members.Count.ShouldBe(membersBefore);
leader.Members.ShouldNotContain("n4");
}
[Fact]
public async Task Add_peer_creates_log_entry()
{
// The membership change should appear in the RAFT log
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
var logCountBefore = leader.Log.Entries.Count;
await leader.ProposeAddPeerAsync("n4", default);
leader.Log.Entries.Count.ShouldBe(logCountBefore + 1);
leader.Log.Entries[^1].Command.ShouldContain("n4");
}
[Fact]
public async Task Remove_peer_creates_log_entry()
{
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
var logCountBefore = leader.Log.Entries.Count;
await leader.ProposeRemovePeerAsync("n2", default);
leader.Log.Entries.Count.ShouldBe(logCountBefore + 1);
leader.Log.Entries[^1].Command.ShouldContain("n2");
}
}

View File

@@ -1,149 +0,0 @@
using NATS.Server.Raft;
namespace NATS.Server.Tests.Raft;
public class RaftNodeParityBatch2Tests
{
private static RaftNode ElectSingleNodeLeader()
{
var node = new RaftNode("n1");
node.ConfigureCluster([node]);
node.StartElection(1);
node.IsLeader.ShouldBeTrue();
return node;
}
[Fact]
public void Leader_tracking_flags_update_on_election_and_heartbeat()
{
var node1 = new RaftNode("n1");
var node2 = new RaftNode("n2");
var node3 = new RaftNode("n3");
node1.ConfigureCluster([node1, node2, node3]);
node2.ConfigureCluster([node1, node2, node3]);
node3.ConfigureCluster([node1, node2, node3]);
node1.StartElection(3);
node1.ReceiveVote(node2.GrantVote(node1.Term, node1.Id), 3);
node1.IsLeader.ShouldBeTrue();
node1.GroupLeader.ShouldBe("n1");
node1.Leaderless.ShouldBeFalse();
node1.HadPreviousLeader.ShouldBeTrue();
node1.LeaderSince.ShouldNotBeNull();
node2.ReceiveHeartbeat(node1.Term, fromPeerId: "n1");
node2.IsLeader.ShouldBeFalse();
node2.GroupLeader.ShouldBe("n1");
node2.Leaderless.ShouldBeFalse();
node2.HadPreviousLeader.ShouldBeTrue();
node2.LeaderSince.ShouldBeNull();
}
[Fact]
public void Stepdown_clears_group_leader_and_leader_since()
{
using var leader = ElectSingleNodeLeader();
leader.GroupLeader.ShouldBe("n1");
leader.LeaderSince.ShouldNotBeNull();
leader.RequestStepDown();
leader.Leaderless.ShouldBeTrue();
leader.GroupLeader.ShouldBe(RaftNode.NoLeader);
leader.LeaderSince.ShouldBeNull();
}
[Fact]
public void Observer_mode_can_be_toggled()
{
using var node = new RaftNode("n1");
node.IsObserver.ShouldBeFalse();
node.SetObserver(true);
node.IsObserver.ShouldBeTrue();
node.SetObserver(false);
node.IsObserver.ShouldBeFalse();
}
[Fact]
public void Cluster_size_adjustments_enforce_boot_and_leader_rules()
{
using var node = new RaftNode("n1");
node.ClusterSize().ShouldBe(1);
node.AdjustBootClusterSize(1).ShouldBeTrue();
node.ClusterSize().ShouldBe(2); // floor is 2
node.ConfigureCluster([node]);
node.StartElection(1);
node.IsLeader.ShouldBeTrue();
node.AdjustClusterSize(5).ShouldBeTrue();
node.ClusterSize().ShouldBe(5);
node.AdjustBootClusterSize(7).ShouldBeFalse();
}
[Fact]
public async Task Progress_size_and_applied_accessors_report_expected_values()
{
using var leader = ElectSingleNodeLeader();
await leader.ProposeAsync("abc", CancellationToken.None);
await leader.ProposeAsync("de", CancellationToken.None);
var progress = leader.Progress();
progress.Index.ShouldBe(2);
progress.Commit.ShouldBe(2);
progress.Applied.ShouldBe(2);
var size = leader.Size();
size.Entries.ShouldBe(2);
size.Bytes.ShouldBe(5);
var applied = leader.Applied(1);
applied.Entries.ShouldBe(1);
applied.Bytes.ShouldBe(3);
leader.ProcessedIndex.ShouldBe(1);
}
[Fact]
public void Campaign_timeout_randomization_and_defaults_match_go_constants()
{
using var node = new RaftNode("n1");
for (var i = 0; i < 20; i++)
{
var timeout = node.RandomizedCampaignTimeout();
timeout.ShouldBeGreaterThanOrEqualTo(RaftNode.MinCampaignTimeoutDefault);
timeout.ShouldBeLessThan(RaftNode.MaxCampaignTimeoutDefault);
}
RaftNode.HbIntervalDefault.ShouldBe(TimeSpan.FromSeconds(1));
RaftNode.LostQuorumIntervalDefault.ShouldBe(TimeSpan.FromSeconds(10));
RaftNode.ObserverModeIntervalDefault.ShouldBe(TimeSpan.FromHours(48));
RaftNode.PeerRemoveTimeoutDefault.ShouldBe(TimeSpan.FromMinutes(5));
RaftNode.NoLeader.ShouldBe(string.Empty);
RaftNode.NoVote.ShouldBe(string.Empty);
}
[Fact]
public void Stop_wait_for_stop_and_delete_set_lifecycle_state()
{
var path = Path.Combine(Path.GetTempPath(), $"raft-node-delete-{Guid.NewGuid():N}");
Directory.CreateDirectory(path);
File.WriteAllText(Path.Combine(path, "marker.txt"), "x");
using var node = new RaftNode("n1", persistDirectory: path);
node.IsDeleted.ShouldBeFalse();
node.Stop();
node.WaitForStop();
node.IsDeleted.ShouldBeFalse();
Directory.Exists(path).ShouldBeTrue();
node.Delete();
node.IsDeleted.ShouldBeTrue();
Directory.Exists(path).ShouldBeFalse();
}
}

View File

@@ -1,17 +0,0 @@
namespace NATS.Server.Tests;
public class RaftOperationalConvergenceParityTests
{
[Fact]
public async Task Lagging_follower_converges_via_next_index_backtrack_then_snapshot_install_under_membership_change()
{
var advanced = new RaftConsensusAdvancedParityTests();
await advanced.Leader_heartbeats_keep_followers_current_and_next_index_backtracks_on_mismatch();
var snapshot = new RaftSnapshotTransferRuntimeParityTests();
await snapshot.Raft_snapshot_install_catches_up_lagging_follower();
var membership = new RaftMembershipParityTests();
membership.Membership_changes_update_node_membership_state();
}
}

View File

@@ -1,79 +0,0 @@
using NATS.Server.Raft;
namespace NATS.Server.Tests.Raft;
public class RaftParityBatch3Tests
{
[Fact]
public async Task ProposeMulti_proposes_entries_in_order()
{
using var leader = ElectSingleNodeLeader();
var indexes = await leader.ProposeMultiAsync(["cmd-1", "cmd-2", "cmd-3"], CancellationToken.None);
indexes.Count.ShouldBe(3);
indexes[0].ShouldBe(1);
indexes[1].ShouldBe(2);
indexes[2].ShouldBe(3);
leader.Log.Entries.Count.ShouldBe(3);
}
[Fact]
public void PeerState_tracks_lag_and_current_flags()
{
var peer = new RaftPeerState
{
PeerId = "n2",
NextIndex = 10,
MatchIndex = 7,
LastContact = DateTime.UtcNow,
};
peer.RecalculateLag();
peer.RefreshCurrent(TimeSpan.FromSeconds(1));
peer.Lag.ShouldBe(2);
peer.Current.ShouldBeTrue();
peer.LastContact = DateTime.UtcNow - TimeSpan.FromSeconds(5);
peer.RefreshCurrent(TimeSpan.FromSeconds(1));
peer.Current.ShouldBeFalse();
}
[Fact]
public void CommittedEntry_contains_index_and_entries()
{
var entries = new[]
{
new RaftLogEntry(42, 3, "set x"),
new RaftLogEntry(43, 3, "set y"),
};
var committed = new CommittedEntry(43, entries);
committed.Index.ShouldBe(43);
committed.Entries.Count.ShouldBe(2);
committed.Entries[0].Command.ShouldBe("set x");
}
[Fact]
public void RaftEntry_roundtrips_to_wire_shape()
{
var entry = new RaftEntry(RaftEntryType.AddPeer, new byte[] { 1, 2, 3 });
var wire = entry.ToWire();
var decoded = RaftEntry.FromWire(wire);
decoded.Type.ShouldBe(RaftEntryType.AddPeer);
decoded.Data.ShouldBe(new byte[] { 1, 2, 3 });
}
private static RaftNode ElectSingleNodeLeader()
{
var node = new RaftNode("n1");
node.ConfigureCluster([node]);
node.StartElection(1);
node.IsLeader.ShouldBeTrue();
return node;
}
}

View File

@@ -1,300 +0,0 @@
using NATS.Server.Raft;
namespace NATS.Server.Tests.Raft;
/// <summary>
/// Tests for B6: Pre-Vote Protocol.
/// Go reference: raft.go:1600-1700 (pre-vote logic).
/// Pre-vote prevents partitioned nodes from disrupting the cluster by
/// incrementing their term without actually winning an election.
/// </summary>
public class RaftPreVoteTests
{
// -- Helpers --
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;
}
// -- Wire format tests --
[Fact]
public void PreVote_request_encoding_roundtrip()
{
var request = new RaftPreVoteRequestWire(
Term: 5,
LastTerm: 4,
LastIndex: 100,
CandidateId: "n1");
var encoded = request.Encode();
encoded.Length.ShouldBe(RaftWireConstants.VoteRequestLen); // 32 bytes
var decoded = RaftPreVoteRequestWire.Decode(encoded);
decoded.Term.ShouldBe(5UL);
decoded.LastTerm.ShouldBe(4UL);
decoded.LastIndex.ShouldBe(100UL);
decoded.CandidateId.ShouldBe("n1");
}
[Fact]
public void PreVote_response_encoding_roundtrip()
{
var response = new RaftPreVoteResponseWire(
Term: 5,
PeerId: "n2",
Granted: true);
var encoded = response.Encode();
encoded.Length.ShouldBe(RaftWireConstants.VoteResponseLen); // 17 bytes
var decoded = RaftPreVoteResponseWire.Decode(encoded);
decoded.Term.ShouldBe(5UL);
decoded.PeerId.ShouldBe("n2");
decoded.Granted.ShouldBeTrue();
}
[Fact]
public void PreVote_response_denied_roundtrip()
{
var response = new RaftPreVoteResponseWire(Term: 3, PeerId: "n3", Granted: false);
var decoded = RaftPreVoteResponseWire.Decode(response.Encode());
decoded.Granted.ShouldBeFalse();
decoded.PeerId.ShouldBe("n3");
decoded.Term.ShouldBe(3UL);
}
[Fact]
public void PreVote_request_decode_throws_on_wrong_length()
{
Should.Throw<ArgumentException>(() =>
RaftPreVoteRequestWire.Decode(new byte[10]));
}
[Fact]
public void PreVote_response_decode_throws_on_wrong_length()
{
Should.Throw<ArgumentException>(() =>
RaftPreVoteResponseWire.Decode(new byte[10]));
}
// -- RequestPreVote logic tests --
[Fact]
public void PreVote_granted_when_candidate_log_is_up_to_date()
{
// Go reference: raft.go pre-vote grants when candidate log >= voter log
var node = new RaftNode("voter");
node.Log.Append(1, "cmd-1"); // voter has entry at index 1, term 1
// Candidate has same term and same or higher index: should grant
var granted = node.RequestPreVote(
term: (ulong)node.Term,
lastTerm: 1,
lastIndex: 1,
candidateId: "candidate");
granted.ShouldBeTrue();
}
[Fact]
public void PreVote_granted_when_candidate_has_higher_term_log()
{
var node = new RaftNode("voter");
node.Log.Append(1, "cmd-1"); // voter: term 1, index 1
// Candidate has higher last term: should grant
var granted = node.RequestPreVote(
term: 0,
lastTerm: 2,
lastIndex: 1,
candidateId: "candidate");
granted.ShouldBeTrue();
}
[Fact]
public void PreVote_denied_when_candidate_log_is_stale()
{
// Go reference: raft.go pre-vote denied when candidate log behind voter
var node = new RaftNode("voter");
node.TermState.CurrentTerm = 2;
node.Log.Append(2, "cmd-1");
node.Log.Append(2, "cmd-2"); // voter: term 2, index 2
// Candidate has lower last term: should deny
var granted = node.RequestPreVote(
term: 2,
lastTerm: 1,
lastIndex: 5,
candidateId: "candidate");
granted.ShouldBeFalse();
}
[Fact]
public void PreVote_denied_when_candidate_term_behind()
{
var node = new RaftNode("voter");
node.TermState.CurrentTerm = 5;
// Candidate's term is behind the voter's current term
var granted = node.RequestPreVote(
term: 3,
lastTerm: 3,
lastIndex: 100,
candidateId: "candidate");
granted.ShouldBeFalse();
}
[Fact]
public void PreVote_granted_for_empty_logs()
{
// Both node and candidate have empty logs: grant
var node = new RaftNode("voter");
var granted = node.RequestPreVote(
term: 0,
lastTerm: 0,
lastIndex: 0,
candidateId: "candidate");
granted.ShouldBeTrue();
}
// -- Pre-vote integration with election flow --
[Fact]
public void Successful_prevote_leads_to_real_election()
{
// Go reference: after pre-vote success, proceed to real election with term increment
var (nodes, _) = CreateCluster(3);
var candidate = nodes[0];
var termBefore = candidate.Term;
// With pre-vote enabled, CampaignWithPreVote should succeed (all peers have equal logs)
// and then start a real election (incrementing term)
candidate.PreVoteEnabled = true;
candidate.CampaignWithPreVote();
// Term should have been incremented by the real election
candidate.Term.ShouldBe(termBefore + 1);
candidate.Role.ShouldBe(RaftRole.Candidate);
}
[Fact]
public void Failed_prevote_does_not_increment_term()
{
// Go reference: failed pre-vote stays follower, doesn't disrupt cluster
var (nodes, _) = CreateCluster(3);
var candidate = nodes[0];
// Give the other nodes higher-term logs so pre-vote will be denied
nodes[1].TermState.CurrentTerm = 10;
nodes[1].Log.Append(10, "advanced-cmd");
nodes[2].TermState.CurrentTerm = 10;
nodes[2].Log.Append(10, "advanced-cmd");
var termBefore = candidate.Term;
candidate.PreVoteEnabled = true;
candidate.CampaignWithPreVote();
// Term should NOT have been incremented — pre-vote failed
candidate.Term.ShouldBe(termBefore);
candidate.Role.ShouldBe(RaftRole.Follower);
}
[Fact]
public void PreVote_disabled_goes_directly_to_election()
{
// When PreVoteEnabled is false, skip pre-vote and go straight to election
var (nodes, _) = CreateCluster(3);
var candidate = nodes[0];
var termBefore = candidate.Term;
candidate.PreVoteEnabled = false;
candidate.CampaignWithPreVote();
// Should have gone directly to election, incrementing term
candidate.Term.ShouldBe(termBefore + 1);
candidate.Role.ShouldBe(RaftRole.Candidate);
}
[Fact]
public void Partitioned_node_with_stale_term_does_not_disrupt_via_prevote()
{
// Go reference: pre-vote prevents partitioned nodes from disrupting the cluster.
// A node with a stale term that reconnects should fail the pre-vote round
// and NOT increment its term, which would force other nodes to step down.
var (nodes, _) = CreateCluster(3);
// Simulate: n1 was partitioned and has term 0, others advanced to term 5
nodes[1].TermState.CurrentTerm = 5;
nodes[1].Log.Append(5, "cmd-a");
nodes[1].Log.Append(5, "cmd-b");
nodes[2].TermState.CurrentTerm = 5;
nodes[2].Log.Append(5, "cmd-a");
nodes[2].Log.Append(5, "cmd-b");
var partitioned = nodes[0];
partitioned.PreVoteEnabled = true;
var termBefore = partitioned.Term;
// Pre-vote should fail because the partitioned node has a stale log
partitioned.CampaignWithPreVote();
// The partitioned node should NOT have incremented its term
partitioned.Term.ShouldBe(termBefore);
partitioned.Role.ShouldBe(RaftRole.Follower);
}
[Fact]
public void PreVote_enabled_by_default()
{
var node = new RaftNode("n1");
node.PreVoteEnabled.ShouldBeTrue();
}
[Fact]
public void StartPreVote_returns_true_when_majority_grants()
{
// All nodes have empty, equal logs: pre-vote should succeed
var (nodes, _) = CreateCluster(3);
var candidate = nodes[0];
var result = candidate.StartPreVote();
result.ShouldBeTrue();
}
[Fact]
public void StartPreVote_returns_false_when_majority_denies()
{
var (nodes, _) = CreateCluster(3);
var candidate = nodes[0];
// Make majority have more advanced logs
nodes[1].TermState.CurrentTerm = 10;
nodes[1].Log.Append(10, "cmd");
nodes[2].TermState.CurrentTerm = 10;
nodes[2].Log.Append(10, "cmd");
var result = candidate.StartPreVote();
result.ShouldBeFalse();
}
}

View File

@@ -1,282 +0,0 @@
using NATS.Server.Raft;
namespace NATS.Server.Tests.Raft;
/// <summary>
/// Tests for HasQuorum() and the quorum guard in ProposeAsync (Gap 8.6).
///
/// A leader must confirm that a majority of peers have contacted it recently
/// (within 2 × ElectionTimeoutMaxMs) before it is allowed to append new log entries.
/// This prevents a partitioned leader from diverging the log while isolated from
/// the rest of the cluster.
///
/// Go reference: raft.go checkQuorum / stepDown — a leader steps down (and therefore
/// blocks proposals) when it has not heard from a quorum of peers within the
/// election-timeout window.
/// </summary>
public class RaftQuorumCheckTests
{
// -- 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);
// Short timeouts so tests do not need real async delays.
node.ElectionTimeoutMinMs = 50;
node.ElectionTimeoutMaxMs = 100;
}
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;
}
// -- HasQuorum tests --
// Go reference: raft.go checkQuorum (leader confirms majority contact before acting)
[Fact]
public void HasQuorum_returns_true_with_majority_peers_current()
{
// 3-node cluster: leader + 2 peers. Both peers are freshly initialized by
// ConfigureCluster so their LastContact is very close to UtcNow.
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
// Peers were initialized with DateTime.UtcNow — they are within the quorum window.
leader.HasQuorum().ShouldBeTrue();
}
// Go reference: raft.go checkQuorum (leader steps down when peers are stale)
[Fact]
public void HasQuorum_returns_false_with_stale_peers()
{
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
// Set all peer contacts well beyond the quorum window (2 × 100 ms = 200 ms).
foreach (var (_, state) in leader.GetPeerStates())
state.LastContact = DateTime.UtcNow.AddMinutes(-5);
leader.HasQuorum().ShouldBeFalse();
}
// Go reference: raft.go — followers never have proposer quorum
[Fact]
public void HasQuorum_returns_false_for_non_leader()
{
var (nodes, _) = CreateCluster(3);
_ = ElectLeader(nodes);
// nodes[1] is a follower.
var follower = nodes[1];
follower.IsLeader.ShouldBeFalse();
follower.HasQuorum().ShouldBeFalse();
}
// Go reference: raft.go — candidate also does not have proposer quorum
[Fact]
public void HasQuorum_returns_false_for_candidate()
{
// A node becomes a Candidate when StartElection is called but it has not yet
// received enough votes to become Leader. In a 3-node cluster, after calling
// StartElection on n1 the node is a Candidate (it voted for itself but the
// other 2 nodes have not yet responded).
var (nodes, _) = CreateCluster(3);
var candidate = nodes[0];
// StartElection increments term, sets VotedFor=self, and calls TryBecomeLeader.
// With only 1 self-vote in a 3-node cluster quorum is 2, so role stays Candidate.
candidate.StartElection(clusterSize: 3);
candidate.Role.ShouldBe(RaftRole.Candidate);
candidate.HasQuorum().ShouldBeFalse();
}
// Go reference: raft.go single-node cluster — self is always a majority of one
[Fact]
public void HasQuorum_single_node_always_true()
{
var node = new RaftNode("solo");
node.StartElection(clusterSize: 1);
node.IsLeader.ShouldBeTrue();
node.HasQuorum().ShouldBeTrue();
}
// 5-node cluster: with 2 current peers + self = 3, majority of 5 is 3, so quorum.
[Fact]
public void HasQuorum_five_node_with_two_current_peers_is_true()
{
var (nodes, _) = CreateCluster(5);
var leader = ElectLeader(nodes);
// Make 2 peers stale; keep 2 fresh (plus self = 3 voters, majority of 5 = 3).
var peerStates = leader.GetPeerStates().Values.ToList();
peerStates[0].LastContact = DateTime.UtcNow.AddMinutes(-5);
peerStates[1].LastContact = DateTime.UtcNow.AddMinutes(-5);
// peerStates[2] and peerStates[3] remain fresh (within window).
leader.HasQuorum().ShouldBeTrue();
}
// 5-node cluster: with only 1 current peer + self = 2, majority of 5 is 3, so no quorum.
[Fact]
public void HasQuorum_five_node_with_one_current_peer_is_false()
{
var (nodes, _) = CreateCluster(5);
var leader = ElectLeader(nodes);
// Make 3 out of 4 peers stale; only 1 fresh peer + self = 2 voters (need 3).
var peerStates = leader.GetPeerStates().Values.ToList();
peerStates[0].LastContact = DateTime.UtcNow.AddMinutes(-5);
peerStates[1].LastContact = DateTime.UtcNow.AddMinutes(-5);
peerStates[2].LastContact = DateTime.UtcNow.AddMinutes(-5);
// peerStates[3] is fresh.
leader.HasQuorum().ShouldBeFalse();
}
// -- ProposeAsync quorum guard tests --
// Go reference: raft.go checkQuorum — leader rejects proposals when quorum lost
[Fact]
public async Task ProposeAsync_throws_when_no_quorum()
{
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
// Make all peers stale to break quorum.
foreach (var (_, state) in leader.GetPeerStates())
state.LastContact = DateTime.UtcNow.AddMinutes(-5);
var ex = await Should.ThrowAsync<InvalidOperationException>(
() => leader.ProposeAsync("cmd", CancellationToken.None).AsTask());
ex.Message.ShouldContain("no quorum");
}
// Go reference: raft.go normal proposal path when quorum is confirmed
[Fact]
public async Task ProposeAsync_succeeds_with_quorum()
{
// Peers are initialized with fresh LastContact by ConfigureCluster, so quorum holds.
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
var index = await leader.ProposeAsync("cmd-ok", CancellationToken.None);
index.ShouldBeGreaterThan(0);
leader.AppliedIndex.ShouldBe(index);
}
// After a heartbeat round, peers are fresh and quorum is restored.
[Fact]
public async Task ProposeAsync_succeeds_after_heartbeat_restores_quorum()
{
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
// Make all peers stale.
foreach (var (_, state) in leader.GetPeerStates())
state.LastContact = DateTime.UtcNow.AddMinutes(-5);
// Proposal should fail with no quorum.
await Should.ThrowAsync<InvalidOperationException>(
() => leader.ProposeAsync("should-fail", CancellationToken.None).AsTask());
// Simulate heartbeat responses updating LastContact on the leader.
foreach (var peer in nodes.Skip(1))
leader.GetPeerStates()[peer.Id].LastContact = DateTime.UtcNow;
// Quorum is restored; proposal should now succeed.
var index = await leader.ProposeAsync("after-heartbeat", CancellationToken.None);
index.ShouldBeGreaterThan(0);
}
// -- Heartbeat updates LastContact --
// Go reference: raft.go processHeartbeat — updates peer last-contact on a valid heartbeat
[Fact]
public void Heartbeat_updates_last_contact()
{
var (nodes, _) = CreateCluster(3);
var node = nodes[0];
var peerStates = node.GetPeerStates();
var oldTime = DateTime.UtcNow.AddMinutes(-5);
peerStates["n2"].LastContact = oldTime;
node.ReceiveHeartbeat(term: 1, fromPeerId: "n2");
peerStates["n2"].LastContact.ShouldBeGreaterThan(oldTime);
}
// Heartbeats from the leader to the cluster restore the leader's quorum tracking.
[Fact]
public void Heartbeat_from_peer_restores_peer_freshness_for_quorum()
{
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
// Simulate network partition: mark all peers stale.
foreach (var (_, state) in leader.GetPeerStates())
state.LastContact = DateTime.UtcNow.AddMinutes(-5);
leader.HasQuorum().ShouldBeFalse();
// Leader receives heartbeat ACK from n2 (simulating that n2 is still reachable).
// In a real RAFT loop the leader sends AppendEntries and processes the response;
// here we simulate the response side by directly updating LastContact via ReceiveHeartbeat.
// Note: ReceiveHeartbeat is called on a follower when it receives from the leader, not
// on the leader itself. We instead update LastContact directly to simulate the leader
// processing an AppendEntries response.
leader.GetPeerStates()["n2"].LastContact = DateTime.UtcNow;
// 1 current peer + self = 2 voters; majority of 3 = 2, so quorum is restored.
leader.HasQuorum().ShouldBeTrue();
}
// -- Quorum window boundary tests --
[Fact]
public void HasQuorum_peer_just_within_window_counts_as_current()
{
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
// ElectionTimeoutMaxMs = 100; window = 2 × 100 = 200 ms.
// Set LastContact to 150 ms ago — just inside the 200 ms window.
foreach (var (_, state) in leader.GetPeerStates())
state.LastContact = DateTime.UtcNow.AddMilliseconds(-150);
leader.HasQuorum().ShouldBeTrue();
}
[Fact]
public void HasQuorum_peer_just_outside_window_is_stale()
{
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
// ElectionTimeoutMaxMs = 100; window = 2 × 100 = 200 ms.
// Set LastContact to 500 ms ago — comfortably outside the 200 ms window.
foreach (var (_, state) in leader.GetPeerStates())
state.LastContact = DateTime.UtcNow.AddMilliseconds(-500);
leader.HasQuorum().ShouldBeFalse();
}
}

View File

@@ -1,366 +0,0 @@
using NATS.Server.Raft;
namespace NATS.Server.Tests.Raft;
/// <summary>
/// Tests for ReadIndexAsync() — linearizable reads via quorum confirmation (Gap 8.7).
///
/// ReadIndex avoids appending a log entry for reads (no log growth) while still
/// ensuring linearizability: the leader confirms it holds a quorum heartbeat before
/// returning the CommitIndex. A partitioned leader that can no longer reach a majority
/// will time out rather than serve a potentially stale read.
///
/// Go reference: raft.go — read-index optimisation (send AppendEntries with no payload
/// to verify quorum before responding to a linearizable client read).
/// </summary>
public class RaftReadIndexTests
{
// -- 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);
// Short timeouts so tests finish quickly without real async delays.
node.ElectionTimeoutMinMs = 10;
node.ElectionTimeoutMaxMs = 20;
}
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;
}
// -- Core ReadIndex tests --
// Go reference: raft.go read-index quorum confirmation — leader returns CommitIndex after quorum.
[Fact]
public async Task ReadIndexAsync_returns_commit_index_after_quorum()
{
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
// Commit a proposal so CommitIndex > 0.
await leader.ProposeAsync("cmd1", CancellationToken.None);
leader.CommitIndex.ShouldBeGreaterThan(0);
// ReadIndex should confirm quorum (all peers are registered and reachable)
// and return the current CommitIndex.
var readIndex = await leader.ReadIndexAsync(CancellationToken.None);
readIndex.ShouldBe(leader.CommitIndex);
}
// Go reference: raft.go — only the leader can answer linearizable reads.
[Fact]
public async Task ReadIndexAsync_fails_for_non_leader()
{
var (nodes, _) = CreateCluster(3);
_ = ElectLeader(nodes);
var follower = nodes[1];
follower.IsLeader.ShouldBeFalse();
var ex = await Should.ThrowAsync<InvalidOperationException>(
() => follower.ReadIndexAsync(CancellationToken.None).AsTask());
ex.Message.ShouldContain("Only the leader");
}
// Go reference: raft.go checkQuorum — partitioned leader times out on ReadIndex.
[Fact]
public async Task ReadIndexAsync_fails_without_quorum()
{
// Use a partitioned transport: the leader's peers are unreachable so heartbeat
// acks never arrive and HasQuorum() stays false.
var partitionedTransport = new PartitionedRaftTransport();
var leader = new RaftNode("leader", partitionedTransport);
var peer1 = new RaftNode("n2", partitionedTransport);
var peer2 = new RaftNode("n3", partitionedTransport);
partitionedTransport.Register(leader);
partitionedTransport.Register(peer1);
partitionedTransport.Register(peer2);
leader.ConfigureCluster([leader, peer1, peer2]);
leader.ElectionTimeoutMinMs = 10;
leader.ElectionTimeoutMaxMs = 20;
// Forcibly make the leader by direct role assignment via election.
leader.StartElection(3);
leader.ReceiveVote(new VoteResponse { Granted = true }, 3);
leader.IsLeader.ShouldBeTrue();
// Mark all peers' LastContact as stale so HasQuorum() returns false
// after a heartbeat round that produces no acks.
foreach (var (_, state) in leader.GetPeerStates())
state.LastContact = DateTime.UtcNow.AddMinutes(-5);
// With the partitioned transport, no heartbeat acks arrive. The poll loop will
// exhaust ElectionTimeoutMaxMs (20 ms) and then throw TimeoutException.
var ex = await Should.ThrowAsync<TimeoutException>(
() => leader.ReadIndexAsync(CancellationToken.None).AsTask());
ex.Message.ShouldContain("quorum could not be confirmed");
}
// Go reference: raft.go — single-node cluster is always quorum; ReadIndex is synchronous.
[Fact]
public async Task ReadIndexAsync_single_node_returns_immediately()
{
// Single-node cluster has no peers; self is always majority.
var transport = new InMemoryRaftTransport();
var node = new RaftNode("solo", transport);
node.ConfigureCluster([node]);
transport.Register(node);
node.ElectionTimeoutMinMs = 10;
node.ElectionTimeoutMaxMs = 20;
node.StartElection(1); // single-node quorum — becomes leader.
node.IsLeader.ShouldBeTrue();
// ReadIndex on a single-node cluster must return immediately (no heartbeat round).
var readIndex = await node.ReadIndexAsync(CancellationToken.None);
// CommitIndex is 0 before any proposals; ReadIndex returns 0.
readIndex.ShouldBe(node.CommitIndex);
}
// Go reference: raft.go — ReadIndex reflects the latest committed entries.
[Fact]
public async Task ReadIndexAsync_reflects_latest_commit()
{
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
// Submit several proposals to advance CommitIndex.
await leader.ProposeAsync("cmd1", CancellationToken.None);
await leader.ProposeAsync("cmd2", CancellationToken.None);
await leader.ProposeAsync("cmd3", CancellationToken.None);
var expectedCommitIndex = leader.CommitIndex;
expectedCommitIndex.ShouldBe(3);
// ReadIndex should return the current CommitIndex after quorum confirmation.
var readIndex = await leader.ReadIndexAsync(CancellationToken.None);
readIndex.ShouldBe(expectedCommitIndex);
}
// Go reference: raft.go read-index does not append a log entry — no log growth.
[Fact]
public async Task ReadIndexAsync_does_not_grow_log()
{
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
// Commit one proposal to give the log a known size.
await leader.ProposeAsync("cmd1", CancellationToken.None);
var logSizeBefore = leader.Log.Entries.Count;
// ReadIndex must NOT append any log entry.
await leader.ReadIndexAsync(CancellationToken.None);
var logSizeAfter = leader.Log.Entries.Count;
logSizeAfter.ShouldBe(logSizeBefore);
}
// Candidate node cannot serve reads — it has not confirmed quorum.
// Go reference: raft.go — only leader role can answer reads.
[Fact]
public async Task ReadIndexAsync_fails_for_candidate()
{
var (nodes, _) = CreateCluster(3);
// Force n1 into Candidate role without winning the election.
var candidate = nodes[0];
candidate.StartElection(3); // self-vote only; role = Candidate (needs 2 of 3).
candidate.Role.ShouldBe(RaftRole.Candidate);
var ex = await Should.ThrowAsync<InvalidOperationException>(
() => candidate.ReadIndexAsync(CancellationToken.None).AsTask());
ex.Message.ShouldContain("Only the leader");
}
// Follower cannot serve reads — only the leader has an authoritative CommitIndex.
// Go reference: raft.go — only leader can answer linearizable reads.
[Fact]
public async Task ReadIndexAsync_fails_for_follower()
{
var (nodes, _) = CreateCluster(3);
_ = ElectLeader(nodes);
var follower = nodes[2];
follower.Role.ShouldBe(RaftRole.Follower);
var ex = await Should.ThrowAsync<InvalidOperationException>(
() => follower.ReadIndexAsync(CancellationToken.None).AsTask());
ex.Message.ShouldContain("Only the leader");
}
// ReadIndex at CommitIndex=0 (before any proposals) is still valid — the leader
// confirms it is the current leader even before any entries are committed.
// Go reference: raft.go — read-index at term start (empty log).
[Fact]
public async Task ReadIndexAsync_at_zero_commit_index_returns_zero()
{
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
// No proposals yet — CommitIndex = 0.
leader.CommitIndex.ShouldBe(0);
var readIndex = await leader.ReadIndexAsync(CancellationToken.None);
readIndex.ShouldBe(0);
}
// Multiple concurrent ReadIndex calls all complete correctly.
// Go reference: raft.go — concurrent reads are safe because ReadIndex is idempotent.
[Fact]
public async Task ReadIndexAsync_multiple_calls_all_return_commit_index()
{
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
await leader.ProposeAsync("cmd", CancellationToken.None);
var expected = leader.CommitIndex;
// Issue three parallel ReadIndex calls.
var tasks = Enumerable.Range(0, 3)
.Select(_ => leader.ReadIndexAsync(CancellationToken.None).AsTask())
.ToArray();
var results = await Task.WhenAll(tasks);
foreach (var r in results)
r.ShouldBe(expected);
}
// After ReadIndex returns, the caller must only serve reads once AppliedIndex
// reaches the returned value. Verify this contract is held by ProposeAsync
// (which advances AppliedIndex to CommitIndex after quorum).
// Go reference: raft.go — caller waits for appliedIndex >= readIndex before responding.
[Fact]
public async Task ReadIndexAsync_caller_can_serve_read_when_applied_reaches_index()
{
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
await leader.ProposeAsync("data1", CancellationToken.None);
var readIndex = await leader.ReadIndexAsync(CancellationToken.None);
// After ProposeAsync, AppliedIndex == CommitIndex == readIndex.
// The caller is safe to serve the read.
leader.AppliedIndex.ShouldBeGreaterThanOrEqualTo(readIndex);
}
// ReadIndex in a 5-node cluster with a majority of reachable peers.
// Go reference: raft.go quorum calculation — majority of N+1 nodes required.
[Fact]
public async Task ReadIndexAsync_five_node_cluster_with_majority_quorum()
{
var (nodes, _) = CreateCluster(5);
var leader = ElectLeader(nodes);
await leader.ProposeAsync("five-node-cmd", CancellationToken.None);
var readIndex = await leader.ReadIndexAsync(CancellationToken.None);
readIndex.ShouldBe(leader.CommitIndex);
}
// Heartbeat acks from quorum peers update LastContact, making HasQuorum() true.
// Go reference: raft.go — heartbeat ACKs refresh peer freshness for quorum check.
[Fact]
public async Task ReadIndexAsync_heartbeat_updates_peer_last_contact()
{
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
// Stale peers — quorum would fail without the heartbeat round.
foreach (var (_, state) in leader.GetPeerStates())
state.LastContact = DateTime.UtcNow.AddMinutes(-5);
// ReadIndexAsync sends heartbeats which update LastContact for reachable peers.
// InMemoryRaftTransport delivers acks synchronously so quorum is restored
// immediately after the heartbeat call.
var readIndex = await leader.ReadIndexAsync(CancellationToken.None);
readIndex.ShouldBe(leader.CommitIndex);
// Verify peer states were refreshed by the heartbeat round.
var peerStates = leader.GetPeerStates().Values.ToList();
peerStates.ShouldAllBe(p => DateTime.UtcNow - p.LastContact < TimeSpan.FromSeconds(5));
}
}
/// <summary>
/// A transport that drops all heartbeat acks (simulating a partitioned leader).
/// AppendEntries and vote RPCs behave normally, but SendHeartbeatAsync delivers
/// no acks, so the leader's peer LastContact timestamps are never updated.
/// Go reference: raft.go network partition scenario.
/// </summary>
file sealed class PartitionedRaftTransport : 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);
foreach (var followerId in followerIds)
{
if (_nodes.TryGetValue(followerId, out var node))
{
node.ReceiveReplicatedEntry(entry);
results.Add(new AppendResult { FollowerId = followerId, Success = true });
}
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)
{
if (_nodes.TryGetValue(voterId, out var node))
return Task.FromResult(node.GrantVote(request.Term, candidateId));
return Task.FromResult(new VoteResponse { Granted = false });
}
public Task InstallSnapshotAsync(
string leaderId, string followerId, RaftSnapshot snapshot, CancellationToken ct)
=> Task.CompletedTask;
public Task SendTimeoutNowAsync(string leaderId, string targetId, ulong term, CancellationToken ct)
=> Task.CompletedTask;
/// <summary>
/// Simulates a network partition: heartbeats are lost and no acks are delivered.
/// The <paramref name="onAck"/> callback is never invoked.
/// </summary>
public Task SendHeartbeatAsync(
string leaderId, IReadOnlyList<string> followerIds, int term, Action<string> onAck, CancellationToken ct)
=> Task.CompletedTask; // acks dropped — leader stays isolated
}

View File

@@ -1,253 +0,0 @@
using NATS.Server.Raft;
namespace NATS.Server.Tests.Raft;
/// <summary>
/// Tests for B5: Snapshot Checkpoints and Log Compaction.
/// Go reference: raft.go:3200-3400 (CreateSnapshotCheckpoint), raft.go:3500-3700 (installSnapshot).
/// </summary>
public class RaftSnapshotCheckpointTests
{
// -- Helpers --
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;
}
// -- RaftSnapshotCheckpoint type tests --
[Fact]
public void Checkpoint_creation_with_data()
{
var checkpoint = new RaftSnapshotCheckpoint
{
SnapshotIndex = 10,
SnapshotTerm = 2,
Data = [1, 2, 3, 4, 5],
};
checkpoint.SnapshotIndex.ShouldBe(10);
checkpoint.SnapshotTerm.ShouldBe(2);
checkpoint.Data.Length.ShouldBe(5);
checkpoint.IsComplete.ShouldBeFalse();
}
[Fact]
public void Chunk_assembly_single_chunk()
{
var checkpoint = new RaftSnapshotCheckpoint
{
SnapshotIndex = 5,
SnapshotTerm = 1,
};
checkpoint.AddChunk([10, 20, 30]);
var result = checkpoint.Assemble();
result.Length.ShouldBe(3);
result[0].ShouldBe((byte)10);
result[1].ShouldBe((byte)20);
result[2].ShouldBe((byte)30);
checkpoint.IsComplete.ShouldBeTrue();
}
[Fact]
public void Chunk_assembly_multiple_chunks()
{
var checkpoint = new RaftSnapshotCheckpoint
{
SnapshotIndex = 5,
SnapshotTerm = 1,
};
checkpoint.AddChunk([1, 2]);
checkpoint.AddChunk([3, 4, 5]);
checkpoint.AddChunk([6]);
var result = checkpoint.Assemble();
result.Length.ShouldBe(6);
result.ShouldBe(new byte[] { 1, 2, 3, 4, 5, 6 });
checkpoint.IsComplete.ShouldBeTrue();
}
[Fact]
public void Chunk_assembly_empty_returns_data()
{
// When no chunks added, Assemble returns the initial Data property
var checkpoint = new RaftSnapshotCheckpoint
{
SnapshotIndex = 5,
SnapshotTerm = 1,
Data = [99, 100],
};
var result = checkpoint.Assemble();
result.ShouldBe(new byte[] { 99, 100 });
checkpoint.IsComplete.ShouldBeFalse(); // no chunks to assemble
}
// -- RaftLog.Compact tests --
[Fact]
public void CompactLog_removes_old_entries()
{
// Go reference: raft.go WAL compact
var log = new RaftLog();
log.Append(1, "cmd-1");
log.Append(1, "cmd-2");
log.Append(1, "cmd-3");
log.Append(2, "cmd-4");
log.Entries.Count.ShouldBe(4);
// Compact up to index 2 — entries 1 and 2 should be removed
log.Compact(2);
log.Entries.Count.ShouldBe(2);
log.Entries[0].Index.ShouldBe(3);
log.Entries[1].Index.ShouldBe(4);
}
[Fact]
public void CompactLog_updates_base_index()
{
var log = new RaftLog();
log.Append(1, "cmd-1");
log.Append(1, "cmd-2");
log.Append(1, "cmd-3");
log.BaseIndex.ShouldBe(0);
log.Compact(2);
log.BaseIndex.ShouldBe(2);
}
[Fact]
public void CompactLog_with_no_entries_is_noop()
{
var log = new RaftLog();
log.Entries.Count.ShouldBe(0);
log.BaseIndex.ShouldBe(0);
// Should not throw or change anything
log.Compact(5);
log.Entries.Count.ShouldBe(0);
log.BaseIndex.ShouldBe(0);
}
[Fact]
public void CompactLog_preserves_append_indexing()
{
// After compaction, new appends should continue from the correct index
var log = new RaftLog();
log.Append(1, "cmd-1");
log.Append(1, "cmd-2");
log.Append(1, "cmd-3");
log.Compact(2);
log.BaseIndex.ShouldBe(2);
// New entry should get index 4 (baseIndex 2 + 1 remaining entry + 1)
var newEntry = log.Append(2, "cmd-4");
newEntry.Index.ShouldBe(4);
}
// -- Streaming snapshot install on RaftNode --
[Fact]
public async Task Streaming_snapshot_install_from_chunks()
{
// Go reference: raft.go:3500-3700 (installSnapshot with chunked transfer)
var node = new RaftNode("n1");
node.Log.Append(1, "cmd-1");
node.Log.Append(1, "cmd-2");
node.Log.Append(1, "cmd-3");
byte[][] chunks = [[1, 2, 3], [4, 5, 6]];
await node.InstallSnapshotFromChunksAsync(chunks, snapshotIndex: 10, snapshotTerm: 3, default);
// Log should be replaced (entries cleared, base index set to snapshot)
node.Log.Entries.Count.ShouldBe(0);
node.AppliedIndex.ShouldBe(10);
node.CommitIndex.ShouldBe(10);
}
[Fact]
public async Task Log_after_compaction_starts_at_correct_index()
{
// After snapshot + compaction, new entries should continue from the right index
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
await leader.ProposeAsync("cmd-1", default);
await leader.ProposeAsync("cmd-2", default);
await leader.ProposeAsync("cmd-3", default);
leader.Log.Entries.Count.ShouldBe(3);
// Create snapshot at current applied index and compact
var snapshot = await leader.CreateSnapshotCheckpointAsync(default);
snapshot.LastIncludedIndex.ShouldBe(leader.AppliedIndex);
// Log should now be empty (all entries covered by snapshot)
leader.Log.Entries.Count.ShouldBe(0);
leader.Log.BaseIndex.ShouldBe(leader.AppliedIndex);
// New entries should continue from the right index
var index4 = await leader.ProposeAsync("cmd-4", default);
index4.ShouldBe(leader.AppliedIndex); // should be appliedIndex after new propose
leader.Log.Entries.Count.ShouldBe(1);
}
// -- CompactLogAsync on RaftNode --
[Fact]
public async Task CompactLogAsync_compacts_up_to_applied_index()
{
var (nodes, _) = CreateCluster(3);
var leader = ElectLeader(nodes);
await leader.ProposeAsync("cmd-1", default);
await leader.ProposeAsync("cmd-2", default);
await leader.ProposeAsync("cmd-3", default);
leader.Log.Entries.Count.ShouldBe(3);
var appliedIndex = leader.AppliedIndex;
appliedIndex.ShouldBeGreaterThan(0);
await leader.CompactLogAsync(default);
// All entries up to applied index should be compacted
leader.Log.BaseIndex.ShouldBe(appliedIndex);
leader.Log.Entries.Count.ShouldBe(0);
}
[Fact]
public async Task CompactLogAsync_noop_when_nothing_applied()
{
var node = new RaftNode("n1");
node.AppliedIndex.ShouldBe(0);
// Should be a no-op — nothing to compact
await node.CompactLogAsync(default);
node.Log.BaseIndex.ShouldBe(0);
node.Log.Entries.Count.ShouldBe(0);
}
}

View File

@@ -1,288 +0,0 @@
using System.IO.Hashing;
using NATS.Server.Raft;
namespace NATS.Server.Tests.Raft;
/// <summary>
/// Tests for Gap 8.3: chunk-based snapshot streaming with CRC32 validation.
///
/// Covers <see cref="SnapshotChunkEnumerator"/> enumeration/CRC behaviour and
/// the <see cref="RaftNode.InstallSnapshotFromChunksAsync"/> CRC validation path.
///
/// Go reference: raft.go:3500-3700 (installSnapshot chunked transfer + CRC validation)
/// </summary>
public class RaftSnapshotStreamingTests
{
// -----------------------------------------------------------------------
// SnapshotChunkEnumerator tests
// -----------------------------------------------------------------------
[Fact]
public void SnapshotChunkEnumerator_yields_correct_chunk_count()
{
// 200 KB of data at 64 KB per chunk → ceil(200/64) = 4 chunks
// Go reference: raft.go snapshotChunkSize chunking logic
const int dataSize = 200 * 1024;
var data = new byte[dataSize];
Random.Shared.NextBytes(data);
var enumerator = new SnapshotChunkEnumerator(data, chunkSize: 65536);
var chunks = enumerator.ToList();
chunks.Count.ShouldBe(4);
enumerator.ChunkCount.ShouldBe(4);
}
[Fact]
public void SnapshotChunkEnumerator_chunks_reassemble_to_original()
{
// Concatenating all chunks must reproduce the original byte array exactly
// Go reference: raft.go installSnapshot chunk reassembly
const int dataSize = 150 * 1024; // 150 KB → 3 chunks at 64 KB
var original = new byte[dataSize];
Random.Shared.NextBytes(original);
var enumerator = new SnapshotChunkEnumerator(original, chunkSize: 65536);
var assembled = enumerator.SelectMany(c => c).ToArray();
assembled.Length.ShouldBe(original.Length);
assembled.ShouldBe(original);
}
[Fact]
public void SnapshotChunkEnumerator_crc32_matches()
{
// The CRC32 reported by the enumerator must equal the CRC32 computed
// directly over the original data — proving it covers the full payload
// Go reference: raft.go installSnapshot CRC32 computation
var data = new byte[100 * 1024];
Random.Shared.NextBytes(data);
var enumerator = new SnapshotChunkEnumerator(data, chunkSize: 65536);
var expectedCrc = new Crc32();
expectedCrc.Append(data);
var expected = expectedCrc.GetCurrentHashAsUInt32();
enumerator.Crc32Value.ShouldBe(expected);
}
[Fact]
public void SnapshotChunkEnumerator_single_chunk_for_small_data()
{
// Data that fits in a single chunk — only one chunk should be yielded,
// and it should be identical to the input
// Go reference: raft.go installSnapshot — single-chunk case
var data = new byte[] { 1, 2, 3, 4, 5, 10, 20, 30 };
var enumerator = new SnapshotChunkEnumerator(data, chunkSize: 65536);
var chunks = enumerator.ToList();
chunks.Count.ShouldBe(1);
enumerator.ChunkCount.ShouldBe(1);
chunks[0].ShouldBe(data);
}
[Fact]
public void SnapshotChunkEnumerator_last_chunk_is_remainder()
{
// 10 bytes with chunk size 3 → chunks of [3, 3, 3, 1]
var data = Enumerable.Range(0, 10).Select(i => (byte)i).ToArray();
var enumerator = new SnapshotChunkEnumerator(data, chunkSize: 3);
var chunks = enumerator.ToList();
chunks.Count.ShouldBe(4);
chunks[0].Length.ShouldBe(3);
chunks[1].Length.ShouldBe(3);
chunks[2].Length.ShouldBe(3);
chunks[3].Length.ShouldBe(1); // remainder
chunks[3][0].ShouldBe((byte)9);
}
[Fact]
public void SnapshotChunkEnumerator_crc32_is_stable_across_multiple_reads()
{
// CRC32Value must return the same value on every call (cached)
var data = new byte[1024];
Random.Shared.NextBytes(data);
var enumerator = new SnapshotChunkEnumerator(data, chunkSize: 256);
var first = enumerator.Crc32Value;
var second = enumerator.Crc32Value;
var third = enumerator.Crc32Value;
second.ShouldBe(first);
third.ShouldBe(first);
}
// -----------------------------------------------------------------------
// InstallSnapshotFromChunksAsync CRC32 validation tests
// -----------------------------------------------------------------------
[Fact]
public async Task InstallSnapshot_assembles_chunks_into_snapshot()
{
// Snapshot assembled from multiple chunks should produce the correct
// LastIncludedIndex, LastIncludedTerm, and Data on the node.
// Go reference: raft.go:3500-3700 installSnapshot
var node = new RaftNode("n1");
var data = new byte[] { 10, 20, 30, 40, 50 };
var enumerator = new SnapshotChunkEnumerator(data, chunkSize: 2);
var chunks = enumerator.ToList();
await node.InstallSnapshotFromChunksAsync(
chunks,
snapshotIndex: 42,
snapshotTerm: 7,
ct: default);
node.AppliedIndex.ShouldBe(42);
node.CommitIndex.ShouldBe(42);
}
[Fact]
public async Task InstallSnapshot_validates_crc32_success()
{
// When the correct CRC32 is supplied the install should complete without error.
// Go reference: raft.go installSnapshot CRC validation
var node = new RaftNode("n1");
var data = new byte[256];
Random.Shared.NextBytes(data);
var enumerator = new SnapshotChunkEnumerator(data, chunkSize: 64);
var crc = enumerator.Crc32Value;
var chunks = enumerator.ToList();
// Should not throw
await node.InstallSnapshotFromChunksAsync(
chunks,
snapshotIndex: 10,
snapshotTerm: 2,
ct: default,
expectedCrc32: crc);
node.AppliedIndex.ShouldBe(10);
}
[Fact]
public async Task InstallSnapshot_validates_crc32_throws_on_mismatch()
{
// A wrong CRC32 must cause InvalidDataException before any state is mutated.
// Go reference: raft.go installSnapshot CRC mismatch → abort
var node = new RaftNode("n1");
node.Log.Append(1, "cmd-1"); // pre-existing state
var data = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 };
var chunks = new[] { data }; // single chunk
const uint wrongCrc = 0xDEADBEEF;
var ex = await Should.ThrowAsync<InvalidDataException>(async () =>
await node.InstallSnapshotFromChunksAsync(
chunks,
snapshotIndex: 99,
snapshotTerm: 5,
ct: default,
expectedCrc32: wrongCrc));
ex.Message.ShouldContain("CRC32");
ex.Message.ShouldContain("DEADBEEF");
// State must NOT have been mutated since CRC failed before any writes
node.AppliedIndex.ShouldBe(0);
node.CommitIndex.ShouldBe(0);
}
[Fact]
public async Task InstallSnapshot_no_crc_parameter_installs_without_validation()
{
// When expectedCrc32 is omitted (null), no validation occurs and any data installs.
// Go reference: raft.go optional CRC field (backward compat)
var node = new RaftNode("n1");
var chunks = new[] { new byte[] { 7, 8, 9 } };
// Should not throw even with no CRC supplied
await node.InstallSnapshotFromChunksAsync(
chunks,
snapshotIndex: 5,
snapshotTerm: 1,
ct: default);
node.AppliedIndex.ShouldBe(5);
node.CommitIndex.ShouldBe(5);
}
// -----------------------------------------------------------------------
// RaftInstallSnapshotChunkWire encode/decode roundtrip tests
// -----------------------------------------------------------------------
[Fact]
public void SnapshotChunkWire_roundtrip_with_data()
{
// Encode and decode a chunk message and verify all fields survive the roundtrip.
// Go reference: raft.go wire format for InstallSnapshot RPC chunks
var payload = new byte[] { 0xAA, 0xBB, 0xCC, 0xDD };
var wire = new RaftInstallSnapshotChunkWire(
SnapshotIndex: 42UL,
SnapshotTerm: 3U,
ChunkIndex: 1U,
TotalChunks: 4U,
Crc32: 0x12345678U,
Data: payload);
var encoded = wire.Encode();
var decoded = RaftInstallSnapshotChunkWire.Decode(encoded);
decoded.SnapshotIndex.ShouldBe(42UL);
decoded.SnapshotTerm.ShouldBe(3U);
decoded.ChunkIndex.ShouldBe(1U);
decoded.TotalChunks.ShouldBe(4U);
decoded.Crc32.ShouldBe(0x12345678U);
decoded.Data.ShouldBe(payload);
}
[Fact]
public void SnapshotChunkWire_header_length_is_24_bytes()
{
// Header must be exactly 24 bytes as documented in the wire format.
RaftInstallSnapshotChunkWire.HeaderLen.ShouldBe(24);
}
[Fact]
public void SnapshotChunkWire_encode_total_length_is_header_plus_data()
{
var data = new byte[100];
var wire = new RaftInstallSnapshotChunkWire(1UL, 1U, 0U, 1U, 0U, data);
wire.Encode().Length.ShouldBe(RaftInstallSnapshotChunkWire.HeaderLen + 100);
}
[Fact]
public void SnapshotChunkWire_decode_throws_on_short_buffer()
{
// Buffers shorter than the header should throw ArgumentException
var tooShort = new byte[10]; // < 24 bytes
Should.Throw<ArgumentException>(() => RaftInstallSnapshotChunkWire.Decode(tooShort));
}
[Fact]
public void SnapshotChunkWire_roundtrip_empty_payload()
{
// A header-only message (no chunk data) should encode and decode cleanly.
var wire = new RaftInstallSnapshotChunkWire(
SnapshotIndex: 0UL,
SnapshotTerm: 0U,
ChunkIndex: 0U,
TotalChunks: 0U,
Crc32: 0U,
Data: []);
var encoded = wire.Encode();
encoded.Length.ShouldBe(RaftInstallSnapshotChunkWire.HeaderLen);
var decoded = RaftInstallSnapshotChunkWire.Decode(encoded);
decoded.Data.Length.ShouldBe(0);
}
}

View File

@@ -1,425 +0,0 @@
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

@@ -1,15 +0,0 @@
namespace NATS.Server.Tests;
public class RaftSnapshotTransferRuntimeParityTests
{
[Fact]
public async Task Raft_snapshot_install_catches_up_lagging_follower()
{
var cluster = RaftTestCluster.Create(3);
await cluster.GenerateCommittedEntriesAsync(3);
await cluster.RestartLaggingFollowerAsync();
await cluster.WaitForFollowerCatchupAsync();
cluster.LaggingFollower.AppliedIndex.ShouldBeGreaterThan(0);
}
}

View File

@@ -1,50 +0,0 @@
using NATS.Server.Raft;
namespace NATS.Server.Tests.Raft;
public class RaftStrictConsensusRuntimeTests
{
[Fact]
public async Task Quorum_and_nextindex_rules_gate_commit_visibility_and_snapshot_catchup_convergence()
{
var voter = new RaftNode("v1");
voter.GrantVote(2, "cand-a").Granted.ShouldBeTrue();
voter.GrantVote(2, "cand-b").Granted.ShouldBeFalse();
var transport = new RejectingRaftTransport();
var leader = new RaftNode("n1", transport);
var followerA = new RaftNode("n2", transport);
var followerB = new RaftNode("n3", transport);
var cluster = new[] { leader, followerA, followerB };
foreach (var node in cluster)
node.ConfigureCluster(cluster);
leader.StartElection(cluster.Length);
leader.ReceiveVote(new VoteResponse { Granted = true }, cluster.Length);
leader.IsLeader.ShouldBeTrue();
_ = await leader.ProposeAsync("cmd-1", default);
leader.AppliedIndex.ShouldBe(0);
followerA.AppliedIndex.ShouldBe(0);
followerB.AppliedIndex.ShouldBe(0);
}
private sealed class RejectingRaftTransport : 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 = true });
public Task InstallSnapshotAsync(string leaderId, string followerId, RaftSnapshot snapshot, CancellationToken ct)
=> Task.CompletedTask;
public Task SendTimeoutNowAsync(string leaderId, string targetId, ulong term, CancellationToken ct)
=> Task.CompletedTask;
public Task SendHeartbeatAsync(string leaderId, IReadOnlyList<string> followerIds, int term, Action<string> onAck, CancellationToken ct)
=> Task.CompletedTask;
}
}

View File

@@ -1,33 +0,0 @@
using NATS.Server.Raft;
namespace NATS.Server.Tests.Raft;
public class RaftStrictConvergenceRuntimeTests
{
[Fact]
public async Task Quorum_and_nextindex_rules_gate_commit_visibility_and_snapshot_catchup_convergence()
{
var file = Path.Combine(Path.GetTempPath(), $"nats-raft-snapshot-{Guid.NewGuid():N}.json");
try
{
var first = new RaftSnapshotStore(file);
await first.SaveAsync(new RaftSnapshot
{
LastIncludedIndex = 7,
LastIncludedTerm = 3,
}, default);
var reopened = new RaftSnapshotStore(file);
var loaded = await reopened.LoadAsync(default);
loaded.ShouldNotBeNull();
loaded.LastIncludedIndex.ShouldBe(7);
loaded.LastIncludedTerm.ShouldBe(3);
}
finally
{
if (File.Exists(file))
File.Delete(file);
}
}
}

View File

@@ -1,155 +0,0 @@
using NATS.Server.Raft;
namespace NATS.Server.Tests.Raft;
/// <summary>
/// Verifies that RaftSubjects produces the exact $NRG.* subject strings
/// defined in Go's raft.go constants.
///
/// Go reference: golang/nats-server/server/raft.go:2161-2169
/// raftAllSubj = "$NRG.>"
/// raftVoteSubj = "$NRG.V.%s"
/// raftAppendSubj = "$NRG.AE.%s"
/// raftPropSubj = "$NRG.P.%s"
/// raftRemovePeerSubj = "$NRG.RP.%s"
/// raftReply = "$NRG.R.%s"
/// raftCatchupReply = "$NRG.CR.%s"
/// </summary>
public class RaftSubjectsTests
{
// Go: server/raft.go:2162 — raftAllSubj = "$NRG.>"
[Fact]
public void All_constant_matches_go_raftAllSubj()
{
RaftSubjects.All.ShouldBe("$NRG.>");
}
// Go: server/raft.go:2163 — raftVoteSubj = "$NRG.V.%s"
[Fact]
public void Vote_formats_subject_with_group()
{
RaftSubjects.Vote("mygroup").ShouldBe("$NRG.V.mygroup");
}
// Go: server/raft.go:2163 — fmt.Sprintf(raftVoteSubj, n.group)
[Fact]
public void Vote_uses_group_verbatim()
{
RaftSubjects.Vote("meta").ShouldBe("$NRG.V.meta");
RaftSubjects.Vote("stream-A").ShouldBe("$NRG.V.stream-A");
RaftSubjects.Vote("_raft_").ShouldBe("$NRG.V._raft_");
}
// Go: server/raft.go:2164 — raftAppendSubj = "$NRG.AE.%s"
[Fact]
public void AppendEntry_formats_subject_with_group()
{
RaftSubjects.AppendEntry("mygroup").ShouldBe("$NRG.AE.mygroup");
}
// Go: server/raft.go:2164 — fmt.Sprintf(raftAppendSubj, n.group)
[Fact]
public void AppendEntry_uses_group_verbatim()
{
RaftSubjects.AppendEntry("meta").ShouldBe("$NRG.AE.meta");
RaftSubjects.AppendEntry("stream-B").ShouldBe("$NRG.AE.stream-B");
}
// Go: server/raft.go:2165 — raftPropSubj = "$NRG.P.%s"
[Fact]
public void Proposal_formats_subject_with_group()
{
RaftSubjects.Proposal("mygroup").ShouldBe("$NRG.P.mygroup");
}
// Go: server/raft.go:2165 — fmt.Sprintf(raftPropSubj, n.group)
[Fact]
public void Proposal_uses_group_verbatim()
{
RaftSubjects.Proposal("meta").ShouldBe("$NRG.P.meta");
RaftSubjects.Proposal("consumer-1").ShouldBe("$NRG.P.consumer-1");
}
// Go: server/raft.go:2166 — raftRemovePeerSubj = "$NRG.RP.%s"
[Fact]
public void RemovePeer_formats_subject_with_group()
{
RaftSubjects.RemovePeer("mygroup").ShouldBe("$NRG.RP.mygroup");
}
// Go: server/raft.go:2166 — fmt.Sprintf(raftRemovePeerSubj, n.group)
[Fact]
public void RemovePeer_uses_group_verbatim()
{
RaftSubjects.RemovePeer("meta").ShouldBe("$NRG.RP.meta");
RaftSubjects.RemovePeer("stream-C").ShouldBe("$NRG.RP.stream-C");
}
// Go: server/raft.go:2167 — raftReply = "$NRG.R.%s"
[Fact]
public void Reply_formats_subject_with_id()
{
RaftSubjects.Reply("abc123").ShouldBe("$NRG.R.abc123");
}
// Go: server/raft.go:2167 — fmt.Sprintf(raftReply, b[:])
[Fact]
public void Reply_uses_id_verbatim()
{
RaftSubjects.Reply("ABCDEFGH").ShouldBe("$NRG.R.ABCDEFGH");
RaftSubjects.Reply("00000001").ShouldBe("$NRG.R.00000001");
}
// Go: server/raft.go:2168 — raftCatchupReply = "$NRG.CR.%s"
[Fact]
public void CatchupReply_formats_subject_with_id()
{
RaftSubjects.CatchupReply("xyz789").ShouldBe("$NRG.CR.xyz789");
}
// Go: server/raft.go:2168 — fmt.Sprintf(raftCatchupReply, b[:])
[Fact]
public void CatchupReply_uses_id_verbatim()
{
RaftSubjects.CatchupReply("ABCDEFGH").ShouldBe("$NRG.CR.ABCDEFGH");
RaftSubjects.CatchupReply("00000001").ShouldBe("$NRG.CR.00000001");
}
// Verify that subjects for different groups are distinct (no collisions)
[Fact]
public void Subjects_for_different_groups_are_distinct()
{
RaftSubjects.Vote("group1").ShouldNotBe(RaftSubjects.Vote("group2"));
RaftSubjects.AppendEntry("group1").ShouldNotBe(RaftSubjects.AppendEntry("group2"));
RaftSubjects.Proposal("group1").ShouldNotBe(RaftSubjects.Proposal("group2"));
RaftSubjects.RemovePeer("group1").ShouldNotBe(RaftSubjects.RemovePeer("group2"));
}
// Verify that different verb subjects for the same group are distinct
[Fact]
public void Different_verbs_for_same_group_are_distinct()
{
var group = "meta";
var subjects = new[]
{
RaftSubjects.Vote(group),
RaftSubjects.AppendEntry(group),
RaftSubjects.Proposal(group),
RaftSubjects.RemovePeer(group),
};
subjects.Distinct().Count().ShouldBe(subjects.Length);
}
// All group subjects must be sub-subjects of the wildcard $NRG.>
[Fact]
public void All_group_subjects_are_under_NRG_namespace()
{
var group = "g";
RaftSubjects.Vote(group).ShouldStartWith("$NRG.");
RaftSubjects.AppendEntry(group).ShouldStartWith("$NRG.");
RaftSubjects.Proposal(group).ShouldStartWith("$NRG.");
RaftSubjects.RemovePeer(group).ShouldStartWith("$NRG.");
RaftSubjects.Reply("id").ShouldStartWith("$NRG.");
RaftSubjects.CatchupReply("id").ShouldStartWith("$NRG.");
}
}

View File

@@ -1,147 +0,0 @@
using NATS.Server.Raft;
// Go reference: server/raft.go (WAL binary format, compaction, CRC integrity)
namespace NATS.Server.Tests.Raft;
public class RaftWalTests : IDisposable
{
private readonly string _root;
public RaftWalTests()
{
_root = Path.Combine(Path.GetTempPath(), $"nats-wal-{Guid.NewGuid():N}");
Directory.CreateDirectory(_root);
}
public void Dispose()
{
if (Directory.Exists(_root))
Directory.Delete(_root, recursive: true);
}
// Go reference: server/raft.go WAL append + recover
[Fact]
public async Task Wal_persists_and_recovers_entries()
{
var walPath = Path.Combine(_root, "raft.wal");
// Write entries
{
using var wal = new RaftWal(walPath);
await wal.AppendAsync(new RaftLogEntry(1, 1, "cmd-1"));
await wal.AppendAsync(new RaftLogEntry(2, 1, "cmd-2"));
await wal.AppendAsync(new RaftLogEntry(3, 2, "cmd-3"));
await wal.SyncAsync();
}
// Recover
using var recovered = RaftWal.Load(walPath);
var entries = recovered.Entries;
entries.Count.ShouldBe(3);
entries[0].Index.ShouldBe(1);
entries[0].Term.ShouldBe(1);
entries[0].Command.ShouldBe("cmd-1");
entries[2].Index.ShouldBe(3);
entries[2].Term.ShouldBe(2);
}
// Go reference: server/raft.go compactLog
[Fact]
public async Task Wal_compact_removes_old_entries()
{
var walPath = Path.Combine(_root, "compact.wal");
using var wal = new RaftWal(walPath);
for (int i = 1; i <= 10; i++)
await wal.AppendAsync(new RaftLogEntry(i, 1, $"cmd-{i}"));
await wal.SyncAsync();
await wal.CompactAsync(5); // remove entries 1-5
using var recovered = RaftWal.Load(walPath);
recovered.Entries.Count.ShouldBe(5);
recovered.Entries.First().Index.ShouldBe(6);
}
// Go reference: server/raft.go WAL crash-truncation tolerance
[Fact]
public async Task Wal_handles_truncated_file()
{
var walPath = Path.Combine(_root, "truncated.wal");
{
using var wal = new RaftWal(walPath);
await wal.AppendAsync(new RaftLogEntry(1, 1, "good-entry"));
await wal.AppendAsync(new RaftLogEntry(2, 1, "will-be-truncated"));
await wal.SyncAsync();
}
// Truncate last few bytes to simulate crash
using (var fs = File.OpenWrite(walPath))
fs.SetLength(fs.Length - 3);
using var recovered = RaftWal.Load(walPath);
recovered.Entries.Count.ShouldBe(1);
recovered.Entries.First().Command.ShouldBe("good-entry");
}
// Go reference: server/raft.go storeMeta (term + votedFor persistence)
[Fact]
public async Task RaftNode_persists_term_and_vote()
{
var dir = Path.Combine(_root, "node-persist");
Directory.CreateDirectory(dir);
{
using var node = new RaftNode("n1", persistDirectory: dir);
node.TermState.CurrentTerm = 5;
node.TermState.VotedFor = "n2";
await node.PersistAsync(default);
}
using var recovered = new RaftNode("n1", persistDirectory: dir);
await recovered.LoadPersistedStateAsync(default);
recovered.Term.ShouldBe(5);
recovered.TermState.VotedFor.ShouldBe("n2");
}
// Go reference: server/raft.go WAL empty file edge case
[Fact]
public async Task Wal_empty_file_loads_no_entries()
{
var walPath = Path.Combine(_root, "empty.wal");
{
using var wal = new RaftWal(walPath);
await wal.SyncAsync();
}
using var recovered = RaftWal.Load(walPath);
recovered.Entries.Count.ShouldBe(0);
}
// Go reference: server/raft.go WAL CRC integrity check
[Fact]
public async Task Wal_crc_validates_record_integrity()
{
var walPath = Path.Combine(_root, "crc.wal");
{
using var wal = new RaftWal(walPath);
await wal.AppendAsync(new RaftLogEntry(1, 1, "valid"));
await wal.AppendAsync(new RaftLogEntry(2, 1, "also-valid"));
await wal.SyncAsync();
}
// Corrupt one byte in the tail of the file (inside the second record)
var bytes = File.ReadAllBytes(walPath);
bytes[^5] ^= 0xFF;
File.WriteAllBytes(walPath, bytes);
// Load should recover exactly the first record, stopping at the corrupt second
using var recovered = RaftWal.Load(walPath);
recovered.Entries.Count.ShouldBe(1);
recovered.Entries.First().Command.ShouldBe("valid");
}
}

View File

@@ -1,166 +0,0 @@
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));
}
}

View File

@@ -1,22 +0,0 @@
using NATS.Server.Raft;
namespace NATS.Server.Tests;
public class RaftConsensusAdvancedParityTests
{
[Fact]
public async Task Leader_heartbeats_keep_followers_current_and_next_index_backtracks_on_mismatch()
{
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: 2, default);
follower.Term.ShouldBe(2);
RaftReplicator.BacktrackNextIndex(5).ShouldBe(4);
RaftReplicator.BacktrackNextIndex(1).ShouldBe(1);
}
}

View File

@@ -1,82 +0,0 @@
using NATS.Server.Raft;
namespace NATS.Server.Tests;
public class RaftElectionTests
{
[Fact]
public async Task Candidate_becomes_leader_after_majority_votes()
{
var cluster = RaftTestCluster.Create(3);
var leader = await cluster.ElectLeaderAsync();
leader.Role.ShouldBe(RaftRole.Leader);
leader.Term.ShouldBe(1);
}
}
internal sealed class RaftTestCluster
{
public List<RaftNode> Nodes { get; }
public RaftNode Leader { get; private set; }
public RaftNode LaggingFollower { get; private set; }
private RaftTestCluster(List<RaftNode> nodes)
{
Nodes = nodes;
Leader = nodes[0];
LaggingFollower = nodes[^1];
}
public static RaftTestCluster Create(int nodes)
{
var created = Enumerable.Range(1, nodes).Select(i => new RaftNode($"n{i}")).ToList();
foreach (var node in created)
node.ConfigureCluster(created);
return new RaftTestCluster(created);
}
public Task<RaftNode> ElectLeaderAsync()
{
var candidate = Nodes[0];
candidate.StartElection(Nodes.Count);
foreach (var voter in Nodes.Skip(1))
candidate.ReceiveVote(voter.GrantVote(candidate.Term));
Leader = candidate;
return Task.FromResult(candidate);
}
public async Task WaitForAppliedAsync(long index)
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(2));
while (!timeout.IsCancellationRequested)
{
if (Nodes.All(n => n.AppliedIndex >= index))
return;
await Task.Delay(20, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
}
}
public async Task GenerateCommittedEntriesAsync(int count)
{
var leader = await ElectLeaderAsync();
for (int i = 0; i < count; i++)
_ = await leader.ProposeAsync($"cmd-{i}", default);
}
public Task RestartLaggingFollowerAsync()
{
LaggingFollower = Nodes[^1];
LaggingFollower.AppliedIndex = 0;
return Task.CompletedTask;
}
public async Task WaitForFollowerCatchupAsync()
{
var snapshot = await Leader.CreateSnapshotAsync(default);
await LaggingFollower.InstallSnapshotAsync(snapshot, default);
}
}

View File

@@ -1,19 +0,0 @@
using NATS.Server.Raft;
namespace NATS.Server.Tests;
public class RaftMembershipParityTests
{
[Fact]
public void Membership_changes_update_node_membership_state()
{
var node = new RaftNode("N1");
node.AddMember("N2");
node.AddMember("N3");
node.Members.ShouldContain("N2");
node.Members.ShouldContain("N3");
node.RemoveMember("N2");
node.Members.ShouldNotContain("N2");
}
}

View File

@@ -1,19 +0,0 @@
using NATS.Server.Raft;
namespace NATS.Server.Tests;
public class RaftReplicationTests
{
[Fact]
public async Task Leader_replicates_entry_to_quorum_and_applies()
{
var cluster = RaftTestCluster.Create(3);
var leader = await cluster.ElectLeaderAsync();
var idx = await leader.ProposeAsync("create-stream", default);
idx.ShouldBeGreaterThan(0);
await cluster.WaitForAppliedAsync(idx);
cluster.Nodes.All(n => n.AppliedIndex >= idx).ShouldBeTrue();
}
}

View File

@@ -1,19 +0,0 @@
using NATS.Server.Raft;
namespace NATS.Server.Tests;
public class RaftSafetyContractTests
{
[Fact]
public async Task Follower_rejects_stale_term_vote_and_append()
{
var node = new RaftNode("n1");
node.StartElection(clusterSize: 1);
var staleVote = node.GrantVote(term: node.Term - 1);
staleVote.Granted.ShouldBeFalse();
await Should.ThrowAsync<InvalidOperationException>(async () =>
await node.TryAppendFromLeaderAsync(new RaftLogEntry(1, node.Term - 1, "cmd"), default));
}
}

View File

@@ -1,16 +0,0 @@
namespace NATS.Server.Tests;
public class RaftSnapshotCatchupTests
{
[Fact]
public async Task Lagging_follower_catches_up_via_snapshot()
{
var cluster = RaftTestCluster.Create(3);
await cluster.GenerateCommittedEntriesAsync(500);
await cluster.RestartLaggingFollowerAsync();
await cluster.WaitForFollowerCatchupAsync();
cluster.LaggingFollower.AppliedIndex.ShouldBe(cluster.Leader.AppliedIndex);
}
}

View File

@@ -1,25 +0,0 @@
using NATS.Server.Raft;
namespace NATS.Server.Tests;
public class RaftSnapshotTransferParityTests
{
[Fact]
public async Task Snapshot_transfer_installs_snapshot_when_follower_falls_behind()
{
var transport = new InMemoryRaftTransport();
var leader = new RaftNode("L", transport);
var follower = new RaftNode("F", transport);
transport.Register(leader);
transport.Register(follower);
var snapshot = new RaftSnapshot
{
LastIncludedIndex = 10,
LastIncludedTerm = 3,
};
await transport.InstallSnapshotAsync("L", "F", snapshot, default);
follower.AppliedIndex.ShouldBe(10);
}
}

View File

@@ -1,89 +0,0 @@
using NATS.Server.Raft;
namespace NATS.Server.Tests;
public class RaftTransportPersistenceTests
{
[Fact]
public async Task Raft_node_recovers_log_and_term_after_restart()
{
await using var fx = await RaftFixture.StartPersistentClusterAsync();
var idx = await fx.Leader.ProposeAsync("cmd", default);
await fx.RestartNodeAsync("n2");
(await fx.ReadNodeAppliedIndexAsync("n2")).ShouldBeGreaterThanOrEqualTo(idx);
}
}
internal sealed class RaftFixture : IAsyncDisposable
{
private readonly string _root;
private readonly InMemoryRaftTransport _transport;
private readonly Dictionary<string, RaftNode> _nodes;
private RaftFixture(string root, InMemoryRaftTransport transport, Dictionary<string, RaftNode> nodes)
{
_root = root;
_transport = transport;
_nodes = nodes;
}
public RaftNode Leader => _nodes["n1"];
public static Task<RaftFixture> StartPersistentClusterAsync()
{
var root = Path.Combine(Path.GetTempPath(), $"nats-raft-{Guid.NewGuid():N}");
Directory.CreateDirectory(root);
var transport = new InMemoryRaftTransport();
var nodes = new Dictionary<string, RaftNode>(StringComparer.Ordinal);
foreach (var id in new[] { "n1", "n2", "n3" })
{
var node = new RaftNode(id, transport, Path.Combine(root, id));
transport.Register(node);
nodes[id] = node;
}
var all = nodes.Values.ToArray();
foreach (var node in all)
node.ConfigureCluster(all);
var leader = nodes["n1"];
leader.StartElection(all.Length);
leader.ReceiveVote(nodes["n2"].GrantVote(leader.Term), all.Length);
leader.ReceiveVote(nodes["n3"].GrantVote(leader.Term), all.Length);
return Task.FromResult(new RaftFixture(root, transport, nodes));
}
public async Task RestartNodeAsync(string id)
{
var nodePath = Path.Combine(_root, id);
var replacement = new RaftNode(id, _transport, nodePath);
await replacement.LoadPersistedStateAsync(default);
_transport.Register(replacement);
_nodes[id] = replacement;
var all = _nodes.Values.ToArray();
foreach (var node in all)
node.ConfigureCluster(all);
}
public Task<long> ReadNodeAppliedIndexAsync(string id)
{
return Task.FromResult(_nodes[id].AppliedIndex);
}
public ValueTask DisposeAsync()
{
try
{
if (Directory.Exists(_root))
Directory.Delete(_root, recursive: true);
}
catch
{
}
return ValueTask.CompletedTask;
}
}