- CommitQueue<T>: channel-based queue for committed entries awaiting state machine application - RaftPeerState: tracks replication and health state (nextIndex, matchIndex, lastContact) - RaftNode: CommitIndex/ProcessedIndex tracking, election timer with randomized 150-300ms interval, peer state integration with heartbeat and replication updates - 52 new tests across RaftApplyQueueTests, RaftElectionTimerTests, RaftHealthTests
257 lines
7.6 KiB
C#
257 lines
7.6 KiB
C#
using NATS.Server.Raft;
|
|
|
|
namespace NATS.Server.Tests.Raft;
|
|
|
|
/// <summary>
|
|
/// Tests for CommitQueue and commit/processed index tracking in RaftNode.
|
|
/// Go reference: raft.go:150-160 (applied/processed fields), raft.go:2100-2150 (ApplyQ).
|
|
/// </summary>
|
|
public class RaftApplyQueueTests
|
|
{
|
|
// -- Helpers --
|
|
|
|
private static (RaftNode[] nodes, InMemoryRaftTransport transport) CreateCluster(int size)
|
|
{
|
|
var transport = new InMemoryRaftTransport();
|
|
var nodes = Enumerable.Range(1, size)
|
|
.Select(i => new RaftNode($"n{i}", transport))
|
|
.ToArray();
|
|
foreach (var node in nodes)
|
|
{
|
|
transport.Register(node);
|
|
node.ConfigureCluster(nodes);
|
|
}
|
|
return (nodes, transport);
|
|
}
|
|
|
|
private static RaftNode ElectLeader(RaftNode[] nodes)
|
|
{
|
|
var candidate = nodes[0];
|
|
candidate.StartElection(nodes.Length);
|
|
foreach (var voter in nodes.Skip(1))
|
|
candidate.ReceiveVote(voter.GrantVote(candidate.Term, candidate.Id), nodes.Length);
|
|
return candidate;
|
|
}
|
|
|
|
// -- CommitQueue<T> unit tests --
|
|
|
|
[Fact]
|
|
public async Task Enqueue_and_dequeue_lifecycle()
|
|
{
|
|
var queue = new CommitQueue<RaftLogEntry>();
|
|
|
|
var entry = new RaftLogEntry(1, 1, "cmd-1");
|
|
await queue.EnqueueAsync(entry);
|
|
queue.Count.ShouldBe(1);
|
|
|
|
var dequeued = await queue.DequeueAsync();
|
|
dequeued.ShouldBe(entry);
|
|
queue.Count.ShouldBe(0);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Multiple_items_dequeue_in_fifo_order()
|
|
{
|
|
var queue = new CommitQueue<RaftLogEntry>();
|
|
|
|
var entry1 = new RaftLogEntry(1, 1, "cmd-1");
|
|
var entry2 = new RaftLogEntry(2, 1, "cmd-2");
|
|
var entry3 = new RaftLogEntry(3, 1, "cmd-3");
|
|
|
|
await queue.EnqueueAsync(entry1);
|
|
await queue.EnqueueAsync(entry2);
|
|
await queue.EnqueueAsync(entry3);
|
|
queue.Count.ShouldBe(3);
|
|
|
|
(await queue.DequeueAsync()).ShouldBe(entry1);
|
|
(await queue.DequeueAsync()).ShouldBe(entry2);
|
|
(await queue.DequeueAsync()).ShouldBe(entry3);
|
|
queue.Count.ShouldBe(0);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryDequeue_returns_false_when_empty()
|
|
{
|
|
var queue = new CommitQueue<RaftLogEntry>();
|
|
queue.TryDequeue(out var item).ShouldBeFalse();
|
|
item.ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TryDequeue_returns_true_when_item_available()
|
|
{
|
|
var queue = new CommitQueue<RaftLogEntry>();
|
|
var entry = new RaftLogEntry(1, 1, "cmd-1");
|
|
await queue.EnqueueAsync(entry);
|
|
|
|
queue.TryDequeue(out var item).ShouldBeTrue();
|
|
item.ShouldBe(entry);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Complete_prevents_further_enqueue()
|
|
{
|
|
var queue = new CommitQueue<RaftLogEntry>();
|
|
await queue.EnqueueAsync(new RaftLogEntry(1, 1, "cmd-1"));
|
|
queue.Complete();
|
|
|
|
// After completion, writing should throw ChannelClosedException
|
|
await Should.ThrowAsync<System.Threading.Channels.ChannelClosedException>(
|
|
async () => await queue.EnqueueAsync(new RaftLogEntry(2, 1, "cmd-2")));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Complete_allows_draining_remaining_items()
|
|
{
|
|
var queue = new CommitQueue<RaftLogEntry>();
|
|
var entry = new RaftLogEntry(1, 1, "cmd-1");
|
|
await queue.EnqueueAsync(entry);
|
|
queue.Complete();
|
|
|
|
// Should still be able to read remaining items
|
|
var dequeued = await queue.DequeueAsync();
|
|
dequeued.ShouldBe(entry);
|
|
}
|
|
|
|
[Fact]
|
|
public void Count_reflects_current_queue_depth()
|
|
{
|
|
var queue = new CommitQueue<RaftLogEntry>();
|
|
queue.Count.ShouldBe(0);
|
|
}
|
|
|
|
// -- RaftNode CommitIndex tracking tests --
|
|
|
|
[Fact]
|
|
public async Task CommitIndex_advances_when_proposal_succeeds_quorum()
|
|
{
|
|
var (nodes, _) = CreateCluster(3);
|
|
var leader = ElectLeader(nodes);
|
|
leader.CommitIndex.ShouldBe(0);
|
|
|
|
var index1 = await leader.ProposeAsync("cmd-1", default);
|
|
leader.CommitIndex.ShouldBe(index1);
|
|
|
|
var index2 = await leader.ProposeAsync("cmd-2", default);
|
|
leader.CommitIndex.ShouldBe(index2);
|
|
index2.ShouldBeGreaterThan(index1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CommitIndex_starts_at_zero()
|
|
{
|
|
var node = new RaftNode("n1");
|
|
node.CommitIndex.ShouldBe(0);
|
|
await Task.CompletedTask;
|
|
}
|
|
|
|
// -- RaftNode ProcessedIndex tracking tests --
|
|
|
|
[Fact]
|
|
public void ProcessedIndex_starts_at_zero()
|
|
{
|
|
var node = new RaftNode("n1");
|
|
node.ProcessedIndex.ShouldBe(0);
|
|
}
|
|
|
|
[Fact]
|
|
public void MarkProcessed_advances_ProcessedIndex()
|
|
{
|
|
var node = new RaftNode("n1");
|
|
node.MarkProcessed(5);
|
|
node.ProcessedIndex.ShouldBe(5);
|
|
}
|
|
|
|
[Fact]
|
|
public void MarkProcessed_does_not_go_backward()
|
|
{
|
|
var node = new RaftNode("n1");
|
|
node.MarkProcessed(10);
|
|
node.MarkProcessed(5);
|
|
node.ProcessedIndex.ShouldBe(10);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ProcessedIndex_tracks_separately_from_CommitIndex()
|
|
{
|
|
var (nodes, _) = CreateCluster(3);
|
|
var leader = ElectLeader(nodes);
|
|
|
|
var index1 = await leader.ProposeAsync("cmd-1", default);
|
|
var index2 = await leader.ProposeAsync("cmd-2", default);
|
|
|
|
// CommitIndex should have advanced
|
|
leader.CommitIndex.ShouldBe(index2);
|
|
|
|
// ProcessedIndex stays at 0 until explicitly marked
|
|
leader.ProcessedIndex.ShouldBe(0);
|
|
|
|
// Simulate state machine processing one entry
|
|
leader.MarkProcessed(index1);
|
|
leader.ProcessedIndex.ShouldBe(index1);
|
|
|
|
// CommitIndex is still ahead of ProcessedIndex
|
|
leader.CommitIndex.ShouldBeGreaterThan(leader.ProcessedIndex);
|
|
|
|
// Process the second entry
|
|
leader.MarkProcessed(index2);
|
|
leader.ProcessedIndex.ShouldBe(index2);
|
|
leader.ProcessedIndex.ShouldBe(leader.CommitIndex);
|
|
}
|
|
|
|
// -- CommitQueue integration with RaftNode --
|
|
|
|
[Fact]
|
|
public async Task CommitQueue_receives_entries_after_successful_quorum()
|
|
{
|
|
var (nodes, _) = CreateCluster(3);
|
|
var leader = ElectLeader(nodes);
|
|
|
|
var index1 = await leader.ProposeAsync("cmd-1", default);
|
|
var index2 = await leader.ProposeAsync("cmd-2", default);
|
|
|
|
// CommitQueue should have 2 entries
|
|
leader.CommitQueue.Count.ShouldBe(2);
|
|
|
|
// Dequeue and verify order
|
|
var entry1 = await leader.CommitQueue.DequeueAsync();
|
|
entry1.Index.ShouldBe(index1);
|
|
entry1.Command.ShouldBe("cmd-1");
|
|
|
|
var entry2 = await leader.CommitQueue.DequeueAsync();
|
|
entry2.Index.ShouldBe(index2);
|
|
entry2.Command.ShouldBe("cmd-2");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CommitQueue_entries_match_committed_log_entries()
|
|
{
|
|
var (nodes, _) = CreateCluster(3);
|
|
var leader = ElectLeader(nodes);
|
|
|
|
await leader.ProposeAsync("alpha", default);
|
|
await leader.ProposeAsync("beta", default);
|
|
await leader.ProposeAsync("gamma", default);
|
|
|
|
// Drain the commit queue and verify entries match log
|
|
for (int i = 0; i < 3; i++)
|
|
{
|
|
var committed = await leader.CommitQueue.DequeueAsync();
|
|
committed.ShouldBe(leader.Log.Entries[i]);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Non_leader_proposal_throws_and_does_not_affect_commit_queue()
|
|
{
|
|
var node = new RaftNode("follower");
|
|
node.CommitQueue.Count.ShouldBe(0);
|
|
|
|
await Should.ThrowAsync<InvalidOperationException>(
|
|
async () => await node.ProposeAsync("cmd", default));
|
|
|
|
node.CommitQueue.Count.ShouldBe(0);
|
|
node.CommitIndex.ShouldBe(0);
|
|
}
|
|
}
|