diff --git a/src/NATS.Server/Raft/RaftNode.cs b/src/NATS.Server/Raft/RaftNode.cs new file mode 100644 index 0000000..b49f005 --- /dev/null +++ b/src/NATS.Server/Raft/RaftNode.cs @@ -0,0 +1,56 @@ +namespace NATS.Server.Raft; + +public sealed class RaftNode +{ + private int _votesReceived; + + public string Id { get; } + public int Term => TermState.CurrentTerm; + public RaftRole Role { get; private set; } = RaftRole.Follower; + public RaftTermState TermState { get; } = new(); + public long AppliedIndex { get; set; } + + public RaftNode(string id) + { + Id = id; + } + + public void StartElection(int clusterSize) + { + Role = RaftRole.Candidate; + TermState.CurrentTerm++; + TermState.VotedFor = Id; + _votesReceived = 1; + TryBecomeLeader(clusterSize); + } + + public VoteResponse GrantVote(int term) + { + if (term < TermState.CurrentTerm) + return new VoteResponse { Granted = false }; + + TermState.CurrentTerm = term; + return new VoteResponse { Granted = true }; + } + + public void ReceiveVote(VoteResponse response, int clusterSize = 3) + { + if (!response.Granted) + return; + + _votesReceived++; + TryBecomeLeader(clusterSize); + } + + private void TryBecomeLeader(int clusterSize) + { + var quorum = (clusterSize / 2) + 1; + if (_votesReceived >= quorum) + Role = RaftRole.Leader; + } + + public void RequestStepDown() + { + Role = RaftRole.Follower; + } +} diff --git a/src/NATS.Server/Raft/RaftRpcContracts.cs b/src/NATS.Server/Raft/RaftRpcContracts.cs new file mode 100644 index 0000000..8bad073 --- /dev/null +++ b/src/NATS.Server/Raft/RaftRpcContracts.cs @@ -0,0 +1,12 @@ +namespace NATS.Server.Raft; + +public sealed class VoteRequest +{ + public int Term { get; init; } + public string CandidateId { get; init; } = string.Empty; +} + +public sealed class VoteResponse +{ + public bool Granted { get; init; } +} diff --git a/src/NATS.Server/Raft/RaftTermState.cs b/src/NATS.Server/Raft/RaftTermState.cs new file mode 100644 index 0000000..55d2229 --- /dev/null +++ b/src/NATS.Server/Raft/RaftTermState.cs @@ -0,0 +1,14 @@ +namespace NATS.Server.Raft; + +public sealed class RaftTermState +{ + public int CurrentTerm { get; set; } + public string? VotedFor { get; set; } +} + +public enum RaftRole +{ + Follower, + Candidate, + Leader, +} diff --git a/tests/NATS.Server.Tests/RaftElectionTests.cs b/tests/NATS.Server.Tests/RaftElectionTests.cs new file mode 100644 index 0000000..9925dab --- /dev/null +++ b/tests/NATS.Server.Tests/RaftElectionTests.cs @@ -0,0 +1,43 @@ +using NATS.Server.Raft; + +namespace NATS.Server.Tests; + +public class RaftElectionTests +{ + [Fact] + public async Task Candidate_becomes_leader_after_majority_votes() + { + var cluster = RaftTestCluster.Create(3); + var leader = await cluster.ElectLeaderAsync(); + + leader.Role.ShouldBe(RaftRole.Leader); + leader.Term.ShouldBe(1); + } +} + +internal sealed class RaftTestCluster +{ + public List Nodes { get; } + + private RaftTestCluster(List nodes) + { + Nodes = nodes; + } + + public static RaftTestCluster Create(int nodes) + { + var created = Enumerable.Range(1, nodes).Select(i => new RaftNode($"n{i}")).ToList(); + return new RaftTestCluster(created); + } + + public Task ElectLeaderAsync() + { + var candidate = Nodes[0]; + candidate.StartElection(Nodes.Count); + + foreach (var voter in Nodes.Skip(1)) + candidate.ReceiveVote(voter.GrantVote(candidate.Term)); + + return Task.FromResult(candidate); + } +}