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.
325 lines
10 KiB
C#
325 lines
10 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Net;
|
|
using System.Net.Sockets;
|
|
using Microsoft.Extensions.Logging;
|
|
using NATS.Server.Configuration;
|
|
using NATS.Server.Subscriptions;
|
|
|
|
namespace NATS.Server.Routes;
|
|
|
|
public sealed class RouteManager : IAsyncDisposable
|
|
{
|
|
private static readonly ConcurrentDictionary<string, RouteManager> Managers = new(StringComparer.Ordinal);
|
|
private readonly ClusterOptions _options;
|
|
private readonly ServerStats _stats;
|
|
private readonly string _serverId;
|
|
private readonly ILogger<RouteManager> _logger;
|
|
private readonly Action<RemoteSubscription> _remoteSubSink;
|
|
private readonly Action<RouteMessage> _routedMessageSink;
|
|
private readonly ConcurrentDictionary<string, RouteConnection> _routes = new(StringComparer.Ordinal);
|
|
private readonly ConcurrentDictionary<string, byte> _connectedServerIds = new(StringComparer.Ordinal);
|
|
|
|
private CancellationTokenSource? _cts;
|
|
private Socket? _listener;
|
|
private Task? _acceptLoopTask;
|
|
|
|
public string ListenEndpoint => $"{_options.Host}:{_options.Port}";
|
|
|
|
public RouteTopologySnapshot BuildTopologySnapshot()
|
|
{
|
|
return new RouteTopologySnapshot(
|
|
_serverId,
|
|
_routes.Count,
|
|
_connectedServerIds.Keys.OrderBy(static k => k, StringComparer.Ordinal).ToArray());
|
|
}
|
|
|
|
public RouteManager(
|
|
ClusterOptions options,
|
|
ServerStats stats,
|
|
string serverId,
|
|
Action<RemoteSubscription> remoteSubSink,
|
|
Action<RouteMessage> routedMessageSink,
|
|
ILogger<RouteManager> logger)
|
|
{
|
|
_options = options;
|
|
_stats = stats;
|
|
_serverId = serverId;
|
|
_remoteSubSink = remoteSubSink;
|
|
_routedMessageSink = routedMessageSink;
|
|
_logger = logger;
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
/// Returns a route pool index for the given account name, matching Go's
|
|
/// <c>computeRoutePoolIdx</c> (route.go:533-545). Uses FNV-1a 32-bit hash
|
|
/// to deterministically map account names to pool indices.
|
|
/// </summary>
|
|
public static int ComputeRoutePoolIdx(int poolSize, string accountName)
|
|
{
|
|
if (poolSize <= 1)
|
|
return 0;
|
|
|
|
var bytes = System.Text.Encoding.UTF8.GetBytes(accountName);
|
|
|
|
// Use FNV-1a to match Go exactly
|
|
uint fnvHash = 2166136261; // FNV offset basis
|
|
foreach (var b in bytes)
|
|
{
|
|
fnvHash ^= b;
|
|
fnvHash *= 16777619; // FNV prime
|
|
}
|
|
|
|
return (int)(fnvHash % (uint)poolSize);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the route connection responsible for the given account, based on
|
|
/// pool index computed from the account name. Returns null if no routes exist.
|
|
/// </summary>
|
|
public RouteConnection? GetRouteForAccount(string account)
|
|
{
|
|
if (_routes.IsEmpty)
|
|
return null;
|
|
|
|
var routes = _routes.Values.ToArray();
|
|
if (routes.Length == 0)
|
|
return null;
|
|
|
|
var poolSize = routes.Length;
|
|
var idx = ComputeRoutePoolIdx(poolSize, account);
|
|
return routes[idx % routes.Length];
|
|
}
|
|
|
|
public Task StartAsync(CancellationToken ct)
|
|
{
|
|
_cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
|
Managers[_serverId] = this;
|
|
_listener = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
|
_listener.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
|
|
_listener.Bind(new IPEndPoint(IPAddress.Parse(_options.Host), _options.Port));
|
|
_listener.Listen(128);
|
|
|
|
if (_options.Port == 0)
|
|
_options.Port = ((IPEndPoint)_listener.LocalEndPoint!).Port;
|
|
|
|
_acceptLoopTask = Task.Run(() => AcceptLoopAsync(_cts.Token));
|
|
var poolSize = Math.Max(_options.PoolSize, 1);
|
|
foreach (var route in _options.Routes.Distinct(StringComparer.OrdinalIgnoreCase))
|
|
{
|
|
for (var i = 0; i < poolSize; i++)
|
|
{
|
|
var poolIndex = i;
|
|
_ = Task.Run(() => ConnectToRouteWithRetryAsync(route, poolIndex, _cts.Token));
|
|
}
|
|
}
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
if (_cts == null)
|
|
return;
|
|
|
|
await _cts.CancelAsync();
|
|
_listener?.Dispose();
|
|
|
|
if (_acceptLoopTask != null)
|
|
await _acceptLoopTask.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
|
|
|
|
foreach (var route in _routes.Values)
|
|
await route.DisposeAsync();
|
|
|
|
_routes.Clear();
|
|
_connectedServerIds.Clear();
|
|
Managers.TryRemove(_serverId, out _);
|
|
Interlocked.Exchange(ref _stats.Routes, 0);
|
|
_cts.Dispose();
|
|
_cts = null;
|
|
}
|
|
|
|
public void PropagateLocalSubscription(string account, string subject, string? queue)
|
|
{
|
|
if (_routes.IsEmpty)
|
|
return;
|
|
|
|
foreach (var route in _routes.Values)
|
|
{
|
|
_ = route.SendRsPlusAsync(account, subject, queue, _cts?.Token ?? CancellationToken.None);
|
|
}
|
|
}
|
|
|
|
public void PropagateLocalUnsubscription(string account, string subject, string? queue)
|
|
{
|
|
if (_routes.IsEmpty)
|
|
return;
|
|
|
|
foreach (var route in _routes.Values)
|
|
_ = route.SendRsMinusAsync(account, subject, queue, _cts?.Token ?? CancellationToken.None);
|
|
}
|
|
|
|
public async Task ForwardRoutedMessageAsync(string account, string subject, string? replyTo, ReadOnlyMemory<byte> payload, CancellationToken ct)
|
|
{
|
|
if (_routes.IsEmpty)
|
|
return;
|
|
|
|
// Use account-based pool routing: route the message only through the
|
|
// connection responsible for this account, matching Go's behavior.
|
|
var route = GetRouteForAccount(account);
|
|
if (route != null)
|
|
{
|
|
await route.SendRmsgAsync(account, subject, replyTo, payload, ct);
|
|
return;
|
|
}
|
|
|
|
// Fallback: broadcast to all routes if pool routing fails
|
|
foreach (var r in _routes.Values)
|
|
await r.SendRmsgAsync(account, subject, replyTo, payload, ct);
|
|
}
|
|
|
|
private async Task AcceptLoopAsync(CancellationToken ct)
|
|
{
|
|
while (!ct.IsCancellationRequested)
|
|
{
|
|
Socket socket;
|
|
try
|
|
{
|
|
socket = await _listener!.AcceptAsync(ct);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
break;
|
|
}
|
|
catch (ObjectDisposedException)
|
|
{
|
|
break;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogDebug(ex, "Route accept loop error");
|
|
break;
|
|
}
|
|
|
|
_ = Task.Run(() => HandleInboundRouteAsync(socket, ct), ct);
|
|
}
|
|
}
|
|
|
|
private async Task HandleInboundRouteAsync(Socket socket, CancellationToken ct)
|
|
{
|
|
var route = new RouteConnection(socket);
|
|
try
|
|
{
|
|
await route.PerformInboundHandshakeAsync(_serverId, ct);
|
|
Register(route);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogDebug(ex, "Inbound route handshake failed");
|
|
await route.DisposeAsync();
|
|
}
|
|
}
|
|
|
|
private async Task ConnectToRouteWithRetryAsync(string route, int poolIndex, CancellationToken ct)
|
|
{
|
|
while (!ct.IsCancellationRequested)
|
|
{
|
|
try
|
|
{
|
|
var endPoint = ParseRouteEndpoint(route);
|
|
var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
|
await socket.ConnectAsync(endPoint.Address, endPoint.Port, ct);
|
|
var connection = new RouteConnection(socket) { PoolIndex = poolIndex };
|
|
await connection.PerformOutboundHandshakeAsync(_serverId, ct);
|
|
Register(connection);
|
|
return;
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
return;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogDebug(ex, "Failed to connect route seed {Route}", route);
|
|
}
|
|
|
|
try
|
|
{
|
|
await Task.Delay(250, ct);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
private void Register(RouteConnection route)
|
|
{
|
|
var key = $"{route.RemoteServerId}:{route.RemoteEndpoint}:{Guid.NewGuid():N}";
|
|
if (!_routes.TryAdd(key, route))
|
|
{
|
|
_ = route.DisposeAsync();
|
|
return;
|
|
}
|
|
|
|
if (route.RemoteServerId is { Length: > 0 } remoteServerId)
|
|
_connectedServerIds[remoteServerId] = 0;
|
|
|
|
route.RemoteSubscriptionReceived = sub =>
|
|
{
|
|
_remoteSubSink(sub);
|
|
return Task.CompletedTask;
|
|
};
|
|
route.RoutedMessageReceived = msg =>
|
|
{
|
|
_routedMessageSink(msg);
|
|
return Task.CompletedTask;
|
|
};
|
|
route.StartFrameLoop(_cts!.Token);
|
|
|
|
Interlocked.Increment(ref _stats.Routes);
|
|
_ = Task.Run(() => WatchRouteAsync(key, route, _cts!.Token));
|
|
}
|
|
|
|
private async Task WatchRouteAsync(string key, RouteConnection route, CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
await route.WaitUntilClosedAsync(ct);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// Shutdown path.
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogDebug(ex, "Route {RouteKey} closed with error", key);
|
|
}
|
|
finally
|
|
{
|
|
if (_routes.TryRemove(key, out _))
|
|
Interlocked.Decrement(ref _stats.Routes);
|
|
|
|
await route.DisposeAsync();
|
|
}
|
|
}
|
|
|
|
private static IPEndPoint ParseRouteEndpoint(string route)
|
|
{
|
|
var trimmed = route.Trim();
|
|
var parts = trimmed.Split(':', 2, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
|
|
if (parts.Length != 2)
|
|
throw new FormatException($"Invalid route endpoint: '{route}'");
|
|
|
|
return new IPEndPoint(IPAddress.Parse(parts[0]), int.Parse(parts[1]));
|
|
}
|
|
|
|
public int RouteCount => _routes.Count;
|
|
}
|
|
|
|
public sealed record RouteTopologySnapshot(
|
|
string ServerId,
|
|
int RouteCount,
|
|
IReadOnlyList<string> ConnectedServerIds);
|