Fix E2E test gaps and add comprehensive E2E + parity test suites
- Fix pull consumer fetch: send original stream subject in HMSG (not inbox) so NATS client distinguishes data messages from control messages - Fix MaxAge expiry: add background timer in StreamManager for periodic pruning - Fix JetStream wire format: Go-compatible anonymous objects with string enums, proper offset-based pagination for stream/consumer list APIs - Add 42 E2E black-box tests (core messaging, auth, TLS, accounts, JetStream) - Add ~1000 parity tests across all subsystems (gaps closure) - Update gap inventory docs to reflect implementation status
This commit is contained in:
@@ -16,6 +16,7 @@ public sealed class GatewayConnection(Socket socket) : IAsyncDisposable
|
||||
private Task? _loopTask;
|
||||
|
||||
public string? RemoteId { get; private set; }
|
||||
public bool IsOutbound { get; internal set; }
|
||||
public string RemoteEndpoint => socket.RemoteEndPoint?.ToString() ?? Guid.NewGuid().ToString("N");
|
||||
public Func<RemoteSubscription, Task>? RemoteSubscriptionReceived { get; set; }
|
||||
public Func<GatewayMessage, Task>? MessageReceived { get; set; }
|
||||
|
||||
@@ -65,6 +65,9 @@ public sealed class GatewayReconnectPolicy
|
||||
|
||||
public sealed class GatewayManager : IAsyncDisposable
|
||||
{
|
||||
public const string GatewayTlsInsecureWarning =
|
||||
"Gateway TLS insecure configuration is enabled; verify certificates and hostname validation for production.";
|
||||
|
||||
private readonly GatewayOptions _options;
|
||||
private readonly ServerStats _stats;
|
||||
private readonly string _serverId;
|
||||
@@ -103,6 +106,43 @@ public sealed class GatewayManager : IAsyncDisposable
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates gateway options for required fields and basic endpoint correctness.
|
||||
/// Go reference: validateGatewayOptions.
|
||||
/// </summary>
|
||||
public static bool ValidateGatewayOptions(GatewayOptions? options, out string? error)
|
||||
{
|
||||
if (options is null)
|
||||
{
|
||||
error = "Gateway options are required.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.Name))
|
||||
{
|
||||
error = "Gateway name is required.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (options.Port is < 0 or > 65535)
|
||||
{
|
||||
error = "Gateway port must be in range 0-65535.";
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var remote in options.Remotes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(remote))
|
||||
{
|
||||
error = "Gateway remote entries cannot be empty.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
error = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gateway clusters auto-discovered via INFO gossip.
|
||||
/// Go reference: server/gateway.go processImplicitGateway.
|
||||
@@ -284,6 +324,48 @@ public sealed class GatewayManager : IAsyncDisposable
|
||||
public int GetConnectedGatewayCount()
|
||||
=> _registrations.Values.Count(r => r.State == GatewayConnectionState.Connected);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the number of active outbound gateway connections.
|
||||
/// Go reference: server/gateway.go NumOutboundGateways / numOutboundGateways.
|
||||
/// </summary>
|
||||
public int NumOutboundGateways()
|
||||
=> _connections.Values.Count(c => c.IsOutbound);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the number of active inbound gateway connections.
|
||||
/// Go reference: server/gateway.go numInboundGateways.
|
||||
/// </summary>
|
||||
public int NumInboundGateways()
|
||||
=> _connections.Values.Count(c => !c.IsOutbound);
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if an inbound gateway connection exists for the given remote server id.
|
||||
/// Go reference: server/gateway.go srvGateway.hasInbound.
|
||||
/// </summary>
|
||||
public bool HasInbound(string remoteServerId)
|
||||
=> _connections.Values.Any(c => !c.IsOutbound && string.Equals(c.RemoteId, remoteServerId, StringComparison.Ordinal));
|
||||
|
||||
/// <summary>
|
||||
/// Returns the first outbound gateway connection for the given remote server id, or null.
|
||||
/// Go reference: server/gateway.go getOutboundGatewayConnection.
|
||||
/// </summary>
|
||||
public GatewayConnection? GetOutboundGatewayConnection(string remoteServerId)
|
||||
=> _connections.Values.FirstOrDefault(c => c.IsOutbound && string.Equals(c.RemoteId, remoteServerId, StringComparison.Ordinal));
|
||||
|
||||
/// <summary>
|
||||
/// Returns all outbound gateway connections.
|
||||
/// Go reference: server/gateway.go getOutboundGatewayConnections.
|
||||
/// </summary>
|
||||
public IReadOnlyList<GatewayConnection> GetOutboundGatewayConnections()
|
||||
=> _connections.Values.Where(c => c.IsOutbound).ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Returns all inbound gateway connections.
|
||||
/// Go reference: server/gateway.go getInboundGatewayConnections.
|
||||
/// </summary>
|
||||
public IReadOnlyList<GatewayConnection> GetInboundGatewayConnections()
|
||||
=> _connections.Values.Where(c => !c.IsOutbound).ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Atomically increments the messages-sent counter for the named gateway.
|
||||
/// Go reference: server/gateway.go outboundGateway.msgs.
|
||||
@@ -364,7 +446,7 @@ public sealed class GatewayManager : IAsyncDisposable
|
||||
var endPoint = ParseEndpoint(remote);
|
||||
var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await socket.ConnectAsync(endPoint.Address, endPoint.Port, ct);
|
||||
var connection = new GatewayConnection(socket);
|
||||
var connection = new GatewayConnection(socket) { IsOutbound = true };
|
||||
await connection.PerformOutboundHandshakeAsync(_serverId, ct);
|
||||
Register(connection);
|
||||
return;
|
||||
|
||||
@@ -9,14 +9,42 @@ namespace NATS.Server.Gateways;
|
||||
/// </summary>
|
||||
public static class ReplyMapper
|
||||
{
|
||||
private const string GatewayReplyPrefix = "_GR_.";
|
||||
public const string GatewayReplyPrefix = "_GR_.";
|
||||
public const string OldGatewayReplyPrefix = "$GR.";
|
||||
public const int GatewayReplyPrefixLen = 5;
|
||||
public const int OldGatewayReplyPrefixLen = 4;
|
||||
public const int GatewayHashLen = 6;
|
||||
public const int OldGatewayHashLen = 4;
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the subject starts with the gateway reply prefix <c>_GR_.</c>.
|
||||
/// Checks whether the subject starts with either gateway reply prefix:
|
||||
/// <c>_GR_.</c> (current) or <c>$GR.</c> (legacy).
|
||||
/// </summary>
|
||||
public static bool HasGatewayReplyPrefix(string? subject)
|
||||
=> !string.IsNullOrWhiteSpace(subject)
|
||||
&& subject.StartsWith(GatewayReplyPrefix, StringComparison.Ordinal);
|
||||
=> IsGatewayRoutedSubject(subject, out _);
|
||||
|
||||
/// <summary>
|
||||
/// Returns true when the subject is gateway-routed and indicates if the legacy
|
||||
/// old prefix (<c>$GR.</c>) was used.
|
||||
/// Go reference: isGWRoutedSubjectAndIsOldPrefix.
|
||||
/// </summary>
|
||||
public static bool IsGatewayRoutedSubject(string? subject, out bool isOldPrefix)
|
||||
{
|
||||
isOldPrefix = false;
|
||||
if (string.IsNullOrWhiteSpace(subject))
|
||||
return false;
|
||||
|
||||
if (subject.StartsWith(GatewayReplyPrefix, StringComparison.Ordinal))
|
||||
return true;
|
||||
|
||||
if (subject.StartsWith(OldGatewayReplyPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
isOldPrefix = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a deterministic FNV-1a hash of the reply subject.
|
||||
@@ -40,6 +68,26 @@ public static class ReplyMapper
|
||||
return (long)(hash & 0x7FFFFFFFFFFFFFFF);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the short (6-char) gateway hash used in modern gateway reply routing.
|
||||
/// Go reference: getGWHash.
|
||||
/// </summary>
|
||||
public static string ComputeGatewayHash(string gatewayName)
|
||||
{
|
||||
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(gatewayName));
|
||||
return Convert.ToHexString(digest.AsSpan(0, 3)).ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the short (4-char) legacy gateway hash used with old prefixes.
|
||||
/// Go reference: getOldHash.
|
||||
/// </summary>
|
||||
public static string ComputeOldGatewayHash(string gatewayName)
|
||||
{
|
||||
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(gatewayName));
|
||||
return Convert.ToHexString(digest.AsSpan(0, 2)).ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a reply subject to gateway form with an explicit hash segment.
|
||||
/// Format: <c>_GR_.{clusterId}.{hash}.{originalReply}</c>.
|
||||
@@ -75,14 +123,14 @@ public static class ReplyMapper
|
||||
{
|
||||
restoredReply = string.Empty;
|
||||
|
||||
if (!HasGatewayReplyPrefix(gatewayReply))
|
||||
if (!IsGatewayRoutedSubject(gatewayReply, out _))
|
||||
return false;
|
||||
|
||||
var current = gatewayReply!;
|
||||
while (HasGatewayReplyPrefix(current))
|
||||
while (IsGatewayRoutedSubject(current, out var isOldPrefix))
|
||||
{
|
||||
// Skip the "_GR_." prefix
|
||||
var afterPrefix = current[GatewayReplyPrefix.Length..];
|
||||
var prefixLen = isOldPrefix ? OldGatewayReplyPrefixLen : GatewayReplyPrefixLen;
|
||||
var afterPrefix = current[prefixLen..];
|
||||
|
||||
// Find the first dot (end of clusterId)
|
||||
var firstDot = afterPrefix.IndexOf('.');
|
||||
@@ -117,10 +165,10 @@ public static class ReplyMapper
|
||||
{
|
||||
clusterId = string.Empty;
|
||||
|
||||
if (!HasGatewayReplyPrefix(gatewayReply))
|
||||
if (!IsGatewayRoutedSubject(gatewayReply, out var isOldPrefix))
|
||||
return false;
|
||||
|
||||
var afterPrefix = gatewayReply![GatewayReplyPrefix.Length..];
|
||||
var afterPrefix = gatewayReply![(isOldPrefix ? OldGatewayReplyPrefixLen : GatewayReplyPrefixLen)..];
|
||||
var dot = afterPrefix.IndexOf('.');
|
||||
if (dot <= 0)
|
||||
return false;
|
||||
@@ -137,10 +185,10 @@ public static class ReplyMapper
|
||||
{
|
||||
hash = 0;
|
||||
|
||||
if (!HasGatewayReplyPrefix(gatewayReply))
|
||||
if (!IsGatewayRoutedSubject(gatewayReply, out var isOldPrefix))
|
||||
return false;
|
||||
|
||||
var afterPrefix = gatewayReply![GatewayReplyPrefix.Length..];
|
||||
var afterPrefix = gatewayReply![(isOldPrefix ? OldGatewayReplyPrefixLen : GatewayReplyPrefixLen)..];
|
||||
|
||||
// Skip clusterId
|
||||
var firstDot = afterPrefix.IndexOf('.');
|
||||
|
||||
Reference in New Issue
Block a user