feat: add no-pool route fallback for backward compatibility (Gap 13.6)
Add SupportsPooling/IsLegacyRoute properties to RouteConnection and GetLegacyRoute/GetLegacyRoutes/HasLegacyRoutes to RouteManager. Update GetRouteForAccount to fall back to legacy routes when no dedicated or pool-capable route is available, matching Go route.go getRoutesExcludePool.
This commit is contained in:
@@ -30,6 +30,20 @@ public sealed class RouteConnection(Socket socket) : IAsyncDisposable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public int NegotiatedPoolSize { get; private set; }
|
public int NegotiatedPoolSize { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true when pool size negotiation has completed and the remote peer
|
||||||
|
/// supports route pooling (<see cref="NegotiatedPoolSize"/> > 0).
|
||||||
|
/// Go reference: server/route.go — pool-capable route check.
|
||||||
|
/// </summary>
|
||||||
|
public bool SupportsPooling => NegotiatedPoolSize > 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true when this connection is a legacy (pre-pool) route, i.e. the
|
||||||
|
/// remote peer does not support route pooling (<see cref="NegotiatedPoolSize"/> == 0).
|
||||||
|
/// Go reference: server/route.go getRoutesExcludePool.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsLegacyRoute => NegotiatedPoolSize == 0;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Negotiates the effective route pool size between local and remote peers.
|
/// Negotiates the effective route pool size between local and remote peers.
|
||||||
/// Returns <c>Math.Min(localPoolSize, remotePoolSize)</c>, but returns 0 if
|
/// Returns <c>Math.Min(localPoolSize, remotePoolSize)</c>, but returns 0 if
|
||||||
|
|||||||
@@ -166,27 +166,58 @@ public sealed class RouteManager : IAsyncDisposable
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the route connection responsible for the given account. Checks
|
/// Returns the route connection responsible for the given account. Checks
|
||||||
/// dedicated account routes first, then falls back to pool-based selection.
|
/// dedicated account routes first, then falls back to pool-based selection,
|
||||||
/// Go reference: server/route.go — per-account dedicated route lookup.
|
/// and finally falls back to the first legacy (pre-pool) route.
|
||||||
|
/// Go reference: server/route.go — per-account dedicated route lookup,
|
||||||
|
/// getRoutesExcludePool (legacy fallback).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public RouteConnection? GetRouteForAccount(string account)
|
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))
|
if (_accountRoutes.TryGetValue(account, out var dedicated))
|
||||||
return dedicated;
|
return dedicated;
|
||||||
|
|
||||||
if (_routes.IsEmpty)
|
if (_routes.IsEmpty)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
var routes = _routes.Values.ToArray();
|
// 2nd: Try pool-based routes (current behavior).
|
||||||
if (routes.Length == 0)
|
var poolRoutes = _routes.Values.Where(r => r.SupportsPooling).ToArray();
|
||||||
return null;
|
if (poolRoutes.Length > 0)
|
||||||
|
{
|
||||||
var poolSize = routes.Length;
|
var idx = ComputeRoutePoolIdx(poolRoutes.Length, account);
|
||||||
var idx = ComputeRoutePoolIdx(poolSize, account);
|
return poolRoutes[idx % poolRoutes.Length];
|
||||||
return routes[idx % routes.Length];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3rd: Fall back to legacy route when no pool routes exist (Gap 13.6).
|
||||||
|
return GetLegacyRoute();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the first route connection with <see cref="RouteConnection.IsLegacyRoute"/>
|
||||||
|
/// equal to <c>true</c>, or <c>null</c> 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.
|
||||||
|
/// </summary>
|
||||||
|
public RouteConnection? GetLegacyRoute()
|
||||||
|
=> _routes.Values.FirstOrDefault(r => r.IsLegacyRoute);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns all route connections with <see cref="RouteConnection.IsLegacyRoute"/>
|
||||||
|
/// equal to <c>true</c>. Returns an empty list when no legacy routes exist.
|
||||||
|
/// Go reference: server/route.go getRoutesExcludePool.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<RouteConnection> GetLegacyRoutes()
|
||||||
|
=> _routes.Values.Where(r => r.IsLegacyRoute).ToList();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns <c>true</c> when at least one registered route connection is a
|
||||||
|
/// legacy (pre-pool) route.
|
||||||
|
/// Go reference: server/route.go getRoutesExcludePool.
|
||||||
|
/// </summary>
|
||||||
|
public bool HasLegacyRoutes
|
||||||
|
=> _routes.Values.Any(r => r.IsLegacyRoute);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Registers a dedicated route connection for a specific account. If a
|
/// Registers a dedicated route connection for a specific account. If a
|
||||||
/// previous connection was registered for the same account it is replaced.
|
/// previous connection was registered for the same account it is replaced.
|
||||||
|
|||||||
217
tests/NATS.Server.Tests/Routes/NoPoolFallbackTests.cs
Normal file
217
tests/NATS.Server.Tests/Routes/NoPoolFallbackTests.cs
Normal file
@@ -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;
|
||||||
|
|
||||||
|
/// <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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user