diff --git a/src/NATS.Server/Routes/RouteConnection.cs b/src/NATS.Server/Routes/RouteConnection.cs index e8e97ab..d97478b 100644 --- a/src/NATS.Server/Routes/RouteConnection.cs +++ b/src/NATS.Server/Routes/RouteConnection.cs @@ -30,6 +30,20 @@ public sealed class RouteConnection(Socket socket) : IAsyncDisposable /// public int NegotiatedPoolSize { get; private set; } + /// + /// Returns true when pool size negotiation has completed and the remote peer + /// supports route pooling ( > 0). + /// Go reference: server/route.go — pool-capable route check. + /// + public bool SupportsPooling => NegotiatedPoolSize > 0; + + /// + /// Returns true when this connection is a legacy (pre-pool) route, i.e. the + /// remote peer does not support route pooling ( == 0). + /// Go reference: server/route.go getRoutesExcludePool. + /// + public bool IsLegacyRoute => NegotiatedPoolSize == 0; + /// /// Negotiates the effective route pool size between local and remote peers. /// Returns Math.Min(localPoolSize, remotePoolSize), but returns 0 if diff --git a/src/NATS.Server/Routes/RouteManager.cs b/src/NATS.Server/Routes/RouteManager.cs index 51f2008..a483a5c 100644 --- a/src/NATS.Server/Routes/RouteManager.cs +++ b/src/NATS.Server/Routes/RouteManager.cs @@ -166,27 +166,58 @@ public sealed class RouteManager : IAsyncDisposable /// /// Returns the route connection responsible for the given account. Checks - /// dedicated account routes first, then falls back to pool-based selection. - /// Go reference: server/route.go — per-account dedicated route lookup. + /// dedicated account routes first, then falls back to pool-based selection, + /// and finally falls back to the first legacy (pre-pool) route. + /// Go reference: server/route.go — per-account dedicated route lookup, + /// getRoutesExcludePool (legacy fallback). /// public RouteConnection? GetRouteForAccount(string account) { - // Check dedicated account routes first (Gap 13.2). + // 1st: Check dedicated account routes (Gap 13.2). if (_accountRoutes.TryGetValue(account, out var dedicated)) return dedicated; if (_routes.IsEmpty) return null; - var routes = _routes.Values.ToArray(); - if (routes.Length == 0) - return null; + // 2nd: Try pool-based routes (current behavior). + var poolRoutes = _routes.Values.Where(r => r.SupportsPooling).ToArray(); + if (poolRoutes.Length > 0) + { + var idx = ComputeRoutePoolIdx(poolRoutes.Length, account); + return poolRoutes[idx % poolRoutes.Length]; + } - var poolSize = routes.Length; - var idx = ComputeRoutePoolIdx(poolSize, account); - return routes[idx % routes.Length]; + // 3rd: Fall back to legacy route when no pool routes exist (Gap 13.6). + return GetLegacyRoute(); } + /// + /// Returns the first route connection with + /// equal to true, or null if no legacy routes are registered. + /// Legacy routes are connections to pre-pool peers that did not negotiate a + /// pool size during the handshake. + /// Go reference: server/route.go getRoutesExcludePool. + /// + public RouteConnection? GetLegacyRoute() + => _routes.Values.FirstOrDefault(r => r.IsLegacyRoute); + + /// + /// Returns all route connections with + /// equal to true. Returns an empty list when no legacy routes exist. + /// Go reference: server/route.go getRoutesExcludePool. + /// + public IReadOnlyList GetLegacyRoutes() + => _routes.Values.Where(r => r.IsLegacyRoute).ToList(); + + /// + /// Returns true when at least one registered route connection is a + /// legacy (pre-pool) route. + /// Go reference: server/route.go getRoutesExcludePool. + /// + public bool HasLegacyRoutes + => _routes.Values.Any(r => r.IsLegacyRoute); + /// /// Registers a dedicated route connection for a specific account. If a /// previous connection was registered for the same account it is replaced. diff --git a/tests/NATS.Server.Tests/Routes/NoPoolFallbackTests.cs b/tests/NATS.Server.Tests/Routes/NoPoolFallbackTests.cs new file mode 100644 index 0000000..24a65b5 --- /dev/null +++ b/tests/NATS.Server.Tests/Routes/NoPoolFallbackTests.cs @@ -0,0 +1,217 @@ +// 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; + +/// +/// Tests for no-pool (legacy) route fallback — Gap 13.6. +/// Verifies that , +/// , and the new +/// legacy-route helpers behave correctly, and that +/// falls back to a legacy route +/// when neither a dedicated nor a pool route exists. +/// Go reference: server/route.go getRoutesExcludePool. +/// +public class NoPoolFallbackTests : IDisposable +{ + private readonly List _listeners = []; + private readonly List _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.Instance); + + /// + /// Creates a backed by a connected loopback + /// socket pair so that the internal + /// can be constructed without throwing. Both sockets are tracked for disposal. + /// + 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); + } +}