Files
natsdotnet/tests/NATS.Server.Tests/Routes/ClusterSplitTests.cs
Joseph Doherty 071717dcbf feat: add cluster split handling (Gap 13.5)
Add RemoveRoute, RemoveAllRoutesExcept, RegisterRoute (internal), and
DetectClusterSplit to RouteManager with ClusterSplitResult record, plus
10 tests covering partition detection and route removal behavior.
2026-02-25 12:03:05 -05:00

285 lines
9.0 KiB
C#

// 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;
/// <summary>
/// Tests for <see cref="RouteManager.RemoveRoute"/>,
/// <see cref="RouteManager.RemoveAllRoutesExcept"/>, and
/// <see cref="RouteManager.DetectClusterSplit"/>.
/// Go reference: server/route.go removeRoute (3113), removeAllRoutesExcept (3085).
/// </summary>
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<RouteManager>.Instance);
/// <summary>
/// Creates a connected socket pair (listener + client) so that
/// <see cref="RouteConnection"/> can build its internal <see cref="NetworkStream"/>.
/// Returns both the RouteConnection (which owns the server-side socket) and the
/// client-side socket (kept alive for the duration of the test).
/// </summary>
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<string> { "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<string>());
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<string> { "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<string> { "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<string> { "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<string> { "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<string>());
result.IsSplit.ShouldBeFalse();
result.MissingPeers.ShouldBeEmpty();
result.UnexpectedPeers.ShouldBeEmpty();
}
}