From f69f9b3220e999d37c6270062cd38324617a4a27 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 25 Feb 2026 08:59:36 -0500 Subject: [PATCH] feat: add RaftGroup lifecycle methods (Gap 2.9) Implement IsMember, SetPreferred, RemovePeer, AddPeer, CreateRaftGroup factory, IsUnderReplicated, and IsOverReplicated on RaftGroup. Add 22 RaftGroupLifecycleTests covering all helpers and quorum size calculation. --- .../Cluster/ClusterAssignmentTypes.cs | 74 +++++ .../Cluster/RaftGroupLifecycleTests.cs | 260 ++++++++++++++++++ 2 files changed, 334 insertions(+) create mode 100644 tests/NATS.Server.Tests/JetStream/Cluster/RaftGroupLifecycleTests.cs diff --git a/src/NATS.Server/JetStream/Cluster/ClusterAssignmentTypes.cs b/src/NATS.Server/JetStream/Cluster/ClusterAssignmentTypes.cs index b227cdc..a905392 100644 --- a/src/NATS.Server/JetStream/Cluster/ClusterAssignmentTypes.cs +++ b/src/NATS.Server/JetStream/Cluster/ClusterAssignmentTypes.cs @@ -28,6 +28,80 @@ public sealed class RaftGroup public int QuorumSize => (Peers.Count / 2) + 1; public bool HasQuorum(int ackCount) => ackCount >= QuorumSize; + + /// + /// True when the group has fewer peers than the desired replica count. + /// Go reference: jetstream_cluster.go missingPeers — len(rg.Peers) < DesiredReplicas. + /// + public bool IsUnderReplicated => HasDesiredReplicas && Peers.Count < DesiredReplicas; + + /// + /// True when the group has more peers than the desired replica count. + /// Go reference: jetstream_cluster.go extraPeers — len(rg.Peers) > DesiredReplicas. + /// + public bool IsOverReplicated => HasDesiredReplicas && Peers.Count > DesiredReplicas; + + /// + /// Returns true if the given peerId is a member of this group (case-sensitive). + /// Go reference: jetstream_cluster.go isMember helper. + /// + public bool IsMember(string peerId) => Peers.Contains(peerId, StringComparer.Ordinal); + + /// + /// Sets the preferred leader peer for this group. + /// Throws if peerId is not a member. + /// Go reference: jetstream_cluster.go setPreferred / rg.Preferred assignment. + /// + public void SetPreferred(string peerId) + { + if (!IsMember(peerId)) + throw new InvalidOperationException( + $"Cannot set preferred peer '{peerId}': not a member of RAFT group '{Name}'."); + Preferred = peerId; + } + + /// + /// Removes a peer from the group. If the removed peer was the preferred peer, + /// is cleared. Returns true if the peer was found and removed. + /// Go reference: jetstream_cluster.go removePeer. + /// + public bool RemovePeer(string peerId) + { + var removed = Peers.Remove(peerId); + if (removed && string.Equals(Preferred, peerId, StringComparison.Ordinal)) + Preferred = string.Empty; + return removed; + } + + /// + /// Adds a peer to the group if not already present. Returns true if the peer was added. + /// Go reference: jetstream_cluster.go addPeer / expandGroup. + /// + public bool AddPeer(string peerId) + { + if (IsMember(peerId)) + return false; + Peers.Add(peerId); + return true; + } + + /// + /// Factory method that uses to create a + /// with topology-aware peer selection and sets + /// to the requested replica count. + /// Go reference: jetstream_cluster.go createGroupForStream — calls selectPeerGroup then + /// assigns rg.DesiredReplicas = replicas. + /// + public static RaftGroup CreateRaftGroup( + string groupName, + int replicas, + IReadOnlyList availablePeers, + PlacementPolicy? policy = null) + { + var group = PlacementEngine.SelectPeerGroup(groupName, replicas, availablePeers, policy); + group.DesiredReplicas = replicas; + return group; + } } /// diff --git a/tests/NATS.Server.Tests/JetStream/Cluster/RaftGroupLifecycleTests.cs b/tests/NATS.Server.Tests/JetStream/Cluster/RaftGroupLifecycleTests.cs new file mode 100644 index 0000000..19cee88 --- /dev/null +++ b/tests/NATS.Server.Tests/JetStream/Cluster/RaftGroupLifecycleTests.cs @@ -0,0 +1,260 @@ +// Go parity: golang/nats-server/server/jetstream_cluster.go +// Covers: RaftGroup member management, peer add/remove/preferred, +// factory method via PlacementEngine, replication health properties, +// and quorum size calculation. +using NATS.Server.JetStream.Cluster; + +namespace NATS.Server.Tests.JetStream.Cluster; + +/// +/// Tests for RaftGroup lifecycle: membership helpers, factory method, +/// replication status properties, and quorum size. +/// Go reference: jetstream_cluster.go:154-163 raftGroup struct and peer management. +/// +public class RaftGroupLifecycleTests +{ + // --------------------------------------------------------------- + // IsMember — membership check + // Go reference: jetstream_cluster.go isMember helper + // --------------------------------------------------------------- + + [Fact] + public void IsMember_returns_true_for_existing_peer() + { + // Go reference: jetstream_cluster.go isMember — checks rg.Peers contains id + var group = new RaftGroup { Name = "test", Peers = ["peer-1", "peer-2", "peer-3"] }; + + group.IsMember("peer-2").ShouldBeTrue(); + } + + [Fact] + public void IsMember_returns_false_for_non_member() + { + // Go reference: jetstream_cluster.go isMember — returns false when not in Peers + var group = new RaftGroup { Name = "test", Peers = ["peer-1", "peer-2"] }; + + group.IsMember("peer-9").ShouldBeFalse(); + } + + // --------------------------------------------------------------- + // SetPreferred — assign preferred peer + // Go reference: jetstream_cluster.go setPreferred / rg.Preferred + // --------------------------------------------------------------- + + [Fact] + public void SetPreferred_sets_preferred_peer() + { + // Go reference: jetstream_cluster.go setPreferred — assigns rg.Preferred when member + var group = new RaftGroup { Name = "test", Peers = ["peer-1", "peer-2", "peer-3"] }; + + group.SetPreferred("peer-3"); + + group.Preferred.ShouldBe("peer-3"); + } + + [Fact] + public void SetPreferred_throws_for_non_member() + { + // Go reference: jetstream_cluster.go setPreferred — validates membership before setting + var group = new RaftGroup { Name = "test", Peers = ["peer-1", "peer-2"] }; + + Should.Throw(() => group.SetPreferred("peer-99")); + } + + // --------------------------------------------------------------- + // RemovePeer — remove a peer from the group + // Go reference: jetstream_cluster.go removePeer + // --------------------------------------------------------------- + + [Fact] + public void RemovePeer_removes_existing_peer() + { + // Go reference: jetstream_cluster.go removePeer — removes peer from rg.Peers + var group = new RaftGroup { Name = "test", Peers = ["peer-1", "peer-2", "peer-3"] }; + + var removed = group.RemovePeer("peer-2"); + + removed.ShouldBeTrue(); + group.Peers.ShouldNotContain("peer-2"); + group.Peers.Count.ShouldBe(2); + } + + [Fact] + public void RemovePeer_clears_preferred_when_removing_preferred() + { + // Go reference: jetstream_cluster.go removePeer — clears rg.Preferred if it matches removed peer + var group = new RaftGroup { Name = "test", Peers = ["peer-1", "peer-2", "peer-3"], Preferred = "peer-2" }; + + group.RemovePeer("peer-2"); + + group.Preferred.ShouldBe(string.Empty); + } + + [Fact] + public void RemovePeer_returns_false_for_non_member() + { + // Go reference: jetstream_cluster.go removePeer — returns false when peer not found + var group = new RaftGroup { Name = "test", Peers = ["peer-1", "peer-2"] }; + + var removed = group.RemovePeer("peer-99"); + + removed.ShouldBeFalse(); + group.Peers.Count.ShouldBe(2); + } + + // --------------------------------------------------------------- + // AddPeer — add a new peer to the group + // Go reference: jetstream_cluster.go addPeer / expandGroup + // --------------------------------------------------------------- + + [Fact] + public void AddPeer_adds_new_peer() + { + // Go reference: jetstream_cluster.go addPeer — appends peer to rg.Peers + var group = new RaftGroup { Name = "test", Peers = ["peer-1", "peer-2"] }; + + var added = group.AddPeer("peer-3"); + + added.ShouldBeTrue(); + group.Peers.ShouldContain("peer-3"); + group.Peers.Count.ShouldBe(3); + } + + [Fact] + public void AddPeer_returns_false_for_existing_peer() + { + // Go reference: jetstream_cluster.go addPeer — skips duplicate, returns false + var group = new RaftGroup { Name = "test", Peers = ["peer-1", "peer-2"] }; + + var added = group.AddPeer("peer-1"); + + added.ShouldBeFalse(); + group.Peers.Count.ShouldBe(2); + } + + // --------------------------------------------------------------- + // CreateRaftGroup factory — uses PlacementEngine + // Go reference: jetstream_cluster.go:7212 selectPeerGroup called from createGroupForStream + // --------------------------------------------------------------- + + [Fact] + public void CreateRaftGroup_uses_placement_engine() + { + // Go reference: jetstream_cluster.go createGroupForStream — calls selectPeerGroup + var peers = new List + { + new() { PeerId = "peer-A", Available = true, AvailableStorage = 9000 }, + new() { PeerId = "peer-B", Available = true, AvailableStorage = 8000 }, + new() { PeerId = "peer-C", Available = true, AvailableStorage = 7000 }, + }; + + var group = RaftGroup.CreateRaftGroup("my-stream", 3, peers); + + group.Name.ShouldBe("my-stream"); + group.Peers.Count.ShouldBe(3); + group.Peers.ShouldContain("peer-A"); + group.Peers.ShouldContain("peer-B"); + group.Peers.ShouldContain("peer-C"); + } + + [Fact] + public void CreateRaftGroup_sets_desired_replicas() + { + // Go reference: jetstream_cluster.go rg.DesiredReplicas = replicas after group creation + var peers = new List + { + new() { PeerId = "peer-X", Available = true }, + new() { PeerId = "peer-Y", Available = true }, + new() { PeerId = "peer-Z", Available = true }, + }; + + var group = RaftGroup.CreateRaftGroup("replicated-stream", 3, peers); + + group.DesiredReplicas.ShouldBe(3); + group.HasDesiredReplicas.ShouldBeTrue(); + } + + // --------------------------------------------------------------- + // IsUnderReplicated — replication health + // Go reference: jetstream_cluster.go missingPeers — len(Peers) < DesiredReplicas + // --------------------------------------------------------------- + + [Fact] + public void IsUnderReplicated_true_when_peers_less_than_desired() + { + // Go reference: jetstream_cluster.go:2284 sa.missingPeers() + var group = new RaftGroup { Name = "test", Peers = ["peer-1"], DesiredReplicas = 3 }; + + group.IsUnderReplicated.ShouldBeTrue(); + } + + [Fact] + public void IsUnderReplicated_false_when_peers_equal_desired() + { + // Go reference: jetstream_cluster.go:2284 sa.missingPeers() — no deficit when equal + var group = new RaftGroup { Name = "test", Peers = ["peer-1", "peer-2", "peer-3"], DesiredReplicas = 3 }; + + group.IsUnderReplicated.ShouldBeFalse(); + } + + [Fact] + public void IsUnderReplicated_false_when_no_desired_replicas_set() + { + // Go reference: jetstream_cluster.go — without DesiredReplicas set, no under-replication + var group = new RaftGroup { Name = "test", Peers = ["peer-1"] }; + + group.IsUnderReplicated.ShouldBeFalse(); + } + + // --------------------------------------------------------------- + // IsOverReplicated — excess replication detection + // Go reference: jetstream_cluster.go extraPeers — len(Peers) > DesiredReplicas + // --------------------------------------------------------------- + + [Fact] + public void IsOverReplicated_true_when_peers_more_than_desired() + { + // Go reference: jetstream_cluster.go extraPeers detection for scale-down + var group = new RaftGroup { Name = "test", Peers = ["p1", "p2", "p3", "p4"], DesiredReplicas = 3 }; + + group.IsOverReplicated.ShouldBeTrue(); + } + + [Fact] + public void IsOverReplicated_false_when_peers_equal_desired() + { + // Go reference: jetstream_cluster.go — no excess when equal + var group = new RaftGroup { Name = "test", Peers = ["p1", "p2", "p3"], DesiredReplicas = 3 }; + + group.IsOverReplicated.ShouldBeFalse(); + } + + [Fact] + public void IsOverReplicated_false_when_no_desired_replicas_set() + { + // Go reference: jetstream_cluster.go — without DesiredReplicas set, no over-replication + var group = new RaftGroup { Name = "test", Peers = ["p1", "p2", "p3", "p4"] }; + + group.IsOverReplicated.ShouldBeFalse(); + } + + // --------------------------------------------------------------- + // QuorumSize — majority quorum calculation + // Go reference: jetstream_cluster.go quorumNeeded — (n/2)+1 + // --------------------------------------------------------------- + + [Theory] + [InlineData(1, 1)] // R=1 → quorum=1 + [InlineData(3, 2)] // R=3 → quorum=2 + [InlineData(5, 3)] // R=5 → quorum=3 + [InlineData(2, 2)] // R=2 → quorum=2 (degenerate, but formula consistent) + [InlineData(4, 3)] // R=4 → quorum=3 + public void QuorumSize_correct_for_various_counts(int peerCount, int expectedQuorum) + { + // Go reference: jetstream_cluster.go quorumNeeded — (n/2)+1 + var peers = Enumerable.Range(1, peerCount).Select(i => $"peer-{i}").ToList(); + var group = new RaftGroup { Name = "quorum-test", Peers = peers }; + + group.QuorumSize.ShouldBe(expectedQuorum); + } +}