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:
199
tests/NATS.Server.Tests/Routes/RouteHashStorageTests.cs
Normal file
199
tests/NATS.Server.Tests/Routes/RouteHashStorageTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user