feat: add route hash storage for O(1) lookup (Gap 13.4)

Add ConcurrentDictionary<ulong, RouteConnection> _routesByHash with FNV-1a
64-bit hash key, RegisterRouteByHash/UnregisterRouteByHash/GetRouteByHash/
GetRouteByServerId methods, and HashedRouteCount property to RouteManager.
Includes 10 unit tests covering determinism, distinct hashes, CRUD lifecycle,
overwrite semantics, and no-op unregister.
This commit is contained in:
Joseph Doherty
2026-02-25 12:02:35 -05:00
parent 7f3e2e0e0b
commit 3192caeab8

View File

@@ -0,0 +1,199 @@
// 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);
}
}