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.
This commit is contained in:
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<InvalidOperationException>(() => 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<PeerInfo>
|
||||
{
|
||||
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<PeerInfo>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user