Document gateway manager behavior and checkpoint doc-fix scan outputs

This commit is contained in:
Joseph Doherty
2026-03-14 01:41:19 -04:00
parent 88a82ee860
commit 007baf3fa4
4 changed files with 70428 additions and 0 deletions

View File

@@ -28,12 +28,33 @@ public sealed class GatewayRegistration
internal long _messagesSent;
internal long _messagesReceived;
/// <summary>
/// Gets or sets gateway name.
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Gets or sets current connection state.
/// </summary>
public GatewayConnectionState State { get; set; } = GatewayConnectionState.Connecting;
/// <summary>
/// Gets or sets UTC timestamp when gateway reached connected state.
/// </summary>
public DateTime ConnectedAtUtc { get; set; }
/// <summary>
/// Gets or sets UTC timestamp when gateway reached disconnected state.
/// </summary>
public DateTime? DisconnectedAtUtc { get; set; }
/// <summary>
/// Gets or sets remote endpoint address string.
/// </summary>
public string? RemoteAddress { get; set; }
/// <summary>
/// Gets or sets cumulative messages sent counter.
/// </summary>
public long MessagesSent { get => Interlocked.Read(ref _messagesSent); set => Interlocked.Exchange(ref _messagesSent, value); }
/// <summary>
/// Gets or sets cumulative messages received counter.
/// </summary>
public long MessagesReceived { get => Interlocked.Read(ref _messagesReceived); set => Interlocked.Exchange(ref _messagesReceived, value); }
}
@@ -43,11 +64,28 @@ public sealed class GatewayRegistration
/// </summary>
public sealed class GatewayReconnectPolicy
{
/// <summary>
/// Gets initial reconnect delay.
/// </summary>
public TimeSpan InitialDelay { get; init; } = TimeSpan.FromSeconds(1);
/// <summary>
/// Gets maximum reconnect delay.
/// </summary>
public TimeSpan MaxDelay { get; init; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Gets random jitter factor applied to reconnect delays.
/// </summary>
public double JitterFactor { get; init; } = 0.2;
/// <summary>
/// Gets maximum reconnect attempts.
/// </summary>
public int MaxAttempts { get; init; } = int.MaxValue; // 0 = unlimited
/// <summary>
/// Calculates exponential reconnect delay without jitter.
/// </summary>
/// <param name="attempt">Reconnect attempt index.</param>
/// <returns>Calculated delay.</returns>
public TimeSpan CalculateDelay(int attempt)
{
var baseDelay = InitialDelay.TotalMilliseconds * Math.Pow(2, Math.Min(attempt, 10));
@@ -55,6 +93,11 @@ public sealed class GatewayReconnectPolicy
return TimeSpan.FromMilliseconds(capped);
}
/// <summary>
/// Calculates exponential reconnect delay with jitter.
/// </summary>
/// <param name="attempt">Reconnect attempt index.</param>
/// <returns>Calculated jittered delay.</returns>
public TimeSpan CalculateDelayWithJitter(int attempt)
{
var delay = CalculateDelay(attempt);
@@ -84,12 +127,34 @@ public sealed class GatewayManager : IAsyncDisposable
private Socket? _listener;
private Task? _acceptLoopTask;
/// <summary>
/// Gets local gateway listener endpoint in host:port form.
/// </summary>
public string ListenEndpoint => $"{_options.Host}:{_options.Port}";
/// <summary>
/// Gets number of forwarded JetStream cluster messages.
/// </summary>
public long ForwardedJetStreamClusterMessages => Interlocked.Read(ref _forwardedJetStreamClusterMessages);
/// <summary>
/// Determines whether interest-only forwarding should occur for a publish.
/// </summary>
/// <param name="subList">Subscription list to evaluate.</param>
/// <param name="account">Account name.</param>
/// <param name="subject">Published subject.</param>
/// <returns>True when remote interest exists.</returns>
internal static bool ShouldForwardInterestOnly(SubList subList, string account, string subject)
=> subList.HasRemoteInterest(account, subject);
/// <summary>
/// Creates a gateway manager.
/// </summary>
/// <param name="options">Gateway options.</param>
/// <param name="stats">Server stats sink.</param>
/// <param name="serverId">Local server ID.</param>
/// <param name="remoteSubSink">Remote subscription callback.</param>
/// <param name="messageSink">Inbound gateway message callback.</param>
/// <param name="logger">Logger instance.</param>
public GatewayManager(
GatewayOptions options,
ServerStats stats,
@@ -110,6 +175,9 @@ public sealed class GatewayManager : IAsyncDisposable
/// Validates gateway options for required fields and basic endpoint correctness.
/// Go reference: validateGatewayOptions.
/// </summary>
/// <param name="options">Gateway options to validate.</param>
/// <param name="error">Validation error message when invalid.</param>
/// <returns>True when options are valid.</returns>
public static bool ValidateGatewayOptions(GatewayOptions? options, out string? error)
{
if (options is null)
@@ -156,6 +224,7 @@ public sealed class GatewayManager : IAsyncDisposable
/// Processes a gateway info message from a peer, discovering new gateway clusters.
/// Go reference: server/gateway.go:800-850 (processImplicitGateway).
/// </summary>
/// <param name="gwInfo">Discovered gateway info.</param>
public void ProcessImplicitGateway(GatewayInfo gwInfo)
{
ArgumentNullException.ThrowIfNull(gwInfo);
@@ -171,6 +240,8 @@ public sealed class GatewayManager : IAsyncDisposable
/// Returns 0 if no reconnect attempt has been recorded yet.
/// Go reference: server/gateway.go reconnectGateway attempt tracking.
/// </summary>
/// <param name="gatewayName">Gateway name.</param>
/// <returns>Reconnect attempt count.</returns>
public int GetReconnectAttempts(string gatewayName)
=> _reconnectAttempts.TryGetValue(gatewayName, out var n) ? n : 0;
@@ -178,6 +249,7 @@ public sealed class GatewayManager : IAsyncDisposable
/// Resets the reconnect attempt counter for a named gateway (called on successful connection).
/// Go reference: server/gateway.go solicitGateway successful connect path.
/// </summary>
/// <param name="gatewayName">Gateway name.</param>
public void ResetReconnectAttempts(string gatewayName)
=> _reconnectAttempts.TryRemove(gatewayName, out _);
@@ -186,6 +258,9 @@ public sealed class GatewayManager : IAsyncDisposable
/// Increments the attempt counter, waits the backoff delay, then attempts to connect.
/// Go reference: server/gateway.go reconnectGateway / solicitGateway.
/// </summary>
/// <param name="gatewayName">Gateway name.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A task that completes after backoff delay.</returns>
public async Task ReconnectGatewayAsync(string gatewayName, CancellationToken ct)
{
var attempt = _reconnectAttempts.AddOrUpdate(gatewayName, 1, (_, n) => n + 1);
@@ -198,6 +273,11 @@ public sealed class GatewayManager : IAsyncDisposable
await Task.Delay(delay, ct);
}
/// <summary>
/// Starts gateway listener and outbound connector loops.
/// </summary>
/// <param name="ct">Cancellation token.</param>
/// <returns>A completed task when startup work is scheduled.</returns>
public Task StartAsync(CancellationToken ct)
{
_cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
@@ -228,24 +308,51 @@ public sealed class GatewayManager : IAsyncDisposable
return Task.CompletedTask;
}
/// <summary>
/// Forwards a message to all connected gateways.
/// </summary>
/// <param name="account">Publishing account.</param>
/// <param name="subject">Message subject.</param>
/// <param name="replyTo">Optional reply subject.</param>
/// <param name="payload">Message payload.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A task that completes when forwarding finishes.</returns>
public async Task ForwardMessageAsync(string account, string subject, string? replyTo, ReadOnlyMemory<byte> payload, CancellationToken ct)
{
foreach (var connection in _connections.Values)
await connection.SendMessageAsync(account, subject, replyTo, payload, ct);
}
/// <summary>
/// Forwards a JetStream cluster message to gateways.
/// </summary>
/// <param name="message">Gateway message envelope.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A task that completes when forwarding finishes.</returns>
public async Task ForwardJetStreamClusterMessageAsync(GatewayMessage message, CancellationToken ct)
{
Interlocked.Increment(ref _forwardedJetStreamClusterMessages);
await ForwardMessageAsync(message.Account, message.Subject, message.ReplyTo, message.Payload, ct);
}
/// <summary>
/// Propagates a local subscription to gateway peers.
/// </summary>
/// <param name="account">Account name.</param>
/// <param name="subject">Subscribed subject.</param>
/// <param name="queue">Optional queue group.</param>
public void PropagateLocalSubscription(string account, string subject, string? queue)
{
foreach (var connection in _connections.Values)
_ = connection.SendAPlusAsync(account, subject, queue, _cts?.Token ?? CancellationToken.None);
}
/// <summary>
/// Propagates a local unsubscription to gateway peers.
/// </summary>
/// <param name="account">Account name.</param>
/// <param name="subject">Unsubscribed subject.</param>
/// <param name="queue">Optional queue group.</param>
public void PropagateLocalUnsubscription(string account, string subject, string? queue)
{
foreach (var connection in _connections.Values)
@@ -257,6 +364,9 @@ public sealed class GatewayManager : IAsyncDisposable
/// them in that connection's per-account subscription set.
/// Go: gateway.go — account-specific subscription propagation on outbound routes.
/// </summary>
/// <param name="gatewayName">Gateway name.</param>
/// <param name="account">Account name.</param>
/// <param name="subjects">Subjects to propagate.</param>
public void SendAccountSubscriptions(string gatewayName, string account, IEnumerable<string> subjects)
{
if (!_connections.TryGetValue(gatewayName, out var conn)) return;
@@ -268,6 +378,9 @@ public sealed class GatewayManager : IAsyncDisposable
/// Returns a snapshot of all subjects tracked for the given account on the named gateway connection.
/// Returns an empty set when the connection is not found.
/// </summary>
/// <param name="gatewayName">Gateway name.</param>
/// <param name="account">Account name.</param>
/// <returns>Tracked subject set.</returns>
public IReadOnlySet<string> GetAccountSubscriptions(string gatewayName, string account)
{
if (!_connections.TryGetValue(gatewayName, out var conn))
@@ -281,6 +394,8 @@ public sealed class GatewayManager : IAsyncDisposable
/// Registers a new gateway by name, starting in the Connecting state.
/// Go reference: server/gateway.go solicitGateway creates outbound entry before dialling.
/// </summary>
/// <param name="name">Gateway name.</param>
/// <param name="remoteAddress">Optional remote endpoint address.</param>
public void RegisterGateway(string name, string? remoteAddress = null)
{
var reg = new GatewayRegistration
@@ -297,6 +412,8 @@ public sealed class GatewayManager : IAsyncDisposable
/// Setting Connected stamps ConnectedAtUtc; setting Disconnected stamps DisconnectedAtUtc.
/// Go reference: server/gateway.go gwConnState transitions.
/// </summary>
/// <param name="name">Gateway name.</param>
/// <param name="state">New connection state.</param>
public void UpdateState(string name, GatewayConnectionState state)
{
if (!_registrations.TryGetValue(name, out var reg)) return;
@@ -311,6 +428,8 @@ public sealed class GatewayManager : IAsyncDisposable
/// Returns the registration for the named gateway, or null if not registered.
/// Go reference: server/gateway.go server.getOutboundGatewayConnection.
/// </summary>
/// <param name="name">Gateway name.</param>
/// <returns>Registration snapshot, or null.</returns>
public GatewayRegistration? GetRegistration(string name)
=> _registrations.TryGetValue(name, out var reg) ? reg : null;
@@ -324,6 +443,7 @@ public sealed class GatewayManager : IAsyncDisposable
/// Removes the named gateway registration.
/// Go reference: server/gateway.go outboundGateway teardown.
/// </summary>
/// <param name="name">Gateway name.</param>
public void UnregisterGateway(string name)
=> _registrations.TryRemove(name, out _);
@@ -352,6 +472,8 @@ public sealed class GatewayManager : IAsyncDisposable
/// Returns true if an inbound gateway connection exists for the given remote server id.
/// Go reference: server/gateway.go srvGateway.hasInbound.
/// </summary>
/// <param name="remoteServerId">Remote server ID.</param>
/// <returns>True when inbound exists.</returns>
public bool HasInbound(string remoteServerId)
=> _connections.Values.Any(c => !c.IsOutbound && string.Equals(c.RemoteId, remoteServerId, StringComparison.Ordinal));
@@ -359,6 +481,8 @@ public sealed class GatewayManager : IAsyncDisposable
/// Returns the first outbound gateway connection for the given remote server id, or null.
/// Go reference: server/gateway.go getOutboundGatewayConnection.
/// </summary>
/// <param name="remoteServerId">Remote server ID.</param>
/// <returns>Outbound connection or null.</returns>
public GatewayConnection? GetOutboundGatewayConnection(string remoteServerId)
=> _connections.Values.FirstOrDefault(c => c.IsOutbound && string.Equals(c.RemoteId, remoteServerId, StringComparison.Ordinal));
@@ -380,6 +504,7 @@ public sealed class GatewayManager : IAsyncDisposable
/// Atomically increments the messages-sent counter for the named gateway.
/// Go reference: server/gateway.go outboundGateway.msgs.
/// </summary>
/// <param name="name">Gateway name.</param>
public void IncrementMessagesSent(string name)
{
if (_registrations.TryGetValue(name, out var reg))
@@ -390,12 +515,17 @@ public sealed class GatewayManager : IAsyncDisposable
/// Atomically increments the messages-received counter for the named gateway.
/// Go reference: server/gateway.go inboundGateway.msgs.
/// </summary>
/// <param name="name">Gateway name.</param>
public void IncrementMessagesReceived(string name)
{
if (_registrations.TryGetValue(name, out var reg))
Interlocked.Increment(ref reg._messagesReceived);
}
/// <summary>
/// Stops listener, tears down gateway connections, and releases resources.
/// </summary>
/// <returns>A task that completes when disposal finishes.</returns>
public async ValueTask DisposeAsync()
{
if (_cts == null)