using NATS.Server.Raft; namespace NATS.Server.Tests.Raft; /// /// Tests for CommitQueue and commit/processed index tracking in RaftNode. /// Go reference: raft.go:150-160 (applied/processed fields), raft.go:2100-2150 (ApplyQ). /// 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 unit tests -- [Fact] public async Task Enqueue_and_dequeue_lifecycle() { var queue = new CommitQueue(); 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(); 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(); queue.TryDequeue(out var item).ShouldBeFalse(); item.ShouldBeNull(); } [Fact] public async Task TryDequeue_returns_true_when_item_available() { var queue = new CommitQueue(); 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(); await queue.EnqueueAsync(new RaftLogEntry(1, 1, "cmd-1")); queue.Complete(); // After completion, writing should throw ChannelClosedException await Should.ThrowAsync( async () => await queue.EnqueueAsync(new RaftLogEntry(2, 1, "cmd-2"))); } [Fact] public async Task Complete_allows_draining_remaining_items() { var queue = new CommitQueue(); 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(); 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( async () => await node.ProposeAsync("cmd", default)); node.CommitQueue.Count.ShouldBe(0); node.CommitIndex.ShouldBe(0); } }