feat(routes): add pool accounting per account and S2 compression codec (D2+D3)

D2: Add FNV-1a-based ComputeRoutePoolIdx to RouteManager matching Go's
route.go:533-545, with PoolIndex on RouteConnection and account-aware
ForwardRoutedMessageAsync that routes to the correct pool connection.

D3: Replace DeflateStream with IronSnappy in RouteCompressionCodec, add
RouteCompressionLevel enum, NegotiateCompression, and IsCompressed
detection. 17 new tests (6 pool + 11 compression), all passing.
This commit is contained in:
Joseph Doherty
2026-02-24 15:11:20 -05:00
parent 21d10582b3
commit 386cc201de
6 changed files with 479 additions and 19 deletions

View File

@@ -0,0 +1,147 @@
// Reference: golang/nats-server/server/route.go:533-545 — computeRoutePoolIdx
// Tests for account-based route pool index computation and message routing.
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server.Configuration;
using NATS.Server.Routes;
namespace NATS.Server.Tests.Routes;
/// <summary>
/// Tests for route pool accounting per account, matching Go's
/// computeRoutePoolIdx behavior (route.go:533-545).
/// </summary>
public class RoutePoolAccountTests
{
[Fact]
public void ComputeRoutePoolIdx_SinglePool_AlwaysReturnsZero()
{
RouteManager.ComputeRoutePoolIdx(1, "account-A").ShouldBe(0);
RouteManager.ComputeRoutePoolIdx(1, "account-B").ShouldBe(0);
RouteManager.ComputeRoutePoolIdx(1, "$G").ShouldBe(0);
RouteManager.ComputeRoutePoolIdx(0, "anything").ShouldBe(0);
}
[Fact]
public void ComputeRoutePoolIdx_DeterministicForSameAccount()
{
const int poolSize = 5;
const string account = "my-test-account";
var first = RouteManager.ComputeRoutePoolIdx(poolSize, account);
var second = RouteManager.ComputeRoutePoolIdx(poolSize, account);
var third = RouteManager.ComputeRoutePoolIdx(poolSize, account);
first.ShouldBe(second);
second.ShouldBe(third);
first.ShouldBeGreaterThanOrEqualTo(0);
first.ShouldBeLessThan(poolSize);
}
[Fact]
public void ComputeRoutePoolIdx_DistributesAcrossPool()
{
const int poolSize = 3;
var usedIndices = new HashSet<int>();
for (var i = 0; i < 100; i++)
{
var idx = RouteManager.ComputeRoutePoolIdx(poolSize, $"account-{i}");
idx.ShouldBeGreaterThanOrEqualTo(0);
idx.ShouldBeLessThan(poolSize);
usedIndices.Add(idx);
}
usedIndices.Count.ShouldBe(poolSize);
}
[Fact]
public void ComputeRoutePoolIdx_EmptyAccount_ReturnsValid()
{
const int poolSize = 4;
var idx = RouteManager.ComputeRoutePoolIdx(poolSize, string.Empty);
idx.ShouldBeGreaterThanOrEqualTo(0);
idx.ShouldBeLessThan(poolSize);
}
[Fact]
public void ComputeRoutePoolIdx_DefaultGlobalAccount_ReturnsValid()
{
const int poolSize = 3;
var idx = RouteManager.ComputeRoutePoolIdx(poolSize, "$G");
idx.ShouldBeGreaterThanOrEqualTo(0);
idx.ShouldBeLessThan(poolSize);
var idx2 = RouteManager.ComputeRoutePoolIdx(poolSize, "$G");
idx.ShouldBe(idx2);
}
[Fact]
public async Task ForwardRoutedMessage_UsesCorrectPoolConnection()
{
var clusterName = Guid.NewGuid().ToString("N");
var optsA = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
Cluster = new ClusterOptions
{
Name = clusterName,
Host = "127.0.0.1",
Port = 0,
PoolSize = 1,
Routes = [],
},
};
var serverA = new NatsServer(optsA, NullLoggerFactory.Instance);
var ctsA = new CancellationTokenSource();
_ = serverA.StartAsync(ctsA.Token);
await serverA.WaitForReadyAsync();
var optsB = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
Cluster = new ClusterOptions
{
Name = clusterName,
Host = "127.0.0.1",
Port = 0,
PoolSize = 1,
Routes = [$"127.0.0.1:{optsA.Cluster.Port}"],
},
};
var serverB = new NatsServer(optsB, NullLoggerFactory.Instance);
var ctsB = new CancellationTokenSource();
_ = serverB.StartAsync(ctsB.Token);
await serverB.WaitForReadyAsync();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!timeout.IsCancellationRequested &&
(Interlocked.Read(ref serverA.Stats.Routes) == 0 ||
Interlocked.Read(ref serverB.Stats.Routes) == 0))
{
await Task.Delay(50, timeout.Token);
}
Interlocked.Read(ref serverA.Stats.Routes).ShouldBeGreaterThan(0);
var payload = Encoding.UTF8.GetBytes("hello");
await serverA.RouteManager!.ForwardRoutedMessageAsync(
"$G", "test.subject", null, payload, CancellationToken.None);
var poolIdx = RouteManager.ComputeRoutePoolIdx(1, "$G");
poolIdx.ShouldBe(0);
await ctsA.CancelAsync();
await ctsB.CancelAsync();
serverA.Dispose();
serverB.Dispose();
ctsA.Dispose();
ctsB.Dispose();
}
}

