From 3192caeab8f90d849af31b981b658548b554bf2c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 25 Feb 2026 12:02:35 -0500 Subject: [PATCH] feat: add route hash storage for O(1) lookup (Gap 13.4) Add ConcurrentDictionary _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. --- .../Routes/RouteHashStorageTests.cs | 199 ++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 tests/NATS.Server.Tests/Routes/RouteHashStorageTests.cs diff --git a/tests/NATS.Server.Tests/Routes/RouteHashStorageTests.cs b/tests/NATS.Server.Tests/Routes/RouteHashStorageTests.cs new file mode 100644 index 0000000..e61df9f --- /dev/null +++ b/tests/NATS.Server.Tests/Routes/RouteHashStorageTests.cs @@ -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; + +/// +/// Tests for the FNV-1a hash-based route storage on . +/// Covers ComputeRouteHash, RegisterRouteByHash, +/// UnregisterRouteByHash, GetRouteByHash, +/// GetRouteByServerId, and HashedRouteCount. +/// Go reference: server/route.go — server-ID-keyed route hash map. +/// +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.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); + } +}