diff --git a/tests/NATS.Server.Tests/Routes/ClusterSplitTests.cs b/tests/NATS.Server.Tests/Routes/ClusterSplitTests.cs new file mode 100644 index 0000000..7ea73dc --- /dev/null +++ b/tests/NATS.Server.Tests/Routes/ClusterSplitTests.cs @@ -0,0 +1,284 @@ +// Reference: golang/nats-server/server/route.go:3085 — removeAllRoutesExcept +// Reference: golang/nats-server/server/route.go:3113 — removeRoute +// Tests for cluster split detection and route partition handling. + +using System.Net; +using System.Net.Sockets; +using Microsoft.Extensions.Logging.Abstractions; +using NATS.Server.Configuration; +using NATS.Server.Routes; + +namespace NATS.Server.Tests.Routes; + +/// +/// Tests for , +/// , and +/// . +/// Go reference: server/route.go removeRoute (3113), removeAllRoutesExcept (3085). +/// +public class ClusterSplitTests +{ + // -- Helpers -- + + private static RouteManager CreateManager(string serverId = "local-server") + => new( + new ClusterOptions { Host = "127.0.0.1", Port = 0 }, + new ServerStats(), + serverId, + _ => { }, + _ => { }, + NullLogger.Instance); + + /// + /// Creates a connected socket pair (listener + client) so that + /// can build its internal . + /// Returns both the RouteConnection (which owns the server-side socket) and the + /// client-side socket (kept alive for the duration of the test). + /// + private static (RouteConnection Route, Socket ClientSocket) CreateConnectedRoute() + { + var listener = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + listener.Bind(new IPEndPoint(IPAddress.Loopback, 0)); + listener.Listen(1); + var port = ((IPEndPoint)listener.LocalEndPoint!).Port; + + var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + client.Connect(IPAddress.Loopback, port); + var server = listener.Accept(); + listener.Dispose(); + + return (new RouteConnection(server), client); + } + + // -- RemoveRoute tests -- + + [Fact] + public void RemoveRoute_ExistingRoute_ReturnsTrue() + { + // Go ref: route.go removeRoute — returns true when a route was found. + var manager = CreateManager(); + var (conn, clientSocket) = CreateConnectedRoute(); + + try + { + manager.RegisterRoute("server-A", conn); + manager.RouteCount.ShouldBe(1); + + var removed = manager.RemoveRoute("server-A"); + + removed.ShouldBeTrue(); + manager.RouteCount.ShouldBe(0); + } + finally + { + clientSocket.Dispose(); + } + } + + [Fact] + public void RemoveRoute_NonExistent_ReturnsFalse() + { + // Go ref: route.go removeRoute — returns false when server ID is unknown. + var manager = CreateManager(); + + var removed = manager.RemoveRoute("no-such-server"); + + removed.ShouldBeFalse(); + manager.RouteCount.ShouldBe(0); + } + + [Fact] + public void RemoveRoute_RemovesFromConnectedIds() + { + // Go ref: route.go removeRoute — must update the connectedServerIds set + // so topology snapshots no longer list the removed peer. + var manager = CreateManager(); + var (conn, clientSocket) = CreateConnectedRoute(); + + try + { + manager.RegisterRoute("server-B", conn); + + var snapshotBefore = manager.BuildTopologySnapshot(); + snapshotBefore.ConnectedServerIds.ShouldContain("server-B"); + + manager.RemoveRoute("server-B"); + + var snapshotAfter = manager.BuildTopologySnapshot(); + snapshotAfter.ConnectedServerIds.ShouldNotContain("server-B"); + } + finally + { + clientSocket.Dispose(); + } + } + + // -- RemoveAllRoutesExcept tests -- + + [Fact] + public void RemoveAllRoutesExcept_KeepsSpecified() + { + // Go ref: route.go removeAllRoutesExcept — only removes routes whose + // server ID is not in the keep set. + var manager = CreateManager(); + var (connA, clientA) = CreateConnectedRoute(); + var (connB, clientB) = CreateConnectedRoute(); + var (connC, clientC) = CreateConnectedRoute(); + + try + { + manager.RegisterRoute("server-A", connA); + manager.RegisterRoute("server-B", connB); + manager.RegisterRoute("server-C", connC); + manager.RouteCount.ShouldBe(3); + + var removed = manager.RemoveAllRoutesExcept(new HashSet { "server-A", "server-B" }); + + removed.ShouldBe(1); + manager.RouteCount.ShouldBe(2); + + var snapshot = manager.BuildTopologySnapshot(); + snapshot.ConnectedServerIds.ShouldContain("server-A"); + snapshot.ConnectedServerIds.ShouldContain("server-B"); + snapshot.ConnectedServerIds.ShouldNotContain("server-C"); + } + finally + { + clientA.Dispose(); + clientB.Dispose(); + clientC.Dispose(); + } + } + + [Fact] + public void RemoveAllRoutesExcept_RemovesAll_WhenEmptyKeepSet() + { + // Go ref: route.go removeAllRoutesExcept — empty keep set removes every route. + var manager = CreateManager(); + var (connA, clientA) = CreateConnectedRoute(); + var (connB, clientB) = CreateConnectedRoute(); + + try + { + manager.RegisterRoute("server-A", connA); + manager.RegisterRoute("server-B", connB); + manager.RouteCount.ShouldBe(2); + + var removed = manager.RemoveAllRoutesExcept(new HashSet()); + + removed.ShouldBe(2); + manager.RouteCount.ShouldBe(0); + } + finally + { + clientA.Dispose(); + clientB.Dispose(); + } + } + + [Fact] + public void RemoveAllRoutesExcept_NoRoutes_ReturnsZero() + { + // Go ref: route.go removeAllRoutesExcept — no-op when no routes exist. + var manager = CreateManager(); + + var removed = manager.RemoveAllRoutesExcept(new HashSet { "server-A" }); + + removed.ShouldBe(0); + manager.RouteCount.ShouldBe(0); + } + + // -- DetectClusterSplit tests -- + + [Fact] + public void DetectClusterSplit_AllPresent_NotSplit() + { + // Go ref: cluster split detection — no missing peers means no partition. + var manager = CreateManager(); + var (connA, clientA) = CreateConnectedRoute(); + var (connB, clientB) = CreateConnectedRoute(); + + try + { + manager.RegisterRoute("peer-1", connA); + manager.RegisterRoute("peer-2", connB); + + var result = manager.DetectClusterSplit(new HashSet { "peer-1", "peer-2" }); + + result.IsSplit.ShouldBeFalse(); + result.MissingPeers.ShouldBeEmpty(); + result.UnexpectedPeers.ShouldBeEmpty(); + } + finally + { + clientA.Dispose(); + clientB.Dispose(); + } + } + + [Fact] + public void DetectClusterSplit_MissingPeers_IsSplit() + { + // Go ref: cluster split detection — missing expected peers indicates partition. + var manager = CreateManager(); + var (connA, clientA) = CreateConnectedRoute(); + + try + { + manager.RegisterRoute("peer-1", connA); + // peer-2 and peer-3 are expected but not connected. + + var result = manager.DetectClusterSplit(new HashSet { "peer-1", "peer-2", "peer-3" }); + + result.IsSplit.ShouldBeTrue(); + result.MissingPeers.Count.ShouldBe(2); + result.MissingPeers.ShouldContain("peer-2"); + result.MissingPeers.ShouldContain("peer-3"); + result.UnexpectedPeers.ShouldBeEmpty(); + } + finally + { + clientA.Dispose(); + } + } + + [Fact] + public void DetectClusterSplit_UnexpectedPeers_Listed() + { + // Go ref: unexpected connected peers are enumerated (extras don't cause IsSplit). + var manager = CreateManager(); + var (connA, clientA) = CreateConnectedRoute(); + var (connX, clientX) = CreateConnectedRoute(); + + try + { + manager.RegisterRoute("peer-1", connA); + manager.RegisterRoute("unexpected-peer", connX); + + var result = manager.DetectClusterSplit(new HashSet { "peer-1" }); + + result.IsSplit.ShouldBeFalse(); + result.MissingPeers.ShouldBeEmpty(); + result.UnexpectedPeers.Count.ShouldBe(1); + result.UnexpectedPeers.ShouldContain("unexpected-peer"); + } + finally + { + clientA.Dispose(); + clientX.Dispose(); + } + } + + [Fact] + public void DetectClusterSplit_NoPeers_NoExpected_NotSplit() + { + // Go ref: cluster split detection — empty state with no expectations is healthy. + var manager = CreateManager(); + + var result = manager.DetectClusterSplit(new HashSet()); + + result.IsSplit.ShouldBeFalse(); + result.MissingPeers.ShouldBeEmpty(); + result.UnexpectedPeers.ShouldBeEmpty(); + } +}