refactor: extract NATS.Server.Clustering.Tests project
Move 29 clustering/routing test files from NATS.Server.Tests to a dedicated NATS.Server.Clustering.Tests project. Update namespaces, replace private GetFreePort/ReadUntilAsync helpers with TestUtilities calls, and extract TestServerFactory/ClusterTestServer to TestUtilities to fix cross-project reference from JetStreamStartupTests.
This commit is contained in:
@@ -1,236 +0,0 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server.Configuration;
|
||||
using NATS.Server;
|
||||
using NATS.Server.Gateways;
|
||||
using NATS.Server.Protocol;
|
||||
using NATS.Server.Routes;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
// Go reference: server/route.go processImplicitRoute, server/gateway.go processImplicitGateway
|
||||
|
||||
public class ImplicitDiscoveryTests
|
||||
{
|
||||
[Fact]
|
||||
public void ProcessImplicitRoute_discovers_new_peer()
|
||||
{
|
||||
// Go reference: server/route.go processImplicitRoute
|
||||
var mgr = RouteManagerTestHelper.Create();
|
||||
var serverInfo = new ServerInfo
|
||||
{
|
||||
ServerId = "server-2",
|
||||
ServerName = "server-2",
|
||||
Version = "0.1.0",
|
||||
Host = "0.0.0.0",
|
||||
Port = 4222,
|
||||
ConnectUrls = ["nats://10.0.0.2:6222", "nats://10.0.0.3:6222"],
|
||||
};
|
||||
|
||||
mgr.ProcessImplicitRoute(serverInfo);
|
||||
|
||||
mgr.DiscoveredRoutes.ShouldContain("nats://10.0.0.2:6222");
|
||||
mgr.DiscoveredRoutes.ShouldContain("nats://10.0.0.3:6222");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessImplicitRoute_skips_known_peers()
|
||||
{
|
||||
// Go reference: server/route.go processImplicitRoute — skip already-known
|
||||
var mgr = RouteManagerTestHelper.Create();
|
||||
mgr.AddKnownRoute("nats://10.0.0.2:6222");
|
||||
|
||||
var serverInfo = new ServerInfo
|
||||
{
|
||||
ServerId = "server-2",
|
||||
ServerName = "server-2",
|
||||
Version = "0.1.0",
|
||||
Host = "0.0.0.0",
|
||||
Port = 4222,
|
||||
ConnectUrls = ["nats://10.0.0.2:6222", "nats://10.0.0.3:6222"],
|
||||
};
|
||||
|
||||
mgr.ProcessImplicitRoute(serverInfo);
|
||||
|
||||
mgr.DiscoveredRoutes.Count.ShouldBe(1); // only 10.0.0.3 is new
|
||||
mgr.DiscoveredRoutes.ShouldContain("nats://10.0.0.3:6222");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessImplicitRoute_with_null_urls_is_noop()
|
||||
{
|
||||
// Go reference: server/route.go processImplicitRoute — nil ConnectUrls guard
|
||||
var mgr = RouteManagerTestHelper.Create();
|
||||
var serverInfo = new ServerInfo
|
||||
{
|
||||
ServerId = "server-2",
|
||||
ServerName = "server-2",
|
||||
Version = "0.1.0",
|
||||
Host = "0.0.0.0",
|
||||
Port = 4222,
|
||||
ConnectUrls = null,
|
||||
};
|
||||
|
||||
mgr.ProcessImplicitRoute(serverInfo);
|
||||
|
||||
mgr.DiscoveredRoutes.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessImplicitRoute_with_empty_urls_is_noop()
|
||||
{
|
||||
// Go reference: server/route.go processImplicitRoute — empty ConnectUrls guard
|
||||
var mgr = RouteManagerTestHelper.Create();
|
||||
var serverInfo = new ServerInfo
|
||||
{
|
||||
ServerId = "server-2",
|
||||
ServerName = "server-2",
|
||||
Version = "0.1.0",
|
||||
Host = "0.0.0.0",
|
||||
Port = 4222,
|
||||
ConnectUrls = [],
|
||||
};
|
||||
|
||||
mgr.ProcessImplicitRoute(serverInfo);
|
||||
|
||||
mgr.DiscoveredRoutes.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessImplicitGateway_discovers_new_gateway()
|
||||
{
|
||||
// Go reference: server/gateway.go processImplicitGateway
|
||||
var mgr = GatewayManagerTestHelper.Create();
|
||||
var gwInfo = new GatewayInfo
|
||||
{
|
||||
Name = "cluster-B",
|
||||
Urls = ["nats://10.0.1.1:7222"],
|
||||
};
|
||||
|
||||
mgr.ProcessImplicitGateway(gwInfo);
|
||||
|
||||
mgr.DiscoveredGateways.ShouldContain("cluster-B");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessImplicitGateway_with_null_throws()
|
||||
{
|
||||
// Go reference: server/gateway.go processImplicitGateway — null guard
|
||||
var mgr = GatewayManagerTestHelper.Create();
|
||||
|
||||
Should.Throw<ArgumentNullException>(() => mgr.ProcessImplicitGateway(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessImplicitGateway_deduplicates_same_cluster()
|
||||
{
|
||||
// Go reference: server/gateway.go processImplicitGateway — idempotent discovery
|
||||
var mgr = GatewayManagerTestHelper.Create();
|
||||
var gwInfo = new GatewayInfo { Name = "cluster-B", Urls = ["nats://10.0.1.1:7222"] };
|
||||
|
||||
mgr.ProcessImplicitGateway(gwInfo);
|
||||
mgr.ProcessImplicitGateway(gwInfo);
|
||||
|
||||
mgr.DiscoveredGateways.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForwardNewRouteInfo_invokes_event()
|
||||
{
|
||||
// Go reference: server/route.go forwardNewRouteInfoToKnownServers
|
||||
var mgr = RouteManagerTestHelper.Create();
|
||||
var forwarded = new List<string>();
|
||||
mgr.OnForwardInfo += urls => forwarded.AddRange(urls);
|
||||
|
||||
mgr.ForwardNewRouteInfoToKnownServers("nats://10.0.0.5:6222");
|
||||
|
||||
forwarded.ShouldContain("nats://10.0.0.5:6222");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForwardNewRouteInfo_with_no_handler_does_not_throw()
|
||||
{
|
||||
// Go reference: server/route.go forwardNewRouteInfoToKnownServers — no subscribers
|
||||
var mgr = RouteManagerTestHelper.Create();
|
||||
|
||||
Should.NotThrow(() => mgr.ForwardNewRouteInfoToKnownServers("nats://10.0.0.5:6222"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddKnownRoute_prevents_later_discovery()
|
||||
{
|
||||
// Go reference: server/route.go processImplicitRoute — pre-seeded known routes
|
||||
var mgr = RouteManagerTestHelper.Create();
|
||||
mgr.AddKnownRoute("nats://10.0.0.9:6222");
|
||||
|
||||
var serverInfo = new ServerInfo
|
||||
{
|
||||
ServerId = "server-3",
|
||||
ServerName = "server-3",
|
||||
Version = "0.1.0",
|
||||
Host = "0.0.0.0",
|
||||
Port = 4222,
|
||||
ConnectUrls = ["nats://10.0.0.9:6222"],
|
||||
};
|
||||
|
||||
mgr.ProcessImplicitRoute(serverInfo);
|
||||
|
||||
mgr.DiscoveredRoutes.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConnectUrls_is_serialized_when_set()
|
||||
{
|
||||
// Go reference: server/route.go INFO message includes connect_urls
|
||||
var info = new ServerInfo
|
||||
{
|
||||
ServerId = "s1",
|
||||
ServerName = "s1",
|
||||
Version = "0.1.0",
|
||||
Host = "0.0.0.0",
|
||||
Port = 4222,
|
||||
ConnectUrls = ["nats://10.0.0.1:4222"],
|
||||
};
|
||||
|
||||
var json = System.Text.Json.JsonSerializer.Serialize(info);
|
||||
json.ShouldContain("connect_urls");
|
||||
json.ShouldContain("nats://10.0.0.1:4222");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConnectUrls_is_omitted_when_null()
|
||||
{
|
||||
// Go reference: server/route.go INFO omits connect_urls when empty
|
||||
var info = new ServerInfo
|
||||
{
|
||||
ServerId = "s1",
|
||||
ServerName = "s1",
|
||||
Version = "0.1.0",
|
||||
Host = "0.0.0.0",
|
||||
Port = 4222,
|
||||
ConnectUrls = null,
|
||||
};
|
||||
|
||||
var json = System.Text.Json.JsonSerializer.Serialize(info);
|
||||
json.ShouldNotContain("connect_urls");
|
||||
}
|
||||
}
|
||||
|
||||
public static class RouteManagerTestHelper
|
||||
{
|
||||
public static RouteManager Create()
|
||||
{
|
||||
var options = new ClusterOptions { Name = "test-cluster", Host = "127.0.0.1", Port = 0 };
|
||||
var stats = new ServerStats();
|
||||
return new RouteManager(options, stats, "server-1", _ => { }, _ => { }, NullLogger<RouteManager>.Instance);
|
||||
}
|
||||
}
|
||||
|
||||
public static class GatewayManagerTestHelper
|
||||
{
|
||||
public static GatewayManager Create()
|
||||
{
|
||||
var options = new GatewayOptions { Name = "cluster-A", Host = "127.0.0.1", Port = 0 };
|
||||
var stats = new ServerStats();
|
||||
return new GatewayManager(options, stats, "server-1", _ => { }, _ => { }, NullLogger<GatewayManager>.Instance);
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using NATS.Server.Gateways;
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class InterServerAccountProtocolTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Aplus_Aminus_frames_include_account_scope_and_do_not_leak_interest_across_accounts()
|
||||
{
|
||||
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
using var remoteSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await remoteSocket.ConnectAsync(IPAddress.Loopback, port);
|
||||
using var gatewaySocket = await listener.AcceptSocketAsync();
|
||||
await using var gateway = new GatewayConnection(gatewaySocket);
|
||||
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
var handshakeTask = gateway.PerformOutboundHandshakeAsync("LOCAL", timeout.Token);
|
||||
(await ReadLineAsync(remoteSocket, timeout.Token)).ShouldBe("GATEWAY LOCAL");
|
||||
await WriteLineAsync(remoteSocket, "GATEWAY REMOTE", timeout.Token);
|
||||
await handshakeTask;
|
||||
|
||||
var received = new TaskCompletionSource<RemoteSubscription>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
gateway.RemoteSubscriptionReceived = sub =>
|
||||
{
|
||||
received.TrySetResult(sub);
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
gateway.StartLoop(timeout.Token);
|
||||
|
||||
await WriteLineAsync(remoteSocket, "A+ A orders.*", timeout.Token);
|
||||
var aPlus = await received.Task.WaitAsync(timeout.Token);
|
||||
aPlus.Account.ShouldBe("A");
|
||||
aPlus.Subject.ShouldBe("orders.*");
|
||||
aPlus.IsRemoval.ShouldBeFalse();
|
||||
|
||||
var subList = new SubList();
|
||||
subList.ApplyRemoteSub(aPlus);
|
||||
subList.HasRemoteInterest("A", "orders.created").ShouldBeTrue();
|
||||
subList.HasRemoteInterest("B", "orders.created").ShouldBeFalse();
|
||||
|
||||
var removedTcs = new TaskCompletionSource<RemoteSubscription>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
gateway.RemoteSubscriptionReceived = sub =>
|
||||
{
|
||||
removedTcs.TrySetResult(sub);
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
|
||||
await WriteLineAsync(remoteSocket, "A- A orders.*", timeout.Token);
|
||||
var aMinus = await removedTcs.Task.WaitAsync(timeout.Token);
|
||||
aMinus.Account.ShouldBe("A");
|
||||
aMinus.IsRemoval.ShouldBeTrue();
|
||||
|
||||
subList.ApplyRemoteSub(aMinus);
|
||||
subList.HasRemoteInterest("A", "orders.created").ShouldBeFalse();
|
||||
}
|
||||
|
||||
private static async Task<string> ReadLineAsync(Socket socket, CancellationToken ct)
|
||||
{
|
||||
var bytes = new List<byte>(64);
|
||||
var single = new byte[1];
|
||||
while (true)
|
||||
{
|
||||
var read = await socket.ReceiveAsync(single, SocketFlags.None, ct);
|
||||
if (read == 0)
|
||||
break;
|
||||
if (single[0] == (byte)'\n')
|
||||
break;
|
||||
if (single[0] != (byte)'\r')
|
||||
bytes.Add(single[0]);
|
||||
}
|
||||
|
||||
return Encoding.ASCII.GetString([.. bytes]);
|
||||
}
|
||||
|
||||
private static Task WriteLineAsync(Socket socket, string line, CancellationToken ct)
|
||||
=> socket.SendAsync(Encoding.ASCII.GetBytes($"{line}\r\n"), SocketFlags.None, ct).AsTask();
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using NATS.Server.TestUtilities;
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class JetStreamStartupTests
|
||||
|
||||
@@ -1,992 +0,0 @@
|
||||
// Go parity: golang/nats-server/server/routes_test.go
|
||||
// Covers: route pooling, pool index computation, per-account routes, S2 compression
|
||||
// negotiation matrix, slow consumer detection, route ping keepalive, cluster formation,
|
||||
// pool size validation, and origin cluster message argument parsing.
|
||||
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server.Configuration;
|
||||
using NATS.Server.Routes;
|
||||
|
||||
namespace NATS.Server.Tests.Route;
|
||||
|
||||
/// <summary>
|
||||
/// Go parity tests for the .NET route subsystem ported from
|
||||
/// golang/nats-server/server/routes_test.go.
|
||||
///
|
||||
/// The .NET server does not expose per-server runtime internals (routes map,
|
||||
/// per-route stats) in the same way as Go. Tests that require Go-internal access
|
||||
/// are ported as structural/unit tests against the public .NET API surface, or as
|
||||
/// integration tests using two NatsServer instances.
|
||||
/// </summary>
|
||||
public class RouteGoParityTests
|
||||
{
|
||||
// ---------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
private static NatsOptions MakeClusterOpts(
|
||||
string? clusterName = null,
|
||||
string? seed = null,
|
||||
int poolSize = 1)
|
||||
{
|
||||
return new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Cluster = new ClusterOptions
|
||||
{
|
||||
Name = clusterName ?? Guid.NewGuid().ToString("N"),
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
PoolSize = poolSize,
|
||||
Routes = seed is null ? [] : [seed],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<(NatsServer Server, CancellationTokenSource Cts)> StartAsync(NatsOptions opts)
|
||||
{
|
||||
var server = new NatsServer(opts, NullLoggerFactory.Instance);
|
||||
var cts = new CancellationTokenSource();
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
return (server, cts);
|
||||
}
|
||||
|
||||
private static async Task WaitForRoutes(NatsServer a, NatsServer b, int timeoutSec = 5)
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSec));
|
||||
while (!timeout.IsCancellationRequested &&
|
||||
(Interlocked.Read(ref a.Stats.Routes) == 0 ||
|
||||
Interlocked.Read(ref b.Stats.Routes) == 0))
|
||||
{
|
||||
await Task.Delay(50, timeout.Token)
|
||||
.ContinueWith(_ => { }, TaskScheduler.Default);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task DisposeAll(params (NatsServer Server, CancellationTokenSource Cts)[] servers)
|
||||
{
|
||||
foreach (var (server, cts) in servers)
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
cts.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestRoutePool (routes_test.go:1966)
|
||||
// Pool index computation: A maps to 0, B maps to 1 with pool_size=2
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void RoutePool_AccountA_MapsToIndex0_WithPoolSize2()
|
||||
{
|
||||
// Go: TestRoutePool (routes_test.go:1966)
|
||||
// With pool_size=2, account "A" always maps to index 0.
|
||||
var idx = RouteManager.ComputeRoutePoolIdx(2, "A");
|
||||
idx.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoutePool_AccountB_MapsToIndex1_WithPoolSize2()
|
||||
{
|
||||
// Go: TestRoutePool (routes_test.go:1966)
|
||||
// With pool_size=2, account "B" always maps to index 1.
|
||||
var idx = RouteManager.ComputeRoutePoolIdx(2, "B");
|
||||
idx.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoutePool_IndexIsConsistentAcrossBothSides()
|
||||
{
|
||||
// Go: TestRoutePool (routes_test.go:1966)
|
||||
// checkRoutePoolIdx verifies that both s1 and s2 agree on the pool index
|
||||
// for the same account. FNV-1a is deterministic so any two callers agree.
|
||||
var idx1 = RouteManager.ComputeRoutePoolIdx(2, "A");
|
||||
var idx2 = RouteManager.ComputeRoutePoolIdx(2, "A");
|
||||
idx1.ShouldBe(idx2);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestRoutePoolAndPerAccountErrors (routes_test.go:1906)
|
||||
// Duplicate account in per-account routes list should produce an error.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void RoutePerAccount_DuplicateAccount_RejectedAtValidation()
|
||||
{
|
||||
// Go: TestRoutePoolAndPerAccountErrors (routes_test.go:1906)
|
||||
// The config "accounts: [abc, def, abc]" must be rejected with "duplicate".
|
||||
// In .NET we validate during ClusterOptions construction or at server start.
|
||||
var opts = MakeClusterOpts();
|
||||
opts.Cluster!.Accounts = ["abc", "def", "abc"];
|
||||
|
||||
// Duplicate accounts in the per-account list is invalid.
|
||||
var duplicateCount = opts.Cluster.Accounts
|
||||
.GroupBy(a => a, StringComparer.Ordinal)
|
||||
.Any(g => g.Count() > 1);
|
||||
duplicateCount.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestRoutePoolRouteStoredSameIndexBothSides (routes_test.go:2180)
|
||||
// Same pool index is assigned consistently from both sides of a connection.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void RoutePool_SameIndexAssignedFromBothSides_Deterministic()
|
||||
{
|
||||
// Go: TestRoutePoolRouteStoredSameIndexBothSides (routes_test.go:2180)
|
||||
// Both S1 and S2 compute the same pool index for a given account name,
|
||||
// because FNV-1a is deterministic and symmetric.
|
||||
const int poolSize = 4;
|
||||
var accounts = new[] { "A", "B", "C", "D" };
|
||||
|
||||
foreach (var acc in accounts)
|
||||
{
|
||||
var idxLeft = RouteManager.ComputeRoutePoolIdx(poolSize, acc);
|
||||
var idxRight = RouteManager.ComputeRoutePoolIdx(poolSize, acc);
|
||||
idxLeft.ShouldBe(idxRight, $"Pool index for '{acc}' must match on both sides");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestRoutePoolSizeDifferentOnEachServer (routes_test.go:2254)
|
||||
// Pool sizes may differ between servers; the larger pool pads with extra conns.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void RoutePool_SizeDiffers_SmallPoolIndexInRange()
|
||||
{
|
||||
// Go: TestRoutePoolSizeDifferentOnEachServer (routes_test.go:2254)
|
||||
// When S1 has pool_size=5 and S2 has pool_size=2, the smaller side
|
||||
// still maps all accounts to indices 0..1 (its own pool size).
|
||||
const int smallPool = 2;
|
||||
var accounts = new[] { "A", "B", "C", "D", "E" };
|
||||
|
||||
foreach (var acc in accounts)
|
||||
{
|
||||
var idx = RouteManager.ComputeRoutePoolIdx(smallPool, acc);
|
||||
idx.ShouldBeInRange(0, smallPool - 1,
|
||||
$"Pool index for '{acc}' must be within [0, {smallPool - 1}]");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestRoutePerAccount (routes_test.go:2539)
|
||||
// Per-account route: account list mapped to dedicated connections.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void RoutePerAccount_PoolIndexForPerAccountIsAlwaysZero()
|
||||
{
|
||||
// Go: TestRoutePerAccount (routes_test.go:2539)
|
||||
// When an account is in the per-account list, pool_size=1 means index 0.
|
||||
var idx = RouteManager.ComputeRoutePoolIdx(1, "MY_ACCOUNT");
|
||||
idx.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoutePerAccount_DifferentAccountsSeparateIndicesWithPoolSize3()
|
||||
{
|
||||
// Go: TestRoutePerAccount (routes_test.go:2539)
|
||||
// With pool_size=3, different accounts should map to various indices.
|
||||
var seen = new HashSet<int>();
|
||||
for (var i = 0; i < 20; i++)
|
||||
{
|
||||
var idx = RouteManager.ComputeRoutePoolIdx(3, $"account-{i}");
|
||||
seen.Add(idx);
|
||||
idx.ShouldBeInRange(0, 2);
|
||||
}
|
||||
|
||||
// Multiple distinct indices should be seen across 20 accounts.
|
||||
seen.Count.ShouldBeGreaterThan(1);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestRoutePerAccountDefaultForSysAccount (routes_test.go:2705)
|
||||
// System account ($SYS) always uses pool index 0.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void RoutePerAccount_SystemAccount_AlwaysMapsToZero_SinglePool()
|
||||
{
|
||||
// Go: TestRoutePerAccountDefaultForSysAccount (routes_test.go:2705)
|
||||
// With pool_size=1, system account maps to 0.
|
||||
var idx = RouteManager.ComputeRoutePoolIdx(1, "$SYS");
|
||||
idx.ShouldBe(0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestRoutePoolSubUnsubProtoParsing (routes_test.go:3104)
|
||||
// RS+/RS- protocol messages parsed correctly with account+subject+queue.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void RouteProtocol_RsPlus_ParsedWithAccount()
|
||||
{
|
||||
// Go: TestRoutePoolPerAccountSubUnsubProtoParsing (routes_test.go:3104)
|
||||
// RS+ protocol: "RS+ ACC foo" — account scoped subscription.
|
||||
var line = "RS+ MY_ACC foo";
|
||||
var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
parts.Length.ShouldBe(3);
|
||||
parts[0].ShouldBe("RS+");
|
||||
parts[1].ShouldBe("MY_ACC");
|
||||
parts[2].ShouldBe("foo");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RouteProtocol_RsPlus_ParsedWithAccountAndQueue()
|
||||
{
|
||||
// Go: TestRoutePoolPerAccountSubUnsubProtoParsing (routes_test.go:3104)
|
||||
// RS+ protocol: "RS+ ACC foo grp" — account + subject + queue.
|
||||
var line = "RS+ MY_ACC foo grp";
|
||||
var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
parts.Length.ShouldBe(4);
|
||||
parts[0].ShouldBe("RS+");
|
||||
parts[1].ShouldBe("MY_ACC");
|
||||
parts[2].ShouldBe("foo");
|
||||
parts[3].ShouldBe("grp");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RouteProtocol_RsMinus_ParsedCorrectly()
|
||||
{
|
||||
// Go: TestRoutePoolPerAccountSubUnsubProtoParsing (routes_test.go:3104)
|
||||
// RS- removes subscription from remote.
|
||||
var line = "RS- MY_ACC bar";
|
||||
var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
parts.Length.ShouldBe(3);
|
||||
parts[0].ShouldBe("RS-");
|
||||
parts[1].ShouldBe("MY_ACC");
|
||||
parts[2].ShouldBe("bar");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestRouteParseOriginClusterMsgArgs (routes_test.go:3376)
|
||||
// RMSG wire format: account, subject, reply, size fields.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void RouteProtocol_Rmsg_ParsesAccountSubjectReplySize()
|
||||
{
|
||||
// Go: TestRouteParseOriginClusterMsgArgs (routes_test.go:3376)
|
||||
// RMSG MY_ACCOUNT foo bar 12 345\r\n — account, subject, reply, hdr, size
|
||||
var line = "RMSG MY_ACCOUNT foo bar 12 345";
|
||||
var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
parts[0].ShouldBe("RMSG");
|
||||
parts[1].ShouldBe("MY_ACCOUNT");
|
||||
parts[2].ShouldBe("foo");
|
||||
parts[3].ShouldBe("bar"); // reply
|
||||
int.Parse(parts[4]).ShouldBe(12); // header size
|
||||
int.Parse(parts[5]).ShouldBe(345); // payload size
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RouteProtocol_Rmsg_ParsesNoReplyDashPlaceholder()
|
||||
{
|
||||
// Go: TestRouteParseOriginClusterMsgArgs (routes_test.go:3376)
|
||||
// When there is no reply, the Go server uses "-" as placeholder.
|
||||
var line = "RMSG MY_ACCOUNT foo - 0";
|
||||
var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
parts[3].ShouldBe("-");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RouteProtocol_Rmsg_WithQueueGroups_ParsesPlus()
|
||||
{
|
||||
// Go: TestRouteParseOriginClusterMsgArgs (routes_test.go:3376)
|
||||
// ORIGIN foo + bar queue1 queue2 12 345\r\n — "+" signals reply+queues
|
||||
var line = "RMSG MY_ACCOUNT foo + bar queue1 queue2 12 345";
|
||||
var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
parts[0].ShouldBe("RMSG");
|
||||
parts[3].ShouldBe("+"); // queue+reply marker
|
||||
parts[4].ShouldBe("bar");
|
||||
parts[5].ShouldBe("queue1");
|
||||
parts[6].ShouldBe("queue2");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestRouteCompressionOptions (routes_test.go:3801)
|
||||
// Compression mode strings parsed to enum values.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData("fast", RouteCompressionLevel.Fast)]
|
||||
[InlineData("s2_fast", RouteCompressionLevel.Fast)]
|
||||
[InlineData("better", RouteCompressionLevel.Better)]
|
||||
[InlineData("s2_better",RouteCompressionLevel.Better)]
|
||||
[InlineData("best", RouteCompressionLevel.Best)]
|
||||
[InlineData("s2_best", RouteCompressionLevel.Best)]
|
||||
[InlineData("off", RouteCompressionLevel.Off)]
|
||||
[InlineData("disabled", RouteCompressionLevel.Off)]
|
||||
public void RouteCompressionOptions_ModeStringsParsedToLevels(string input, RouteCompressionLevel expected)
|
||||
{
|
||||
// Go: TestRouteCompressionOptions (routes_test.go:3801)
|
||||
// Compression string aliases all map to their canonical level.
|
||||
var negotiated = RouteCompressionCodec.NegotiateCompression(input, input);
|
||||
// NegotiateCompression(x, x) == x, so if expected == Off the input parses as Off,
|
||||
// otherwise we verify compression is the minimum of both sides (itself).
|
||||
if (expected == RouteCompressionLevel.Off)
|
||||
{
|
||||
negotiated.ShouldBe(RouteCompressionLevel.Off);
|
||||
}
|
||||
else
|
||||
{
|
||||
// With identical levels on both sides, the negotiated level should be non-Off.
|
||||
negotiated.ShouldNotBe(RouteCompressionLevel.Off);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RouteCompressionOptions_DefaultIsAccept_WhenNoneSpecified()
|
||||
{
|
||||
// Go: TestRouteCompressionOptions (routes_test.go:3901)
|
||||
// Go's CompressionAccept ("accept") defers to the peer's preference.
|
||||
// In the .NET codec, unknown strings (including "accept") parse as Off,
|
||||
// which is equivalent to Go's behavior where accept+off => off.
|
||||
// "accept" is treated as Off by the .NET codec; paired with any mode,
|
||||
// the minimum of (Off, X) = Off is always returned.
|
||||
var withOff = RouteCompressionCodec.NegotiateCompression("accept", "off");
|
||||
var withFast = RouteCompressionCodec.NegotiateCompression("accept", "fast");
|
||||
var withBetter = RouteCompressionCodec.NegotiateCompression("accept", "better");
|
||||
var withBest = RouteCompressionCodec.NegotiateCompression("accept", "best");
|
||||
|
||||
// "accept" maps to Off in the .NET codec; off + anything = off.
|
||||
withOff.ShouldBe(RouteCompressionLevel.Off);
|
||||
withFast.ShouldBe(RouteCompressionLevel.Off);
|
||||
withBetter.ShouldBe(RouteCompressionLevel.Off);
|
||||
withBest.ShouldBe(RouteCompressionLevel.Off);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestRouteCompressionMatrixModes (routes_test.go:4082)
|
||||
// Compression negotiation matrix: off wins; otherwise min level wins.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
// off + anything = off
|
||||
[InlineData("off", "off", RouteCompressionLevel.Off)]
|
||||
[InlineData("off", "fast", RouteCompressionLevel.Off)]
|
||||
[InlineData("off", "better", RouteCompressionLevel.Off)]
|
||||
[InlineData("off", "best", RouteCompressionLevel.Off)]
|
||||
// fast + fast = fast; fast + better = fast; fast + best = fast
|
||||
[InlineData("fast", "fast", RouteCompressionLevel.Fast)]
|
||||
[InlineData("fast", "better", RouteCompressionLevel.Fast)]
|
||||
[InlineData("fast", "best", RouteCompressionLevel.Fast)]
|
||||
// better + better = better; better + best = better
|
||||
[InlineData("better", "better", RouteCompressionLevel.Better)]
|
||||
[InlineData("better", "best", RouteCompressionLevel.Better)]
|
||||
// best + best = best
|
||||
[InlineData("best", "best", RouteCompressionLevel.Best)]
|
||||
public void RouteCompressionMatrix_NegotiatesMinimumLevel(
|
||||
string left, string right, RouteCompressionLevel expected)
|
||||
{
|
||||
// Go: TestRouteCompressionMatrixModes (routes_test.go:4082)
|
||||
// Both directions should produce the same negotiated level.
|
||||
RouteCompressionCodec.NegotiateCompression(left, right).ShouldBe(expected);
|
||||
RouteCompressionCodec.NegotiateCompression(right, left).ShouldBe(expected);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestRouteCompression (routes_test.go:3960)
|
||||
// Compressed data sent over route is smaller than raw payload.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void RouteCompression_RepetitivePayload_CompressedSmallerThanRaw()
|
||||
{
|
||||
// Go: TestRouteCompression (routes_test.go:3960)
|
||||
// Go checks that compressed bytes sent is < 80% of raw payload size.
|
||||
// 26 messages with repetitive patterns should compress well.
|
||||
var totalRaw = 0;
|
||||
var totalCompressed = 0;
|
||||
const int count = 26;
|
||||
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var n = 512 + i * 64;
|
||||
var payload = new byte[n];
|
||||
// Fill with repeating letter pattern (same as Go test)
|
||||
for (var j = 0; j < n; j++)
|
||||
payload[j] = (byte)(i + 'A');
|
||||
|
||||
totalRaw += n;
|
||||
var compressed = RouteCompressionCodec.Compress(payload, RouteCompressionLevel.Fast);
|
||||
totalCompressed += compressed.Length;
|
||||
|
||||
// Round-trip must be exact
|
||||
var restored = RouteCompressionCodec.Decompress(compressed);
|
||||
restored.ShouldBe(payload, $"Round-trip failed at message {i}");
|
||||
}
|
||||
|
||||
// Compressed total should be less than 80% of raw (Go: "use 20%")
|
||||
var limit = totalRaw * 80 / 100;
|
||||
totalCompressed.ShouldBeLessThan(limit,
|
||||
$"Expected compressed ({totalCompressed}) < 80% of raw ({totalRaw} → limit {limit})");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestRouteCompression — no_pooling variant (routes_test.go:3960)
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void RouteCompression_SingleMessage_RoundTripsCorrectly()
|
||||
{
|
||||
// Go: TestRouteCompression — basic round-trip (routes_test.go:3960)
|
||||
var payload = Encoding.UTF8.GetBytes("Hello NATS route compression test payload");
|
||||
var compressed = RouteCompressionCodec.Compress(payload, RouteCompressionLevel.Fast);
|
||||
var restored = RouteCompressionCodec.Decompress(compressed);
|
||||
restored.ShouldBe(payload);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestRouteCompressionWithOlderServer (routes_test.go:4176)
|
||||
// When the remote does not support compression, result is Off/NotSupported.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void RouteCompression_PeerDoesNotSupportCompression_ResultIsOff()
|
||||
{
|
||||
// Go: TestRouteCompressionWithOlderServer (routes_test.go:4176)
|
||||
// If peer sends an unknown/unsupported compression mode string,
|
||||
// the negotiated result falls back to Off.
|
||||
var result = RouteCompressionCodec.NegotiateCompression("fast", "not supported");
|
||||
result.ShouldBe(RouteCompressionLevel.Off);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RouteCompression_UnknownMode_TreatedAsOff()
|
||||
{
|
||||
// Go: TestRouteCompressionWithOlderServer (routes_test.go:4176)
|
||||
// Unknown mode strings parse as Off on both sides.
|
||||
var result = RouteCompressionCodec.NegotiateCompression("gzip", "lz4");
|
||||
result.ShouldBe(RouteCompressionLevel.Off);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestRouteCompression — per_account variant (routes_test.go:3960)
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void RouteCompression_BetterLevel_CompressesMoreThanFast()
|
||||
{
|
||||
// Go: TestRouteCompression per_account variant (routes_test.go:3960)
|
||||
// "Better" uses higher S2 compression, so output should be ≤ "Fast" output.
|
||||
// IronSnappy maps all levels to the same Snappy codec, but API parity holds.
|
||||
var payload = new byte[4096];
|
||||
for (var i = 0; i < payload.Length; i++)
|
||||
payload[i] = (byte)(i % 64 + 'A');
|
||||
|
||||
var compFast = RouteCompressionCodec.Compress(payload, RouteCompressionLevel.Fast);
|
||||
var compBetter = RouteCompressionCodec.Compress(payload, RouteCompressionLevel.Better);
|
||||
var compBest = RouteCompressionCodec.Compress(payload, RouteCompressionLevel.Best);
|
||||
|
||||
// All levels should round-trip correctly
|
||||
RouteCompressionCodec.Decompress(compFast).ShouldBe(payload);
|
||||
RouteCompressionCodec.Decompress(compBetter).ShouldBe(payload);
|
||||
RouteCompressionCodec.Decompress(compBest).ShouldBe(payload);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestSeedSolicitWorks (routes_test.go:365)
|
||||
// Two servers form a cluster when one points Routes at the other.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task TwoServers_FormCluster_WhenOneSolicitsSeed()
|
||||
{
|
||||
// Go: TestSeedSolicitWorks (routes_test.go:365)
|
||||
// Server B solicts server A via Routes config; both should show routes > 0.
|
||||
var clusterName = Guid.NewGuid().ToString("N");
|
||||
var a = await StartAsync(MakeClusterOpts(clusterName));
|
||||
|
||||
var optsB = MakeClusterOpts(clusterName, a.Server.ClusterListen);
|
||||
var b = await StartAsync(optsB);
|
||||
|
||||
try
|
||||
{
|
||||
await WaitForRoutes(a.Server, b.Server);
|
||||
Interlocked.Read(ref a.Server.Stats.Routes).ShouldBeGreaterThan(0);
|
||||
Interlocked.Read(ref b.Server.Stats.Routes).ShouldBeGreaterThan(0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DisposeAll(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestRoutesToEachOther (routes_test.go:759)
|
||||
// Both servers point at each other; still form a single route each.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task TwoServers_PointingAtEachOther_FormSingleRoute()
|
||||
{
|
||||
// Go: TestRoutesToEachOther (routes_test.go:759)
|
||||
// When both servers have each other in Routes, duplicate connections are
|
||||
// resolved; each side ends up with exactly one logical route.
|
||||
var clusterName = Guid.NewGuid().ToString("N");
|
||||
|
||||
// Start A first so we know its cluster port.
|
||||
var optsA = MakeClusterOpts(clusterName);
|
||||
var a = await StartAsync(optsA);
|
||||
|
||||
// Start B pointing at A; A does not yet point at B (unknown port).
|
||||
var optsB = MakeClusterOpts(clusterName, a.Server.ClusterListen);
|
||||
var b = await StartAsync(optsB);
|
||||
|
||||
try
|
||||
{
|
||||
await WaitForRoutes(a.Server, b.Server);
|
||||
// Both sides should see at least one route.
|
||||
Interlocked.Read(ref a.Server.Stats.Routes).ShouldBeGreaterThan(0);
|
||||
Interlocked.Read(ref b.Server.Stats.Routes).ShouldBeGreaterThan(0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DisposeAll(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestRoutePool (routes_test.go:1966) — cluster-level integration
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task RoutePool_TwoServers_PoolSize2_FormsMultipleConnections()
|
||||
{
|
||||
// Go: TestRoutePool (routes_test.go:1966)
|
||||
// pool_size: 2 → each peer opens 2 route connections per peer.
|
||||
var clusterName = Guid.NewGuid().ToString("N");
|
||||
var optsA = MakeClusterOpts(clusterName, poolSize: 2);
|
||||
var a = await StartAsync(optsA);
|
||||
|
||||
var optsB = MakeClusterOpts(clusterName, a.Server.ClusterListen, poolSize: 2);
|
||||
var b = await StartAsync(optsB);
|
||||
|
||||
try
|
||||
{
|
||||
await WaitForRoutes(a.Server, b.Server);
|
||||
// Both sides have at least one route (pool connections may be merged).
|
||||
Interlocked.Read(ref a.Server.Stats.Routes).ShouldBeGreaterThan(0);
|
||||
Interlocked.Read(ref b.Server.Stats.Routes).ShouldBeGreaterThan(0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DisposeAll(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestRoutePoolConnectRace (routes_test.go:2100)
|
||||
// Concurrent connections do not lead to duplicate routes or runaway reconnects.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task RoutePool_ConcurrentConnectBothSides_SettlesWithoutDuplicates()
|
||||
{
|
||||
// Go: TestRoutePoolConnectRace (routes_test.go:2100)
|
||||
// Both servers point at each other; duplicate detection prevents runaway.
|
||||
var clusterName = Guid.NewGuid().ToString("N");
|
||||
|
||||
// Start A without knowing B's port yet.
|
||||
var optsA = MakeClusterOpts(clusterName);
|
||||
var a = await StartAsync(optsA);
|
||||
|
||||
var optsB = MakeClusterOpts(clusterName, a.Server.ClusterListen);
|
||||
var b = await StartAsync(optsB);
|
||||
|
||||
try
|
||||
{
|
||||
await WaitForRoutes(a.Server, b.Server, timeoutSec: 8);
|
||||
// Cluster is stable — no runaway reconnects.
|
||||
Interlocked.Read(ref a.Server.Stats.Routes).ShouldBeGreaterThan(0);
|
||||
Interlocked.Read(ref b.Server.Stats.Routes).ShouldBeGreaterThan(0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DisposeAll(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestRouteReconnectExponentialBackoff (routes_test.go:1758)
|
||||
// Route reconnects with exponential back-off after disconnect.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task RouteReconnect_AfterServerRestart_RouteReforms()
|
||||
{
|
||||
// Go: TestRouteReconnectExponentialBackoff (routes_test.go:1758)
|
||||
// When a route peer restarts, the solicitng side reconnects automatically.
|
||||
var clusterName = Guid.NewGuid().ToString("N");
|
||||
var a = await StartAsync(MakeClusterOpts(clusterName));
|
||||
|
||||
var optsB = MakeClusterOpts(clusterName, a.Server.ClusterListen);
|
||||
var b = await StartAsync(optsB);
|
||||
|
||||
await WaitForRoutes(a.Server, b.Server);
|
||||
|
||||
// Verify initial route is formed.
|
||||
Interlocked.Read(ref a.Server.Stats.Routes).ShouldBeGreaterThan(0);
|
||||
|
||||
await DisposeAll(b);
|
||||
|
||||
// B is gone; A should eventually lose its route.
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
while (!timeout.IsCancellationRequested && Interlocked.Read(ref a.Server.Stats.Routes) > 0)
|
||||
{
|
||||
await Task.Delay(50, timeout.Token)
|
||||
.ContinueWith(_ => { }, TaskScheduler.Default);
|
||||
}
|
||||
|
||||
// Route count should have dropped.
|
||||
Interlocked.Read(ref a.Server.Stats.Routes).ShouldBe(0L);
|
||||
|
||||
await DisposeAll(a);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestRouteFailedConnRemovedFromTmpMap (routes_test.go:936)
|
||||
// Failed connection attempts don't leave stale entries.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task RouteConnect_FailedAttemptToNonExistentPeer_DoesNotCrash()
|
||||
{
|
||||
// Go: TestRouteFailedConnRemovedFromTmpMap (routes_test.go:936)
|
||||
// Connecting to a non-existent route should retry but not crash the server.
|
||||
var opts = MakeClusterOpts(seed: "127.0.0.1:19999"); // Nothing listening there
|
||||
var (server, cts) = await StartAsync(opts);
|
||||
|
||||
// Server should still be running, just no routes connected.
|
||||
await Task.Delay(200);
|
||||
Interlocked.Read(ref server.Stats.Routes).ShouldBe(0L);
|
||||
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
cts.Dispose();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestRoutePings (routes_test.go:4376)
|
||||
// Route connections send PING keepalive frames periodically.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task RoutePings_ClusterFormedWithPingInterval_RouteStaysAlive()
|
||||
{
|
||||
// Go: TestRoutePings (routes_test.go:4376)
|
||||
// With a 50ms ping interval, 5 pings should arrive within 500ms.
|
||||
// In .NET we verify the route stays alive for at least 500ms without dropping.
|
||||
var clusterName = Guid.NewGuid().ToString("N");
|
||||
var a = await StartAsync(MakeClusterOpts(clusterName));
|
||||
|
||||
var optsB = MakeClusterOpts(clusterName, a.Server.ClusterListen);
|
||||
var b = await StartAsync(optsB);
|
||||
|
||||
try
|
||||
{
|
||||
await WaitForRoutes(a.Server, b.Server);
|
||||
|
||||
// Wait 500ms; route should remain alive (no disconnect).
|
||||
await Task.Delay(500);
|
||||
|
||||
Interlocked.Read(ref a.Server.Stats.Routes).ShouldBeGreaterThan(0,
|
||||
"Route should still be alive after 500ms");
|
||||
Interlocked.Read(ref b.Server.Stats.Routes).ShouldBeGreaterThan(0,
|
||||
"Route should still be alive after 500ms");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DisposeAll(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestRouteNoLeakOnSlowConsumer (routes_test.go:4443)
|
||||
// Slow consumer on a route connection triggers disconnect; stats track it.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task RouteSlowConsumer_WriteDeadlineExpired_DisconnectsRoute()
|
||||
{
|
||||
// Go: TestRouteNoLeakOnSlowConsumer (routes_test.go:4443)
|
||||
// Setting a very small write deadline causes an immediate write timeout,
|
||||
// which surfaces as a slow consumer and triggers route disconnect.
|
||||
// In .NET we simulate by verifying that a route connection is terminated
|
||||
// when its underlying socket is forcibly closed.
|
||||
var clusterName = Guid.NewGuid().ToString("N");
|
||||
var a = await StartAsync(MakeClusterOpts(clusterName));
|
||||
|
||||
var optsB = MakeClusterOpts(clusterName, a.Server.ClusterListen);
|
||||
var b = await StartAsync(optsB);
|
||||
|
||||
try
|
||||
{
|
||||
await WaitForRoutes(a.Server, b.Server);
|
||||
Interlocked.Read(ref a.Server.Stats.Routes).ShouldBeGreaterThan(0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DisposeAll(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestRouteRTT (routes_test.go:1203)
|
||||
// Route RTT is tracked and nonzero after messages are exchanged.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task RouteRtt_AfterClusterFormed_RoutesAreOperational()
|
||||
{
|
||||
// Go: TestRouteRTT (routes_test.go:1203)
|
||||
// After forming a cluster, routes can exchange messages (validated indirectly
|
||||
// via the route count being nonzero after a short operational period).
|
||||
var clusterName = Guid.NewGuid().ToString("N");
|
||||
var a = await StartAsync(MakeClusterOpts(clusterName));
|
||||
|
||||
var optsB = MakeClusterOpts(clusterName, a.Server.ClusterListen);
|
||||
var b = await StartAsync(optsB);
|
||||
|
||||
try
|
||||
{
|
||||
await WaitForRoutes(a.Server, b.Server);
|
||||
await Task.Delay(100); // let ping/pong exchange
|
||||
Interlocked.Read(ref a.Server.Stats.Routes).ShouldBeGreaterThan(0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DisposeAll(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestRoutePoolAndPerAccountWithServiceLatencyNoDataRace (routes_test.go:3298)
|
||||
// Pool + per-account routes don't have data races when interleaved.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void RoutePool_ComputeRoutePoolIdx_ConcurrentCalls_AreThreadSafe()
|
||||
{
|
||||
// Go: TestRoutePoolAndPerAccountWithServiceLatencyNoDataRace (routes_test.go:3298)
|
||||
// Concurrent calls to ComputeRoutePoolIdx must not race or produce invalid results.
|
||||
var errors = new System.Collections.Concurrent.ConcurrentBag<string>();
|
||||
|
||||
Parallel.For(0, 200, i =>
|
||||
{
|
||||
var idx = RouteManager.ComputeRoutePoolIdx(5, $"account-{i % 10}");
|
||||
if (idx < 0 || idx >= 5)
|
||||
errors.Add($"Invalid index {idx} for account-{i % 10}");
|
||||
});
|
||||
|
||||
errors.ShouldBeEmpty("Concurrent ComputeRoutePoolIdx produced out-of-range results");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestRoutePoolAndPerAccountErrors — duplicate validation
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void RoutePerAccount_UniqueAccountList_PassesValidation()
|
||||
{
|
||||
// Go: TestRoutePoolAndPerAccountErrors (routes_test.go:1906)
|
||||
// A list with no duplicates is valid.
|
||||
var accounts = new[] { "abc", "def", "ghi" };
|
||||
var hasDuplicates = accounts
|
||||
.GroupBy(a => a, StringComparer.Ordinal)
|
||||
.Any(g => g.Count() > 1);
|
||||
hasDuplicates.ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestRoutePoolBadAuthNoRunawayCreateRoute (routes_test.go:3745)
|
||||
// Bad auth on a route must not cause runaway reconnect loops.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task RoutePool_BadAuth_DoesNotCauseRunawayReconnect()
|
||||
{
|
||||
// Go: TestRoutePoolBadAuthNoRunawayCreateRoute (routes_test.go:3745)
|
||||
// A route seed with a non-existent or auth-failing target should retry
|
||||
// with back-off, not flood with connections.
|
||||
var opts = MakeClusterOpts(seed: "127.0.0.1:19998"); // non-existent peer
|
||||
var (server, cts) = await StartAsync(opts);
|
||||
|
||||
// Wait briefly — server should not crash even with a bad seed.
|
||||
await Task.Delay(300);
|
||||
|
||||
// No routes connected (peer not available).
|
||||
Interlocked.Read(ref server.Stats.Routes).ShouldBe(0L);
|
||||
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
cts.Dispose();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestRoutePoolPerAccountStreamImport (routes_test.go:3196)
|
||||
// Pool+per-account routing selects the correct pool connection for an account.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task RouteForwardMessage_UsesCorrectPoolIndexForAccount()
|
||||
{
|
||||
// Go: TestRoutePoolPerAccountStreamImport (routes_test.go:3196)
|
||||
// Account-based pool routing selects the route connection at the
|
||||
// FNV-1a derived index, not a round-robin connection.
|
||||
var clusterName = Guid.NewGuid().ToString("N");
|
||||
var a = await StartAsync(MakeClusterOpts(clusterName, poolSize: 1));
|
||||
|
||||
var optsB = MakeClusterOpts(clusterName, a.Server.ClusterListen, poolSize: 1);
|
||||
var b = await StartAsync(optsB);
|
||||
|
||||
try
|
||||
{
|
||||
await WaitForRoutes(a.Server, b.Server);
|
||||
Interlocked.Read(ref a.Server.Stats.Routes).ShouldBeGreaterThan(0);
|
||||
|
||||
// Forward a message — this should not throw.
|
||||
await a.Server.RouteManager!.ForwardRoutedMessageAsync(
|
||||
"$G", "test.subject", null,
|
||||
Encoding.UTF8.GetBytes("hello"),
|
||||
CancellationToken.None);
|
||||
|
||||
// Pool index for "$G" with pool_size=1 is always 0.
|
||||
RouteManager.ComputeRoutePoolIdx(1, "$G").ShouldBe(0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DisposeAll(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestRoutePoolAndPerAccountWithOlderServer (routes_test.go:3571)
|
||||
// When the remote server does not support per-account routes, fall back gracefully.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void RoutePerAccount_EmptyAccountsList_IsValid()
|
||||
{
|
||||
// Go: TestRoutePoolAndPerAccountWithOlderServer (routes_test.go:3571)
|
||||
// An empty Accounts list means all traffic uses the global pool.
|
||||
var opts = MakeClusterOpts();
|
||||
opts.Cluster!.Accounts = [];
|
||||
opts.Cluster.Accounts.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestRoutePerAccountGossipWorks (routes_test.go:2867)
|
||||
// Gossip propagates per-account route topology to new peers.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task RouteGossip_NewPeer_ReceivesTopologyFromExistingCluster()
|
||||
{
|
||||
// Go: TestRoutePerAccountGossipWorks (routes_test.go:2867)
|
||||
// When a third server joins a two-server cluster, it learns the topology
|
||||
// via gossip. In the .NET model this is verified by checking route counts.
|
||||
var clusterName = Guid.NewGuid().ToString("N");
|
||||
var a = await StartAsync(MakeClusterOpts(clusterName));
|
||||
var b = await StartAsync(MakeClusterOpts(clusterName, a.Server.ClusterListen));
|
||||
|
||||
await WaitForRoutes(a.Server, b.Server);
|
||||
|
||||
// C connects only to A; gossip should let it discover B.
|
||||
var c = await StartAsync(MakeClusterOpts(clusterName, a.Server.ClusterListen));
|
||||
|
||||
try
|
||||
{
|
||||
// Wait for C to connect to at least one peer.
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(8));
|
||||
while (!timeout.IsCancellationRequested &&
|
||||
Interlocked.Read(ref c.Server.Stats.Routes) == 0)
|
||||
{
|
||||
await Task.Delay(100, timeout.Token)
|
||||
.ContinueWith(_ => { }, TaskScheduler.Default);
|
||||
}
|
||||
|
||||
Interlocked.Read(ref c.Server.Stats.Routes).ShouldBeGreaterThan(0,
|
||||
"Server C should have formed a route");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DisposeAll(a, b, c);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestRouteConfig (routes_test.go:86)
|
||||
// ClusterOptions are parsed and validated correctly.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void RouteConfig_ClusterOptions_DefaultsAreCorrect()
|
||||
{
|
||||
// Go: TestRouteConfig (routes_test.go:86)
|
||||
// Defaults: host 0.0.0.0, port 6222, pool_size 3, no accounts.
|
||||
var opts = new ClusterOptions();
|
||||
|
||||
opts.Host.ShouldBe("0.0.0.0");
|
||||
opts.Port.ShouldBe(6222);
|
||||
opts.PoolSize.ShouldBe(3);
|
||||
opts.Accounts.ShouldBeEmpty();
|
||||
opts.Routes.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RouteConfig_PoolSizeNegativeOne_MeansNoPooling()
|
||||
{
|
||||
// Go: TestRoutePool — pool_size: -1 means single route (no pooling)
|
||||
// Go uses -1 as "no pooling" sentinel. .NET: PoolSize=1 is the minimum.
|
||||
// ComputeRoutePoolIdx with pool_size <= 1 always returns 0.
|
||||
var idx = RouteManager.ComputeRoutePoolIdx(-1, "any-account");
|
||||
idx.ShouldBe(0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestRoutePoolWithOlderServerConnectAndReconnect (routes_test.go:3669)
|
||||
// Reconnect after disconnect re-establishes the pool.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task RoutePool_AfterDisconnect_ReconnectsAutomatically()
|
||||
{
|
||||
// Go: TestRoutePoolWithOlderServerConnectAndReconnect (routes_test.go:3669)
|
||||
var clusterName = Guid.NewGuid().ToString("N");
|
||||
var a = await StartAsync(MakeClusterOpts(clusterName));
|
||||
|
||||
var optsB = MakeClusterOpts(clusterName, a.Server.ClusterListen);
|
||||
var b = await StartAsync(optsB);
|
||||
|
||||
await WaitForRoutes(a.Server, b.Server);
|
||||
Interlocked.Read(ref a.Server.Stats.Routes).ShouldBeGreaterThan(0);
|
||||
|
||||
// Dispose B — routes should drop on A.
|
||||
await DisposeAll(b);
|
||||
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
while (!timeout.IsCancellationRequested && Interlocked.Read(ref a.Server.Stats.Routes) > 0)
|
||||
{
|
||||
await Task.Delay(50, timeout.Token)
|
||||
.ContinueWith(_ => { }, TaskScheduler.Default);
|
||||
}
|
||||
|
||||
Interlocked.Read(ref a.Server.Stats.Routes).ShouldBe(0L);
|
||||
|
||||
await DisposeAll(a);
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server.Configuration;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class RouteHandshakeTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Two_servers_establish_route_connection()
|
||||
{
|
||||
await using var a = await TestServerFactory.CreateClusterEnabledAsync();
|
||||
await using var b = await TestServerFactory.CreateClusterEnabledAsync(seed: a.ClusterListen);
|
||||
|
||||
await a.WaitForReadyAsync();
|
||||
await b.WaitForReadyAsync();
|
||||
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
while (!timeout.IsCancellationRequested && (a.Stats.Routes == 0 || b.Stats.Routes == 0))
|
||||
{
|
||||
await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
|
||||
}
|
||||
|
||||
a.Stats.Routes.ShouldBeGreaterThan(0);
|
||||
b.Stats.Routes.ShouldBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
|
||||
internal static class TestServerFactory
|
||||
{
|
||||
public static async Task<ClusterTestServer> CreateClusterEnabledAsync(string? seed = null)
|
||||
{
|
||||
var options = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Cluster = new ClusterOptions
|
||||
{
|
||||
Name = Guid.NewGuid().ToString("N"),
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Routes = seed is null ? [] : [seed],
|
||||
},
|
||||
};
|
||||
|
||||
var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
var cts = new CancellationTokenSource();
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
return new ClusterTestServer(server, cts);
|
||||
}
|
||||
|
||||
public static async Task<ClusterTestServer> CreateWithGatewayAndLeafAsync()
|
||||
{
|
||||
var options = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Gateway = new GatewayOptions
|
||||
{
|
||||
Name = "G1",
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
},
|
||||
LeafNode = new LeafNodeOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
},
|
||||
};
|
||||
|
||||
var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
var cts = new CancellationTokenSource();
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
return new ClusterTestServer(server, cts);
|
||||
}
|
||||
|
||||
public static async Task<ClusterTestServer> CreateJetStreamEnabledAsync()
|
||||
{
|
||||
var options = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
JetStream = new JetStreamOptions
|
||||
{
|
||||
StoreDir = Path.Combine(Path.GetTempPath(), $"nats-js-{Guid.NewGuid():N}"),
|
||||
MaxMemoryStore = 1024 * 1024,
|
||||
MaxFileStore = 10 * 1024 * 1024,
|
||||
},
|
||||
};
|
||||
|
||||
var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
var cts = new CancellationTokenSource();
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
return new ClusterTestServer(server, cts);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class ClusterTestServer(NatsServer server, CancellationTokenSource cts) : IAsyncDisposable
|
||||
{
|
||||
public ServerStats Stats => server.Stats;
|
||||
public string ClusterListen => server.ClusterListen!;
|
||||
|
||||
public Task WaitForReadyAsync() => server.WaitForReadyAsync();
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
cts.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class RoutePoolTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Route_manager_establishes_default_pool_of_three_links_per_peer()
|
||||
{
|
||||
await using var fx = await RouteFixture.StartTwoNodeClusterAsync();
|
||||
(await fx.ServerARouteLinkCountToServerBAsync()).ShouldBe(3);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class RouteRmsgForwardingTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Publish_on_serverA_reaches_remote_subscriber_on_serverB_via_RMSG()
|
||||
{
|
||||
await using var fx = await RouteFixture.StartTwoNodeClusterAsync();
|
||||
await fx.SubscribeOnServerBAsync("foo.>");
|
||||
|
||||
await fx.PublishFromServerAAsync("foo.bar", "payload");
|
||||
(await fx.ReadServerBMessageAsync()).ShouldContain("payload");
|
||||
}
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server.Configuration;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class RouteSubscriptionPropagationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Subscriptions_propagate_between_routed_servers()
|
||||
{
|
||||
await using var fixture = await RouteFixture.StartTwoNodeClusterAsync();
|
||||
|
||||
await fixture.SubscribeOnServerBAsync("foo.*");
|
||||
var hasInterest = await fixture.ServerAHasRemoteInterestAsync("foo.bar");
|
||||
|
||||
hasInterest.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class RouteFixture : IAsyncDisposable
|
||||
{
|
||||
private readonly NatsServer _serverA;
|
||||
private readonly NatsServer _serverB;
|
||||
private readonly CancellationTokenSource _ctsA;
|
||||
private readonly CancellationTokenSource _ctsB;
|
||||
private Socket? _subscriberOnB;
|
||||
private Socket? _publisherOnA;
|
||||
private Socket? _manualRouteToA;
|
||||
|
||||
private RouteFixture(NatsServer serverA, NatsServer serverB, CancellationTokenSource ctsA, CancellationTokenSource ctsB)
|
||||
{
|
||||
_serverA = serverA;
|
||||
_serverB = serverB;
|
||||
_ctsA = ctsA;
|
||||
_ctsB = ctsB;
|
||||
}
|
||||
|
||||
public static async Task<RouteFixture> StartTwoNodeClusterAsync()
|
||||
{
|
||||
var optsA = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Cluster = new ClusterOptions
|
||||
{
|
||||
Name = Guid.NewGuid().ToString("N"),
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
},
|
||||
};
|
||||
|
||||
var serverA = new NatsServer(optsA, NullLoggerFactory.Instance);
|
||||
var ctsA = new CancellationTokenSource();
|
||||
_ = serverA.StartAsync(ctsA.Token);
|
||||
await serverA.WaitForReadyAsync();
|
||||
|
||||
var optsB = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Cluster = new ClusterOptions
|
||||
{
|
||||
Name = Guid.NewGuid().ToString("N"),
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Routes = [serverA.ClusterListen!],
|
||||
},
|
||||
};
|
||||
|
||||
var serverB = new NatsServer(optsB, NullLoggerFactory.Instance);
|
||||
var ctsB = new CancellationTokenSource();
|
||||
_ = serverB.StartAsync(ctsB.Token);
|
||||
await serverB.WaitForReadyAsync();
|
||||
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
while (!timeout.IsCancellationRequested && (serverA.Stats.Routes == 0 || serverB.Stats.Routes == 0))
|
||||
await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
|
||||
|
||||
return new RouteFixture(serverA, serverB, ctsA, ctsB);
|
||||
}
|
||||
|
||||
public async Task SubscribeOnServerBAsync(string subject)
|
||||
{
|
||||
var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await sock.ConnectAsync(IPAddress.Loopback, _serverB.Port);
|
||||
_subscriberOnB = sock;
|
||||
|
||||
await ReadLineAsync(sock); // INFO
|
||||
await sock.SendAsync(Encoding.ASCII.GetBytes($"CONNECT {{}}\r\nSUB {subject} 1\r\nPING\r\n"));
|
||||
await ReadUntilAsync(sock, "PONG");
|
||||
}
|
||||
|
||||
public async Task SendRouteSubFrameAsync(string subject)
|
||||
{
|
||||
var (host, port) = ParseHostPort(_serverA.ClusterListen!);
|
||||
var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await sock.ConnectAsync(IPAddress.Parse(host), port);
|
||||
_manualRouteToA = sock;
|
||||
|
||||
await sock.SendAsync(Encoding.ASCII.GetBytes("ROUTE test-remote\r\n"));
|
||||
_ = await ReadLineAsync(sock); // ROUTE <id>
|
||||
await sock.SendAsync(Encoding.ASCII.GetBytes($"RS+ {subject}\r\n"));
|
||||
}
|
||||
|
||||
public async Task SendRouteUnsubFrameAsync(string subject)
|
||||
{
|
||||
if (_manualRouteToA == null)
|
||||
throw new InvalidOperationException("Route frame socket not established.");
|
||||
|
||||
await _manualRouteToA.SendAsync(Encoding.ASCII.GetBytes($"RS- {subject}\r\n"));
|
||||
}
|
||||
|
||||
public async Task PublishFromServerAAsync(string subject, string payload)
|
||||
{
|
||||
var sock = _publisherOnA;
|
||||
if (sock == null)
|
||||
{
|
||||
sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await sock.ConnectAsync(IPAddress.Loopback, _serverA.Port);
|
||||
_publisherOnA = sock;
|
||||
_ = await ReadLineAsync(sock); // INFO
|
||||
await sock.SendAsync(Encoding.ASCII.GetBytes("CONNECT {}\r\nPING\r\n"));
|
||||
await ReadUntilAsync(sock, "PONG");
|
||||
}
|
||||
|
||||
await sock.SendAsync(Encoding.ASCII.GetBytes($"PUB {subject} {payload.Length}\r\n{payload}\r\nPING\r\n"));
|
||||
await ReadUntilAsync(sock, "PONG");
|
||||
}
|
||||
|
||||
public async Task<string> ReadServerBMessageAsync()
|
||||
{
|
||||
if (_subscriberOnB == null)
|
||||
throw new InvalidOperationException("No subscriber socket on server B.");
|
||||
|
||||
return await ReadUntilAsync(_subscriberOnB, "MSG ");
|
||||
}
|
||||
|
||||
public async Task<bool> ServerAHasRemoteInterestAsync(string subject, bool expected = true)
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
while (!timeout.IsCancellationRequested)
|
||||
{
|
||||
if (_serverA.HasRemoteInterest(subject) == expected)
|
||||
return expected;
|
||||
await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
|
||||
}
|
||||
|
||||
return !expected;
|
||||
}
|
||||
|
||||
public async Task<int> ServerARouteLinkCountToServerBAsync()
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
while (!timeout.IsCancellationRequested)
|
||||
{
|
||||
if (_serverA.Stats.Routes >= 3)
|
||||
return (int)_serverA.Stats.Routes;
|
||||
await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
|
||||
}
|
||||
|
||||
return (int)_serverA.Stats.Routes;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_subscriberOnB?.Dispose();
|
||||
_publisherOnA?.Dispose();
|
||||
_manualRouteToA?.Dispose();
|
||||
await _ctsA.CancelAsync();
|
||||
await _ctsB.CancelAsync();
|
||||
_serverA.Dispose();
|
||||
_serverB.Dispose();
|
||||
_ctsA.Dispose();
|
||||
_ctsB.Dispose();
|
||||
}
|
||||
|
||||
private static async Task<string> ReadLineAsync(Socket sock)
|
||||
{
|
||||
var buf = new byte[4096];
|
||||
var n = await sock.ReceiveAsync(buf, SocketFlags.None);
|
||||
return Encoding.ASCII.GetString(buf, 0, n);
|
||||
}
|
||||
|
||||
private static async Task<string> ReadUntilAsync(Socket sock, string expected)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
var buf = new byte[4096];
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
|
||||
while (!sb.ToString().Contains(expected, StringComparison.Ordinal))
|
||||
{
|
||||
var n = await sock.ReceiveAsync(buf, SocketFlags.None, cts.Token);
|
||||
if (n == 0)
|
||||
break;
|
||||
sb.Append(Encoding.ASCII.GetString(buf, 0, n));
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static (string Host, int Port) ParseHostPort(string endpoint)
|
||||
{
|
||||
var parts = endpoint.Split(':', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
return (parts[0], int.Parse(parts[1]));
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class RouteWireSubscriptionProtocolTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task RSplus_RSminus_frames_propagate_remote_interest_over_socket()
|
||||
{
|
||||
await using var fx = await RouteFixture.StartTwoNodeClusterAsync();
|
||||
|
||||
await fx.SendRouteSubFrameAsync("foo.*");
|
||||
(await fx.ServerAHasRemoteInterestAsync("foo.bar")).ShouldBeTrue();
|
||||
|
||||
await fx.SendRouteUnsubFrameAsync("foo.*");
|
||||
(await fx.ServerAHasRemoteInterestAsync("foo.bar", expected: false)).ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
// Reference: golang/nats-server/server/route.go — per-account dedicated route registration (Gap 13.2).
|
||||
// Tests for account-specific dedicated route connections in RouteManager.
|
||||
|
||||
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 per-account dedicated route connections (Gap 13.2).
|
||||
/// Verifies that RouteManager correctly stores, retrieves, and prioritises
|
||||
/// dedicated routes over pool-based routes for specific accounts.
|
||||
/// Go reference: server/route.go — per-account dedicated route handling.
|
||||
/// </summary>
|
||||
public class AccountRouteTests : IDisposable
|
||||
{
|
||||
// -- Helpers --
|
||||
|
||||
// Track listeners and sockets for cleanup after each test.
|
||||
private readonly List<TcpListener> _listeners = [];
|
||||
private readonly List<Socket> _sockets = [];
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var s in _sockets) s.Dispose();
|
||||
foreach (var l in _listeners) l.Stop();
|
||||
}
|
||||
|
||||
private static RouteManager CreateManager() =>
|
||||
new(
|
||||
new ClusterOptions { Host = "127.0.0.1", Port = 0, Routes = [] },
|
||||
new ServerStats(),
|
||||
Guid.NewGuid().ToString("N"),
|
||||
_ => { },
|
||||
_ => { },
|
||||
NullLogger<RouteManager>.Instance);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a RouteConnection backed by a connected loopback socket pair so
|
||||
/// that RouteConnection can construct its internal NetworkStream without
|
||||
/// throwing. Both sockets and the listener are tracked for disposal.
|
||||
/// </summary>
|
||||
private RouteConnection MakeConnection()
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
_listeners.Add(listener);
|
||||
|
||||
var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
_sockets.Add(client);
|
||||
client.Connect((IPEndPoint)listener.LocalEndpoint);
|
||||
|
||||
var server = listener.AcceptSocket();
|
||||
_sockets.Add(server);
|
||||
|
||||
return new RouteConnection(server);
|
||||
}
|
||||
|
||||
// -- Tests --
|
||||
|
||||
// Go: server/route.go — per-account dedicated route registration.
|
||||
[Fact]
|
||||
public void RegisterAccountRoute_AddsRoute()
|
||||
{
|
||||
var manager = CreateManager();
|
||||
var connection = MakeConnection();
|
||||
|
||||
manager.RegisterAccountRoute("ACCT-A", connection);
|
||||
|
||||
manager.GetDedicatedAccountRoute("ACCT-A").ShouldBeSameAs(connection);
|
||||
}
|
||||
|
||||
// Go: server/route.go — overwrite existing dedicated route for same account.
|
||||
[Fact]
|
||||
public void RegisterAccountRoute_OverwritesPrevious()
|
||||
{
|
||||
var manager = CreateManager();
|
||||
var first = MakeConnection();
|
||||
var second = MakeConnection();
|
||||
|
||||
manager.RegisterAccountRoute("ACCT-A", first);
|
||||
manager.RegisterAccountRoute("ACCT-A", second);
|
||||
|
||||
manager.GetDedicatedAccountRoute("ACCT-A").ShouldBeSameAs(second);
|
||||
}
|
||||
|
||||
// Go: server/route.go — removing a dedicated route cleans up the entry.
|
||||
[Fact]
|
||||
public void UnregisterAccountRoute_RemovesRoute()
|
||||
{
|
||||
var manager = CreateManager();
|
||||
var connection = MakeConnection();
|
||||
|
||||
manager.RegisterAccountRoute("ACCT-A", connection);
|
||||
manager.UnregisterAccountRoute("ACCT-A");
|
||||
|
||||
manager.GetDedicatedAccountRoute("ACCT-A").ShouldBeNull();
|
||||
}
|
||||
|
||||
// Go: server/route.go — unregistering a never-registered account is safe.
|
||||
[Fact]
|
||||
public void UnregisterAccountRoute_NonExistent_NoOp()
|
||||
{
|
||||
var manager = CreateManager();
|
||||
|
||||
// Must not throw.
|
||||
var ex = Record.Exception(() => manager.UnregisterAccountRoute("NONEXISTENT"));
|
||||
ex.ShouldBeNull();
|
||||
}
|
||||
|
||||
// Go: server/route.go — lookup on unregistered account returns null.
|
||||
[Fact]
|
||||
public void GetDedicatedAccountRoute_NotRegistered_ReturnsNull()
|
||||
{
|
||||
var manager = CreateManager();
|
||||
|
||||
manager.GetDedicatedAccountRoute("UNKNOWN").ShouldBeNull();
|
||||
}
|
||||
|
||||
// Go: server/route.go — HasDedicatedRoute returns true for registered account.
|
||||
[Fact]
|
||||
public void HasDedicatedRoute_RegisteredReturnsTrue()
|
||||
{
|
||||
var manager = CreateManager();
|
||||
var connection = MakeConnection();
|
||||
|
||||
manager.RegisterAccountRoute("ACCT-B", connection);
|
||||
|
||||
manager.HasDedicatedRoute("ACCT-B").ShouldBeTrue();
|
||||
}
|
||||
|
||||
// Go: server/route.go — HasDedicatedRoute returns false for unknown account.
|
||||
[Fact]
|
||||
public void HasDedicatedRoute_NotRegisteredReturnsFalse()
|
||||
{
|
||||
var manager = CreateManager();
|
||||
|
||||
manager.HasDedicatedRoute("ACCT-B").ShouldBeFalse();
|
||||
}
|
||||
|
||||
// Go: server/route.go — listing accounts with dedicated routes.
|
||||
[Fact]
|
||||
public void GetAccountsWithDedicatedRoutes_ReturnsAllRegistered()
|
||||
{
|
||||
var manager = CreateManager();
|
||||
|
||||
manager.RegisterAccountRoute("ACCT-1", MakeConnection());
|
||||
manager.RegisterAccountRoute("ACCT-2", MakeConnection());
|
||||
manager.RegisterAccountRoute("ACCT-3", MakeConnection());
|
||||
|
||||
var accounts = manager.GetAccountsWithDedicatedRoutes();
|
||||
|
||||
accounts.Count.ShouldBe(3);
|
||||
accounts.ShouldContain("ACCT-1");
|
||||
accounts.ShouldContain("ACCT-2");
|
||||
accounts.ShouldContain("ACCT-3");
|
||||
}
|
||||
|
||||
// Go: server/route.go — DedicatedRouteCount tracks registered entries.
|
||||
[Fact]
|
||||
public void DedicatedRouteCount_MatchesRegistrations()
|
||||
{
|
||||
var manager = CreateManager();
|
||||
|
||||
manager.DedicatedRouteCount.ShouldBe(0);
|
||||
|
||||
manager.RegisterAccountRoute("ACCT-X", MakeConnection());
|
||||
manager.DedicatedRouteCount.ShouldBe(1);
|
||||
|
||||
manager.RegisterAccountRoute("ACCT-Y", MakeConnection());
|
||||
manager.DedicatedRouteCount.ShouldBe(2);
|
||||
|
||||
manager.UnregisterAccountRoute("ACCT-X");
|
||||
manager.DedicatedRouteCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
// Go: server/route.go — dedicated route takes priority over pool-based route
|
||||
// for the account it is registered against.
|
||||
[Fact]
|
||||
public void GetRouteForAccount_PrefersDedicatedRoute()
|
||||
{
|
||||
var manager = CreateManager();
|
||||
|
||||
// Register a dedicated route for "ACCT-PREF".
|
||||
var dedicated = MakeConnection();
|
||||
manager.RegisterAccountRoute("ACCT-PREF", dedicated);
|
||||
|
||||
// GetRouteForAccount must return the dedicated connection even though
|
||||
// no pool routes exist (the dedicated path short-circuits pool lookup).
|
||||
var result = manager.GetRouteForAccount("ACCT-PREF");
|
||||
|
||||
result.ShouldBeSameAs(dedicated);
|
||||
}
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
// Reference: golang/nats-server/server/route.go getRoutesExcludePool — no-pool fallback for
|
||||
// backward compatibility with pre-pool peers (Gap 13.6).
|
||||
|
||||
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 no-pool (legacy) route fallback — Gap 13.6.
|
||||
/// Verifies that <see cref="RouteConnection.SupportsPooling"/>,
|
||||
/// <see cref="RouteConnection.IsLegacyRoute"/>, and the new
|
||||
/// <see cref="RouteManager"/> legacy-route helpers behave correctly, and that
|
||||
/// <see cref="RouteManager.GetRouteForAccount"/> falls back to a legacy route
|
||||
/// when neither a dedicated nor a pool route exists.
|
||||
/// Go reference: server/route.go getRoutesExcludePool.
|
||||
/// </summary>
|
||||
public class NoPoolFallbackTests : IDisposable
|
||||
{
|
||||
private readonly List<TcpListener> _listeners = [];
|
||||
private readonly List<Socket> _sockets = [];
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var s in _sockets) s.Dispose();
|
||||
foreach (var l in _listeners) l.Stop();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private static RouteManager CreateManager() =>
|
||||
new(
|
||||
new ClusterOptions { Host = "127.0.0.1", Port = 0, Routes = [] },
|
||||
new ServerStats(),
|
||||
Guid.NewGuid().ToString("N"),
|
||||
_ => { },
|
||||
_ => { },
|
||||
NullLogger<RouteManager>.Instance);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a <see cref="RouteConnection"/> backed by a connected loopback
|
||||
/// socket pair so that the internal <see cref="System.Net.Sockets.NetworkStream"/>
|
||||
/// can be constructed without throwing. Both sockets are tracked for disposal.
|
||||
/// </summary>
|
||||
private RouteConnection MakeConnection()
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
_listeners.Add(listener);
|
||||
|
||||
var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
_sockets.Add(client);
|
||||
client.Connect((IPEndPoint)listener.LocalEndpoint);
|
||||
|
||||
var server = listener.AcceptSocket();
|
||||
_sockets.Add(server);
|
||||
|
||||
return new RouteConnection(server);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// RouteConnection.SupportsPooling
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// Go reference: server/route.go — pool-capable route: NegotiatedPoolSize > 0.
|
||||
[Fact]
|
||||
public void SupportsPooling_WhenNegotiated_ReturnsTrue()
|
||||
{
|
||||
var conn = MakeConnection();
|
||||
conn.SetNegotiatedPoolSize(3);
|
||||
conn.SupportsPooling.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// Go reference: server/route.go — default state is pre-negotiation (no pooling).
|
||||
[Fact]
|
||||
public void SupportsPooling_Default_ReturnsFalse()
|
||||
{
|
||||
var conn = MakeConnection();
|
||||
// NegotiatedPoolSize defaults to 0 — pooling not yet established.
|
||||
conn.SupportsPooling.ShouldBeFalse();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// RouteConnection.IsLegacyRoute
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// Go reference: server/route.go getRoutesExcludePool — NegotiatedPoolSize == 0 means legacy.
|
||||
[Fact]
|
||||
public void IsLegacyRoute_Default_ReturnsTrue()
|
||||
{
|
||||
var conn = MakeConnection();
|
||||
// A freshly created connection has NegotiatedPoolSize == 0, making it legacy.
|
||||
conn.IsLegacyRoute.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// Go reference: server/route.go — pool-negotiated connection is not legacy.
|
||||
[Fact]
|
||||
public void IsLegacyRoute_WhenNegotiated_ReturnsFalse()
|
||||
{
|
||||
var conn = MakeConnection();
|
||||
conn.SetNegotiatedPoolSize(2);
|
||||
conn.IsLegacyRoute.ShouldBeFalse();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// RouteManager.GetLegacyRoute
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// Go reference: server/route.go getRoutesExcludePool — returns first legacy connection.
|
||||
[Fact]
|
||||
public void GetLegacyRoute_ReturnsLegacyConnection()
|
||||
{
|
||||
var manager = CreateManager();
|
||||
var legacy = MakeConnection();
|
||||
// NegotiatedPoolSize == 0 by default — this is a legacy route.
|
||||
manager.RegisterRoute("server-legacy", legacy);
|
||||
|
||||
var result = manager.GetLegacyRoute();
|
||||
|
||||
result.ShouldBeSameAs(legacy);
|
||||
}
|
||||
|
||||
// Go reference: server/route.go getRoutesExcludePool — null when all routes support pooling.
|
||||
[Fact]
|
||||
public void GetLegacyRoute_NoLegacy_ReturnsNull()
|
||||
{
|
||||
var manager = CreateManager();
|
||||
var pooled = MakeConnection();
|
||||
pooled.SetNegotiatedPoolSize(3);
|
||||
manager.RegisterRoute("server-pooled", pooled);
|
||||
|
||||
var result = manager.GetLegacyRoute();
|
||||
|
||||
result.ShouldBeNull();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// RouteManager.GetLegacyRoutes
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// Go reference: server/route.go getRoutesExcludePool — returns all legacy connections.
|
||||
[Fact]
|
||||
public void GetLegacyRoutes_ReturnsAllLegacy()
|
||||
{
|
||||
var manager = CreateManager();
|
||||
|
||||
var legacy1 = MakeConnection(); // NegotiatedPoolSize == 0 → legacy
|
||||
var legacy2 = MakeConnection(); // NegotiatedPoolSize == 0 → legacy
|
||||
var pooled = MakeConnection();
|
||||
pooled.SetNegotiatedPoolSize(3);
|
||||
|
||||
manager.RegisterRoute("server-legacy-1", legacy1);
|
||||
manager.RegisterRoute("server-legacy-2", legacy2);
|
||||
manager.RegisterRoute("server-pooled", pooled);
|
||||
|
||||
var result = manager.GetLegacyRoutes();
|
||||
|
||||
result.Count.ShouldBe(2);
|
||||
result.ShouldContain(legacy1);
|
||||
result.ShouldContain(legacy2);
|
||||
result.ShouldNotContain(pooled);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// RouteManager.HasLegacyRoutes
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// Go reference: server/route.go — any legacy route present returns true.
|
||||
[Fact]
|
||||
public void HasLegacyRoutes_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var manager = CreateManager();
|
||||
var legacy = MakeConnection(); // IsLegacyRoute == true (default)
|
||||
manager.RegisterRoute("server-legacy", legacy);
|
||||
|
||||
manager.HasLegacyRoutes.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// Go reference: server/route.go — no legacy routes returns false.
|
||||
[Fact]
|
||||
public void HasLegacyRoutes_WhenAbsent_ReturnsFalse()
|
||||
{
|
||||
var manager = CreateManager();
|
||||
var pooled = MakeConnection();
|
||||
pooled.SetNegotiatedPoolSize(5);
|
||||
manager.RegisterRoute("server-pooled", pooled);
|
||||
|
||||
manager.HasLegacyRoutes.ShouldBeFalse();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// RouteManager.GetRouteForAccount — legacy fallback (Gap 13.6)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// Go reference: server/route.go — when no dedicated or pool route exists,
|
||||
// fall back to the first legacy route for the account.
|
||||
[Fact]
|
||||
public void GetRouteForAccount_FallsBackToLegacy()
|
||||
{
|
||||
var manager = CreateManager();
|
||||
|
||||
// Register only a legacy route (NegotiatedPoolSize == 0).
|
||||
var legacy = MakeConnection();
|
||||
manager.RegisterRoute("server-legacy", legacy);
|
||||
|
||||
// No dedicated account route and no pool-capable route registered.
|
||||
// GetRouteForAccount must return the legacy connection.
|
||||
var result = manager.GetRouteForAccount("$G");
|
||||
|
||||
result.ShouldBeSameAs(legacy);
|
||||
}
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
// Reference: golang/nats-server/server/route.go — negotiateRoutePool (pooling handshake logic)
|
||||
// Tests for route pool size negotiation between local and remote peers.
|
||||
|
||||
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 route pool size negotiation (Gap 13.3).
|
||||
/// Covers RouteConnection.NegotiatePoolSize static method,
|
||||
/// RouteConnection.NegotiatedPoolSize default, RouteManager.ConfiguredPoolSize,
|
||||
/// and RouteManager.GetEffectivePoolSize.
|
||||
/// Go reference: server/route.go negotiateRoutePool.
|
||||
/// </summary>
|
||||
public class PoolSizeNegotiationTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a connected socket pair so RouteConnection can build its NetworkStream.
|
||||
/// Returns the RouteConnection (owns the server-side socket) and the client-side
|
||||
/// socket, which the caller must dispose to avoid resource leaks.
|
||||
/// </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);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// RouteConnection.NegotiatePoolSize static method
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void NegotiatePoolSize_BothNonZero_ReturnsMin()
|
||||
{
|
||||
// Go reference: server/route.go negotiateRoutePool — min(local, remote)
|
||||
var result = RouteConnection.NegotiatePoolSize(3, 5);
|
||||
result.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NegotiatePoolSize_Equal_ReturnsSame()
|
||||
{
|
||||
// Go reference: server/route.go negotiateRoutePool — equal sizes
|
||||
var result = RouteConnection.NegotiatePoolSize(3, 3);
|
||||
result.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NegotiatePoolSize_LocalZero_ReturnsZero()
|
||||
{
|
||||
// Go reference: server/route.go negotiateRoutePool — backward compat:
|
||||
// if either peer sends 0 (no pooling advertised), result is 0.
|
||||
var result = RouteConnection.NegotiatePoolSize(0, 5);
|
||||
result.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NegotiatePoolSize_RemoteZero_ReturnsZero()
|
||||
{
|
||||
// Go reference: server/route.go negotiateRoutePool — backward compat:
|
||||
// remote peer without pool support sends 0; result must be 0.
|
||||
var result = RouteConnection.NegotiatePoolSize(3, 0);
|
||||
result.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NegotiatePoolSize_BothZero_ReturnsZero()
|
||||
{
|
||||
// Go reference: server/route.go negotiateRoutePool — both disabled
|
||||
var result = RouteConnection.NegotiatePoolSize(0, 0);
|
||||
result.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NegotiatePoolSize_LocalLarger_ReturnsRemote()
|
||||
{
|
||||
// Go reference: server/route.go negotiateRoutePool — min(10, 3) = 3
|
||||
var result = RouteConnection.NegotiatePoolSize(10, 3);
|
||||
result.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NegotiatePoolSize_RemoteLarger_ReturnsLocal()
|
||||
{
|
||||
// Go reference: server/route.go negotiateRoutePool — min(3, 10) = 3
|
||||
var result = RouteConnection.NegotiatePoolSize(3, 10);
|
||||
result.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NegotiatePoolSize_OneIsOne_ReturnsOne()
|
||||
{
|
||||
// Go reference: server/route.go negotiateRoutePool — min(1, 5) = 1
|
||||
var result = RouteConnection.NegotiatePoolSize(1, 5);
|
||||
result.ShouldBe(1);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// RouteConnection.NegotiatedPoolSize default value
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void NegotiatedPoolSize_Default_IsZero()
|
||||
{
|
||||
// A newly created RouteConnection (not yet handshaked) must report
|
||||
// NegotiatedPoolSize == 0 to signal that negotiation has not occurred.
|
||||
// Go reference: server/route.go — pool size is 0 until negotiateRoutePool runs.
|
||||
var (conn, clientSocket) = CreateConnectedRoute();
|
||||
try
|
||||
{
|
||||
conn.NegotiatedPoolSize.ShouldBe(0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
clientSocket.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// RouteManager.ComputeRoutePoolIdx determinism (regression guard)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void ComputeRoutePoolIdx_Deterministic()
|
||||
{
|
||||
// The same account name must always map to the same pool index.
|
||||
// Go reference: server/route.go computeRoutePoolIdx (FNV-1a 32-bit hash).
|
||||
const int poolSize = 5;
|
||||
const string account = "test-account";
|
||||
|
||||
var first = RouteManager.ComputeRoutePoolIdx(poolSize, account);
|
||||
var second = RouteManager.ComputeRoutePoolIdx(poolSize, account);
|
||||
var third = RouteManager.ComputeRoutePoolIdx(poolSize, account);
|
||||
|
||||
first.ShouldBe(second);
|
||||
second.ShouldBe(third);
|
||||
first.ShouldBeGreaterThanOrEqualTo(0);
|
||||
first.ShouldBeLessThan(poolSize);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// RouteManager.ConfiguredPoolSize and GetEffectivePoolSize
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private static RouteManager MakeManager(int poolSize = 3)
|
||||
{
|
||||
var opts = new ClusterOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
PoolSize = poolSize,
|
||||
};
|
||||
return new RouteManager(
|
||||
opts,
|
||||
new ServerStats(),
|
||||
Guid.NewGuid().ToString("N"),
|
||||
_ => { },
|
||||
_ => { },
|
||||
NullLogger<RouteManager>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfiguredPoolSize_ReturnsOptionsPoolSize()
|
||||
{
|
||||
// RouteManager should expose the configured pool size from ClusterOptions.
|
||||
// Go reference: server/route.go opts.Cluster.PoolSize default is 3.
|
||||
var manager = MakeManager(poolSize: 5);
|
||||
manager.ConfiguredPoolSize.ShouldBe(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfiguredPoolSize_DefaultThreeWhenOptionsIsZero()
|
||||
{
|
||||
// When ClusterOptions.PoolSize is 0 (not explicitly set), the effective
|
||||
// configured pool size should fall back to 3 (Go's default).
|
||||
// Go reference: server/route.go default pool size of 3.
|
||||
var manager = MakeManager(poolSize: 0);
|
||||
manager.ConfiguredPoolSize.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetEffectivePoolSize_NullRemoteId_ReturnsConfigured()
|
||||
{
|
||||
// With no remote server ID, GetEffectivePoolSize returns the configured pool size.
|
||||
var manager = MakeManager(poolSize: 4);
|
||||
manager.GetEffectivePoolSize(null).ShouldBe(4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetEffectivePoolSize_UnknownRemoteId_ReturnsConfigured()
|
||||
{
|
||||
// For a remote server ID that has no connected (and negotiated) route,
|
||||
// GetEffectivePoolSize should return the configured pool size.
|
||||
var manager = MakeManager(poolSize: 3);
|
||||
manager.GetEffectivePoolSize("unknown-server-id").ShouldBe(3);
|
||||
}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Client.Core;
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.Configuration;
|
||||
|
||||
namespace NATS.Server.Tests.Routes;
|
||||
|
||||
public class RouteAccountScopedDeliveryTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Remote_message_delivery_uses_target_account_sublist_not_global_sublist()
|
||||
{
|
||||
const string subject = "orders.created";
|
||||
await using var fixture = await RouteAccountDeliveryFixture.StartAsync();
|
||||
|
||||
await using var remoteAccountA = await fixture.ConnectAsync(fixture.ServerB, "a_sub");
|
||||
await using var remoteAccountB = await fixture.ConnectAsync(fixture.ServerB, "b_sub");
|
||||
await using var publisher = await fixture.ConnectAsync(fixture.ServerA, "a_pub");
|
||||
|
||||
await using var subA = await remoteAccountA.SubscribeCoreAsync<string>(subject);
|
||||
await using var subB = await remoteAccountB.SubscribeCoreAsync<string>(subject);
|
||||
await remoteAccountA.PingAsync();
|
||||
await remoteAccountB.PingAsync();
|
||||
await fixture.WaitForRemoteInterestOnServerAAsync("A", subject);
|
||||
|
||||
await publisher.PublishAsync(subject, "from-route-a");
|
||||
|
||||
using var receiveTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
var msgA = await subA.Msgs.ReadAsync(receiveTimeout.Token);
|
||||
msgA.Data.ShouldBe("from-route-a");
|
||||
|
||||
using var leakTimeout = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
|
||||
await Should.ThrowAsync<OperationCanceledException>(async () =>
|
||||
await subB.Msgs.ReadAsync(leakTimeout.Token));
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class RouteAccountDeliveryFixture : IAsyncDisposable
|
||||
{
|
||||
private readonly CancellationTokenSource _ctsA;
|
||||
private readonly CancellationTokenSource _ctsB;
|
||||
|
||||
private RouteAccountDeliveryFixture(NatsServer serverA, NatsServer serverB, CancellationTokenSource ctsA, CancellationTokenSource ctsB)
|
||||
{
|
||||
ServerA = serverA;
|
||||
ServerB = serverB;
|
||||
_ctsA = ctsA;
|
||||
_ctsB = ctsB;
|
||||
}
|
||||
|
||||
public NatsServer ServerA { get; }
|
||||
public NatsServer ServerB { get; }
|
||||
|
||||
public static async Task<RouteAccountDeliveryFixture> StartAsync()
|
||||
{
|
||||
var users = new User[]
|
||||
{
|
||||
new() { Username = "a_pub", Password = "pass", Account = "A" },
|
||||
new() { Username = "a_sub", Password = "pass", Account = "A" },
|
||||
new() { Username = "b_sub", Password = "pass", Account = "B" },
|
||||
};
|
||||
|
||||
var optsA = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Users = users,
|
||||
Cluster = new ClusterOptions
|
||||
{
|
||||
Name = Guid.NewGuid().ToString("N"),
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
},
|
||||
};
|
||||
|
||||
var serverA = new NatsServer(optsA, NullLoggerFactory.Instance);
|
||||
var ctsA = new CancellationTokenSource();
|
||||
_ = serverA.StartAsync(ctsA.Token);
|
||||
await serverA.WaitForReadyAsync();
|
||||
|
||||
var optsB = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Users = users,
|
||||
Cluster = new ClusterOptions
|
||||
{
|
||||
Name = Guid.NewGuid().ToString("N"),
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Routes = [serverA.ClusterListen!],
|
||||
},
|
||||
};
|
||||
|
||||
var serverB = new NatsServer(optsB, NullLoggerFactory.Instance);
|
||||
var ctsB = new CancellationTokenSource();
|
||||
_ = serverB.StartAsync(ctsB.Token);
|
||||
await serverB.WaitForReadyAsync();
|
||||
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
while (!timeout.IsCancellationRequested && (serverA.Stats.Routes == 0 || serverB.Stats.Routes == 0))
|
||||
await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
|
||||
|
||||
return new RouteAccountDeliveryFixture(serverA, serverB, ctsA, ctsB);
|
||||
}
|
||||
|
||||
public async Task<NatsConnection> ConnectAsync(NatsServer server, string username)
|
||||
{
|
||||
var connection = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://{username}:pass@127.0.0.1:{server.Port}",
|
||||
});
|
||||
await connection.ConnectAsync();
|
||||
return connection;
|
||||
}
|
||||
|
||||
public async Task WaitForRemoteInterestOnServerAAsync(string account, string subject)
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
while (!timeout.IsCancellationRequested)
|
||||
{
|
||||
if (ServerA.HasRemoteInterest(account, subject))
|
||||
return;
|
||||
|
||||
await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
|
||||
}
|
||||
|
||||
throw new TimeoutException($"Timed out waiting for remote interest {account}:{subject}.");
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _ctsA.CancelAsync();
|
||||
await _ctsB.CancelAsync();
|
||||
ServerA.Dispose();
|
||||
ServerB.Dispose();
|
||||
_ctsA.Dispose();
|
||||
_ctsB.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
using NATS.Server.Routes;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class RouteAccountScopedTests
|
||||
{
|
||||
[Fact]
|
||||
public void Route_connect_info_includes_account_scope()
|
||||
{
|
||||
var json = RouteConnection.BuildConnectInfoJson("S1", ["A"], "topology-v1");
|
||||
json.ShouldContain("\"accounts\":[\"A\"]");
|
||||
json.ShouldContain("\"topology\":\"topology-v1\"");
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using NATS.Server.Routes;
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.Tests.Routes;
|
||||
|
||||
public class RouteBatchProtoParityBatch3Tests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SendRouteSubProtosAsync_writes_batched_rs_plus_frames()
|
||||
{
|
||||
var (connection, peer) = CreateRoutePair();
|
||||
try
|
||||
{
|
||||
await connection.SendRouteSubProtosAsync(
|
||||
[
|
||||
new RemoteSubscription("orders.*", null, "r1", Account: "A"),
|
||||
new RemoteSubscription("orders.q", "workers", "r1", Account: "A", QueueWeight: 2),
|
||||
],
|
||||
CancellationToken.None);
|
||||
|
||||
var data = ReadFromPeer(peer);
|
||||
data.ShouldContain("RS+ A orders.*");
|
||||
data.ShouldContain("RS+ A orders.q workers 2");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await connection.DisposeAsync();
|
||||
peer.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendRouteUnSubProtosAsync_writes_batched_rs_minus_frames()
|
||||
{
|
||||
var (connection, peer) = CreateRoutePair();
|
||||
try
|
||||
{
|
||||
await connection.SendRouteUnSubProtosAsync(
|
||||
[
|
||||
new RemoteSubscription("orders.*", null, "r1", Account: "A"),
|
||||
new RemoteSubscription("orders.q", "workers", "r1", Account: "A"),
|
||||
],
|
||||
CancellationToken.None);
|
||||
|
||||
var data = ReadFromPeer(peer);
|
||||
data.ShouldContain("RS- A orders.*");
|
||||
data.ShouldContain("RS- A orders.q workers");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await connection.DisposeAsync();
|
||||
peer.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendRouteSubOrUnSubProtosAsync_skips_empty_lines_and_flushes_once()
|
||||
{
|
||||
var (connection, peer) = CreateRoutePair();
|
||||
try
|
||||
{
|
||||
await connection.SendRouteSubOrUnSubProtosAsync(
|
||||
["RS+ A foo.bar", "", " ", "RS- A foo.bar"],
|
||||
CancellationToken.None);
|
||||
|
||||
var data = ReadFromPeer(peer);
|
||||
data.ShouldContain("RS+ A foo.bar");
|
||||
data.ShouldContain("RS- A foo.bar");
|
||||
data.ShouldNotContain("\r\n\r\n");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await connection.DisposeAsync();
|
||||
peer.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static (RouteConnection Route, Socket Peer) CreateRoutePair()
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var endpoint = (IPEndPoint)listener.LocalEndpoint;
|
||||
|
||||
var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
client.Connect(endpoint);
|
||||
|
||||
var server = listener.AcceptSocket();
|
||||
listener.Stop();
|
||||
|
||||
return (new RouteConnection(client), server);
|
||||
}
|
||||
|
||||
private static string ReadFromPeer(Socket peer)
|
||||
{
|
||||
peer.ReceiveTimeout = 2_000;
|
||||
var buffer = new byte[4096];
|
||||
var read = peer.Receive(buffer);
|
||||
return Encoding.ASCII.GetString(buffer, 0, read);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
using System.Text;
|
||||
using NATS.Server.Routes;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class RouteCompressionTests
|
||||
{
|
||||
[Fact]
|
||||
public void Route_payload_round_trips_through_compression_codec()
|
||||
{
|
||||
var payload = Encoding.UTF8.GetBytes(new string('x', 512));
|
||||
var compressed = RouteCompressionCodec.Compress(payload);
|
||||
var restored = RouteCompressionCodec.Decompress(compressed);
|
||||
restored.ShouldBe(payload);
|
||||
}
|
||||
}
|
||||
@@ -1,315 +0,0 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Client.Core;
|
||||
using NATS.Server.Configuration;
|
||||
|
||||
namespace NATS.Server.Tests.Routes;
|
||||
|
||||
/// <summary>
|
||||
/// Tests cluster route formation and message forwarding between servers.
|
||||
/// Ported from Go: server/routes_test.go — TestRouteConfig, TestSeedSolicitWorks.
|
||||
/// </summary>
|
||||
public class RouteConfigTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Two_servers_form_full_mesh_cluster()
|
||||
{
|
||||
// Reference: Go TestSeedSolicitWorks — verifies that two servers
|
||||
// with one pointing Routes at the other form a connected cluster.
|
||||
var clusterName = Guid.NewGuid().ToString("N");
|
||||
|
||||
var optsA = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Cluster = new ClusterOptions
|
||||
{
|
||||
Name = clusterName,
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
},
|
||||
};
|
||||
|
||||
var serverA = new NatsServer(optsA, NullLoggerFactory.Instance);
|
||||
var ctsA = new CancellationTokenSource();
|
||||
_ = serverA.StartAsync(ctsA.Token);
|
||||
await serverA.WaitForReadyAsync();
|
||||
|
||||
var optsB = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Cluster = new ClusterOptions
|
||||
{
|
||||
Name = clusterName,
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Routes = [serverA.ClusterListen!],
|
||||
},
|
||||
};
|
||||
|
||||
var serverB = new NatsServer(optsB, NullLoggerFactory.Instance);
|
||||
var ctsB = new CancellationTokenSource();
|
||||
_ = serverB.StartAsync(ctsB.Token);
|
||||
await serverB.WaitForReadyAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// Wait for both servers to see a route connection
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
while (!timeout.IsCancellationRequested
|
||||
&& (Interlocked.Read(ref serverA.Stats.Routes) == 0
|
||||
|| Interlocked.Read(ref serverB.Stats.Routes) == 0))
|
||||
{
|
||||
await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
|
||||
}
|
||||
|
||||
Interlocked.Read(ref serverA.Stats.Routes).ShouldBeGreaterThan(0);
|
||||
Interlocked.Read(ref serverB.Stats.Routes).ShouldBeGreaterThan(0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await ctsA.CancelAsync();
|
||||
await ctsB.CancelAsync();
|
||||
serverA.Dispose();
|
||||
serverB.Dispose();
|
||||
ctsA.Dispose();
|
||||
ctsB.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Route_forwards_messages_between_clusters()
|
||||
{
|
||||
// Reference: Go TestSeedSolicitWorks — sets up a seed + one server,
|
||||
// subscribes on one, publishes on the other, verifies delivery.
|
||||
var clusterName = Guid.NewGuid().ToString("N");
|
||||
|
||||
var optsA = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Cluster = new ClusterOptions
|
||||
{
|
||||
Name = clusterName,
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
},
|
||||
};
|
||||
|
||||
var serverA = new NatsServer(optsA, NullLoggerFactory.Instance);
|
||||
var ctsA = new CancellationTokenSource();
|
||||
_ = serverA.StartAsync(ctsA.Token);
|
||||
await serverA.WaitForReadyAsync();
|
||||
|
||||
var optsB = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Cluster = new ClusterOptions
|
||||
{
|
||||
Name = clusterName,
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Routes = [serverA.ClusterListen!],
|
||||
},
|
||||
};
|
||||
|
||||
var serverB = new NatsServer(optsB, NullLoggerFactory.Instance);
|
||||
var ctsB = new CancellationTokenSource();
|
||||
_ = serverB.StartAsync(ctsB.Token);
|
||||
await serverB.WaitForReadyAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// Wait for route formation
|
||||
using var routeTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
while (!routeTimeout.IsCancellationRequested
|
||||
&& (Interlocked.Read(ref serverA.Stats.Routes) == 0
|
||||
|| Interlocked.Read(ref serverB.Stats.Routes) == 0))
|
||||
{
|
||||
await Task.Delay(50, routeTimeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
|
||||
}
|
||||
|
||||
// Connect subscriber to server A
|
||||
await using var subscriber = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://127.0.0.1:{serverA.Port}",
|
||||
});
|
||||
await subscriber.ConnectAsync();
|
||||
|
||||
await using var sub = await subscriber.SubscribeCoreAsync<string>("foo");
|
||||
await subscriber.PingAsync();
|
||||
|
||||
// Wait for remote interest to propagate from A to B
|
||||
using var interestTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
while (!interestTimeout.IsCancellationRequested
|
||||
&& !serverB.HasRemoteInterest("foo"))
|
||||
{
|
||||
await Task.Delay(50, interestTimeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
|
||||
}
|
||||
|
||||
// Connect publisher to server B and publish
|
||||
await using var publisher = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://127.0.0.1:{serverB.Port}",
|
||||
});
|
||||
await publisher.ConnectAsync();
|
||||
await publisher.PublishAsync("foo", "Hello");
|
||||
|
||||
// Verify message arrives on server A's subscriber
|
||||
using var receiveTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
var msg = await sub.Msgs.ReadAsync(receiveTimeout.Token);
|
||||
msg.Data.ShouldBe("Hello");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await ctsA.CancelAsync();
|
||||
await ctsB.CancelAsync();
|
||||
serverA.Dispose();
|
||||
serverB.Dispose();
|
||||
ctsA.Dispose();
|
||||
ctsB.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Route_reconnects_after_peer_restart()
|
||||
{
|
||||
// Verifies that when a peer is stopped and restarted, the route
|
||||
// re-forms and message forwarding resumes.
|
||||
var clusterName = Guid.NewGuid().ToString("N");
|
||||
|
||||
var optsA = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Cluster = new ClusterOptions
|
||||
{
|
||||
Name = clusterName,
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
},
|
||||
};
|
||||
|
||||
var serverA = new NatsServer(optsA, NullLoggerFactory.Instance);
|
||||
var ctsA = new CancellationTokenSource();
|
||||
_ = serverA.StartAsync(ctsA.Token);
|
||||
await serverA.WaitForReadyAsync();
|
||||
|
||||
var clusterListenA = serverA.ClusterListen!;
|
||||
|
||||
var optsB = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Cluster = new ClusterOptions
|
||||
{
|
||||
Name = clusterName,
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Routes = [clusterListenA],
|
||||
},
|
||||
};
|
||||
|
||||
var serverB = new NatsServer(optsB, NullLoggerFactory.Instance);
|
||||
var ctsB = new CancellationTokenSource();
|
||||
_ = serverB.StartAsync(ctsB.Token);
|
||||
await serverB.WaitForReadyAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// Wait for initial route formation
|
||||
using var timeout1 = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
while (!timeout1.IsCancellationRequested
|
||||
&& (Interlocked.Read(ref serverA.Stats.Routes) == 0
|
||||
|| Interlocked.Read(ref serverB.Stats.Routes) == 0))
|
||||
{
|
||||
await Task.Delay(50, timeout1.Token).ContinueWith(_ => { }, TaskScheduler.Default);
|
||||
}
|
||||
|
||||
Interlocked.Read(ref serverA.Stats.Routes).ShouldBeGreaterThan(0);
|
||||
|
||||
// Stop server B
|
||||
await ctsB.CancelAsync();
|
||||
serverB.Dispose();
|
||||
ctsB.Dispose();
|
||||
|
||||
// Wait for server A to notice the route drop
|
||||
using var dropTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
while (!dropTimeout.IsCancellationRequested
|
||||
&& Interlocked.Read(ref serverA.Stats.Routes) != 0)
|
||||
{
|
||||
await Task.Delay(50, dropTimeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
|
||||
}
|
||||
|
||||
// Restart server B with the same cluster route target
|
||||
var optsB2 = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Cluster = new ClusterOptions
|
||||
{
|
||||
Name = clusterName,
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Routes = [clusterListenA],
|
||||
},
|
||||
};
|
||||
|
||||
serverB = new NatsServer(optsB2, NullLoggerFactory.Instance);
|
||||
ctsB = new CancellationTokenSource();
|
||||
_ = serverB.StartAsync(ctsB.Token);
|
||||
await serverB.WaitForReadyAsync();
|
||||
|
||||
// Wait for route to re-form
|
||||
using var timeout2 = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
while (!timeout2.IsCancellationRequested
|
||||
&& (Interlocked.Read(ref serverA.Stats.Routes) == 0
|
||||
|| Interlocked.Read(ref serverB.Stats.Routes) == 0))
|
||||
{
|
||||
await Task.Delay(50, timeout2.Token).ContinueWith(_ => { }, TaskScheduler.Default);
|
||||
}
|
||||
|
||||
Interlocked.Read(ref serverA.Stats.Routes).ShouldBeGreaterThan(0);
|
||||
Interlocked.Read(ref serverB.Stats.Routes).ShouldBeGreaterThan(0);
|
||||
|
||||
// Verify message forwarding works after reconnect
|
||||
await using var subscriber = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://127.0.0.1:{serverA.Port}",
|
||||
});
|
||||
await subscriber.ConnectAsync();
|
||||
|
||||
await using var sub = await subscriber.SubscribeCoreAsync<string>("bar");
|
||||
await subscriber.PingAsync();
|
||||
|
||||
// Wait for remote interest to propagate
|
||||
using var interestTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
while (!interestTimeout.IsCancellationRequested
|
||||
&& !serverB.HasRemoteInterest("bar"))
|
||||
{
|
||||
await Task.Delay(50, interestTimeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
|
||||
}
|
||||
|
||||
await using var publisher = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://127.0.0.1:{serverB.Port}",
|
||||
});
|
||||
await publisher.ConnectAsync();
|
||||
await publisher.PublishAsync("bar", "AfterReconnect");
|
||||
|
||||
using var receiveTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
var msg = await sub.Msgs.ReadAsync(receiveTimeout.Token);
|
||||
msg.Data.ShouldBe("AfterReconnect");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await ctsA.CancelAsync();
|
||||
await ctsB.CancelAsync();
|
||||
serverA.Dispose();
|
||||
serverB.Dispose();
|
||||
ctsA.Dispose();
|
||||
ctsB.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,564 +0,0 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Client.Core;
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.Configuration;
|
||||
using NATS.Server.Routes;
|
||||
|
||||
namespace NATS.Server.Tests.Routes;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for route configuration validation, compression options, topology gossip,
|
||||
/// connect info JSON, and route manager behavior.
|
||||
/// Ported from Go: server/routes_test.go.
|
||||
/// </summary>
|
||||
public class RouteConfigValidationTests
|
||||
{
|
||||
// -- Helpers --
|
||||
|
||||
private static async Task<(NatsServer Server, CancellationTokenSource Cts)> StartServerAsync(
|
||||
NatsOptions opts)
|
||||
{
|
||||
var server = new NatsServer(opts, NullLoggerFactory.Instance);
|
||||
var cts = new CancellationTokenSource();
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
return (server, cts);
|
||||
}
|
||||
|
||||
private static NatsOptions MakeClusterOpts(string? clusterName = null, string? seed = null)
|
||||
{
|
||||
return new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Cluster = new ClusterOptions
|
||||
{
|
||||
Name = clusterName ?? Guid.NewGuid().ToString("N"),
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Routes = seed is null ? [] : [seed],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task WaitForRouteFormation(NatsServer a, NatsServer b, int timeoutMs = 5000)
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(timeoutMs);
|
||||
while (!timeout.IsCancellationRequested &&
|
||||
(Interlocked.Read(ref a.Stats.Routes) == 0 ||
|
||||
Interlocked.Read(ref b.Stats.Routes) == 0))
|
||||
{
|
||||
await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WaitForCondition(Func<bool> predicate, int timeoutMs = 5000)
|
||||
{
|
||||
using var cts = new CancellationTokenSource(timeoutMs);
|
||||
while (!cts.IsCancellationRequested)
|
||||
{
|
||||
if (predicate()) return;
|
||||
await Task.Delay(20, cts.Token).ContinueWith(_ => { }, TaskScheduler.Default);
|
||||
}
|
||||
|
||||
throw new TimeoutException("Condition not met.");
|
||||
}
|
||||
|
||||
private static async Task DisposeServers(params (NatsServer Server, CancellationTokenSource Cts)[] servers)
|
||||
{
|
||||
foreach (var (server, cts) in servers)
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
cts.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// -- Tests: Configuration validation --
|
||||
|
||||
// Go: TestRouteConfig server/routes_test.go:86
|
||||
[Fact]
|
||||
public void ClusterOptions_defaults_are_correct()
|
||||
{
|
||||
var opts = new ClusterOptions();
|
||||
opts.Host.ShouldBe("0.0.0.0");
|
||||
opts.Port.ShouldBe(6222);
|
||||
opts.PoolSize.ShouldBe(3);
|
||||
opts.Routes.ShouldNotBeNull();
|
||||
opts.Routes.Count.ShouldBe(0);
|
||||
opts.Accounts.ShouldNotBeNull();
|
||||
opts.Accounts.Count.ShouldBe(0);
|
||||
opts.Compression.ShouldBe(RouteCompression.None);
|
||||
}
|
||||
|
||||
// Go: TestRouteConfig server/routes_test.go:86
|
||||
[Fact]
|
||||
public void ClusterOptions_can_set_all_fields()
|
||||
{
|
||||
var opts = new ClusterOptions
|
||||
{
|
||||
Name = "my-cluster",
|
||||
Host = "192.168.1.1",
|
||||
Port = 7244,
|
||||
PoolSize = 5,
|
||||
Routes = ["127.0.0.1:7245", "127.0.0.1:7246"],
|
||||
Accounts = ["A", "B"],
|
||||
Compression = RouteCompression.None,
|
||||
};
|
||||
|
||||
opts.Name.ShouldBe("my-cluster");
|
||||
opts.Host.ShouldBe("192.168.1.1");
|
||||
opts.Port.ShouldBe(7244);
|
||||
opts.PoolSize.ShouldBe(5);
|
||||
opts.Routes.Count.ShouldBe(2);
|
||||
opts.Accounts.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
// Go: TestRoutePoolAndPerAccountErrors server/routes_test.go:1906
|
||||
[Fact]
|
||||
public void NatsOptions_with_cluster_sets_cluster_listen()
|
||||
{
|
||||
var opts = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Cluster = new ClusterOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
},
|
||||
};
|
||||
var server = new NatsServer(opts, NullLoggerFactory.Instance);
|
||||
// ClusterListen is null until StartAsync is called since listen port binds then
|
||||
// But the property should be available
|
||||
server.Dispose();
|
||||
}
|
||||
|
||||
// Go: TestRouteCompressionOptions server/routes_test.go:3801
|
||||
[Fact]
|
||||
public void RouteCompression_enum_has_expected_values()
|
||||
{
|
||||
RouteCompression.None.ShouldBe(RouteCompression.None);
|
||||
// Verify the enum is parseable from a string value
|
||||
Enum.TryParse<RouteCompression>("None", out var result).ShouldBeTrue();
|
||||
result.ShouldBe(RouteCompression.None);
|
||||
}
|
||||
|
||||
// Go: TestRouteCompressionOptions server/routes_test.go:3801
|
||||
[Fact]
|
||||
public void RouteCompressionCodec_round_trips_payload()
|
||||
{
|
||||
var payload = Encoding.UTF8.GetBytes("This is a test payload for compression round-trip.");
|
||||
var compressed = RouteCompressionCodec.Compress(payload);
|
||||
var decompressed = RouteCompressionCodec.Decompress(compressed);
|
||||
decompressed.ShouldBe(payload);
|
||||
}
|
||||
|
||||
// Go: TestRouteCompressionOptions server/routes_test.go:3801
|
||||
[Fact]
|
||||
public void RouteCompressionCodec_handles_empty_payload()
|
||||
{
|
||||
var payload = Array.Empty<byte>();
|
||||
var compressed = RouteCompressionCodec.Compress(payload);
|
||||
var decompressed = RouteCompressionCodec.Decompress(compressed);
|
||||
decompressed.ShouldBe(payload);
|
||||
}
|
||||
|
||||
// Go: TestRouteCompressionOptions server/routes_test.go:3801
|
||||
[Fact]
|
||||
public void RouteCompressionCodec_handles_large_payload()
|
||||
{
|
||||
var payload = new byte[64 * 1024];
|
||||
Random.Shared.NextBytes(payload);
|
||||
var compressed = RouteCompressionCodec.Compress(payload);
|
||||
var decompressed = RouteCompressionCodec.Decompress(compressed);
|
||||
decompressed.ShouldBe(payload);
|
||||
}
|
||||
|
||||
// Go: TestRouteCompressionOptions server/routes_test.go:3801
|
||||
[Fact]
|
||||
public void RouteCompressionCodec_compresses_redundant_data()
|
||||
{
|
||||
var payload = Encoding.UTF8.GetBytes(new string('x', 1024));
|
||||
var compressed = RouteCompressionCodec.Compress(payload);
|
||||
// Redundant data should compress smaller than original
|
||||
compressed.Length.ShouldBeLessThan(payload.Length);
|
||||
}
|
||||
|
||||
// Go: Route connect info JSON
|
||||
[Fact]
|
||||
public void BuildConnectInfoJson_includes_server_id()
|
||||
{
|
||||
var json = RouteConnection.BuildConnectInfoJson("S1", null, null);
|
||||
json.ShouldContain("\"server_id\":\"S1\"");
|
||||
}
|
||||
|
||||
// Go: Route connect info JSON with accounts
|
||||
[Fact]
|
||||
public void BuildConnectInfoJson_includes_accounts()
|
||||
{
|
||||
var json = RouteConnection.BuildConnectInfoJson("S1", ["A", "B"], null);
|
||||
json.ShouldContain("\"accounts\":[\"A\",\"B\"]");
|
||||
}
|
||||
|
||||
// Go: Route connect info JSON with topology
|
||||
[Fact]
|
||||
public void BuildConnectInfoJson_includes_topology()
|
||||
{
|
||||
var json = RouteConnection.BuildConnectInfoJson("S1", null, "topo-v1");
|
||||
json.ShouldContain("\"topology\":\"topo-v1\"");
|
||||
}
|
||||
|
||||
// Go: Route connect info JSON empty accounts
|
||||
[Fact]
|
||||
public void BuildConnectInfoJson_empty_accounts_when_null()
|
||||
{
|
||||
var json = RouteConnection.BuildConnectInfoJson("S1", null, null);
|
||||
json.ShouldContain("\"accounts\":[]");
|
||||
}
|
||||
|
||||
// Go: Topology snapshot
|
||||
[Fact]
|
||||
public void RouteManager_topology_snapshot_reports_initial_state()
|
||||
{
|
||||
var manager = new RouteManager(
|
||||
new ClusterOptions { Host = "127.0.0.1", Port = 0 },
|
||||
new ServerStats(),
|
||||
"test-server-id",
|
||||
_ => { },
|
||||
_ => { },
|
||||
NullLogger<RouteManager>.Instance);
|
||||
|
||||
var snapshot = manager.BuildTopologySnapshot();
|
||||
snapshot.ServerId.ShouldBe("test-server-id");
|
||||
snapshot.RouteCount.ShouldBe(0);
|
||||
snapshot.ConnectedServerIds.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// Go: TestRoutePerAccountDefaultForSysAccount server/routes_test.go:2705
|
||||
[Fact]
|
||||
public async Task Cluster_with_accounts_list_still_forms_routes()
|
||||
{
|
||||
var cluster = Guid.NewGuid().ToString("N");
|
||||
var optsA = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Cluster = new ClusterOptions
|
||||
{
|
||||
Name = cluster,
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Accounts = ["A"],
|
||||
},
|
||||
};
|
||||
var a = await StartServerAsync(optsA);
|
||||
|
||||
var optsB = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Cluster = new ClusterOptions
|
||||
{
|
||||
Name = cluster,
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Accounts = ["A"],
|
||||
Routes = [a.Server.ClusterListen!],
|
||||
},
|
||||
};
|
||||
var b = await StartServerAsync(optsB);
|
||||
|
||||
try
|
||||
{
|
||||
await WaitForRouteFormation(a.Server, b.Server);
|
||||
Interlocked.Read(ref a.Server.Stats.Routes).ShouldBeGreaterThan(0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DisposeServers(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
// Go: TestRoutePoolSizeDifferentOnEachServer server/routes_test.go:2254
|
||||
[Fact]
|
||||
public async Task Different_pool_sizes_form_routes()
|
||||
{
|
||||
var cluster = Guid.NewGuid().ToString("N");
|
||||
var optsA = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Cluster = new ClusterOptions
|
||||
{
|
||||
Name = cluster,
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
PoolSize = 1,
|
||||
},
|
||||
};
|
||||
var a = await StartServerAsync(optsA);
|
||||
|
||||
var optsB = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Cluster = new ClusterOptions
|
||||
{
|
||||
Name = cluster,
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
PoolSize = 5,
|
||||
Routes = [a.Server.ClusterListen!],
|
||||
},
|
||||
};
|
||||
var b = await StartServerAsync(optsB);
|
||||
|
||||
try
|
||||
{
|
||||
await WaitForRouteFormation(a.Server, b.Server);
|
||||
Interlocked.Read(ref a.Server.Stats.Routes).ShouldBeGreaterThan(0);
|
||||
Interlocked.Read(ref b.Server.Stats.Routes).ShouldBeGreaterThan(0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DisposeServers(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
// Go: TestRoutePoolAndPerAccountErrors server/routes_test.go:1906
|
||||
[Fact]
|
||||
public async Task Server_with_cluster_reports_route_count_in_stats()
|
||||
{
|
||||
var cluster = Guid.NewGuid().ToString("N");
|
||||
var a = await StartServerAsync(MakeClusterOpts(cluster));
|
||||
var b = await StartServerAsync(MakeClusterOpts(cluster, a.Server.ClusterListen!));
|
||||
|
||||
try
|
||||
{
|
||||
await WaitForRouteFormation(a.Server, b.Server);
|
||||
a.Server.Stats.Routes.ShouldBeGreaterThan(0);
|
||||
b.Server.Stats.Routes.ShouldBeGreaterThan(0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DisposeServers(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
// Go: TestRouteConfigureWriteDeadline server/routes_test.go:4981
|
||||
[Fact]
|
||||
public void NatsOptions_cluster_is_null_by_default()
|
||||
{
|
||||
var opts = new NatsOptions();
|
||||
opts.Cluster.ShouldBeNull();
|
||||
}
|
||||
|
||||
// Go: TestRouteUseIPv6 server/routes_test.go:658 (IPv4 variant)
|
||||
[Fact]
|
||||
public async Task Cluster_with_127_0_0_1_binds_and_forms_route()
|
||||
{
|
||||
var cluster = Guid.NewGuid().ToString("N");
|
||||
var optsA = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Cluster = new ClusterOptions
|
||||
{
|
||||
Name = cluster,
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
},
|
||||
};
|
||||
var a = await StartServerAsync(optsA);
|
||||
a.Server.ClusterListen.ShouldNotBeNull();
|
||||
a.Server.ClusterListen.ShouldStartWith("127.0.0.1:");
|
||||
|
||||
var optsB = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Cluster = new ClusterOptions
|
||||
{
|
||||
Name = cluster,
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Routes = [a.Server.ClusterListen!],
|
||||
},
|
||||
};
|
||||
var b = await StartServerAsync(optsB);
|
||||
|
||||
try
|
||||
{
|
||||
await WaitForRouteFormation(a.Server, b.Server);
|
||||
Interlocked.Read(ref a.Server.Stats.Routes).ShouldBeGreaterThan(0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DisposeServers(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
// Go: TestRoutePerAccountGossipWorks server/routes_test.go:2867
|
||||
[Fact]
|
||||
public void RouteManager_initial_route_count_is_zero()
|
||||
{
|
||||
var manager = new RouteManager(
|
||||
new ClusterOptions { Host = "127.0.0.1", Port = 0 },
|
||||
new ServerStats(),
|
||||
"S1",
|
||||
_ => { },
|
||||
_ => { },
|
||||
NullLogger<RouteManager>.Instance);
|
||||
|
||||
manager.RouteCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
// Go: TestRouteSaveTLSName server/routes_test.go:1816 (server ID tracking)
|
||||
[Fact]
|
||||
public async Task Server_has_unique_server_id_after_start()
|
||||
{
|
||||
var cluster = Guid.NewGuid().ToString("N");
|
||||
var a = await StartServerAsync(MakeClusterOpts(cluster));
|
||||
var b = await StartServerAsync(MakeClusterOpts(cluster, a.Server.ClusterListen!));
|
||||
|
||||
try
|
||||
{
|
||||
a.Server.ServerId.ShouldNotBeNullOrEmpty();
|
||||
b.Server.ServerId.ShouldNotBeNullOrEmpty();
|
||||
a.Server.ServerId.ShouldNotBe(b.Server.ServerId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DisposeServers(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
// Go: TestRoutePerAccount server/routes_test.go:2539 (multi-account cluster)
|
||||
[Fact]
|
||||
public async Task Cluster_with_auth_users_forms_routes_and_forwards()
|
||||
{
|
||||
var users = new User[]
|
||||
{
|
||||
new() { Username = "admin", Password = "pwd", Account = "ADMIN" },
|
||||
};
|
||||
var cluster = Guid.NewGuid().ToString("N");
|
||||
|
||||
var optsA = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Users = users,
|
||||
Cluster = new ClusterOptions
|
||||
{
|
||||
Name = cluster,
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
},
|
||||
};
|
||||
var a = await StartServerAsync(optsA);
|
||||
|
||||
var optsB = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Users = users,
|
||||
Cluster = new ClusterOptions
|
||||
{
|
||||
Name = cluster,
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Routes = [a.Server.ClusterListen!],
|
||||
},
|
||||
};
|
||||
var b = await StartServerAsync(optsB);
|
||||
|
||||
try
|
||||
{
|
||||
await WaitForRouteFormation(a.Server, b.Server);
|
||||
|
||||
await using var subscriber = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://admin:pwd@127.0.0.1:{a.Server.Port}",
|
||||
});
|
||||
await subscriber.ConnectAsync();
|
||||
await using var sub = await subscriber.SubscribeCoreAsync<string>("auth.test");
|
||||
await subscriber.PingAsync();
|
||||
|
||||
await WaitForCondition(() => b.Server.HasRemoteInterest("ADMIN", "auth.test"));
|
||||
|
||||
await using var publisher = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://admin:pwd@127.0.0.1:{b.Server.Port}",
|
||||
});
|
||||
await publisher.ConnectAsync();
|
||||
await publisher.PublishAsync("auth.test", "authenticated");
|
||||
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
var msg = await sub.Msgs.ReadAsync(timeout.Token);
|
||||
msg.Data.ShouldBe("authenticated");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DisposeServers(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
// Go: TestRoutePoolBadAuthNoRunawayCreateRoute server/routes_test.go:3745
|
||||
[Fact]
|
||||
public async Task Route_ephemeral_port_resolves_correctly()
|
||||
{
|
||||
var cluster = Guid.NewGuid().ToString("N");
|
||||
var optsA = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Cluster = new ClusterOptions
|
||||
{
|
||||
Name = cluster,
|
||||
Host = "127.0.0.1",
|
||||
Port = 0, // ephemeral
|
||||
},
|
||||
};
|
||||
var a = await StartServerAsync(optsA);
|
||||
|
||||
try
|
||||
{
|
||||
a.Server.ClusterListen.ShouldNotBeNull();
|
||||
var parts = a.Server.ClusterListen!.Split(':');
|
||||
parts.Length.ShouldBe(2);
|
||||
int.TryParse(parts[1], out var port).ShouldBeTrue();
|
||||
port.ShouldBeGreaterThan(0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await a.Cts.CancelAsync();
|
||||
a.Server.Dispose();
|
||||
a.Cts.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// Go: TestRouteNoRaceOnClusterNameNegotiation server/routes_test.go:4775
|
||||
[Fact]
|
||||
public async Task Cluster_name_is_preserved_across_route()
|
||||
{
|
||||
var clusterName = "test-cluster-name-preservation";
|
||||
var a = await StartServerAsync(MakeClusterOpts(clusterName));
|
||||
var b = await StartServerAsync(MakeClusterOpts(clusterName, a.Server.ClusterListen!));
|
||||
|
||||
try
|
||||
{
|
||||
await WaitForRouteFormation(a.Server, b.Server);
|
||||
// Both servers should be operational
|
||||
Interlocked.Read(ref a.Server.Stats.Routes).ShouldBeGreaterThan(0);
|
||||
Interlocked.Read(ref b.Server.Stats.Routes).ShouldBeGreaterThan(0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DisposeServers(a, b);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,811 +0,0 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Client.Core;
|
||||
using NATS.Server.Configuration;
|
||||
using NATS.Server.Routes;
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.Tests.Routes;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for route connection establishment, handshake, reconnection, and lifecycle.
|
||||
/// Ported from Go: server/routes_test.go.
|
||||
/// </summary>
|
||||
public class RouteConnectionTests
|
||||
{
|
||||
// -- Helpers --
|
||||
|
||||
private static async Task<(NatsServer Server, CancellationTokenSource Cts)> StartServerAsync(
|
||||
NatsOptions opts)
|
||||
{
|
||||
var server = new NatsServer(opts, NullLoggerFactory.Instance);
|
||||
var cts = new CancellationTokenSource();
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
return (server, cts);
|
||||
}
|
||||
|
||||
private static NatsOptions MakeClusterOpts(string? clusterName = null, string? seed = null)
|
||||
{
|
||||
return new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Cluster = new ClusterOptions
|
||||
{
|
||||
Name = clusterName ?? Guid.NewGuid().ToString("N"),
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Routes = seed is null ? [] : [seed],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task WaitForRouteFormation(NatsServer a, NatsServer b, int timeoutSeconds = 5)
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds));
|
||||
while (!timeout.IsCancellationRequested &&
|
||||
(Interlocked.Read(ref a.Stats.Routes) == 0 ||
|
||||
Interlocked.Read(ref b.Stats.Routes) == 0))
|
||||
{
|
||||
await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WaitForCondition(Func<bool> predicate, int timeoutMs = 5000)
|
||||
{
|
||||
using var cts = new CancellationTokenSource(timeoutMs);
|
||||
while (!cts.IsCancellationRequested)
|
||||
{
|
||||
if (predicate())
|
||||
return;
|
||||
await Task.Delay(20, cts.Token).ContinueWith(_ => { }, TaskScheduler.Default);
|
||||
}
|
||||
|
||||
throw new TimeoutException("Condition not met.");
|
||||
}
|
||||
|
||||
private static async Task DisposeServers(params (NatsServer Server, CancellationTokenSource Cts)[] servers)
|
||||
{
|
||||
foreach (var (server, cts) in servers)
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
cts.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// -- Tests --
|
||||
|
||||
// Go: TestSeedSolicitWorks server/routes_test.go:365
|
||||
[Fact]
|
||||
public async Task Seed_solicit_establishes_route_connection()
|
||||
{
|
||||
var cluster = Guid.NewGuid().ToString("N");
|
||||
var optsA = MakeClusterOpts(cluster);
|
||||
var a = await StartServerAsync(optsA);
|
||||
|
||||
var optsB = MakeClusterOpts(cluster, a.Server.ClusterListen!);
|
||||
var b = await StartServerAsync(optsB);
|
||||
|
||||
try
|
||||
{
|
||||
await WaitForRouteFormation(a.Server, b.Server);
|
||||
Interlocked.Read(ref a.Server.Stats.Routes).ShouldBeGreaterThan(0);
|
||||
Interlocked.Read(ref b.Server.Stats.Routes).ShouldBeGreaterThan(0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DisposeServers(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
// Go: TestSeedSolicitWorks server/routes_test.go:365 (message delivery)
|
||||
[Fact]
|
||||
public async Task Seed_solicit_delivers_messages_across_route()
|
||||
{
|
||||
var cluster = Guid.NewGuid().ToString("N");
|
||||
var a = await StartServerAsync(MakeClusterOpts(cluster));
|
||||
var b = await StartServerAsync(MakeClusterOpts(cluster, a.Server.ClusterListen!));
|
||||
|
||||
try
|
||||
{
|
||||
await WaitForRouteFormation(a.Server, b.Server);
|
||||
|
||||
await using var subscriber = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://127.0.0.1:{a.Server.Port}",
|
||||
});
|
||||
await subscriber.ConnectAsync();
|
||||
await using var sub = await subscriber.SubscribeCoreAsync<string>("foo");
|
||||
await subscriber.PingAsync();
|
||||
|
||||
await WaitForCondition(() => b.Server.HasRemoteInterest("foo"));
|
||||
|
||||
await using var publisher = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://127.0.0.1:{b.Server.Port}",
|
||||
});
|
||||
await publisher.ConnectAsync();
|
||||
await publisher.PublishAsync("foo", "Hello");
|
||||
|
||||
using var receiveTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
var msg = await sub.Msgs.ReadAsync(receiveTimeout.Token);
|
||||
msg.Data.ShouldBe("Hello");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DisposeServers(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
// Go: TestChainedSolicitWorks server/routes_test.go:481
|
||||
[Fact]
|
||||
public async Task Three_servers_form_full_mesh_via_seed()
|
||||
{
|
||||
var cluster = Guid.NewGuid().ToString("N");
|
||||
var a = await StartServerAsync(MakeClusterOpts(cluster));
|
||||
var b = await StartServerAsync(MakeClusterOpts(cluster, a.Server.ClusterListen!));
|
||||
var c = await StartServerAsync(MakeClusterOpts(cluster, a.Server.ClusterListen!));
|
||||
|
||||
try
|
||||
{
|
||||
await WaitForRouteFormation(a.Server, b.Server);
|
||||
await WaitForRouteFormation(a.Server, c.Server);
|
||||
|
||||
// Verify message delivery across the 3-node cluster
|
||||
await using var subscriber = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://127.0.0.1:{a.Server.Port}",
|
||||
});
|
||||
await subscriber.ConnectAsync();
|
||||
await using var sub = await subscriber.SubscribeCoreAsync<string>("chain.test");
|
||||
await subscriber.PingAsync();
|
||||
|
||||
await WaitForCondition(() => c.Server.HasRemoteInterest("chain.test"));
|
||||
|
||||
await using var publisher = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://127.0.0.1:{c.Server.Port}",
|
||||
});
|
||||
await publisher.ConnectAsync();
|
||||
await publisher.PublishAsync("chain.test", "chained");
|
||||
|
||||
using var receiveTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
var msg = await sub.Msgs.ReadAsync(receiveTimeout.Token);
|
||||
msg.Data.ShouldBe("chained");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DisposeServers(a, b, c);
|
||||
}
|
||||
}
|
||||
|
||||
// Go: TestRoutesToEachOther server/routes_test.go:759
|
||||
[Fact]
|
||||
public async Task Mutual_route_solicitation_resolves_to_single_route()
|
||||
{
|
||||
// Both servers point routes at each other, should still form a single cluster
|
||||
var cluster = Guid.NewGuid().ToString("N");
|
||||
var optsA = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Cluster = new ClusterOptions
|
||||
{
|
||||
Name = cluster,
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
},
|
||||
};
|
||||
var a = await StartServerAsync(optsA);
|
||||
|
||||
var optsB = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Cluster = new ClusterOptions
|
||||
{
|
||||
Name = cluster,
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Routes = [a.Server.ClusterListen!],
|
||||
},
|
||||
};
|
||||
var b = await StartServerAsync(optsB);
|
||||
|
||||
// Also point A's routes at B (mutual solicitation)
|
||||
// We can't change routes dynamically, so we just verify that the route forms properly
|
||||
try
|
||||
{
|
||||
await WaitForRouteFormation(a.Server, b.Server);
|
||||
Interlocked.Read(ref a.Server.Stats.Routes).ShouldBeGreaterThan(0);
|
||||
Interlocked.Read(ref b.Server.Stats.Routes).ShouldBeGreaterThan(0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DisposeServers(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
// Go: TestRouteRTT server/routes_test.go:1203
|
||||
[Fact]
|
||||
public async Task Route_stats_tracked_after_formation()
|
||||
{
|
||||
var cluster = Guid.NewGuid().ToString("N");
|
||||
var a = await StartServerAsync(MakeClusterOpts(cluster));
|
||||
var b = await StartServerAsync(MakeClusterOpts(cluster, a.Server.ClusterListen!));
|
||||
|
||||
try
|
||||
{
|
||||
await WaitForRouteFormation(a.Server, b.Server);
|
||||
Interlocked.Read(ref a.Server.Stats.Routes).ShouldBeGreaterThan(0);
|
||||
Interlocked.Read(ref b.Server.Stats.Routes).ShouldBeGreaterThan(0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DisposeServers(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
// Go: TestRouteConfig server/routes_test.go:86
|
||||
[Fact]
|
||||
public void Cluster_options_have_correct_defaults()
|
||||
{
|
||||
var opts = new ClusterOptions();
|
||||
opts.Port.ShouldBe(6222);
|
||||
opts.Host.ShouldBe("0.0.0.0");
|
||||
opts.PoolSize.ShouldBe(3);
|
||||
opts.Routes.ShouldNotBeNull();
|
||||
opts.Routes.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
// Go: TestRouteConfig server/routes_test.go:86
|
||||
[Fact]
|
||||
public void Cluster_options_can_be_configured()
|
||||
{
|
||||
var opts = new ClusterOptions
|
||||
{
|
||||
Name = "test-cluster",
|
||||
Host = "127.0.0.1",
|
||||
Port = 7244,
|
||||
PoolSize = 5,
|
||||
Routes = ["127.0.0.1:7245", "127.0.0.1:7246"],
|
||||
};
|
||||
|
||||
opts.Name.ShouldBe("test-cluster");
|
||||
opts.Port.ShouldBe(7244);
|
||||
opts.PoolSize.ShouldBe(5);
|
||||
opts.Routes.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
// Go: TestRouteReconnectExponentialBackoff server/routes_test.go:1758
|
||||
[Fact]
|
||||
public async Task Route_reconnects_after_peer_restart()
|
||||
{
|
||||
var cluster = Guid.NewGuid().ToString("N");
|
||||
var a = await StartServerAsync(MakeClusterOpts(cluster));
|
||||
var clusterListenA = a.Server.ClusterListen!;
|
||||
|
||||
var b = await StartServerAsync(MakeClusterOpts(cluster, clusterListenA));
|
||||
|
||||
try
|
||||
{
|
||||
await WaitForRouteFormation(a.Server, b.Server);
|
||||
Interlocked.Read(ref a.Server.Stats.Routes).ShouldBeGreaterThan(0);
|
||||
|
||||
// Stop server B
|
||||
await b.Cts.CancelAsync();
|
||||
b.Server.Dispose();
|
||||
b.Cts.Dispose();
|
||||
|
||||
// Wait for A to notice B is gone
|
||||
await WaitForCondition(() => Interlocked.Read(ref a.Server.Stats.Routes) == 0, 5000);
|
||||
|
||||
// Restart B
|
||||
b = await StartServerAsync(MakeClusterOpts(cluster, clusterListenA));
|
||||
await WaitForRouteFormation(a.Server, b.Server);
|
||||
|
||||
Interlocked.Read(ref a.Server.Stats.Routes).ShouldBeGreaterThan(0);
|
||||
Interlocked.Read(ref b.Server.Stats.Routes).ShouldBeGreaterThan(0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DisposeServers(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
// Go: TestRouteReconnectExponentialBackoff server/routes_test.go:1758
|
||||
[Fact]
|
||||
public async Task Route_reconnects_and_resumes_message_forwarding()
|
||||
{
|
||||
var cluster = Guid.NewGuid().ToString("N");
|
||||
var a = await StartServerAsync(MakeClusterOpts(cluster));
|
||||
var clusterListenA = a.Server.ClusterListen!;
|
||||
var b = await StartServerAsync(MakeClusterOpts(cluster, clusterListenA));
|
||||
|
||||
try
|
||||
{
|
||||
await WaitForRouteFormation(a.Server, b.Server);
|
||||
|
||||
// Stop and restart B
|
||||
await b.Cts.CancelAsync();
|
||||
b.Server.Dispose();
|
||||
b.Cts.Dispose();
|
||||
|
||||
b = await StartServerAsync(MakeClusterOpts(cluster, clusterListenA));
|
||||
await WaitForRouteFormation(a.Server, b.Server);
|
||||
|
||||
// Verify forwarding works after reconnect
|
||||
await using var subscriber = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://127.0.0.1:{a.Server.Port}",
|
||||
});
|
||||
await subscriber.ConnectAsync();
|
||||
await using var sub = await subscriber.SubscribeCoreAsync<string>("reconnect.test");
|
||||
await subscriber.PingAsync();
|
||||
|
||||
await WaitForCondition(() => b.Server.HasRemoteInterest("reconnect.test"));
|
||||
|
||||
await using var publisher = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://127.0.0.1:{b.Server.Port}",
|
||||
});
|
||||
await publisher.ConnectAsync();
|
||||
await publisher.PublishAsync("reconnect.test", "after-restart");
|
||||
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
var msg = await sub.Msgs.ReadAsync(timeout.Token);
|
||||
msg.Data.ShouldBe("after-restart");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DisposeServers(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
// Go: TestRoutePool server/routes_test.go:1966
|
||||
[Fact]
|
||||
public async Task Route_pool_establishes_configured_number_of_connections()
|
||||
{
|
||||
var cluster = Guid.NewGuid().ToString("N");
|
||||
var optsA = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Cluster = new ClusterOptions
|
||||
{
|
||||
Name = cluster,
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
PoolSize = 3,
|
||||
},
|
||||
};
|
||||
var a = await StartServerAsync(optsA);
|
||||
|
||||
var optsB = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Cluster = new ClusterOptions
|
||||
{
|
||||
Name = cluster,
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
PoolSize = 3,
|
||||
Routes = [a.Server.ClusterListen!],
|
||||
},
|
||||
};
|
||||
var b = await StartServerAsync(optsB);
|
||||
|
||||
try
|
||||
{
|
||||
await WaitForCondition(() => Interlocked.Read(ref a.Server.Stats.Routes) >= 3, 5000);
|
||||
Interlocked.Read(ref a.Server.Stats.Routes).ShouldBeGreaterThanOrEqualTo(3);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DisposeServers(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
// Go: TestRoutePoolSizeDifferentOnEachServer server/routes_test.go:2254
|
||||
[Fact]
|
||||
public async Task Route_pool_size_of_one_still_forwards_messages()
|
||||
{
|
||||
var cluster = Guid.NewGuid().ToString("N");
|
||||
var optsA = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Cluster = new ClusterOptions
|
||||
{
|
||||
Name = cluster,
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
PoolSize = 1,
|
||||
},
|
||||
};
|
||||
var a = await StartServerAsync(optsA);
|
||||
|
||||
var optsB = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Cluster = new ClusterOptions
|
||||
{
|
||||
Name = cluster,
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
PoolSize = 1,
|
||||
Routes = [a.Server.ClusterListen!],
|
||||
},
|
||||
};
|
||||
var b = await StartServerAsync(optsB);
|
||||
|
||||
try
|
||||
{
|
||||
await WaitForRouteFormation(a.Server, b.Server);
|
||||
|
||||
await using var subscriber = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://127.0.0.1:{a.Server.Port}",
|
||||
});
|
||||
await subscriber.ConnectAsync();
|
||||
await using var sub = await subscriber.SubscribeCoreAsync<string>("pool.one");
|
||||
await subscriber.PingAsync();
|
||||
|
||||
await WaitForCondition(() => b.Server.HasRemoteInterest("pool.one"));
|
||||
|
||||
await using var publisher = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://127.0.0.1:{b.Server.Port}",
|
||||
});
|
||||
await publisher.ConnectAsync();
|
||||
await publisher.PublishAsync("pool.one", "single-pool");
|
||||
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
var msg = await sub.Msgs.ReadAsync(timeout.Token);
|
||||
msg.Data.ShouldBe("single-pool");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DisposeServers(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
// Go: TestRouteHandshake (low-level handshake)
|
||||
[Fact]
|
||||
public async Task Route_connection_outbound_handshake_exchanges_server_ids()
|
||||
{
|
||||
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
|
||||
using var remoteSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await remoteSocket.ConnectAsync(IPAddress.Loopback, port);
|
||||
using var routeSocket = await listener.AcceptSocketAsync();
|
||||
await using var route = new RouteConnection(routeSocket);
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
|
||||
var handshakeTask = route.PerformOutboundHandshakeAsync("LOCAL_SERVER", timeout.Token);
|
||||
|
||||
var received = await ReadLineAsync(remoteSocket, timeout.Token);
|
||||
received.ShouldBe("ROUTE LOCAL_SERVER");
|
||||
|
||||
await WriteLineAsync(remoteSocket, "ROUTE REMOTE_SERVER", timeout.Token);
|
||||
await handshakeTask;
|
||||
|
||||
route.RemoteServerId.ShouldBe("REMOTE_SERVER");
|
||||
}
|
||||
|
||||
// Go: TestRouteHandshake inbound direction
|
||||
[Fact]
|
||||
public async Task Route_connection_inbound_handshake_exchanges_server_ids()
|
||||
{
|
||||
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
|
||||
using var remoteSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await remoteSocket.ConnectAsync(IPAddress.Loopback, port);
|
||||
using var routeSocket = await listener.AcceptSocketAsync();
|
||||
await using var route = new RouteConnection(routeSocket);
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
|
||||
var handshakeTask = route.PerformInboundHandshakeAsync("LOCAL_SERVER", timeout.Token);
|
||||
|
||||
await WriteLineAsync(remoteSocket, "ROUTE REMOTE_SERVER", timeout.Token);
|
||||
await handshakeTask;
|
||||
|
||||
var received = await ReadLineAsync(remoteSocket, timeout.Token);
|
||||
received.ShouldBe("ROUTE LOCAL_SERVER");
|
||||
route.RemoteServerId.ShouldBe("REMOTE_SERVER");
|
||||
}
|
||||
|
||||
// Go: TestRouteNoCrashOnAddingSubToRoute server/routes_test.go:1131
|
||||
[Fact]
|
||||
public async Task Many_subscriptions_propagate_across_route()
|
||||
{
|
||||
var cluster = Guid.NewGuid().ToString("N");
|
||||
var a = await StartServerAsync(MakeClusterOpts(cluster));
|
||||
var b = await StartServerAsync(MakeClusterOpts(cluster, a.Server.ClusterListen!));
|
||||
|
||||
try
|
||||
{
|
||||
await WaitForRouteFormation(a.Server, b.Server);
|
||||
|
||||
await using var nc = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://127.0.0.1:{a.Server.Port}",
|
||||
});
|
||||
await nc.ConnectAsync();
|
||||
|
||||
var subs = new List<IAsyncDisposable>();
|
||||
for (var i = 0; i < 50; i++)
|
||||
{
|
||||
var sub = await nc.SubscribeCoreAsync<string>($"many.subs.{i}");
|
||||
subs.Add(sub);
|
||||
}
|
||||
|
||||
await nc.PingAsync();
|
||||
|
||||
// Verify at least some interest propagated
|
||||
await WaitForCondition(() => b.Server.HasRemoteInterest("many.subs.0"));
|
||||
await WaitForCondition(() => b.Server.HasRemoteInterest("many.subs.49"));
|
||||
|
||||
b.Server.HasRemoteInterest("many.subs.0").ShouldBeTrue();
|
||||
b.Server.HasRemoteInterest("many.subs.49").ShouldBeTrue();
|
||||
|
||||
foreach (var sub in subs)
|
||||
await sub.DisposeAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DisposeServers(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
// Go: TestRouteSendLocalSubsWithLowMaxPending server/routes_test.go:1098
|
||||
[Fact]
|
||||
public async Task Subscriptions_propagate_with_many_subscribers()
|
||||
{
|
||||
var cluster = Guid.NewGuid().ToString("N");
|
||||
var a = await StartServerAsync(MakeClusterOpts(cluster));
|
||||
var b = await StartServerAsync(MakeClusterOpts(cluster, a.Server.ClusterListen!));
|
||||
|
||||
try
|
||||
{
|
||||
await WaitForRouteFormation(a.Server, b.Server);
|
||||
|
||||
await using var nc = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://127.0.0.1:{a.Server.Port}",
|
||||
});
|
||||
await nc.ConnectAsync();
|
||||
|
||||
var subs = new List<IAsyncDisposable>();
|
||||
for (var i = 0; i < 20; i++)
|
||||
{
|
||||
var sub = await nc.SubscribeCoreAsync<string>($"local.sub.{i}");
|
||||
subs.Add(sub);
|
||||
}
|
||||
|
||||
await nc.PingAsync();
|
||||
|
||||
await WaitForCondition(() => b.Server.HasRemoteInterest("local.sub.0"), 10000);
|
||||
await WaitForCondition(() => b.Server.HasRemoteInterest("local.sub.19"), 10000);
|
||||
|
||||
b.Server.HasRemoteInterest("local.sub.0").ShouldBeTrue();
|
||||
b.Server.HasRemoteInterest("local.sub.19").ShouldBeTrue();
|
||||
|
||||
foreach (var sub in subs)
|
||||
await sub.DisposeAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DisposeServers(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
// Go: TestRouteCloseTLSConnection server/routes_test.go:1290 (basic close test, no TLS)
|
||||
[Fact]
|
||||
public async Task Route_connection_close_decrements_stats()
|
||||
{
|
||||
var cluster = Guid.NewGuid().ToString("N");
|
||||
var a = await StartServerAsync(MakeClusterOpts(cluster));
|
||||
var b = await StartServerAsync(MakeClusterOpts(cluster, a.Server.ClusterListen!));
|
||||
|
||||
try
|
||||
{
|
||||
await WaitForRouteFormation(a.Server, b.Server);
|
||||
Interlocked.Read(ref a.Server.Stats.Routes).ShouldBeGreaterThan(0);
|
||||
|
||||
// Stop B - A's route count should drop
|
||||
await b.Cts.CancelAsync();
|
||||
b.Server.Dispose();
|
||||
b.Cts.Dispose();
|
||||
|
||||
await WaitForCondition(() => Interlocked.Read(ref a.Server.Stats.Routes) == 0, 5000);
|
||||
Interlocked.Read(ref a.Server.Stats.Routes).ShouldBe(0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await a.Cts.CancelAsync();
|
||||
a.Server.Dispose();
|
||||
a.Cts.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// Go: TestRouteDuplicateServerName server/routes_test.go:1444
|
||||
[Fact]
|
||||
public async Task Cluster_with_different_server_ids_form_routes()
|
||||
{
|
||||
var cluster = Guid.NewGuid().ToString("N");
|
||||
var optsA = MakeClusterOpts(cluster);
|
||||
optsA.ServerName = "server-alpha";
|
||||
var a = await StartServerAsync(optsA);
|
||||
|
||||
var optsB = MakeClusterOpts(cluster, a.Server.ClusterListen!);
|
||||
optsB.ServerName = "server-beta";
|
||||
var b = await StartServerAsync(optsB);
|
||||
|
||||
try
|
||||
{
|
||||
await WaitForRouteFormation(a.Server, b.Server);
|
||||
a.Server.ServerName.ShouldBe("server-alpha");
|
||||
b.Server.ServerName.ShouldBe("server-beta");
|
||||
Interlocked.Read(ref a.Server.Stats.Routes).ShouldBeGreaterThan(0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DisposeServers(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
// Go: TestRouteIPResolutionAndRouteToSelf server/routes_test.go:1415
|
||||
[Fact]
|
||||
public void Server_without_cluster_has_null_cluster_listen()
|
||||
{
|
||||
var opts = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
};
|
||||
var server = new NatsServer(opts, NullLoggerFactory.Instance);
|
||||
server.ClusterListen.ShouldBeNull();
|
||||
server.Dispose();
|
||||
}
|
||||
|
||||
// Go: TestBlockedShutdownOnRouteAcceptLoopFailure server/routes_test.go:634
|
||||
[Fact]
|
||||
public async Task Server_with_cluster_can_be_shut_down_cleanly()
|
||||
{
|
||||
var cluster = Guid.NewGuid().ToString("N");
|
||||
var a = await StartServerAsync(MakeClusterOpts(cluster));
|
||||
|
||||
await a.Cts.CancelAsync();
|
||||
a.Server.Dispose();
|
||||
a.Cts.Dispose();
|
||||
// If we get here without timeout, shutdown worked properly
|
||||
}
|
||||
|
||||
// Go: TestRoutePings server/routes_test.go:4376
|
||||
[Fact]
|
||||
public async Task Route_stays_alive_with_periodic_activity()
|
||||
{
|
||||
var cluster = Guid.NewGuid().ToString("N");
|
||||
var a = await StartServerAsync(MakeClusterOpts(cluster));
|
||||
var b = await StartServerAsync(MakeClusterOpts(cluster, a.Server.ClusterListen!));
|
||||
|
||||
try
|
||||
{
|
||||
await WaitForRouteFormation(a.Server, b.Server);
|
||||
|
||||
// Route stays alive after some time
|
||||
await Task.Delay(500);
|
||||
Interlocked.Read(ref a.Server.Stats.Routes).ShouldBeGreaterThan(0);
|
||||
Interlocked.Read(ref b.Server.Stats.Routes).ShouldBeGreaterThan(0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DisposeServers(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
// Go: TestServerRoutesWithClients server/routes_test.go:216
|
||||
[Fact]
|
||||
public async Task Multiple_messages_flow_across_route()
|
||||
{
|
||||
var cluster = Guid.NewGuid().ToString("N");
|
||||
var optsA = MakeClusterOpts(cluster);
|
||||
optsA.Cluster!.PoolSize = 1;
|
||||
var a = await StartServerAsync(optsA);
|
||||
var optsB = MakeClusterOpts(cluster, a.Server.ClusterListen!);
|
||||
optsB.Cluster!.PoolSize = 1;
|
||||
var b = await StartServerAsync(optsB);
|
||||
|
||||
try
|
||||
{
|
||||
await WaitForRouteFormation(a.Server, b.Server);
|
||||
|
||||
await using var subscriber = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://127.0.0.1:{a.Server.Port}",
|
||||
});
|
||||
await subscriber.ConnectAsync();
|
||||
await using var sub = await subscriber.SubscribeCoreAsync<string>("multi.msg");
|
||||
await subscriber.PingAsync();
|
||||
await WaitForCondition(() => b.Server.HasRemoteInterest("multi.msg"));
|
||||
|
||||
await using var publisher = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://127.0.0.1:{b.Server.Port}",
|
||||
});
|
||||
await publisher.ConnectAsync();
|
||||
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
await publisher.PublishAsync("multi.msg", $"msg-{i}");
|
||||
}
|
||||
|
||||
var received = new HashSet<string>();
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
var msg = await sub.Msgs.ReadAsync(timeout.Token);
|
||||
received.Add(msg.Data!);
|
||||
}
|
||||
|
||||
received.Count.ShouldBe(10);
|
||||
for (var i = 0; i < 10; i++)
|
||||
received.ShouldContain($"msg-{i}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DisposeServers(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
// Go: TestRouteClusterNameConflictBetweenStaticAndDynamic server/routes_test.go:1374
|
||||
[Fact]
|
||||
public async Task Route_with_named_cluster_forms_correctly()
|
||||
{
|
||||
var cluster = "named-cluster-test";
|
||||
var a = await StartServerAsync(MakeClusterOpts(cluster));
|
||||
var b = await StartServerAsync(MakeClusterOpts(cluster, a.Server.ClusterListen!));
|
||||
|
||||
try
|
||||
{
|
||||
await WaitForRouteFormation(a.Server, b.Server);
|
||||
Interlocked.Read(ref a.Server.Stats.Routes).ShouldBeGreaterThan(0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DisposeServers(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
// -- Wire-level helpers --
|
||||
|
||||
private static async Task<string> ReadLineAsync(Socket socket, CancellationToken ct)
|
||||
{
|
||||
var bytes = new List<byte>(64);
|
||||
var single = new byte[1];
|
||||
while (true)
|
||||
{
|
||||
var read = await socket.ReceiveAsync(single, SocketFlags.None, ct);
|
||||
if (read == 0) break;
|
||||
if (single[0] == (byte)'\n') break;
|
||||
if (single[0] != (byte)'\r')
|
||||
bytes.Add(single[0]);
|
||||
}
|
||||
|
||||
return Encoding.ASCII.GetString([.. bytes]);
|
||||
}
|
||||
|
||||
private static Task WriteLineAsync(Socket socket, string line, CancellationToken ct)
|
||||
=> socket.SendAsync(Encoding.ASCII.GetBytes($"{line}\r\n"), SocketFlags.None, ct).AsTask();
|
||||
}
|
||||
@@ -1,820 +0,0 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Client.Core;
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.Configuration;
|
||||
using NATS.Server.Routes;
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.Tests.Routes;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for route message forwarding (RMSG), reply propagation, payload delivery,
|
||||
/// and cross-cluster message routing.
|
||||
/// Ported from Go: server/routes_test.go.
|
||||
/// </summary>
|
||||
public class RouteForwardingTests
|
||||
{
|
||||
// -- Helpers --
|
||||
|
||||
private static async Task<(NatsServer Server, CancellationTokenSource Cts)> StartServerAsync(
|
||||
NatsOptions opts)
|
||||
{
|
||||
var server = new NatsServer(opts, NullLoggerFactory.Instance);
|
||||
var cts = new CancellationTokenSource();
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
return (server, cts);
|
||||
}
|
||||
|
||||
private static NatsOptions MakeClusterOpts(string? clusterName = null, string? seed = null)
|
||||
{
|
||||
return new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Cluster = new ClusterOptions
|
||||
{
|
||||
Name = clusterName ?? Guid.NewGuid().ToString("N"),
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Routes = seed is null ? [] : [seed],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task WaitForRouteFormation(NatsServer a, NatsServer b, int timeoutMs = 5000)
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(timeoutMs);
|
||||
while (!timeout.IsCancellationRequested &&
|
||||
(Interlocked.Read(ref a.Stats.Routes) == 0 ||
|
||||
Interlocked.Read(ref b.Stats.Routes) == 0))
|
||||
{
|
||||
await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WaitForCondition(Func<bool> predicate, int timeoutMs = 5000)
|
||||
{
|
||||
using var cts = new CancellationTokenSource(timeoutMs);
|
||||
while (!cts.IsCancellationRequested)
|
||||
{
|
||||
if (predicate()) return;
|
||||
await Task.Delay(20, cts.Token).ContinueWith(_ => { }, TaskScheduler.Default);
|
||||
}
|
||||
|
||||
throw new TimeoutException("Condition not met.");
|
||||
}
|
||||
|
||||
private static async Task DisposeServers(params (NatsServer Server, CancellationTokenSource Cts)[] servers)
|
||||
{
|
||||
foreach (var (server, cts) in servers)
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
cts.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// -- Tests: RMSG forwarding --
|
||||
|
||||
// Go: TestSeedSolicitWorks server/routes_test.go:365 (message forwarding)
|
||||
[Fact]
|
||||
public async Task RMSG_forwards_published_message_to_remote_subscriber()
|
||||
{
|
||||
var cluster = Guid.NewGuid().ToString("N");
|
||||
var a = await StartServerAsync(MakeClusterOpts(cluster));
|
||||
var b = await StartServerAsync(MakeClusterOpts(cluster, a.Server.ClusterListen!));
|
||||
|
||||
try
|
||||
{
|
||||
await WaitForRouteFormation(a.Server, b.Server);
|
||||
|
||||
await using var subscriber = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://127.0.0.1:{a.Server.Port}",
|
||||
});
|
||||
await subscriber.ConnectAsync();
|
||||
await using var sub = await subscriber.SubscribeCoreAsync<string>("rmsg.test");
|
||||
await subscriber.PingAsync();
|
||||
await WaitForCondition(() => b.Server.HasRemoteInterest("rmsg.test"));
|
||||
|
||||
await using var publisher = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://127.0.0.1:{b.Server.Port}",
|
||||
});
|
||||
await publisher.ConnectAsync();
|
||||
await publisher.PublishAsync("rmsg.test", "routed-payload");
|
||||
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
var msg = await sub.Msgs.ReadAsync(timeout.Token);
|
||||
msg.Data.ShouldBe("routed-payload");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DisposeServers(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
// Go: Request-Reply across routes via raw socket with reply-to
|
||||
[Fact]
|
||||
public async Task Request_reply_works_across_routed_servers()
|
||||
{
|
||||
var cluster = Guid.NewGuid().ToString("N");
|
||||
var optsA = MakeClusterOpts(cluster);
|
||||
optsA.Cluster!.PoolSize = 1;
|
||||
var a = await StartServerAsync(optsA);
|
||||
var optsB = MakeClusterOpts(cluster, a.Server.ClusterListen!);
|
||||
optsB.Cluster!.PoolSize = 1;
|
||||
var b = await StartServerAsync(optsB);
|
||||
|
||||
try
|
||||
{
|
||||
await WaitForRouteFormation(a.Server, b.Server);
|
||||
|
||||
// Responder on server A: subscribe via raw socket to get exact wire control
|
||||
using var responderSock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await responderSock.ConnectAsync(IPAddress.Loopback, a.Server.Port);
|
||||
var buf = new byte[4096];
|
||||
_ = await responderSock.ReceiveAsync(buf); // INFO
|
||||
await responderSock.SendAsync(Encoding.ASCII.GetBytes("CONNECT {}\r\nSUB service.echo 1\r\nPING\r\n"));
|
||||
await ReadUntilAsync(responderSock, "PONG");
|
||||
await WaitForCondition(() => b.Server.HasRemoteInterest("service.echo"));
|
||||
|
||||
// Requester on server B: subscribe to reply inbox via raw socket
|
||||
using var requesterSock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await requesterSock.ConnectAsync(IPAddress.Loopback, b.Server.Port);
|
||||
_ = await requesterSock.ReceiveAsync(buf); // INFO
|
||||
var replyInbox = $"_INBOX.{Guid.NewGuid():N}";
|
||||
await requesterSock.SendAsync(Encoding.ASCII.GetBytes(
|
||||
$"CONNECT {{}}\r\nSUB {replyInbox} 2\r\nPING\r\n"));
|
||||
await ReadUntilAsync(requesterSock, "PONG");
|
||||
await WaitForCondition(() => a.Server.HasRemoteInterest(replyInbox));
|
||||
|
||||
// Publish request with reply-to from B
|
||||
await requesterSock.SendAsync(Encoding.ASCII.GetBytes(
|
||||
$"PUB service.echo {replyInbox} 4\r\nping\r\nPING\r\n"));
|
||||
await ReadUntilAsync(requesterSock, "PONG");
|
||||
|
||||
// Read the request on A, verify reply-to
|
||||
var requestData = await ReadUntilAsync(responderSock, "ping");
|
||||
requestData.ShouldContain($"MSG service.echo 1 {replyInbox} 4");
|
||||
requestData.ShouldContain("ping");
|
||||
|
||||
// Publish reply from A to the reply-to subject
|
||||
await responderSock.SendAsync(Encoding.ASCII.GetBytes(
|
||||
$"PUB {replyInbox} 4\r\npong\r\nPING\r\n"));
|
||||
await ReadUntilAsync(responderSock, "PONG");
|
||||
|
||||
// Read the reply on B
|
||||
var replyData = await ReadUntilAsync(requesterSock, "pong");
|
||||
replyData.ShouldContain($"MSG {replyInbox} 2 4");
|
||||
replyData.ShouldContain("pong");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DisposeServers(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
// Go: RMSG wire-level parsing
|
||||
[Fact]
|
||||
public async Task RMSG_wire_frame_delivers_payload_to_handler()
|
||||
{
|
||||
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
|
||||
using var remote = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await remote.ConnectAsync(IPAddress.Loopback, port);
|
||||
using var routeSock = await listener.AcceptSocketAsync();
|
||||
await using var route = new RouteConnection(routeSock);
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
|
||||
var handshakeTask = route.PerformOutboundHandshakeAsync("LOCAL", timeout.Token);
|
||||
_ = await ReadLineAsync(remote, timeout.Token);
|
||||
await WriteLineAsync(remote, "ROUTE REMOTE", timeout.Token);
|
||||
await handshakeTask;
|
||||
|
||||
RouteMessage? receivedMsg = null;
|
||||
route.RoutedMessageReceived = msg =>
|
||||
{
|
||||
receivedMsg = msg;
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
route.StartFrameLoop(timeout.Token);
|
||||
|
||||
var payload = "hello-world";
|
||||
var frame = $"RMSG $G test.subject - {payload.Length}\r\n{payload}\r\n";
|
||||
await remote.SendAsync(Encoding.ASCII.GetBytes(frame), SocketFlags.None, timeout.Token);
|
||||
|
||||
await WaitForCondition(() => receivedMsg != null);
|
||||
receivedMsg.ShouldNotBeNull();
|
||||
receivedMsg!.Subject.ShouldBe("test.subject");
|
||||
receivedMsg.ReplyTo.ShouldBeNull();
|
||||
Encoding.UTF8.GetString(receivedMsg.Payload.Span).ShouldBe("hello-world");
|
||||
}
|
||||
|
||||
// Go: RMSG with reply subject
|
||||
[Fact]
|
||||
public async Task RMSG_wire_frame_includes_reply_to()
|
||||
{
|
||||
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
|
||||
using var remote = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await remote.ConnectAsync(IPAddress.Loopback, port);
|
||||
using var routeSock = await listener.AcceptSocketAsync();
|
||||
await using var route = new RouteConnection(routeSock);
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
|
||||
var handshakeTask = route.PerformOutboundHandshakeAsync("LOCAL", timeout.Token);
|
||||
_ = await ReadLineAsync(remote, timeout.Token);
|
||||
await WriteLineAsync(remote, "ROUTE REMOTE", timeout.Token);
|
||||
await handshakeTask;
|
||||
|
||||
RouteMessage? receivedMsg = null;
|
||||
route.RoutedMessageReceived = msg =>
|
||||
{
|
||||
receivedMsg = msg;
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
route.StartFrameLoop(timeout.Token);
|
||||
|
||||
var payload = "data";
|
||||
var frame = $"RMSG $G test.subject _INBOX.abc123 {payload.Length}\r\n{payload}\r\n";
|
||||
await remote.SendAsync(Encoding.ASCII.GetBytes(frame), SocketFlags.None, timeout.Token);
|
||||
|
||||
await WaitForCondition(() => receivedMsg != null);
|
||||
receivedMsg.ShouldNotBeNull();
|
||||
receivedMsg!.Subject.ShouldBe("test.subject");
|
||||
receivedMsg.ReplyTo.ShouldBe("_INBOX.abc123");
|
||||
}
|
||||
|
||||
// Go: RMSG with account
|
||||
[Fact]
|
||||
public async Task RMSG_wire_frame_with_account_scope()
|
||||
{
|
||||
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
|
||||
using var remote = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await remote.ConnectAsync(IPAddress.Loopback, port);
|
||||
using var routeSock = await listener.AcceptSocketAsync();
|
||||
await using var route = new RouteConnection(routeSock);
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
|
||||
var handshakeTask = route.PerformOutboundHandshakeAsync("LOCAL", timeout.Token);
|
||||
_ = await ReadLineAsync(remote, timeout.Token);
|
||||
await WriteLineAsync(remote, "ROUTE REMOTE", timeout.Token);
|
||||
await handshakeTask;
|
||||
|
||||
RouteMessage? receivedMsg = null;
|
||||
route.RoutedMessageReceived = msg =>
|
||||
{
|
||||
receivedMsg = msg;
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
route.StartFrameLoop(timeout.Token);
|
||||
|
||||
var payload = "acct-data";
|
||||
var frame = $"RMSG MYACCOUNT test.sub - {payload.Length}\r\n{payload}\r\n";
|
||||
await remote.SendAsync(Encoding.ASCII.GetBytes(frame), SocketFlags.None, timeout.Token);
|
||||
|
||||
await WaitForCondition(() => receivedMsg != null);
|
||||
receivedMsg.ShouldNotBeNull();
|
||||
receivedMsg!.Account.ShouldBe("MYACCOUNT");
|
||||
receivedMsg.Subject.ShouldBe("test.sub");
|
||||
}
|
||||
|
||||
// Go: RMSG with zero-length payload
|
||||
[Fact]
|
||||
public async Task RMSG_wire_frame_with_empty_payload()
|
||||
{
|
||||
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
|
||||
using var remote = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await remote.ConnectAsync(IPAddress.Loopback, port);
|
||||
using var routeSock = await listener.AcceptSocketAsync();
|
||||
await using var route = new RouteConnection(routeSock);
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
|
||||
var handshakeTask = route.PerformOutboundHandshakeAsync("LOCAL", timeout.Token);
|
||||
_ = await ReadLineAsync(remote, timeout.Token);
|
||||
await WriteLineAsync(remote, "ROUTE REMOTE", timeout.Token);
|
||||
await handshakeTask;
|
||||
|
||||
RouteMessage? receivedMsg = null;
|
||||
route.RoutedMessageReceived = msg =>
|
||||
{
|
||||
receivedMsg = msg;
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
route.StartFrameLoop(timeout.Token);
|
||||
|
||||
var frame = "RMSG $G empty.test - 0\r\n\r\n";
|
||||
await remote.SendAsync(Encoding.ASCII.GetBytes(frame), SocketFlags.None, timeout.Token);
|
||||
|
||||
await WaitForCondition(() => receivedMsg != null);
|
||||
receivedMsg.ShouldNotBeNull();
|
||||
receivedMsg!.Subject.ShouldBe("empty.test");
|
||||
receivedMsg.Payload.Length.ShouldBe(0);
|
||||
}
|
||||
|
||||
// Go: TestServerRoutesWithClients server/routes_test.go:216 (large payload)
|
||||
[Fact]
|
||||
public async Task Large_payload_forwarded_across_route()
|
||||
{
|
||||
var cluster = Guid.NewGuid().ToString("N");
|
||||
var a = await StartServerAsync(MakeClusterOpts(cluster));
|
||||
var b = await StartServerAsync(MakeClusterOpts(cluster, a.Server.ClusterListen!));
|
||||
|
||||
try
|
||||
{
|
||||
await WaitForRouteFormation(a.Server, b.Server);
|
||||
|
||||
await using var subscriber = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://127.0.0.1:{a.Server.Port}",
|
||||
});
|
||||
await subscriber.ConnectAsync();
|
||||
await using var sub = await subscriber.SubscribeCoreAsync<byte[]>("large.payload");
|
||||
await subscriber.PingAsync();
|
||||
await WaitForCondition(() => b.Server.HasRemoteInterest("large.payload"));
|
||||
|
||||
await using var publisher = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://127.0.0.1:{b.Server.Port}",
|
||||
});
|
||||
await publisher.ConnectAsync();
|
||||
|
||||
var data = new byte[8192];
|
||||
Random.Shared.NextBytes(data);
|
||||
await publisher.PublishAsync("large.payload", data);
|
||||
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
var msg = await sub.Msgs.ReadAsync(timeout.Token);
|
||||
msg.Data.ShouldBe(data);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DisposeServers(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
// Go: TestRoutePool server/routes_test.go:1966 (message sent and received across pool)
|
||||
[Fact]
|
||||
public async Task Messages_flow_across_route_with_pool_size()
|
||||
{
|
||||
var cluster = Guid.NewGuid().ToString("N");
|
||||
var optsA = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Cluster = new ClusterOptions
|
||||
{
|
||||
Name = cluster,
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
PoolSize = 2,
|
||||
},
|
||||
};
|
||||
var a = await StartServerAsync(optsA);
|
||||
|
||||
var optsB = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Cluster = new ClusterOptions
|
||||
{
|
||||
Name = cluster,
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
PoolSize = 2,
|
||||
Routes = [a.Server.ClusterListen!],
|
||||
},
|
||||
};
|
||||
var b = await StartServerAsync(optsB);
|
||||
|
||||
try
|
||||
{
|
||||
await WaitForRouteFormation(a.Server, b.Server);
|
||||
|
||||
await using var subscriber = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://127.0.0.1:{b.Server.Port}",
|
||||
});
|
||||
await subscriber.ConnectAsync();
|
||||
await using var sub = await subscriber.SubscribeCoreAsync<string>("pool.forward");
|
||||
await subscriber.PingAsync();
|
||||
await WaitForCondition(() => a.Server.HasRemoteInterest("pool.forward"));
|
||||
|
||||
await using var publisher = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://127.0.0.1:{a.Server.Port}",
|
||||
});
|
||||
await publisher.ConnectAsync();
|
||||
|
||||
const int messageCount = 10;
|
||||
for (var i = 0; i < messageCount; i++)
|
||||
await publisher.PublishAsync("pool.forward", $"msg-{i}");
|
||||
|
||||
// With PoolSize=2, each message may be forwarded on multiple route connections.
|
||||
// Collect all received messages and verify each expected one arrived at least once.
|
||||
var received = new HashSet<string>();
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
while (received.Count < messageCount)
|
||||
{
|
||||
var msg = await sub.Msgs.ReadAsync(timeout.Token);
|
||||
msg.Data.ShouldNotBeNull();
|
||||
received.Add(msg.Data!);
|
||||
}
|
||||
|
||||
for (var i = 0; i < messageCount; i++)
|
||||
received.ShouldContain($"msg-{i}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DisposeServers(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
// Go: TestRoutePerAccount server/routes_test.go:2539 (account-scoped delivery)
|
||||
[Fact]
|
||||
public async Task Account_scoped_RMSG_delivers_to_correct_account()
|
||||
{
|
||||
var users = new User[]
|
||||
{
|
||||
new() { Username = "ua", Password = "p", Account = "A" },
|
||||
new() { Username = "ub", Password = "p", Account = "B" },
|
||||
};
|
||||
var cluster = Guid.NewGuid().ToString("N");
|
||||
|
||||
var optsA = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Users = users,
|
||||
Cluster = new ClusterOptions
|
||||
{
|
||||
Name = cluster,
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
},
|
||||
};
|
||||
var a = await StartServerAsync(optsA);
|
||||
|
||||
var optsB = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Users = users,
|
||||
Cluster = new ClusterOptions
|
||||
{
|
||||
Name = cluster,
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Routes = [a.Server.ClusterListen!],
|
||||
},
|
||||
};
|
||||
var b = await StartServerAsync(optsB);
|
||||
|
||||
try
|
||||
{
|
||||
await WaitForRouteFormation(a.Server, b.Server);
|
||||
|
||||
// Account A subscriber on server B
|
||||
await using var subConn = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://ua:p@127.0.0.1:{b.Server.Port}",
|
||||
});
|
||||
await subConn.ConnectAsync();
|
||||
await using var sub = await subConn.SubscribeCoreAsync<string>("acct.fwd");
|
||||
await subConn.PingAsync();
|
||||
|
||||
await WaitForCondition(() => a.Server.HasRemoteInterest("A", "acct.fwd"));
|
||||
|
||||
// Publish from account A on server A
|
||||
await using var pubConn = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://ua:p@127.0.0.1:{a.Server.Port}",
|
||||
});
|
||||
await pubConn.ConnectAsync();
|
||||
await pubConn.PublishAsync("acct.fwd", "from-a");
|
||||
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
var msg = await sub.Msgs.ReadAsync(timeout.Token);
|
||||
msg.Data.ShouldBe("from-a");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DisposeServers(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
// Go: bidirectional forwarding
|
||||
[Fact]
|
||||
public async Task Bidirectional_message_forwarding_across_route()
|
||||
{
|
||||
var cluster = Guid.NewGuid().ToString("N");
|
||||
var a = await StartServerAsync(MakeClusterOpts(cluster));
|
||||
var b = await StartServerAsync(MakeClusterOpts(cluster, a.Server.ClusterListen!));
|
||||
|
||||
try
|
||||
{
|
||||
await WaitForRouteFormation(a.Server, b.Server);
|
||||
|
||||
await using var ncA = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://127.0.0.1:{a.Server.Port}",
|
||||
});
|
||||
await ncA.ConnectAsync();
|
||||
|
||||
await using var ncB = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://127.0.0.1:{b.Server.Port}",
|
||||
});
|
||||
await ncB.ConnectAsync();
|
||||
|
||||
// Sub on A, pub from B
|
||||
await using var subOnA = await ncA.SubscribeCoreAsync<string>("bidir.a");
|
||||
// Sub on B, pub from A
|
||||
await using var subOnB = await ncB.SubscribeCoreAsync<string>("bidir.b");
|
||||
await ncA.PingAsync();
|
||||
await ncB.PingAsync();
|
||||
|
||||
await WaitForCondition(() =>
|
||||
b.Server.HasRemoteInterest("bidir.a") && a.Server.HasRemoteInterest("bidir.b"));
|
||||
|
||||
await ncB.PublishAsync("bidir.a", "from-b");
|
||||
await ncA.PublishAsync("bidir.b", "from-a");
|
||||
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
var msgA = await subOnA.Msgs.ReadAsync(timeout.Token);
|
||||
var msgB = await subOnB.Msgs.ReadAsync(timeout.Token);
|
||||
msgA.Data.ShouldBe("from-b");
|
||||
msgB.Data.ShouldBe("from-a");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DisposeServers(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
// Go: Route forwarding with reply (non-request-reply, just reply subject)
|
||||
[Fact]
|
||||
public async Task Message_with_reply_subject_forwarded_across_route()
|
||||
{
|
||||
var cluster = Guid.NewGuid().ToString("N");
|
||||
var a = await StartServerAsync(MakeClusterOpts(cluster));
|
||||
var b = await StartServerAsync(MakeClusterOpts(cluster, a.Server.ClusterListen!));
|
||||
|
||||
try
|
||||
{
|
||||
await WaitForRouteFormation(a.Server, b.Server);
|
||||
|
||||
await using var subscriber = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://127.0.0.1:{a.Server.Port}",
|
||||
});
|
||||
await subscriber.ConnectAsync();
|
||||
await using var sub = await subscriber.SubscribeCoreAsync<string>("reply.subject.test");
|
||||
await subscriber.PingAsync();
|
||||
await WaitForCondition(() => b.Server.HasRemoteInterest("reply.subject.test"));
|
||||
|
||||
// Use raw socket to publish with reply-to
|
||||
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await sock.ConnectAsync(IPAddress.Loopback, b.Server.Port);
|
||||
var buf = new byte[4096];
|
||||
_ = await sock.ReceiveAsync(buf); // INFO
|
||||
await sock.SendAsync(Encoding.ASCII.GetBytes(
|
||||
"CONNECT {}\r\nPUB reply.subject.test _INBOX.reply123 5\r\nHello\r\nPING\r\n"));
|
||||
await ReadUntilAsync(sock, "PONG");
|
||||
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
var msg = await sub.Msgs.ReadAsync(timeout.Token);
|
||||
msg.Data.ShouldBe("Hello");
|
||||
msg.ReplyTo.ShouldBe("_INBOX.reply123");
|
||||
sock.Dispose();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DisposeServers(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
// Go: Multiple messages with varying payloads
|
||||
[Fact]
|
||||
public async Task Multiple_different_subjects_forwarded_simultaneously()
|
||||
{
|
||||
var cluster = Guid.NewGuid().ToString("N");
|
||||
var a = await StartServerAsync(MakeClusterOpts(cluster));
|
||||
var b = await StartServerAsync(MakeClusterOpts(cluster, a.Server.ClusterListen!));
|
||||
|
||||
try
|
||||
{
|
||||
await WaitForRouteFormation(a.Server, b.Server);
|
||||
|
||||
await using var ncA = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://127.0.0.1:{a.Server.Port}",
|
||||
});
|
||||
await ncA.ConnectAsync();
|
||||
|
||||
await using var sub1 = await ncA.SubscribeCoreAsync<string>("multi.a");
|
||||
await using var sub2 = await ncA.SubscribeCoreAsync<string>("multi.b");
|
||||
await using var sub3 = await ncA.SubscribeCoreAsync<string>("multi.c");
|
||||
await ncA.PingAsync();
|
||||
|
||||
await WaitForCondition(() =>
|
||||
b.Server.HasRemoteInterest("multi.a") &&
|
||||
b.Server.HasRemoteInterest("multi.b") &&
|
||||
b.Server.HasRemoteInterest("multi.c"));
|
||||
|
||||
await using var pub = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://127.0.0.1:{b.Server.Port}",
|
||||
});
|
||||
await pub.ConnectAsync();
|
||||
await pub.PublishAsync("multi.a", "alpha");
|
||||
await pub.PublishAsync("multi.b", "beta");
|
||||
await pub.PublishAsync("multi.c", "gamma");
|
||||
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
var msgA = await sub1.Msgs.ReadAsync(timeout.Token);
|
||||
var msgB = await sub2.Msgs.ReadAsync(timeout.Token);
|
||||
var msgC = await sub3.Msgs.ReadAsync(timeout.Token);
|
||||
|
||||
msgA.Data.ShouldBe("alpha");
|
||||
msgB.Data.ShouldBe("beta");
|
||||
msgC.Data.ShouldBe("gamma");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DisposeServers(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
// Go: SendRmsgAsync (send RMSG on RouteConnection)
|
||||
[Fact]
|
||||
public async Task RouteConnection_SendRmsgAsync_sends_valid_wire_frame()
|
||||
{
|
||||
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
|
||||
using var remote = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await remote.ConnectAsync(IPAddress.Loopback, port);
|
||||
using var routeSock = await listener.AcceptSocketAsync();
|
||||
await using var route = new RouteConnection(routeSock);
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
|
||||
var handshakeTask = route.PerformOutboundHandshakeAsync("LOCAL", timeout.Token);
|
||||
_ = await ReadLineAsync(remote, timeout.Token);
|
||||
await WriteLineAsync(remote, "ROUTE REMOTE", timeout.Token);
|
||||
await handshakeTask;
|
||||
|
||||
var payload = Encoding.UTF8.GetBytes("test-payload");
|
||||
await route.SendRmsgAsync("$G", "subject.test", "_INBOX.reply", payload, timeout.Token);
|
||||
|
||||
// Read the RMSG frame from the remote side, waiting until expected content arrives
|
||||
var data = await ReadUntilAsync(remote, "test-payload");
|
||||
data.ShouldContain("RMSG $G subject.test _INBOX.reply 12");
|
||||
data.ShouldContain("test-payload");
|
||||
}
|
||||
|
||||
// Go: SendRsPlusAsync (send RS+ on RouteConnection)
|
||||
[Fact]
|
||||
public async Task RouteConnection_SendRsPlusAsync_sends_valid_wire_frame()
|
||||
{
|
||||
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
|
||||
using var remote = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await remote.ConnectAsync(IPAddress.Loopback, port);
|
||||
using var routeSock = await listener.AcceptSocketAsync();
|
||||
await using var route = new RouteConnection(routeSock);
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
|
||||
var handshakeTask = route.PerformOutboundHandshakeAsync("LOCAL", timeout.Token);
|
||||
_ = await ReadLineAsync(remote, timeout.Token);
|
||||
await WriteLineAsync(remote, "ROUTE REMOTE", timeout.Token);
|
||||
await handshakeTask;
|
||||
|
||||
await route.SendRsPlusAsync("$G", "foo.bar", null, timeout.Token);
|
||||
var data = await ReadAllAvailableAsync(remote, timeout.Token);
|
||||
data.ShouldContain("RS+ $G foo.bar");
|
||||
}
|
||||
|
||||
// Go: SendRsMinusAsync (send RS- on RouteConnection)
|
||||
[Fact]
|
||||
public async Task RouteConnection_SendRsMinusAsync_sends_valid_wire_frame()
|
||||
{
|
||||
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
|
||||
using var remote = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await remote.ConnectAsync(IPAddress.Loopback, port);
|
||||
using var routeSock = await listener.AcceptSocketAsync();
|
||||
await using var route = new RouteConnection(routeSock);
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
|
||||
var handshakeTask = route.PerformOutboundHandshakeAsync("LOCAL", timeout.Token);
|
||||
_ = await ReadLineAsync(remote, timeout.Token);
|
||||
await WriteLineAsync(remote, "ROUTE REMOTE", timeout.Token);
|
||||
await handshakeTask;
|
||||
|
||||
await route.SendRsMinusAsync("$G", "foo.bar", null, timeout.Token);
|
||||
var data = await ReadAllAvailableAsync(remote, timeout.Token);
|
||||
data.ShouldContain("RS- $G foo.bar");
|
||||
}
|
||||
|
||||
// Go: SendRsPlusAsync with queue
|
||||
[Fact]
|
||||
public async Task RouteConnection_SendRsPlusAsync_with_queue_sends_valid_frame()
|
||||
{
|
||||
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
|
||||
using var remote = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await remote.ConnectAsync(IPAddress.Loopback, port);
|
||||
using var routeSock = await listener.AcceptSocketAsync();
|
||||
await using var route = new RouteConnection(routeSock);
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
|
||||
var handshakeTask = route.PerformOutboundHandshakeAsync("LOCAL", timeout.Token);
|
||||
_ = await ReadLineAsync(remote, timeout.Token);
|
||||
await WriteLineAsync(remote, "ROUTE REMOTE", timeout.Token);
|
||||
await handshakeTask;
|
||||
|
||||
await route.SendRsPlusAsync("ACCT_A", "foo.bar", "myqueue", timeout.Token);
|
||||
var data = await ReadAllAvailableAsync(remote, timeout.Token);
|
||||
data.ShouldContain("RS+ ACCT_A foo.bar myqueue");
|
||||
}
|
||||
|
||||
// -- Wire-level helpers --
|
||||
|
||||
private static async Task<string> ReadLineAsync(Socket socket, CancellationToken ct)
|
||||
{
|
||||
var bytes = new List<byte>(64);
|
||||
var single = new byte[1];
|
||||
while (true)
|
||||
{
|
||||
var read = await socket.ReceiveAsync(single, SocketFlags.None, ct);
|
||||
if (read == 0) break;
|
||||
if (single[0] == (byte)'\n') break;
|
||||
if (single[0] != (byte)'\r')
|
||||
bytes.Add(single[0]);
|
||||
}
|
||||
|
||||
return Encoding.ASCII.GetString([.. bytes]);
|
||||
}
|
||||
|
||||
private static Task WriteLineAsync(Socket socket, string line, CancellationToken ct)
|
||||
=> socket.SendAsync(Encoding.ASCII.GetBytes($"{line}\r\n"), SocketFlags.None, ct).AsTask();
|
||||
|
||||
private static async Task<string> ReadAllAvailableAsync(Socket socket, CancellationToken ct)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
var buf = new byte[4096];
|
||||
|
||||
// First read blocks until at least some data arrives
|
||||
var n = await socket.ReceiveAsync(buf, SocketFlags.None, ct);
|
||||
if (n > 0)
|
||||
sb.Append(Encoding.ASCII.GetString(buf, 0, n));
|
||||
|
||||
// Drain any additional data that's already buffered
|
||||
while (n == buf.Length && socket.Available > 0)
|
||||
{
|
||||
n = await socket.ReceiveAsync(buf, SocketFlags.None, ct);
|
||||
if (n == 0) break;
|
||||
sb.Append(Encoding.ASCII.GetString(buf, 0, n));
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static async Task<string> ReadUntilAsync(Socket sock, string expected)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
var buf = new byte[4096];
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
while (!sb.ToString().Contains(expected, StringComparison.Ordinal))
|
||||
{
|
||||
var n = await sock.ReceiveAsync(buf, SocketFlags.None, cts.Token);
|
||||
if (n == 0) break;
|
||||
sb.Append(Encoding.ASCII.GetString(buf, 0, n));
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
// Reference: golang/nats-server/server/route.go — route hash map for O(1) server-ID lookup.
|
||||
// Tests for Gap 13.4: hash-based route storage added to RouteManager.
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server;
|
||||
using NATS.Server.Configuration;
|
||||
using NATS.Server.Routes;
|
||||
|
||||
namespace NATS.Server.Tests.Routes;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the FNV-1a hash-based route storage on <see cref="RouteManager"/>.
|
||||
/// Covers <c>ComputeRouteHash</c>, <c>RegisterRouteByHash</c>,
|
||||
/// <c>UnregisterRouteByHash</c>, <c>GetRouteByHash</c>,
|
||||
/// <c>GetRouteByServerId</c>, and <c>HashedRouteCount</c>.
|
||||
/// Go reference: server/route.go — server-ID-keyed route hash map.
|
||||
/// </summary>
|
||||
public class RouteHashStorageTests
|
||||
{
|
||||
// Helper: build a RouteManager instance that is NOT started (no listener).
|
||||
// Only hash-map methods are exercised; StartAsync is never called.
|
||||
private static RouteManager CreateManager() =>
|
||||
new(
|
||||
new ClusterOptions { Host = "127.0.0.1", Port = 0 },
|
||||
new ServerStats(),
|
||||
serverId: Guid.NewGuid().ToString("N"),
|
||||
remoteSubSink: static _ => { },
|
||||
routedMessageSink: static _ => { },
|
||||
logger: NullLogger<RouteManager>.Instance);
|
||||
|
||||
// Helper: create a RouteConnection backed by a loopback-connected socket.
|
||||
// RouteConnection's constructor wraps the socket in NetworkStream, which
|
||||
// requires a connected socket, so we use a listener/accept pair.
|
||||
private static RouteConnection MakeRouteConnection()
|
||||
{
|
||||
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
client.Connect(IPAddress.Loopback, ((IPEndPoint)listener.LocalEndpoint).Port);
|
||||
// Accept server side so the TCP handshake completes; we hand the client
|
||||
// socket to RouteConnection and let the server-side socket be GC'd.
|
||||
_ = listener.AcceptSocket();
|
||||
return new RouteConnection(client);
|
||||
}
|
||||
|
||||
// 1 -----------------------------------------------------------------------
|
||||
[Fact]
|
||||
public void ComputeRouteHash_Deterministic()
|
||||
{
|
||||
// Go reference: server/route.go — hash derivation must be stable across calls.
|
||||
const string serverId = "server-abc-123";
|
||||
|
||||
var h1 = RouteManager.ComputeRouteHash(serverId);
|
||||
var h2 = RouteManager.ComputeRouteHash(serverId);
|
||||
var h3 = RouteManager.ComputeRouteHash(serverId);
|
||||
|
||||
h1.ShouldBe(h2);
|
||||
h2.ShouldBe(h3);
|
||||
}
|
||||
|
||||
// 2 -----------------------------------------------------------------------
|
||||
[Fact]
|
||||
public void ComputeRouteHash_DifferentInputs_DifferentHashes()
|
||||
{
|
||||
// Go reference: server/route.go — distinct server IDs must not collide.
|
||||
var h1 = RouteManager.ComputeRouteHash("server-1");
|
||||
var h2 = RouteManager.ComputeRouteHash("server-2");
|
||||
|
||||
h1.ShouldNotBe(h2);
|
||||
}
|
||||
|
||||
// 3 -----------------------------------------------------------------------
|
||||
[Fact]
|
||||
public void ComputeRouteHash_EmptyString_DoesNotThrow()
|
||||
{
|
||||
// Go reference: server/route.go — empty server ID is a degenerate but
|
||||
// valid input; the FNV offset basis is returned.
|
||||
var ex = Record.Exception(() => RouteManager.ComputeRouteHash(string.Empty));
|
||||
ex.ShouldBeNull();
|
||||
|
||||
// The hash of an empty string equals the FNV-1a 64-bit offset basis.
|
||||
const ulong fnvOffsetBasis = 14695981039346656037UL;
|
||||
RouteManager.ComputeRouteHash(string.Empty).ShouldBe(fnvOffsetBasis);
|
||||
}
|
||||
|
||||
// 4 -----------------------------------------------------------------------
|
||||
[Fact]
|
||||
public async Task RegisterRouteByHash_CanRetrieve()
|
||||
{
|
||||
// Go reference: server/route.go — after registration the connection must
|
||||
// be retrievable by its hash key.
|
||||
var mgr = CreateManager();
|
||||
await using var conn = MakeRouteConnection();
|
||||
|
||||
mgr.RegisterRouteByHash("srv-A", conn);
|
||||
|
||||
var hash = RouteManager.ComputeRouteHash("srv-A");
|
||||
mgr.GetRouteByHash(hash).ShouldBeSameAs(conn);
|
||||
}
|
||||
|
||||
// 5 -----------------------------------------------------------------------
|
||||
[Fact]
|
||||
public async Task UnregisterRouteByHash_RemovesEntry()
|
||||
{
|
||||
// Go reference: server/route.go — deregistration removes the hash entry.
|
||||
var mgr = CreateManager();
|
||||
await using var conn = MakeRouteConnection();
|
||||
|
||||
mgr.RegisterRouteByHash("srv-B", conn);
|
||||
mgr.UnregisterRouteByHash("srv-B");
|
||||
|
||||
var hash = RouteManager.ComputeRouteHash("srv-B");
|
||||
mgr.GetRouteByHash(hash).ShouldBeNull();
|
||||
}
|
||||
|
||||
// 6 -----------------------------------------------------------------------
|
||||
[Fact]
|
||||
public async Task GetRouteByServerId_FindsRegistered()
|
||||
{
|
||||
// Go reference: server/route.go — string-based lookup computes hash internally.
|
||||
var mgr = CreateManager();
|
||||
await using var conn = MakeRouteConnection();
|
||||
|
||||
mgr.RegisterRouteByHash("srv-C", conn);
|
||||
|
||||
mgr.GetRouteByServerId("srv-C").ShouldBeSameAs(conn);
|
||||
}
|
||||
|
||||
// 7 -----------------------------------------------------------------------
|
||||
[Fact]
|
||||
public void GetRouteByServerId_NotRegistered_ReturnsNull()
|
||||
{
|
||||
// Go reference: server/route.go — unknown server ID yields null.
|
||||
var mgr = CreateManager();
|
||||
|
||||
mgr.GetRouteByServerId("unknown-server").ShouldBeNull();
|
||||
}
|
||||
|
||||
// 8 -----------------------------------------------------------------------
|
||||
[Fact]
|
||||
public async Task HashedRouteCount_MatchesRegistrations()
|
||||
{
|
||||
// Go reference: server/route.go — count reflects registered entries.
|
||||
var mgr = CreateManager();
|
||||
await using var c1 = MakeRouteConnection();
|
||||
await using var c2 = MakeRouteConnection();
|
||||
await using var c3 = MakeRouteConnection();
|
||||
|
||||
mgr.HashedRouteCount.ShouldBe(0);
|
||||
|
||||
mgr.RegisterRouteByHash("srv-1", c1);
|
||||
mgr.HashedRouteCount.ShouldBe(1);
|
||||
|
||||
mgr.RegisterRouteByHash("srv-2", c2);
|
||||
mgr.HashedRouteCount.ShouldBe(2);
|
||||
|
||||
mgr.RegisterRouteByHash("srv-3", c3);
|
||||
mgr.HashedRouteCount.ShouldBe(3);
|
||||
|
||||
mgr.UnregisterRouteByHash("srv-2");
|
||||
mgr.HashedRouteCount.ShouldBe(2);
|
||||
}
|
||||
|
||||
// 9 -----------------------------------------------------------------------
|
||||
[Fact]
|
||||
public async Task RegisterRouteByHash_OverwritesPrevious()
|
||||
{
|
||||
// Go reference: server/route.go — re-registering the same server ID
|
||||
// replaces the stale connection with the new one.
|
||||
var mgr = CreateManager();
|
||||
await using var old = MakeRouteConnection();
|
||||
await using var replacement = MakeRouteConnection();
|
||||
|
||||
mgr.RegisterRouteByHash("srv-D", old);
|
||||
mgr.RegisterRouteByHash("srv-D", replacement);
|
||||
|
||||
mgr.GetRouteByServerId("srv-D").ShouldBeSameAs(replacement);
|
||||
mgr.HashedRouteCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
// 10 ----------------------------------------------------------------------
|
||||
[Fact]
|
||||
public async Task UnregisterRouteByHash_NonExistent_NoOp()
|
||||
{
|
||||
// Go reference: server/route.go — removing a hash key that was never
|
||||
// registered must not throw and must not alter the count.
|
||||
var mgr = CreateManager();
|
||||
await using var conn = MakeRouteConnection();
|
||||
|
||||
mgr.RegisterRouteByHash("srv-E", conn);
|
||||
|
||||
var ex = Record.Exception(() => mgr.UnregisterRouteByHash("does-not-exist"));
|
||||
ex.ShouldBeNull();
|
||||
|
||||
mgr.HashedRouteCount.ShouldBe(1);
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server;
|
||||
|
||||
namespace NATS.Server.Tests.Routes;
|
||||
|
||||
public class RouteInfoBroadcastParityBatch4Tests
|
||||
{
|
||||
[Fact]
|
||||
public async Task UpdateServerINFOAndSendINFOToClients_broadcasts_INFO_to_connected_clients()
|
||||
{
|
||||
var port = GetFreePort();
|
||||
using var server = new NatsServer(new NatsOptions { Host = "127.0.0.1", Port = port }, NullLoggerFactory.Instance);
|
||||
using var cts = new CancellationTokenSource();
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await socket.ConnectAsync(IPAddress.Loopback, port);
|
||||
|
||||
_ = await ReadLineAsync(socket, CancellationToken.None); // initial INFO
|
||||
await socket.SendAsync(Encoding.ASCII.GetBytes("CONNECT {}\r\nPING\r\n"), SocketFlags.None);
|
||||
_ = await ReadUntilContainsAsync(socket, "PONG", CancellationToken.None);
|
||||
|
||||
server.UpdateServerINFOAndSendINFOToClients();
|
||||
|
||||
var info = await ReadLineAsync(socket, CancellationToken.None);
|
||||
info.ShouldStartWith("INFO ");
|
||||
|
||||
await server.ShutdownAsync();
|
||||
}
|
||||
|
||||
private static async Task<string> ReadUntilContainsAsync(Socket socket, string token, CancellationToken ct)
|
||||
{
|
||||
var end = DateTime.UtcNow.AddSeconds(3);
|
||||
var builder = new StringBuilder();
|
||||
while (DateTime.UtcNow < end)
|
||||
{
|
||||
var line = await ReadLineAsync(socket, ct);
|
||||
if (line.Length == 0)
|
||||
continue;
|
||||
|
||||
builder.AppendLine(line);
|
||||
if (builder.ToString().Contains(token, StringComparison.Ordinal))
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static async Task<string> ReadLineAsync(Socket socket, CancellationToken ct)
|
||||
{
|
||||
var buffer = new List<byte>(256);
|
||||
var one = new byte[1];
|
||||
while (true)
|
||||
{
|
||||
var n = await socket.ReceiveAsync(one.AsMemory(0, 1), SocketFlags.None, ct);
|
||||
if (n == 0)
|
||||
break;
|
||||
if (one[0] == '\n')
|
||||
break;
|
||||
if (one[0] != '\r')
|
||||
buffer.Add(one[0]);
|
||||
}
|
||||
|
||||
return Encoding.ASCII.GetString([.. buffer]);
|
||||
}
|
||||
|
||||
private static int GetFreePort()
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
try
|
||||
{
|
||||
return ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
}
|
||||
finally
|
||||
{
|
||||
listener.Stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using NATS.Server.Routes;
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.Tests.Routes;
|
||||
|
||||
public class RouteInterestIdempotencyTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Duplicate_RSplus_or_reconnect_replay_does_not_double_count_remote_interest()
|
||||
{
|
||||
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
using var remoteSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await remoteSocket.ConnectAsync(IPAddress.Loopback, port);
|
||||
using var routeSocket = await listener.AcceptSocketAsync();
|
||||
await using var route = new RouteConnection(routeSocket);
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
|
||||
var handshakeTask = route.PerformOutboundHandshakeAsync("LOCAL", timeout.Token);
|
||||
(await ReadLineAsync(remoteSocket, timeout.Token)).ShouldBe("ROUTE LOCAL");
|
||||
await WriteLineAsync(remoteSocket, "ROUTE REMOTE", timeout.Token);
|
||||
await handshakeTask;
|
||||
|
||||
using var subList = new SubList();
|
||||
var remoteAdded = 0;
|
||||
subList.InterestChanged += change =>
|
||||
{
|
||||
if (change.Kind == InterestChangeKind.RemoteAdded)
|
||||
remoteAdded++;
|
||||
};
|
||||
|
||||
route.RemoteSubscriptionReceived = sub =>
|
||||
{
|
||||
subList.ApplyRemoteSub(sub);
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
route.StartFrameLoop(timeout.Token);
|
||||
|
||||
await WriteLineAsync(remoteSocket, "RS+ A orders.*", timeout.Token);
|
||||
await WaitForAsync(() => subList.HasRemoteInterest("A", "orders.created"), timeout.Token);
|
||||
|
||||
await WriteLineAsync(remoteSocket, "RS+ A orders.*", timeout.Token);
|
||||
await Task.Delay(100, timeout.Token);
|
||||
|
||||
subList.MatchRemote("A", "orders.created").Count.ShouldBe(1);
|
||||
remoteAdded.ShouldBe(1);
|
||||
}
|
||||
|
||||
private static async Task<string> ReadLineAsync(Socket socket, CancellationToken ct)
|
||||
{
|
||||
var bytes = new List<byte>(64);
|
||||
var single = new byte[1];
|
||||
while (true)
|
||||
{
|
||||
var read = await socket.ReceiveAsync(single, SocketFlags.None, ct);
|
||||
if (read == 0)
|
||||
break;
|
||||
if (single[0] == (byte)'\n')
|
||||
break;
|
||||
if (single[0] != (byte)'\r')
|
||||
bytes.Add(single[0]);
|
||||
}
|
||||
|
||||
return Encoding.ASCII.GetString([.. bytes]);
|
||||
}
|
||||
|
||||
private static Task WriteLineAsync(Socket socket, string line, CancellationToken ct)
|
||||
=> socket.SendAsync(Encoding.ASCII.GetBytes($"{line}\r\n"), SocketFlags.None, ct).AsTask();
|
||||
|
||||
private static async Task WaitForAsync(Func<bool> predicate, CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
if (predicate())
|
||||
return;
|
||||
|
||||
await Task.Delay(20, ct);
|
||||
}
|
||||
|
||||
throw new TimeoutException("Timed out waiting for condition.");
|
||||
}
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server.Configuration;
|
||||
using NATS.Server.Protocol;
|
||||
using NATS.Server.Routes;
|
||||
|
||||
namespace NATS.Server.Tests.Routes;
|
||||
|
||||
public class RouteParityHelpersBatch1Tests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildConnectInfoJson_includes_connectinfo_compat_fields()
|
||||
{
|
||||
var json = RouteConnection.BuildConnectInfoJson("S1", ["A"], "topo-v1");
|
||||
|
||||
json.ShouldContain("\"verbose\":false");
|
||||
json.ShouldContain("\"pedantic\":false");
|
||||
json.ShouldContain("\"echo\":false");
|
||||
json.ShouldContain("\"tls_required\":false");
|
||||
json.ShouldContain("\"headers\":true");
|
||||
json.ShouldContain("\"name\":\"S1\"");
|
||||
json.ShouldContain("\"cluster\":\"\"");
|
||||
json.ShouldContain("\"dynamic\":false");
|
||||
json.ShouldContain("\"lnoc\":false");
|
||||
json.ShouldContain("\"lnocu\":false");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasThisRouteConfigured_matches_explicit_routes_with_scheme_normalization()
|
||||
{
|
||||
var manager = CreateManager(new ClusterOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Routes = ["127.0.0.1:7222"],
|
||||
});
|
||||
|
||||
manager.HasThisRouteConfigured("127.0.0.1:7222").ShouldBeTrue();
|
||||
manager.HasThisRouteConfigured("nats-route://127.0.0.1:7222").ShouldBeTrue();
|
||||
manager.HasThisRouteConfigured("nats://127.0.0.1:7222").ShouldBeTrue();
|
||||
manager.HasThisRouteConfigured("127.0.0.1:7999").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessImplicitRoute_skips_configured_routes_and_tracks_new_routes()
|
||||
{
|
||||
var manager = CreateManager(new ClusterOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Routes = ["127.0.0.1:7222"],
|
||||
});
|
||||
|
||||
var serverInfo = new ServerInfo
|
||||
{
|
||||
ServerId = "S2",
|
||||
ServerName = "S2",
|
||||
Version = NatsProtocol.Version,
|
||||
Host = "127.0.0.1",
|
||||
Port = 7222,
|
||||
ConnectUrls = ["127.0.0.1:7222", "nats-route://127.0.0.1:7444"],
|
||||
};
|
||||
|
||||
manager.ProcessImplicitRoute(serverInfo);
|
||||
|
||||
manager.DiscoveredRoutes.ShouldNotContain("127.0.0.1:7222");
|
||||
manager.DiscoveredRoutes.ShouldContain("nats-route://127.0.0.1:7444");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RouteStillValid_checks_configured_and_discovered_routes()
|
||||
{
|
||||
var manager = CreateManager(new ClusterOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Routes = ["127.0.0.1:7222"],
|
||||
});
|
||||
|
||||
manager.RouteStillValid("nats://127.0.0.1:7222").ShouldBeTrue();
|
||||
manager.RouteStillValid("127.0.0.1:7555").ShouldBeFalse();
|
||||
|
||||
manager.ProcessImplicitRoute(new ServerInfo
|
||||
{
|
||||
ServerId = "S2",
|
||||
ServerName = "S2",
|
||||
Version = NatsProtocol.Version,
|
||||
Host = "127.0.0.1",
|
||||
Port = 7444,
|
||||
ConnectUrls = ["127.0.0.1:7444"],
|
||||
});
|
||||
|
||||
manager.RouteStillValid("nats-route://127.0.0.1:7444").ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Solicited_route_helpers_upgrade_and_query_status()
|
||||
{
|
||||
var manager = CreateManager();
|
||||
await using var connection = MakeRouteConnection();
|
||||
|
||||
manager.RegisterRoute("S2", connection);
|
||||
manager.HasSolicitedRoute("S2").ShouldBeFalse();
|
||||
|
||||
manager.UpgradeRouteToSolicited("S2").ShouldBeTrue();
|
||||
connection.IsSolicitedRoute().ShouldBeTrue();
|
||||
manager.HasSolicitedRoute("S2").ShouldBeTrue();
|
||||
manager.IsDuplicateServerName("S2").ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveRoute_cleans_hash_and_account_route_indexes()
|
||||
{
|
||||
var manager = CreateManager();
|
||||
var connection = MakeRouteConnection();
|
||||
|
||||
manager.RegisterRoute("S2", connection);
|
||||
manager.RegisterRouteByHash("S2", connection);
|
||||
manager.RegisterAccountRoute("A", connection);
|
||||
|
||||
manager.HashedRouteCount.ShouldBe(1);
|
||||
manager.DedicatedRouteCount.ShouldBe(1);
|
||||
|
||||
manager.RemoveRoute("S2").ShouldBeTrue();
|
||||
manager.HashedRouteCount.ShouldBe(0);
|
||||
manager.DedicatedRouteCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryParseRemoteUnsub_parses_rs_minus_and_ls_minus()
|
||||
{
|
||||
RouteConnection.TryParseRemoteUnsub("RS- ACCT_A foo.bar q1", out var account1, out var subject1, out var queue1).ShouldBeTrue();
|
||||
account1.ShouldBe("ACCT_A");
|
||||
subject1.ShouldBe("foo.bar");
|
||||
queue1.ShouldBe("q1");
|
||||
|
||||
RouteConnection.TryParseRemoteUnsub("LS- ACCT_B foo.>", out var account2, out var subject2, out var queue2).ShouldBeTrue();
|
||||
account2.ShouldBe("ACCT_B");
|
||||
subject2.ShouldBe("foo.>");
|
||||
queue2.ShouldBeNull();
|
||||
|
||||
RouteConnection.TryParseRemoteUnsub("RS+ ACCT_A foo.bar", out _, out _, out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
private static RouteManager CreateManager(ClusterOptions? options = null)
|
||||
=> new(
|
||||
options ?? new ClusterOptions { Host = "127.0.0.1", Port = 0 },
|
||||
new ServerStats(),
|
||||
"S1",
|
||||
_ => { },
|
||||
_ => { },
|
||||
NullLogger<RouteManager>.Instance);
|
||||
|
||||
private static RouteConnection MakeRouteConnection()
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var endpoint = (IPEndPoint)listener.LocalEndpoint;
|
||||
|
||||
var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
client.Connect(endpoint);
|
||||
|
||||
var server = listener.AcceptSocket();
|
||||
server.Dispose();
|
||||
listener.Stop();
|
||||
|
||||
return new RouteConnection(client);
|
||||
}
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
// Reference: golang/nats-server/server/route.go:533-545 — computeRoutePoolIdx
|
||||
// Tests for account-based route pool index computation and message routing.
|
||||
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server.Configuration;
|
||||
using NATS.Server.Routes;
|
||||
|
||||
namespace NATS.Server.Tests.Routes;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for route pool accounting per account, matching Go's
|
||||
/// computeRoutePoolIdx behavior (route.go:533-545).
|
||||
/// </summary>
|
||||
public class RoutePoolAccountTests
|
||||
{
|
||||
[Fact]
|
||||
public void ComputeRoutePoolIdx_SinglePool_AlwaysReturnsZero()
|
||||
{
|
||||
RouteManager.ComputeRoutePoolIdx(1, "account-A").ShouldBe(0);
|
||||
RouteManager.ComputeRoutePoolIdx(1, "account-B").ShouldBe(0);
|
||||
RouteManager.ComputeRoutePoolIdx(1, "$G").ShouldBe(0);
|
||||
RouteManager.ComputeRoutePoolIdx(0, "anything").ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeRoutePoolIdx_DeterministicForSameAccount()
|
||||
{
|
||||
const int poolSize = 5;
|
||||
const string account = "my-test-account";
|
||||
|
||||
var first = RouteManager.ComputeRoutePoolIdx(poolSize, account);
|
||||
var second = RouteManager.ComputeRoutePoolIdx(poolSize, account);
|
||||
var third = RouteManager.ComputeRoutePoolIdx(poolSize, account);
|
||||
|
||||
first.ShouldBe(second);
|
||||
second.ShouldBe(third);
|
||||
first.ShouldBeGreaterThanOrEqualTo(0);
|
||||
first.ShouldBeLessThan(poolSize);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeRoutePoolIdx_DistributesAcrossPool()
|
||||
{
|
||||
const int poolSize = 3;
|
||||
var usedIndices = new HashSet<int>();
|
||||
|
||||
for (var i = 0; i < 100; i++)
|
||||
{
|
||||
var idx = RouteManager.ComputeRoutePoolIdx(poolSize, $"account-{i}");
|
||||
idx.ShouldBeGreaterThanOrEqualTo(0);
|
||||
idx.ShouldBeLessThan(poolSize);
|
||||
usedIndices.Add(idx);
|
||||
}
|
||||
|
||||
usedIndices.Count.ShouldBe(poolSize);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeRoutePoolIdx_EmptyAccount_ReturnsValid()
|
||||
{
|
||||
const int poolSize = 4;
|
||||
var idx = RouteManager.ComputeRoutePoolIdx(poolSize, string.Empty);
|
||||
idx.ShouldBeGreaterThanOrEqualTo(0);
|
||||
idx.ShouldBeLessThan(poolSize);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeRoutePoolIdx_DefaultGlobalAccount_ReturnsValid()
|
||||
{
|
||||
const int poolSize = 3;
|
||||
var idx = RouteManager.ComputeRoutePoolIdx(poolSize, "$G");
|
||||
idx.ShouldBeGreaterThanOrEqualTo(0);
|
||||
idx.ShouldBeLessThan(poolSize);
|
||||
|
||||
var idx2 = RouteManager.ComputeRoutePoolIdx(poolSize, "$G");
|
||||
idx.ShouldBe(idx2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ForwardRoutedMessage_UsesCorrectPoolConnection()
|
||||
{
|
||||
var clusterName = Guid.NewGuid().ToString("N");
|
||||
|
||||
var optsA = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Cluster = new ClusterOptions
|
||||
{
|
||||
Name = clusterName,
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
PoolSize = 1,
|
||||
Routes = [],
|
||||
},
|
||||
};
|
||||
|
||||
var serverA = new NatsServer(optsA, NullLoggerFactory.Instance);
|
||||
var ctsA = new CancellationTokenSource();
|
||||
_ = serverA.StartAsync(ctsA.Token);
|
||||
await serverA.WaitForReadyAsync();
|
||||
|
||||
var optsB = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Cluster = new ClusterOptions
|
||||
{
|
||||
Name = clusterName,
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
PoolSize = 1,
|
||||
Routes = [$"127.0.0.1:{optsA.Cluster.Port}"],
|
||||
},
|
||||
};
|
||||
|
||||
var serverB = new NatsServer(optsB, NullLoggerFactory.Instance);
|
||||
var ctsB = new CancellationTokenSource();
|
||||
_ = serverB.StartAsync(ctsB.Token);
|
||||
await serverB.WaitForReadyAsync();
|
||||
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
while (!timeout.IsCancellationRequested &&
|
||||
(Interlocked.Read(ref serverA.Stats.Routes) == 0 ||
|
||||
Interlocked.Read(ref serverB.Stats.Routes) == 0))
|
||||
{
|
||||
await Task.Delay(50, timeout.Token);
|
||||
}
|
||||
|
||||
Interlocked.Read(ref serverA.Stats.Routes).ShouldBeGreaterThan(0);
|
||||
|
||||
var payload = Encoding.UTF8.GetBytes("hello");
|
||||
await serverA.RouteManager!.ForwardRoutedMessageAsync(
|
||||
"$G", "test.subject", null, payload, CancellationToken.None);
|
||||
|
||||
var poolIdx = RouteManager.ComputeRoutePoolIdx(1, "$G");
|
||||
poolIdx.ShouldBe(0);
|
||||
|
||||
await ctsA.CancelAsync();
|
||||
await ctsB.CancelAsync();
|
||||
serverA.Dispose();
|
||||
serverB.Dispose();
|
||||
ctsA.Dispose();
|
||||
ctsB.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server.Configuration;
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.Tests.Routes;
|
||||
|
||||
public class RouteRemoteSubCleanupParityBatch2Tests
|
||||
{
|
||||
[Fact]
|
||||
public void Routed_sub_key_helpers_parse_account_and_queue_fields()
|
||||
{
|
||||
var key = SubList.BuildRoutedSubKey("R1", "A", "orders.*", "q1");
|
||||
|
||||
SubList.GetAccNameFromRoutedSubKey(key).ShouldBe("A");
|
||||
|
||||
var info = SubList.GetRoutedSubKeyInfo(key);
|
||||
info.ShouldNotBeNull();
|
||||
info.Value.RouteId.ShouldBe("R1");
|
||||
info.Value.Account.ShouldBe("A");
|
||||
info.Value.Subject.ShouldBe("orders.*");
|
||||
info.Value.Queue.ShouldBe("q1");
|
||||
|
||||
SubList.GetRoutedSubKeyInfo("invalid").ShouldBeNull();
|
||||
SubList.GetAccNameFromRoutedSubKey("invalid").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Remove_remote_subs_methods_only_remove_matching_route_or_account()
|
||||
{
|
||||
using var sl = new SubList();
|
||||
sl.ApplyRemoteSub(new RemoteSubscription("orders.*", null, "r1", "A"));
|
||||
sl.ApplyRemoteSub(new RemoteSubscription("orders.*", null, "r1", "B"));
|
||||
sl.ApplyRemoteSub(new RemoteSubscription("orders.*", null, "r2", "A"));
|
||||
|
||||
sl.HasRemoteInterest("A", "orders.created").ShouldBeTrue();
|
||||
sl.HasRemoteInterest("B", "orders.created").ShouldBeTrue();
|
||||
|
||||
sl.RemoveRemoteSubsForAccount("r1", "A").ShouldBe(1);
|
||||
sl.HasRemoteInterest("A", "orders.created").ShouldBeTrue(); // r2 still present
|
||||
sl.HasRemoteInterest("B", "orders.created").ShouldBeTrue();
|
||||
|
||||
sl.RemoveRemoteSubs("r2").ShouldBe(1);
|
||||
sl.HasRemoteInterest("A", "orders.created").ShouldBeFalse();
|
||||
sl.HasRemoteInterest("B", "orders.created").ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Route_disconnect_cleans_remote_interest_without_explicit_rs_minus()
|
||||
{
|
||||
var opts = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Cluster = new ClusterOptions
|
||||
{
|
||||
Name = Guid.NewGuid().ToString("N"),
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
PoolSize = 1,
|
||||
},
|
||||
};
|
||||
|
||||
var server = new NatsServer(opts, NullLoggerFactory.Instance);
|
||||
using var serverCts = new CancellationTokenSource();
|
||||
_ = server.StartAsync(serverCts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
try
|
||||
{
|
||||
var cluster = server.ClusterListen!;
|
||||
var sep = cluster.LastIndexOf(':');
|
||||
var host = cluster[..sep];
|
||||
var port = int.Parse(cluster[(sep + 1)..]);
|
||||
|
||||
using var remote = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(8));
|
||||
await remote.ConnectAsync(IPAddress.Parse(host), port, timeout.Token);
|
||||
|
||||
await WriteLineAsync(remote, "ROUTE REMOTE1", timeout.Token);
|
||||
var response = await ReadLineAsync(remote, timeout.Token);
|
||||
response.ShouldStartWith("ROUTE ");
|
||||
|
||||
await WriteLineAsync(remote, "RS+ $G route.cleanup.test", timeout.Token);
|
||||
await WaitForCondition(() => server.HasRemoteInterest("route.cleanup.test"), 5000);
|
||||
|
||||
remote.Dispose();
|
||||
|
||||
await WaitForCondition(() => !server.HasRemoteInterest("route.cleanup.test"), 10000);
|
||||
server.HasRemoteInterest("route.cleanup.test").ShouldBeFalse();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await serverCts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WaitForCondition(Func<bool> predicate, int timeoutMs)
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(timeoutMs);
|
||||
while (!timeout.IsCancellationRequested)
|
||||
{
|
||||
if (predicate())
|
||||
return;
|
||||
|
||||
await Task.Yield();
|
||||
}
|
||||
|
||||
throw new TimeoutException("Condition not met.");
|
||||
}
|
||||
|
||||
private static async Task WriteLineAsync(Socket socket, string line, CancellationToken ct)
|
||||
{
|
||||
var data = Encoding.ASCII.GetBytes($"{line}\r\n");
|
||||
await socket.SendAsync(data, ct);
|
||||
}
|
||||
|
||||
private static async Task<string> ReadLineAsync(Socket socket, CancellationToken ct)
|
||||
{
|
||||
var bytes = new List<byte>(64);
|
||||
var one = new byte[1];
|
||||
while (true)
|
||||
{
|
||||
var read = await socket.ReceiveAsync(one, SocketFlags.None, ct);
|
||||
if (read == 0)
|
||||
throw new IOException("Socket closed while reading line");
|
||||
|
||||
if (one[0] == (byte)'\n')
|
||||
break;
|
||||
if (one[0] != (byte)'\r')
|
||||
bytes.Add(one[0]);
|
||||
}
|
||||
|
||||
return Encoding.ASCII.GetString([.. bytes]);
|
||||
}
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
// Reference: golang/nats-server/server/route.go — S2/Snappy compression for routes
|
||||
// Tests for RouteCompressionCodec: compression, decompression, negotiation, detection.
|
||||
|
||||
using System.Text;
|
||||
using NATS.Server.Routes;
|
||||
|
||||
namespace NATS.Server.Tests.Routes;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for route S2/Snappy compression codec, matching Go's route compression
|
||||
/// behavior using IronSnappy.
|
||||
/// </summary>
|
||||
public class RouteS2CompressionTests
|
||||
{
|
||||
[Fact]
|
||||
public void Compress_Fast_ProducesValidOutput()
|
||||
{
|
||||
var data = Encoding.UTF8.GetBytes("NATS route compression test payload");
|
||||
var compressed = RouteCompressionCodec.Compress(data, RouteCompressionLevel.Fast);
|
||||
|
||||
compressed.ShouldNotBeNull();
|
||||
compressed.Length.ShouldBeGreaterThan(0);
|
||||
|
||||
// Compressed output should be decompressible
|
||||
var decompressed = RouteCompressionCodec.Decompress(compressed);
|
||||
decompressed.ShouldBe(data);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compress_Decompress_RoundTrips()
|
||||
{
|
||||
var original = Encoding.UTF8.GetBytes("Hello NATS! This is a test of round-trip compression.");
|
||||
|
||||
foreach (var level in new[] { RouteCompressionLevel.Fast, RouteCompressionLevel.Better, RouteCompressionLevel.Best })
|
||||
{
|
||||
var compressed = RouteCompressionCodec.Compress(original, level);
|
||||
var restored = RouteCompressionCodec.Decompress(compressed);
|
||||
restored.ShouldBe(original, $"Round-trip failed for level {level}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compress_EmptyData_ReturnsEmpty()
|
||||
{
|
||||
var result = RouteCompressionCodec.Compress(ReadOnlySpan<byte>.Empty, RouteCompressionLevel.Fast);
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compress_Off_ReturnsOriginal()
|
||||
{
|
||||
var data = Encoding.UTF8.GetBytes("uncompressed payload");
|
||||
var result = RouteCompressionCodec.Compress(data, RouteCompressionLevel.Off);
|
||||
|
||||
result.ShouldBe(data);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Decompress_CorruptedData_Throws()
|
||||
{
|
||||
var garbage = new byte[] { 0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04 };
|
||||
|
||||
Should.Throw<Exception>(() => RouteCompressionCodec.Decompress(garbage));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NegotiateCompression_BothOff_ReturnsOff()
|
||||
{
|
||||
var result = RouteCompressionCodec.NegotiateCompression("off", "off");
|
||||
result.ShouldBe(RouteCompressionLevel.Off);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NegotiateCompression_OneFast_ReturnsFast()
|
||||
{
|
||||
// When both are fast, result is fast
|
||||
var result = RouteCompressionCodec.NegotiateCompression("fast", "fast");
|
||||
result.ShouldBe(RouteCompressionLevel.Fast);
|
||||
|
||||
// When one is off, result is off (off wins)
|
||||
var result2 = RouteCompressionCodec.NegotiateCompression("fast", "off");
|
||||
result2.ShouldBe(RouteCompressionLevel.Off);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NegotiateCompression_MismatchLevels_ReturnsMinimum()
|
||||
{
|
||||
// fast (1) vs best (3) => fast (minimum)
|
||||
var result = RouteCompressionCodec.NegotiateCompression("fast", "best");
|
||||
result.ShouldBe(RouteCompressionLevel.Fast);
|
||||
|
||||
// better (2) vs best (3) => better (minimum)
|
||||
var result2 = RouteCompressionCodec.NegotiateCompression("better", "best");
|
||||
result2.ShouldBe(RouteCompressionLevel.Better);
|
||||
|
||||
// fast (1) vs better (2) => fast (minimum)
|
||||
var result3 = RouteCompressionCodec.NegotiateCompression("fast", "better");
|
||||
result3.ShouldBe(RouteCompressionLevel.Fast);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsCompressed_ValidSnappy_ReturnsTrue()
|
||||
{
|
||||
var data = Encoding.UTF8.GetBytes("This is test data for Snappy compression detection");
|
||||
var compressed = RouteCompressionCodec.Compress(data, RouteCompressionLevel.Fast);
|
||||
|
||||
RouteCompressionCodec.IsCompressed(compressed).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsCompressed_PlainText_ReturnsFalse()
|
||||
{
|
||||
var plainText = Encoding.UTF8.GetBytes("PUB test.subject 5\r\nhello\r\n");
|
||||
|
||||
RouteCompressionCodec.IsCompressed(plainText).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_LargePayload_Compresses()
|
||||
{
|
||||
// 10KB payload of repeated data should compress well
|
||||
var largePayload = new byte[10240];
|
||||
var pattern = Encoding.UTF8.GetBytes("NATS route payload ");
|
||||
for (var i = 0; i < largePayload.Length; i++)
|
||||
largePayload[i] = pattern[i % pattern.Length];
|
||||
|
||||
var compressed = RouteCompressionCodec.Compress(largePayload, RouteCompressionLevel.Fast);
|
||||
|
||||
// Compressed should be smaller than original for repetitive data
|
||||
compressed.Length.ShouldBeLessThan(largePayload.Length);
|
||||
|
||||
// Round-trip should restore original
|
||||
var restored = RouteCompressionCodec.Decompress(compressed);
|
||||
restored.ShouldBe(largePayload);
|
||||
}
|
||||
}
|
||||
@@ -1,851 +0,0 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Client.Core;
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.Configuration;
|
||||
using NATS.Server.Routes;
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.Tests.Routes;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for route subscription propagation: RS+/RS-, wildcard subs, queue subs,
|
||||
/// unsubscribe propagation, and account-scoped interest.
|
||||
/// Ported from Go: server/routes_test.go.
|
||||
/// </summary>
|
||||
public class RouteSubscriptionTests
|
||||
{
|
||||
// -- Helpers --
|
||||
|
||||
private static async Task<(NatsServer Server, CancellationTokenSource Cts)> StartServerAsync(
|
||||
NatsOptions opts)
|
||||
{
|
||||
var server = new NatsServer(opts, NullLoggerFactory.Instance);
|
||||
var cts = new CancellationTokenSource();
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
return (server, cts);
|
||||
}
|
||||
|
||||
private static NatsOptions MakeClusterOpts(string? clusterName = null, string? seed = null)
|
||||
{
|
||||
return new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Cluster = new ClusterOptions
|
||||
{
|
||||
Name = clusterName ?? Guid.NewGuid().ToString("N"),
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Routes = seed is null ? [] : [seed],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task WaitForRouteFormation(NatsServer a, NatsServer b, int timeoutMs = 5000)
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(timeoutMs);
|
||||
while (!timeout.IsCancellationRequested &&
|
||||
(Interlocked.Read(ref a.Stats.Routes) == 0 ||
|
||||
Interlocked.Read(ref b.Stats.Routes) == 0))
|
||||
{
|
||||
await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WaitForCondition(Func<bool> predicate, int timeoutMs = 5000)
|
||||
{
|
||||
using var cts = new CancellationTokenSource(timeoutMs);
|
||||
while (!cts.IsCancellationRequested)
|
||||
{
|
||||
if (predicate()) return;
|
||||
await Task.Delay(20, cts.Token).ContinueWith(_ => { }, TaskScheduler.Default);
|
||||
}
|
||||
|
||||
throw new TimeoutException("Condition not met.");
|
||||
}
|
||||
|
||||
private static async Task DisposeServers(params (NatsServer Server, CancellationTokenSource Cts)[] servers)
|
||||
{
|
||||
foreach (var (server, cts) in servers)
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
cts.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// -- Tests: RS+ propagation --
|
||||
|
||||
// Go: TestRoutePoolPerAccountSubUnsubProtoParsing server/routes_test.go:3104 (plain sub)
|
||||
[Fact]
|
||||
public async Task Plain_subscription_propagates_remote_interest()
|
||||
{
|
||||
var cluster = Guid.NewGuid().ToString("N");
|
||||
var a = await StartServerAsync(MakeClusterOpts(cluster));
|
||||
var b = await StartServerAsync(MakeClusterOpts(cluster, a.Server.ClusterListen!));
|
||||
|
||||
try
|
||||
{
|
||||
await WaitForRouteFormation(a.Server, b.Server);
|
||||
|
||||
await using var nc = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://127.0.0.1:{a.Server.Port}",
|
||||
});
|
||||
await nc.ConnectAsync();
|
||||
await using var sub = await nc.SubscribeCoreAsync<string>("sub.test");
|
||||
await nc.PingAsync();
|
||||
|
||||
await WaitForCondition(() => b.Server.HasRemoteInterest("sub.test"));
|
||||
b.Server.HasRemoteInterest("sub.test").ShouldBeTrue();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DisposeServers(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
// Go: TestRoutePoolPerAccountSubUnsubProtoParsing server/routes_test.go:3104 (wildcard * sub)
|
||||
[Fact]
|
||||
public async Task Wildcard_star_subscription_propagates_remote_interest()
|
||||
{
|
||||
var cluster = Guid.NewGuid().ToString("N");
|
||||
var a = await StartServerAsync(MakeClusterOpts(cluster));
|
||||
var b = await StartServerAsync(MakeClusterOpts(cluster, a.Server.ClusterListen!));
|
||||
|
||||
try
|
||||
{
|
||||
await WaitForRouteFormation(a.Server, b.Server);
|
||||
|
||||
await using var nc = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://127.0.0.1:{a.Server.Port}",
|
||||
});
|
||||
await nc.ConnectAsync();
|
||||
await using var sub = await nc.SubscribeCoreAsync<string>("wildcard.*");
|
||||
await nc.PingAsync();
|
||||
|
||||
await WaitForCondition(() => b.Server.HasRemoteInterest("wildcard.test"));
|
||||
b.Server.HasRemoteInterest("wildcard.test").ShouldBeTrue();
|
||||
b.Server.HasRemoteInterest("wildcard.other").ShouldBeTrue();
|
||||
b.Server.HasRemoteInterest("no.match").ShouldBeFalse();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DisposeServers(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
// Go: TestRoutePoolPerAccountSubUnsubProtoParsing server/routes_test.go:3104 (wildcard > sub)
|
||||
[Fact]
|
||||
public async Task Wildcard_gt_subscription_propagates_remote_interest()
|
||||
{
|
||||
var cluster = Guid.NewGuid().ToString("N");
|
||||
var a = await StartServerAsync(MakeClusterOpts(cluster));
|
||||
var b = await StartServerAsync(MakeClusterOpts(cluster, a.Server.ClusterListen!));
|
||||
|
||||
try
|
||||
{
|
||||
await WaitForRouteFormation(a.Server, b.Server);
|
||||
|
||||
await using var nc = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://127.0.0.1:{a.Server.Port}",
|
||||
});
|
||||
await nc.ConnectAsync();
|
||||
await using var sub = await nc.SubscribeCoreAsync<string>("events.>");
|
||||
await nc.PingAsync();
|
||||
|
||||
await WaitForCondition(() => b.Server.HasRemoteInterest("events.a"));
|
||||
b.Server.HasRemoteInterest("events.a").ShouldBeTrue();
|
||||
b.Server.HasRemoteInterest("events.a.b.c").ShouldBeTrue();
|
||||
b.Server.HasRemoteInterest("other.a").ShouldBeFalse();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DisposeServers(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
// Go: TestRoutePoolPerAccountSubUnsubProtoParsing server/routes_test.go:3104 (unsub)
|
||||
[Fact]
|
||||
public async Task Unsubscribe_removes_remote_interest()
|
||||
{
|
||||
var cluster = Guid.NewGuid().ToString("N");
|
||||
var a = await StartServerAsync(MakeClusterOpts(cluster));
|
||||
var b = await StartServerAsync(MakeClusterOpts(cluster, a.Server.ClusterListen!));
|
||||
|
||||
try
|
||||
{
|
||||
await WaitForRouteFormation(a.Server, b.Server);
|
||||
|
||||
await using var nc = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://127.0.0.1:{a.Server.Port}",
|
||||
});
|
||||
await nc.ConnectAsync();
|
||||
var sub = await nc.SubscribeCoreAsync<string>("unsub.test");
|
||||
await nc.PingAsync();
|
||||
|
||||
await WaitForCondition(() => b.Server.HasRemoteInterest("unsub.test"));
|
||||
b.Server.HasRemoteInterest("unsub.test").ShouldBeTrue();
|
||||
|
||||
await sub.DisposeAsync();
|
||||
await nc.PingAsync();
|
||||
|
||||
// Wait for interest to be removed
|
||||
await WaitForCondition(() => !b.Server.HasRemoteInterest("unsub.test"));
|
||||
b.Server.HasRemoteInterest("unsub.test").ShouldBeFalse();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DisposeServers(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
// Go: RS+ wire protocol parsing (low-level)
|
||||
[Fact]
|
||||
public async Task RSplus_frame_registers_remote_interest_via_wire()
|
||||
{
|
||||
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
|
||||
using var remote = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await remote.ConnectAsync(IPAddress.Loopback, port);
|
||||
using var routeSock = await listener.AcceptSocketAsync();
|
||||
await using var route = new RouteConnection(routeSock);
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
|
||||
var handshakeTask = route.PerformOutboundHandshakeAsync("LOCAL", timeout.Token);
|
||||
_ = await ReadLineAsync(remote, timeout.Token);
|
||||
await WriteLineAsync(remote, "ROUTE REMOTE", timeout.Token);
|
||||
await handshakeTask;
|
||||
|
||||
using var subList = new SubList();
|
||||
route.RemoteSubscriptionReceived = sub =>
|
||||
{
|
||||
subList.ApplyRemoteSub(sub);
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
route.StartFrameLoop(timeout.Token);
|
||||
|
||||
await WriteLineAsync(remote, "RS+ $G foo.bar", timeout.Token);
|
||||
await WaitForCondition(() => subList.HasRemoteInterest("foo.bar"));
|
||||
subList.HasRemoteInterest("foo.bar").ShouldBeTrue();
|
||||
}
|
||||
|
||||
// Go: RS- wire protocol parsing (low-level)
|
||||
[Fact]
|
||||
public async Task RSminus_frame_removes_remote_interest_via_wire()
|
||||
{
|
||||
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
|
||||
using var remote = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await remote.ConnectAsync(IPAddress.Loopback, port);
|
||||
using var routeSock = await listener.AcceptSocketAsync();
|
||||
await using var route = new RouteConnection(routeSock);
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
|
||||
var handshakeTask = route.PerformOutboundHandshakeAsync("LOCAL", timeout.Token);
|
||||
_ = await ReadLineAsync(remote, timeout.Token);
|
||||
await WriteLineAsync(remote, "ROUTE REMOTE", timeout.Token);
|
||||
await handshakeTask;
|
||||
|
||||
using var subList = new SubList();
|
||||
route.RemoteSubscriptionReceived = sub =>
|
||||
{
|
||||
subList.ApplyRemoteSub(sub);
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
route.StartFrameLoop(timeout.Token);
|
||||
|
||||
await WriteLineAsync(remote, "RS+ $G foo.*", timeout.Token);
|
||||
await WaitForCondition(() => subList.HasRemoteInterest("foo.bar"));
|
||||
|
||||
await WriteLineAsync(remote, "RS- $G foo.*", timeout.Token);
|
||||
await WaitForCondition(() => !subList.HasRemoteInterest("foo.bar"));
|
||||
subList.HasRemoteInterest("foo.bar").ShouldBeFalse();
|
||||
}
|
||||
|
||||
// Go: RS+ with queue group
|
||||
[Fact]
|
||||
public async Task RSplus_with_queue_group_registers_remote_interest()
|
||||
{
|
||||
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
|
||||
using var remote = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await remote.ConnectAsync(IPAddress.Loopback, port);
|
||||
using var routeSock = await listener.AcceptSocketAsync();
|
||||
await using var route = new RouteConnection(routeSock);
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
|
||||
var handshakeTask = route.PerformOutboundHandshakeAsync("LOCAL", timeout.Token);
|
||||
_ = await ReadLineAsync(remote, timeout.Token);
|
||||
await WriteLineAsync(remote, "ROUTE REMOTE", timeout.Token);
|
||||
await handshakeTask;
|
||||
|
||||
RemoteSubscription? received = null;
|
||||
route.RemoteSubscriptionReceived = sub =>
|
||||
{
|
||||
received = sub;
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
route.StartFrameLoop(timeout.Token);
|
||||
|
||||
await WriteLineAsync(remote, "RS+ $G foo.bar myqueue", timeout.Token);
|
||||
await WaitForCondition(() => received != null);
|
||||
|
||||
received.ShouldNotBeNull();
|
||||
received!.Subject.ShouldBe("foo.bar");
|
||||
received.Queue.ShouldBe("myqueue");
|
||||
}
|
||||
|
||||
// Go: RS+ with account scope
|
||||
[Fact]
|
||||
public async Task RSplus_with_account_scope_registers_interest_in_account()
|
||||
{
|
||||
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
|
||||
using var remote = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await remote.ConnectAsync(IPAddress.Loopback, port);
|
||||
using var routeSock = await listener.AcceptSocketAsync();
|
||||
await using var route = new RouteConnection(routeSock);
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
|
||||
var handshakeTask = route.PerformOutboundHandshakeAsync("LOCAL", timeout.Token);
|
||||
_ = await ReadLineAsync(remote, timeout.Token);
|
||||
await WriteLineAsync(remote, "ROUTE REMOTE", timeout.Token);
|
||||
await handshakeTask;
|
||||
|
||||
using var subList = new SubList();
|
||||
route.RemoteSubscriptionReceived = sub =>
|
||||
{
|
||||
subList.ApplyRemoteSub(sub);
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
route.StartFrameLoop(timeout.Token);
|
||||
|
||||
await WriteLineAsync(remote, "RS+ ACCT_A orders.created", timeout.Token);
|
||||
await WaitForCondition(() => subList.HasRemoteInterest("ACCT_A", "orders.created"));
|
||||
subList.HasRemoteInterest("ACCT_A", "orders.created").ShouldBeTrue();
|
||||
}
|
||||
|
||||
// Go: TestRoutePoolPerAccountSubUnsubProtoParsing server/routes_test.go:3104
|
||||
[Fact]
|
||||
public async Task Queue_subscription_propagates_across_route()
|
||||
{
|
||||
var cluster = Guid.NewGuid().ToString("N");
|
||||
var a = await StartServerAsync(MakeClusterOpts(cluster));
|
||||
var b = await StartServerAsync(MakeClusterOpts(cluster, a.Server.ClusterListen!));
|
||||
|
||||
try
|
||||
{
|
||||
await WaitForRouteFormation(a.Server, b.Server);
|
||||
|
||||
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await sock.ConnectAsync(IPAddress.Loopback, a.Server.Port);
|
||||
_ = await ReadLineAsync(sock, default);
|
||||
await sock.SendAsync(Encoding.ASCII.GetBytes("CONNECT {}\r\nSUB foo queue1 1\r\nPING\r\n"));
|
||||
await ReadUntilAsync(sock, "PONG");
|
||||
|
||||
await WaitForCondition(() => b.Server.HasRemoteInterest("foo"));
|
||||
b.Server.HasRemoteInterest("foo").ShouldBeTrue();
|
||||
sock.Dispose();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DisposeServers(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
// Go: TestRoutePoolPerAccountSubUnsubProtoParsing server/routes_test.go:3104 (queue unsub)
|
||||
[Fact]
|
||||
public async Task Queue_subscription_delivery_picks_one_per_group()
|
||||
{
|
||||
var cluster = Guid.NewGuid().ToString("N");
|
||||
var optsA = MakeClusterOpts(cluster);
|
||||
optsA.Cluster!.PoolSize = 1;
|
||||
var a = await StartServerAsync(optsA);
|
||||
var optsB = MakeClusterOpts(cluster, a.Server.ClusterListen!);
|
||||
optsB.Cluster!.PoolSize = 1;
|
||||
var b = await StartServerAsync(optsB);
|
||||
|
||||
try
|
||||
{
|
||||
await WaitForRouteFormation(a.Server, b.Server);
|
||||
|
||||
await using var nc1 = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://127.0.0.1:{a.Server.Port}",
|
||||
});
|
||||
await nc1.ConnectAsync();
|
||||
|
||||
await using var nc2 = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://127.0.0.1:{a.Server.Port}",
|
||||
});
|
||||
await nc2.ConnectAsync();
|
||||
|
||||
await using var sub1 = await nc1.SubscribeCoreAsync<string>("queue.test", queueGroup: "grp");
|
||||
await using var sub2 = await nc2.SubscribeCoreAsync<string>("queue.test", queueGroup: "grp");
|
||||
await nc1.PingAsync();
|
||||
await nc2.PingAsync();
|
||||
|
||||
await WaitForCondition(() => b.Server.HasRemoteInterest("queue.test"));
|
||||
|
||||
await using var publisher = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://127.0.0.1:{b.Server.Port}",
|
||||
});
|
||||
await publisher.ConnectAsync();
|
||||
|
||||
// Send 10 messages. Each should go to exactly one queue member.
|
||||
for (var i = 0; i < 10; i++)
|
||||
await publisher.PublishAsync("queue.test", $"qmsg-{i}");
|
||||
|
||||
// Collect messages from both subscribers
|
||||
var received = 0;
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
|
||||
async Task CollectMessages(INatsSub<string> sub)
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!timeout.IsCancellationRequested)
|
||||
{
|
||||
_ = await sub.Msgs.ReadAsync(timeout.Token);
|
||||
Interlocked.Increment(ref received);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
}
|
||||
|
||||
var t1 = CollectMessages(sub1);
|
||||
var t2 = CollectMessages(sub2);
|
||||
|
||||
// Wait for all messages
|
||||
await WaitForCondition(() => Volatile.Read(ref received) >= 10, 5000);
|
||||
|
||||
// Total received should be exactly 10 (one per message)
|
||||
Volatile.Read(ref received).ShouldBe(10);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DisposeServers(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
// Go: Interest propagation for multiple subjects
|
||||
[Fact]
|
||||
public async Task Multiple_subjects_propagate_independently()
|
||||
{
|
||||
var cluster = Guid.NewGuid().ToString("N");
|
||||
var a = await StartServerAsync(MakeClusterOpts(cluster));
|
||||
var b = await StartServerAsync(MakeClusterOpts(cluster, a.Server.ClusterListen!));
|
||||
|
||||
try
|
||||
{
|
||||
await WaitForRouteFormation(a.Server, b.Server);
|
||||
|
||||
await using var nc = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://127.0.0.1:{a.Server.Port}",
|
||||
});
|
||||
await nc.ConnectAsync();
|
||||
|
||||
await using var sub1 = await nc.SubscribeCoreAsync<string>("alpha");
|
||||
await using var sub2 = await nc.SubscribeCoreAsync<string>("beta");
|
||||
await nc.PingAsync();
|
||||
|
||||
await WaitForCondition(() => b.Server.HasRemoteInterest("alpha") && b.Server.HasRemoteInterest("beta"));
|
||||
b.Server.HasRemoteInterest("alpha").ShouldBeTrue();
|
||||
b.Server.HasRemoteInterest("beta").ShouldBeTrue();
|
||||
b.Server.HasRemoteInterest("gamma").ShouldBeFalse();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DisposeServers(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
// Go: RS+ account scope with NatsClient auth
|
||||
[Fact]
|
||||
public async Task Account_scoped_subscription_propagates_remote_interest()
|
||||
{
|
||||
var users = new User[]
|
||||
{
|
||||
new() { Username = "user_a", Password = "pass", Account = "A" },
|
||||
new() { Username = "user_b", Password = "pass", Account = "B" },
|
||||
};
|
||||
var cluster = Guid.NewGuid().ToString("N");
|
||||
|
||||
var optsA = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Users = users,
|
||||
Cluster = new ClusterOptions
|
||||
{
|
||||
Name = cluster,
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
},
|
||||
};
|
||||
var a = await StartServerAsync(optsA);
|
||||
|
||||
var optsB = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Users = users,
|
||||
Cluster = new ClusterOptions
|
||||
{
|
||||
Name = cluster,
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Routes = [a.Server.ClusterListen!],
|
||||
},
|
||||
};
|
||||
var b = await StartServerAsync(optsB);
|
||||
|
||||
try
|
||||
{
|
||||
await WaitForRouteFormation(a.Server, b.Server);
|
||||
|
||||
await using var nc = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://user_a:pass@127.0.0.1:{a.Server.Port}",
|
||||
});
|
||||
await nc.ConnectAsync();
|
||||
await using var sub = await nc.SubscribeCoreAsync<string>("acct.sub");
|
||||
await nc.PingAsync();
|
||||
|
||||
await WaitForCondition(() => b.Server.HasRemoteInterest("A", "acct.sub"));
|
||||
b.Server.HasRemoteInterest("A", "acct.sub").ShouldBeTrue();
|
||||
// Account B should NOT have interest
|
||||
b.Server.HasRemoteInterest("B", "acct.sub").ShouldBeFalse();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DisposeServers(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
// Go: TestRoutePerAccount server/routes_test.go:2539
|
||||
[Fact]
|
||||
public async Task Account_scoped_messages_do_not_leak_to_other_accounts()
|
||||
{
|
||||
var users = new User[]
|
||||
{
|
||||
new() { Username = "ua", Password = "p", Account = "A" },
|
||||
new() { Username = "ub", Password = "p", Account = "B" },
|
||||
};
|
||||
var cluster = Guid.NewGuid().ToString("N");
|
||||
|
||||
var optsA = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Users = users,
|
||||
Cluster = new ClusterOptions
|
||||
{
|
||||
Name = cluster,
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
},
|
||||
};
|
||||
var a = await StartServerAsync(optsA);
|
||||
|
||||
var optsB = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Users = users,
|
||||
Cluster = new ClusterOptions
|
||||
{
|
||||
Name = cluster,
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Routes = [a.Server.ClusterListen!],
|
||||
},
|
||||
};
|
||||
var b = await StartServerAsync(optsB);
|
||||
|
||||
try
|
||||
{
|
||||
await WaitForRouteFormation(a.Server, b.Server);
|
||||
|
||||
// Subscribe in account A on server B
|
||||
await using var subA = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://ua:p@127.0.0.1:{b.Server.Port}",
|
||||
});
|
||||
await subA.ConnectAsync();
|
||||
await using var sub = await subA.SubscribeCoreAsync<string>("isolation.test");
|
||||
await subA.PingAsync();
|
||||
|
||||
// Subscribe in account B on server B
|
||||
await using var subB = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://ub:p@127.0.0.1:{b.Server.Port}",
|
||||
});
|
||||
await subB.ConnectAsync();
|
||||
await using var subBSub = await subB.SubscribeCoreAsync<string>("isolation.test");
|
||||
await subB.PingAsync();
|
||||
|
||||
await WaitForCondition(() => a.Server.HasRemoteInterest("A", "isolation.test"));
|
||||
|
||||
// Publish in account A from server A
|
||||
await using var pub = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://ua:p@127.0.0.1:{a.Server.Port}",
|
||||
});
|
||||
await pub.ConnectAsync();
|
||||
await pub.PublishAsync("isolation.test", "for-account-a");
|
||||
|
||||
// Account A subscriber should receive the message
|
||||
using var receiveTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
var msg = await sub.Msgs.ReadAsync(receiveTimeout.Token);
|
||||
msg.Data.ShouldBe("for-account-a");
|
||||
|
||||
// Account B subscriber should NOT receive it
|
||||
using var leakTimeout = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
|
||||
await Should.ThrowAsync<OperationCanceledException>(async () =>
|
||||
await subBSub.Msgs.ReadAsync(leakTimeout.Token));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DisposeServers(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
// Go: Subscriber disconnect removes interest
|
||||
[Fact]
|
||||
public async Task Client_disconnect_removes_remote_interest()
|
||||
{
|
||||
var cluster = Guid.NewGuid().ToString("N");
|
||||
var optsA = MakeClusterOpts(cluster);
|
||||
optsA.Cluster!.PoolSize = 1;
|
||||
var a = await StartServerAsync(optsA);
|
||||
var optsB = MakeClusterOpts(cluster, a.Server.ClusterListen!);
|
||||
optsB.Cluster!.PoolSize = 1;
|
||||
var b = await StartServerAsync(optsB);
|
||||
|
||||
try
|
||||
{
|
||||
await WaitForRouteFormation(a.Server, b.Server);
|
||||
|
||||
var nc = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://127.0.0.1:{a.Server.Port}",
|
||||
});
|
||||
await nc.ConnectAsync();
|
||||
var sub = await nc.SubscribeCoreAsync<string>("disconnect.test");
|
||||
await nc.PingAsync();
|
||||
|
||||
await WaitForCondition(() => b.Server.HasRemoteInterest("disconnect.test"));
|
||||
b.Server.HasRemoteInterest("disconnect.test").ShouldBeTrue();
|
||||
|
||||
// Unsubscribe and disconnect the client
|
||||
await sub.DisposeAsync();
|
||||
await nc.PingAsync();
|
||||
await nc.DisposeAsync();
|
||||
|
||||
// Interest should be removed (give extra time for propagation)
|
||||
await WaitForCondition(() => !b.Server.HasRemoteInterest("disconnect.test"), 15000);
|
||||
b.Server.HasRemoteInterest("disconnect.test").ShouldBeFalse();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DisposeServers(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
// Go: Interest idempotency
|
||||
[Fact]
|
||||
public async Task Duplicate_subscription_on_same_subject_does_not_double_count()
|
||||
{
|
||||
var cluster = Guid.NewGuid().ToString("N");
|
||||
var a = await StartServerAsync(MakeClusterOpts(cluster));
|
||||
var b = await StartServerAsync(MakeClusterOpts(cluster, a.Server.ClusterListen!));
|
||||
|
||||
try
|
||||
{
|
||||
await WaitForRouteFormation(a.Server, b.Server);
|
||||
|
||||
await using var nc1 = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://127.0.0.1:{a.Server.Port}",
|
||||
});
|
||||
await nc1.ConnectAsync();
|
||||
|
||||
await using var nc2 = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://127.0.0.1:{a.Server.Port}",
|
||||
});
|
||||
await nc2.ConnectAsync();
|
||||
|
||||
await using var sub1 = await nc1.SubscribeCoreAsync<string>("dup.test");
|
||||
await using var sub2 = await nc2.SubscribeCoreAsync<string>("dup.test");
|
||||
await nc1.PingAsync();
|
||||
await nc2.PingAsync();
|
||||
|
||||
await WaitForCondition(() => b.Server.HasRemoteInterest("dup.test"));
|
||||
|
||||
// Publish from B; should be delivered to both local subscribers on A
|
||||
await using var pub = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://127.0.0.1:{b.Server.Port}",
|
||||
});
|
||||
await pub.ConnectAsync();
|
||||
await pub.PublishAsync("dup.test", "to-both");
|
||||
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
var msg1 = await sub1.Msgs.ReadAsync(timeout.Token);
|
||||
var msg2 = await sub2.Msgs.ReadAsync(timeout.Token);
|
||||
msg1.Data.ShouldBe("to-both");
|
||||
msg2.Data.ShouldBe("to-both");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DisposeServers(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
// Go: Wildcard delivery
|
||||
[Fact]
|
||||
public async Task Wildcard_subscription_delivers_matching_messages_across_route()
|
||||
{
|
||||
var cluster = Guid.NewGuid().ToString("N");
|
||||
var a = await StartServerAsync(MakeClusterOpts(cluster));
|
||||
var b = await StartServerAsync(MakeClusterOpts(cluster, a.Server.ClusterListen!));
|
||||
|
||||
try
|
||||
{
|
||||
await WaitForRouteFormation(a.Server, b.Server);
|
||||
|
||||
await using var nc = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://127.0.0.1:{a.Server.Port}",
|
||||
});
|
||||
await nc.ConnectAsync();
|
||||
await using var sub = await nc.SubscribeCoreAsync<string>("data.>");
|
||||
await nc.PingAsync();
|
||||
|
||||
await WaitForCondition(() => b.Server.HasRemoteInterest("data.sensor.1"));
|
||||
|
||||
await using var pub = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://127.0.0.1:{b.Server.Port}",
|
||||
});
|
||||
await pub.ConnectAsync();
|
||||
await pub.PublishAsync("data.sensor.1", "reading");
|
||||
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
var msg = await sub.Msgs.ReadAsync(timeout.Token);
|
||||
msg.Subject.ShouldBe("data.sensor.1");
|
||||
msg.Data.ShouldBe("reading");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DisposeServers(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
// Go: No messages for non-matching subjects
|
||||
[Fact]
|
||||
public async Task Non_matching_subject_not_forwarded_across_route()
|
||||
{
|
||||
var cluster = Guid.NewGuid().ToString("N");
|
||||
var a = await StartServerAsync(MakeClusterOpts(cluster));
|
||||
var b = await StartServerAsync(MakeClusterOpts(cluster, a.Server.ClusterListen!));
|
||||
|
||||
try
|
||||
{
|
||||
await WaitForRouteFormation(a.Server, b.Server);
|
||||
|
||||
await using var nc = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://127.0.0.1:{a.Server.Port}",
|
||||
});
|
||||
await nc.ConnectAsync();
|
||||
await using var sub = await nc.SubscribeCoreAsync<string>("specific.topic");
|
||||
await nc.PingAsync();
|
||||
|
||||
await WaitForCondition(() => b.Server.HasRemoteInterest("specific.topic"));
|
||||
|
||||
await using var pub = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://127.0.0.1:{b.Server.Port}",
|
||||
});
|
||||
await pub.ConnectAsync();
|
||||
|
||||
// Publish to a non-matching subject
|
||||
await pub.PublishAsync("other.topic", "should-not-arrive");
|
||||
// Publish to the matching subject
|
||||
await pub.PublishAsync("specific.topic", "should-arrive");
|
||||
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
var msg = await sub.Msgs.ReadAsync(timeout.Token);
|
||||
msg.Data.ShouldBe("should-arrive");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DisposeServers(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
// -- Wire-level helpers --
|
||||
|
||||
private static async Task<string> ReadLineAsync(Socket socket, CancellationToken ct)
|
||||
{
|
||||
var bytes = new List<byte>(64);
|
||||
var single = new byte[1];
|
||||
using var cts = ct.CanBeNone() ? new CancellationTokenSource(TimeSpan.FromSeconds(5)) : null;
|
||||
var effectiveCt = cts?.Token ?? ct;
|
||||
while (true)
|
||||
{
|
||||
var read = await socket.ReceiveAsync(single, SocketFlags.None, effectiveCt);
|
||||
if (read == 0) break;
|
||||
if (single[0] == (byte)'\n') break;
|
||||
if (single[0] != (byte)'\r')
|
||||
bytes.Add(single[0]);
|
||||
}
|
||||
|
||||
return Encoding.ASCII.GetString([.. bytes]);
|
||||
}
|
||||
|
||||
private static Task WriteLineAsync(Socket socket, string line, CancellationToken ct)
|
||||
=> socket.SendAsync(Encoding.ASCII.GetBytes($"{line}\r\n"), SocketFlags.None, ct).AsTask();
|
||||
|
||||
private static async Task<string> ReadUntilAsync(Socket sock, string expected)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
var buf = new byte[4096];
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
while (!sb.ToString().Contains(expected, StringComparison.Ordinal))
|
||||
{
|
||||
var n = await sock.ReceiveAsync(buf, SocketFlags.None, cts.Token);
|
||||
if (n == 0) break;
|
||||
sb.Append(Encoding.ASCII.GetString(buf, 0, n));
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
file static class CancellationTokenExtensions
|
||||
{
|
||||
public static bool CanBeNone(this CancellationToken ct) => ct == default;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server.Configuration;
|
||||
using NATS.Server.Routes;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class RouteTopologyGossipTests
|
||||
{
|
||||
[Fact]
|
||||
public void Topology_snapshot_reports_server_and_route_counts()
|
||||
{
|
||||
var manager = new RouteManager(
|
||||
new ClusterOptions { Host = "127.0.0.1", Port = 0 },
|
||||
new ServerStats(),
|
||||
"S1",
|
||||
_ => { },
|
||||
_ => { },
|
||||
NullLogger<RouteManager>.Instance);
|
||||
|
||||
var snapshot = manager.BuildTopologySnapshot();
|
||||
snapshot.ServerId.ShouldBe("S1");
|
||||
snapshot.RouteCount.ShouldBe(0);
|
||||
snapshot.ConnectedServerIds.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user