Files
natsdotnet/tests/NATS.Server.Clustering.Tests/Route/RouteGoParityTests.cs
Joseph Doherty 615752cdc2 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.
2026-03-12 15:31:58 -04:00

993 lines
40 KiB
C#

// 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.Clustering.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);
}
}