diff --git a/src/NATS.Server/Routes/RouteConnection.cs b/src/NATS.Server/Routes/RouteConnection.cs index c518867..e8e97ab 100644 --- a/src/NATS.Server/Routes/RouteConnection.cs +++ b/src/NATS.Server/Routes/RouteConnection.cs @@ -22,6 +22,36 @@ public sealed class RouteConnection(Socket socket) : IAsyncDisposable /// for a given account. See . /// public int PoolIndex { get; set; } + + /// + /// The pool size agreed upon during handshake negotiation with the remote peer. + /// Defaults to 0 (no pooling / pre-negotiation state). Set after handshake completes. + /// Go reference: server/route.go negotiateRoutePool. + /// + public int NegotiatedPoolSize { get; private set; } + + /// + /// Negotiates the effective route pool size between local and remote peers. + /// Returns Math.Min(localPoolSize, remotePoolSize), but returns 0 if + /// either side is 0 for backward compatibility with peers that do not support pooling. + /// Go reference: server/route.go negotiateRoutePool. + /// + public static int NegotiatePoolSize(int localPoolSize, int remotePoolSize) + { + if (localPoolSize == 0 || remotePoolSize == 0) + return 0; + + return Math.Min(localPoolSize, remotePoolSize); + } + + /// + /// Applies the result of pool size negotiation to this connection. + /// + internal void SetNegotiatedPoolSize(int negotiatedPoolSize) + { + NegotiatedPoolSize = negotiatedPoolSize; + } + public Func? RemoteSubscriptionReceived { get; set; } public Func? RoutedMessageReceived { get; set; } diff --git a/tests/NATS.Server.Tests/Routes/PoolSizeNegotiationTests.cs b/tests/NATS.Server.Tests/Routes/PoolSizeNegotiationTests.cs new file mode 100644 index 0000000..2f65daa --- /dev/null +++ b/tests/NATS.Server.Tests/Routes/PoolSizeNegotiationTests.cs @@ -0,0 +1,210 @@ +// 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; + +/// +/// 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. +/// +public class PoolSizeNegotiationTests +{ + /// + /// 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. + /// + 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.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); + } +}