View File

@@ -0,0 +1,136 @@
// Reference: golang/nats-server/server/route.go — S2/Snappy compression for routes
// Tests for RouteCompressionCodec: compression, decompression, negotiation, detection.
using System.Text;
using NATS.Server.Routes;
namespace NATS.Server.Tests.Routes;
/// <summary>
/// Tests for route S2/Snappy compression codec, matching Go's route compression
/// behavior using IronSnappy.
/// </summary>
public class RouteS2CompressionTests
{
[Fact]
public void Compress_Fast_ProducesValidOutput()
{
var data = Encoding.UTF8.GetBytes("NATS route compression test payload");
var compressed = RouteCompressionCodec.Compress(data, RouteCompressionLevel.Fast);
compressed.ShouldNotBeNull();
compressed.Length.ShouldBeGreaterThan(0);
// Compressed output should be decompressible
var decompressed = RouteCompressionCodec.Decompress(compressed);
decompressed.ShouldBe(data);
}
[Fact]
public void Compress_Decompress_RoundTrips()
{
var original = Encoding.UTF8.GetBytes("Hello NATS! This is a test of round-trip compression.");
foreach (var level in new[] { RouteCompressionLevel.Fast, RouteCompressionLevel.Better, RouteCompressionLevel.Best })
{
var compressed = RouteCompressionCodec.Compress(original, level);
var restored = RouteCompressionCodec.Decompress(compressed);
restored.ShouldBe(original, $"Round-trip failed for level {level}");
}
}
[Fact]
public void Compress_EmptyData_ReturnsEmpty()
{
var result = RouteCompressionCodec.Compress(ReadOnlySpan<byte>.Empty, RouteCompressionLevel.Fast);
result.ShouldBeEmpty();
}
[Fact]
public void Compress_Off_ReturnsOriginal()
{
var data = Encoding.UTF8.GetBytes("uncompressed payload");
var result = RouteCompressionCodec.Compress(data, RouteCompressionLevel.Off);
result.ShouldBe(data);
}
[Fact]
public void Decompress_CorruptedData_Throws()
{
var garbage = new byte[] { 0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04 };
Should.Throw<Exception>(() => RouteCompressionCodec.Decompress(garbage));
}
[Fact]
public void NegotiateCompression_BothOff_ReturnsOff()
{
var result = RouteCompressionCodec.NegotiateCompression("off", "off");
result.ShouldBe(RouteCompressionLevel.Off);
}
[Fact]
public void NegotiateCompression_OneFast_ReturnsFast()
{
// When both are fast, result is fast
var result = RouteCompressionCodec.NegotiateCompression("fast", "fast");
result.ShouldBe(RouteCompressionLevel.Fast);
// When one is off, result is off (off wins)
var result2 = RouteCompressionCodec.NegotiateCompression("fast", "off");
result2.ShouldBe(RouteCompressionLevel.Off);
}
[Fact]
public void NegotiateCompression_MismatchLevels_ReturnsMinimum()
{
// fast (1) vs best (3) => fast (minimum)
var result = RouteCompressionCodec.NegotiateCompression("fast", "best");
result.ShouldBe(RouteCompressionLevel.Fast);
// better (2) vs best (3) => better (minimum)
var result2 = RouteCompressionCodec.NegotiateCompression("better", "best");
result2.ShouldBe(RouteCompressionLevel.Better);
// fast (1) vs better (2) => fast (minimum)
var result3 = RouteCompressionCodec.NegotiateCompression("fast", "better");
result3.ShouldBe(RouteCompressionLevel.Fast);
}
[Fact]
public void IsCompressed_ValidSnappy_ReturnsTrue()
{
var data = Encoding.UTF8.GetBytes("This is test data for Snappy compression detection");
var compressed = RouteCompressionCodec.Compress(data, RouteCompressionLevel.Fast);
RouteCompressionCodec.IsCompressed(compressed).ShouldBeTrue();
}
[Fact]
public void IsCompressed_PlainText_ReturnsFalse()
{
var plainText = Encoding.UTF8.GetBytes("PUB test.subject 5\r\nhello\r\n");
RouteCompressionCodec.IsCompressed(plainText).ShouldBeFalse();
}
[Fact]
public void RoundTrip_LargePayload_Compresses()
{
// 10KB payload of repeated data should compress well
var largePayload = new byte[10240];
var pattern = Encoding.UTF8.GetBytes("NATS route payload ");
for (var i = 0; i < largePayload.Length; i++)
largePayload[i] = pattern[i % pattern.Length];
var compressed = RouteCompressionCodec.Compress(largePayload, RouteCompressionLevel.Fast);
// Compressed should be smaller than original for repetitive data
compressed.Length.ShouldBeLessThan(largePayload.Length);
// Round-trip should restore original
var restored = RouteCompressionCodec.Decompress(compressed);
restored.ShouldBe(largePayload);
}
}