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:
488
tests/NATS.Server.Raft.Tests/Raft/NatsRaftTransportTests.cs
Normal file
488
tests/NATS.Server.Raft.Tests/Raft/NatsRaftTransportTests.cs
Normal file
@@ -0,0 +1,488 @@
|
||||
using NATS.Server;
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.Raft;
|
||||
|
||||
namespace NATS.Server.Raft.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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace NATS.Server.Raft.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();
|
||||
}
|
||||
}
|
||||
188
tests/NATS.Server.Raft.Tests/Raft/RaftAppendEntryTests.cs
Normal file
188
tests/NATS.Server.Raft.Tests/Raft/RaftAppendEntryTests.cs
Normal file
@@ -0,0 +1,188 @@
|
||||
using System.Text.Json;
|
||||
using NATS.Server.Raft;
|
||||
|
||||
namespace NATS.Server.Raft.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);
|
||||
}
|
||||
}
|
||||
256
tests/NATS.Server.Raft.Tests/Raft/RaftApplyQueueTests.cs
Normal file
256
tests/NATS.Server.Raft.Tests/Raft/RaftApplyQueueTests.cs
Normal file
@@ -0,0 +1,256 @@
|
||||
using NATS.Server.Raft;
|
||||
|
||||
namespace NATS.Server.Raft.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);
|
||||
}
|
||||
}
|
||||
519
tests/NATS.Server.Raft.Tests/Raft/RaftBinaryWireFormatTests.cs
Normal file
519
tests/NATS.Server.Raft.Tests/Raft/RaftBinaryWireFormatTests.cs
Normal file
@@ -0,0 +1,519 @@
|
||||
using NATS.Server.Raft;
|
||||
|
||||
namespace NATS.Server.Raft.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);
|
||||
}
|
||||
}
|
||||
344
tests/NATS.Server.Raft.Tests/Raft/RaftCompactionPolicyTests.cs
Normal file
344
tests/NATS.Server.Raft.Tests/Raft/RaftCompactionPolicyTests.cs
Normal file
@@ -0,0 +1,344 @@
|
||||
using NATS.Server.Raft;
|
||||
|
||||
namespace NATS.Server.Raft.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 1–5: old (2 hours ago)
|
||||
for (var i = 1; i <= 5; i++)
|
||||
log.AppendWithTimestamp(term: 1, command: $"old-{i}", timestamp: old);
|
||||
|
||||
// Entries 6–10: 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using NATS.Server.Raft;
|
||||
|
||||
namespace NATS.Server.Raft.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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using NATS.Server.Raft;
|
||||
|
||||
namespace NATS.Server.Raft.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();
|
||||
}
|
||||
}
|
||||
180
tests/NATS.Server.Raft.Tests/Raft/RaftCoreTypeTests.cs
Normal file
180
tests/NATS.Server.Raft.Tests/Raft/RaftCoreTypeTests.cs
Normal file
@@ -0,0 +1,180 @@
|
||||
using System.Text.Json;
|
||||
using NATS.Server.Raft;
|
||||
|
||||
namespace NATS.Server.Raft.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);
|
||||
}
|
||||
}
|
||||
139
tests/NATS.Server.Raft.Tests/Raft/RaftElectionBasicTests.cs
Normal file
139
tests/NATS.Server.Raft.Tests/Raft/RaftElectionBasicTests.cs
Normal file
@@ -0,0 +1,139 @@
|
||||
using NATS.Server.Raft;
|
||||
|
||||
namespace NATS.Server.Raft.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);
|
||||
}
|
||||
}
|
||||
157
tests/NATS.Server.Raft.Tests/Raft/RaftElectionJitterTests.cs
Normal file
157
tests/NATS.Server.Raft.Tests/Raft/RaftElectionJitterTests.cs
Normal file
@@ -0,0 +1,157 @@
|
||||
using NATS.Server.Raft;
|
||||
|
||||
namespace NATS.Server.Raft.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
421
tests/NATS.Server.Raft.Tests/Raft/RaftElectionTests.cs
Normal file
421
tests/NATS.Server.Raft.Tests/Raft/RaftElectionTests.cs
Normal file
@@ -0,0 +1,421 @@
|
||||
using NATS.Server.Raft;
|
||||
|
||||
namespace NATS.Server.Raft.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();
|
||||
}
|
||||
}
|
||||
270
tests/NATS.Server.Raft.Tests/Raft/RaftElectionTimerTests.cs
Normal file
270
tests/NATS.Server.Raft.Tests/Raft/RaftElectionTimerTests.cs
Normal file
@@ -0,0 +1,270 @@
|
||||
using NATS.Server;
|
||||
using NATS.Server.Raft;
|
||||
|
||||
namespace NATS.Server.Raft.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]
|
||||
[SlopwatchSuppress("SW004", "Testing election timer reset requires real delays to verify timer does not fire prematurely")]
|
||||
public async Task 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++)
|
||||
{
|
||||
await Task.Delay(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]
|
||||
[SlopwatchSuppress("SW004", "Testing election timer expiry requires waiting longer than the configured timeout to observe state change")]
|
||||
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]
|
||||
[SlopwatchSuppress("SW004", "Testing that leaders ignore election timer requires waiting for timer expiry to confirm no state transition")]
|
||||
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]
|
||||
[SlopwatchSuppress("SW004", "Testing that candidates ignore election timer requires waiting for timer expiry to confirm no state transition")]
|
||||
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]
|
||||
[SlopwatchSuppress("SW004", "Testing heartbeat-driven timer reset requires real delays to simulate periodic heartbeat arrival")]
|
||||
public async Task 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++)
|
||||
{
|
||||
await Task.Delay(30);
|
||||
node.ReceiveHeartbeat(term: 1);
|
||||
}
|
||||
|
||||
// Should still be follower since heartbeats kept resetting the timer
|
||||
node.Role.ShouldBe(RaftRole.Follower);
|
||||
node.StopElectionTimer();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[SlopwatchSuppress("SW004", "Testing timer fires after heartbeats stop requires real delays for heartbeat simulation and timeout expiry")]
|
||||
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++)
|
||||
{
|
||||
await Task.Delay(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();
|
||||
}
|
||||
}
|
||||
1384
tests/NATS.Server.Raft.Tests/Raft/RaftGoParityTests.cs
Normal file
1384
tests/NATS.Server.Raft.Tests/Raft/RaftGoParityTests.cs
Normal file
File diff suppressed because it is too large
Load Diff
342
tests/NATS.Server.Raft.Tests/Raft/RaftHealthTests.cs
Normal file
342
tests/NATS.Server.Raft.Tests/Raft/RaftHealthTests.cs
Normal file
@@ -0,0 +1,342 @@
|
||||
using NATS.Server.Raft;
|
||||
|
||||
namespace NATS.Server.Raft.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);
|
||||
}
|
||||
}
|
||||
285
tests/NATS.Server.Raft.Tests/Raft/RaftJointConsensusTests.cs
Normal file
285
tests/NATS.Server.Raft.Tests/Raft/RaftJointConsensusTests.cs
Normal file
@@ -0,0 +1,285 @@
|
||||
using NATS.Server.Raft;
|
||||
|
||||
namespace NATS.Server.Raft.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();
|
||||
}
|
||||
}
|
||||
417
tests/NATS.Server.Raft.Tests/Raft/RaftLeadershipTransferTests.cs
Normal file
417
tests/NATS.Server.Raft.Tests/Raft/RaftLeadershipTransferTests.cs
Normal file
@@ -0,0 +1,417 @@
|
||||
using NATS.Server.Raft;
|
||||
|
||||
namespace NATS.Server.Raft.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;
|
||||
}
|
||||
606
tests/NATS.Server.Raft.Tests/Raft/RaftLogReplicationTests.cs
Normal file
606
tests/NATS.Server.Raft.Tests/Raft/RaftLogReplicationTests.cs
Normal file
@@ -0,0 +1,606 @@
|
||||
using NATS.Server.Raft;
|
||||
|
||||
namespace NATS.Server.Raft.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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
using NATS.Server.Raft;
|
||||
|
||||
namespace NATS.Server.Raft.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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using NATS.Server.Raft;
|
||||
|
||||
namespace NATS.Server.Raft.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");
|
||||
}
|
||||
}
|
||||
226
tests/NATS.Server.Raft.Tests/Raft/RaftMembershipTests.cs
Normal file
226
tests/NATS.Server.Raft.Tests/Raft/RaftMembershipTests.cs
Normal file
@@ -0,0 +1,226 @@
|
||||
using NATS.Server.Raft;
|
||||
|
||||
namespace NATS.Server.Raft.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");
|
||||
}
|
||||
}
|
||||
149
tests/NATS.Server.Raft.Tests/Raft/RaftNodeParityBatch2Tests.cs
Normal file
149
tests/NATS.Server.Raft.Tests/Raft/RaftNodeParityBatch2Tests.cs
Normal file
@@ -0,0 +1,149 @@
|
||||
using NATS.Server.Raft;
|
||||
|
||||
namespace NATS.Server.Raft.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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace NATS.Server.Raft.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();
|
||||
}
|
||||
}
|
||||
79
tests/NATS.Server.Raft.Tests/Raft/RaftParityBatch3Tests.cs
Normal file
79
tests/NATS.Server.Raft.Tests/Raft/RaftParityBatch3Tests.cs
Normal file
@@ -0,0 +1,79 @@
|
||||
using NATS.Server.Raft;
|
||||
|
||||
namespace NATS.Server.Raft.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;
|
||||
}
|
||||
}
|
||||
300
tests/NATS.Server.Raft.Tests/Raft/RaftPreVoteTests.cs
Normal file
300
tests/NATS.Server.Raft.Tests/Raft/RaftPreVoteTests.cs
Normal file
@@ -0,0 +1,300 @@
|
||||
using NATS.Server.Raft;
|
||||
|
||||
namespace NATS.Server.Raft.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();
|
||||
}
|
||||
}
|
||||
282
tests/NATS.Server.Raft.Tests/Raft/RaftQuorumCheckTests.cs
Normal file
282
tests/NATS.Server.Raft.Tests/Raft/RaftQuorumCheckTests.cs
Normal file
@@ -0,0 +1,282 @@
|
||||
using NATS.Server.Raft;
|
||||
|
||||
namespace NATS.Server.Raft.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();
|
||||
}
|
||||
}
|
||||
366
tests/NATS.Server.Raft.Tests/Raft/RaftReadIndexTests.cs
Normal file
366
tests/NATS.Server.Raft.Tests/Raft/RaftReadIndexTests.cs
Normal file
@@ -0,0 +1,366 @@
|
||||
using NATS.Server.Raft;
|
||||
|
||||
namespace NATS.Server.Raft.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
|
||||
}
|
||||
253
tests/NATS.Server.Raft.Tests/Raft/RaftSnapshotCheckpointTests.cs
Normal file
253
tests/NATS.Server.Raft.Tests/Raft/RaftSnapshotCheckpointTests.cs
Normal file
@@ -0,0 +1,253 @@
|
||||
using NATS.Server.Raft;
|
||||
|
||||
namespace NATS.Server.Raft.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);
|
||||
}
|
||||
}
|
||||
288
tests/NATS.Server.Raft.Tests/Raft/RaftSnapshotStreamingTests.cs
Normal file
288
tests/NATS.Server.Raft.Tests/Raft/RaftSnapshotStreamingTests.cs
Normal file
@@ -0,0 +1,288 @@
|
||||
using System.IO.Hashing;
|
||||
using NATS.Server.Raft;
|
||||
|
||||
namespace NATS.Server.Raft.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);
|
||||
}
|
||||
}
|
||||
425
tests/NATS.Server.Raft.Tests/Raft/RaftSnapshotTests.cs
Normal file
425
tests/NATS.Server.Raft.Tests/Raft/RaftSnapshotTests.cs
Normal file
@@ -0,0 +1,425 @@
|
||||
using System.Text.Json;
|
||||
using NATS.Server.Raft;
|
||||
|
||||
namespace NATS.Server.Raft.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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace NATS.Server.Raft.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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using NATS.Server.Raft;
|
||||
|
||||
namespace NATS.Server.Raft.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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using NATS.Server.Raft;
|
||||
|
||||
namespace NATS.Server.Raft.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
155
tests/NATS.Server.Raft.Tests/Raft/RaftSubjectsTests.cs
Normal file
155
tests/NATS.Server.Raft.Tests/Raft/RaftSubjectsTests.cs
Normal file
@@ -0,0 +1,155 @@
|
||||
using NATS.Server.Raft;
|
||||
|
||||
namespace NATS.Server.Raft.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.");
|
||||
}
|
||||
}
|
||||
147
tests/NATS.Server.Raft.Tests/Raft/RaftWalTests.cs
Normal file
147
tests/NATS.Server.Raft.Tests/Raft/RaftWalTests.cs
Normal file
@@ -0,0 +1,147 @@
|
||||
using NATS.Server.Raft;
|
||||
|
||||
// Go reference: server/raft.go (WAL binary format, compaction, CRC integrity)
|
||||
|
||||
namespace NATS.Server.Raft.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");
|
||||
}
|
||||
}
|
||||
166
tests/NATS.Server.Raft.Tests/Raft/RaftWireFormatTests.cs
Normal file
166
tests/NATS.Server.Raft.Tests/Raft/RaftWireFormatTests.cs
Normal file
@@ -0,0 +1,166 @@
|
||||
using System.Text.Json;
|
||||
using NATS.Server.Raft;
|
||||
|
||||
namespace NATS.Server.Raft.Tests.Raft;
|
||||
|
||||
/// <summary>
|
||||
/// Wire format encoding/decoding tests for RAFT RPC contracts.
|
||||
/// Go: TestNRGAppendEntryEncode, TestNRGAppendEntryDecode in server/raft_test.go:82-152.
|
||||
/// The .NET implementation uses JSON serialization rather than binary encoding,
|
||||
/// so these tests validate JSON round-trip fidelity for all RPC types.
|
||||
/// </summary>
|
||||
public class RaftWireFormatTests
|
||||
{
|
||||
// Go: TestNRGAppendEntryEncode server/raft_test.go:82
|
||||
[Fact]
|
||||
public void VoteRequest_json_round_trip()
|
||||
{
|
||||
var original = new VoteRequest { Term = 5, CandidateId = "node-alpha" };
|
||||
var json = JsonSerializer.Serialize(original);
|
||||
json.ShouldNotBeNullOrWhiteSpace();
|
||||
|
||||
var decoded = JsonSerializer.Deserialize<VoteRequest>(json);
|
||||
decoded.ShouldNotBeNull();
|
||||
decoded.Term.ShouldBe(5);
|
||||
decoded.CandidateId.ShouldBe("node-alpha");
|
||||
}
|
||||
|
||||
// Go: TestNRGAppendEntryEncode server/raft_test.go:82
|
||||
[Fact]
|
||||
public void VoteResponse_json_round_trip()
|
||||
{
|
||||
var granted = new VoteResponse { Granted = true };
|
||||
var json = JsonSerializer.Serialize(granted);
|
||||
var decoded = JsonSerializer.Deserialize<VoteResponse>(json);
|
||||
decoded.ShouldNotBeNull();
|
||||
decoded.Granted.ShouldBeTrue();
|
||||
|
||||
var denied = new VoteResponse { Granted = false };
|
||||
var json2 = JsonSerializer.Serialize(denied);
|
||||
var decoded2 = JsonSerializer.Deserialize<VoteResponse>(json2);
|
||||
decoded2.ShouldNotBeNull();
|
||||
decoded2.Granted.ShouldBeFalse();
|
||||
}
|
||||
|
||||
// Go: TestNRGAppendEntryEncode server/raft_test.go:82
|
||||
[Fact]
|
||||
public void AppendResult_json_round_trip()
|
||||
{
|
||||
var original = new AppendResult { FollowerId = "f1", Success = true };
|
||||
var json = JsonSerializer.Serialize(original);
|
||||
var decoded = JsonSerializer.Deserialize<AppendResult>(json);
|
||||
decoded.ShouldNotBeNull();
|
||||
decoded.FollowerId.ShouldBe("f1");
|
||||
decoded.Success.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// Go: TestNRGAppendEntryEncode server/raft_test.go:82 — multiple entries
|
||||
[Fact]
|
||||
public void RaftLogEntry_batch_json_round_trip_preserves_order()
|
||||
{
|
||||
var entries = Enumerable.Range(1, 50)
|
||||
.Select(i => new RaftLogEntry(Index: i, Term: (i % 3) + 1, Command: $"op-{i}"))
|
||||
.ToList();
|
||||
|
||||
var json = JsonSerializer.Serialize(entries);
|
||||
var decoded = JsonSerializer.Deserialize<List<RaftLogEntry>>(json);
|
||||
|
||||
decoded.ShouldNotBeNull();
|
||||
decoded.Count.ShouldBe(50);
|
||||
|
||||
for (var i = 0; i < 50; i++)
|
||||
{
|
||||
decoded[i].Index.ShouldBe(i + 1);
|
||||
decoded[i].Term.ShouldBe((i + 1) % 3 + 1);
|
||||
decoded[i].Command.ShouldBe($"op-{i + 1}");
|
||||
}
|
||||
}
|
||||
|
||||
// Go: TestNRGAppendEntryEncode server/raft_test.go:82 — large payload
|
||||
[Fact]
|
||||
public void RaftLogEntry_large_command_round_trips()
|
||||
{
|
||||
var largeCommand = new string('x', 65536);
|
||||
var entry = new RaftLogEntry(Index: 1, Term: 1, Command: largeCommand);
|
||||
|
||||
var json = JsonSerializer.Serialize(entry);
|
||||
var decoded = JsonSerializer.Deserialize<RaftLogEntry>(json);
|
||||
|
||||
decoded.ShouldNotBeNull();
|
||||
decoded.Command.Length.ShouldBe(65536);
|
||||
decoded.Command.ShouldBe(largeCommand);
|
||||
}
|
||||
|
||||
// Go: TestNRGAppendEntryEncode server/raft_test.go:82 — snapshot marker
|
||||
[Fact]
|
||||
public void RaftSnapshot_json_round_trip()
|
||||
{
|
||||
var data = new byte[256];
|
||||
Random.Shared.NextBytes(data);
|
||||
|
||||
var snapshot = new RaftSnapshot
|
||||
{
|
||||
LastIncludedIndex = 999,
|
||||
LastIncludedTerm = 42,
|
||||
Data = data,
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(snapshot);
|
||||
var decoded = JsonSerializer.Deserialize<RaftSnapshot>(json);
|
||||
|
||||
decoded.ShouldNotBeNull();
|
||||
decoded.LastIncludedIndex.ShouldBe(999);
|
||||
decoded.LastIncludedTerm.ShouldBe(42);
|
||||
decoded.Data.ShouldBe(data);
|
||||
}
|
||||
|
||||
// Go: TestNRGAppendEntryEncode server/raft_test.go:82 — empty snapshot data
|
||||
[Fact]
|
||||
public void RaftSnapshot_empty_data_round_trips()
|
||||
{
|
||||
var snapshot = new RaftSnapshot
|
||||
{
|
||||
LastIncludedIndex = 10,
|
||||
LastIncludedTerm = 2,
|
||||
Data = [],
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(snapshot);
|
||||
var decoded = JsonSerializer.Deserialize<RaftSnapshot>(json);
|
||||
|
||||
decoded.ShouldNotBeNull();
|
||||
decoded.Data.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// Go: TestNRGAppendEntryEncode server/raft_test.go:82 — special characters
|
||||
[Fact]
|
||||
public void RaftLogEntry_special_characters_in_command_round_trips()
|
||||
{
|
||||
var commands = new[]
|
||||
{
|
||||
"hello\nworld",
|
||||
"tab\there",
|
||||
"quote\"inside",
|
||||
"backslash\\path",
|
||||
"unicode-\u00e9\u00e0\u00fc",
|
||||
"{\"nested\":\"json\"}",
|
||||
};
|
||||
|
||||
foreach (var cmd in commands)
|
||||
{
|
||||
var entry = new RaftLogEntry(Index: 1, Term: 1, Command: cmd);
|
||||
var json = JsonSerializer.Serialize(entry);
|
||||
var decoded = JsonSerializer.Deserialize<RaftLogEntry>(json);
|
||||
decoded.ShouldNotBeNull();
|
||||
decoded.Command.ShouldBe(cmd);
|
||||
}
|
||||
}
|
||||
|
||||
// Go: TestNRGAppendEntryDecode server/raft_test.go:125 — deserialization of malformed input
|
||||
[Fact]
|
||||
public void Malformed_json_returns_null_or_throws()
|
||||
{
|
||||
var badJson = "not-json-at-all";
|
||||
Should.Throw<JsonException>(() => JsonSerializer.Deserialize<RaftLogEntry>(badJson));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user