feat(gateways): implement GatewayInterestTracker for interest-only mode state machine (D1)

Ports the gateway interest-only mode from Go (gateway.go:100-150, 1500-1600):

- Add GatewayInterestTracker with Optimistic/Transitioning/InterestOnly modes
- In Optimistic mode, track no-interest set; switch to InterestOnly when set
  exceeds threshold (default 1000, matching Go defaultGatewayMaxRUnsubThreshold)
- In InterestOnly mode, only forward subjects with tracked RS+ interest;
  use SubjectMatch.MatchLiteral for wildcard pattern support
- Integrate tracker into GatewayConnection: A+/A- messages update tracker,
  SendMessageAsync skips send when ShouldForward returns false
- Expose InterestTracker property on GatewayConnection for observability
- Add 13 unit tests covering all 8 specified behaviors plus edge cases
This commit is contained in:
Joseph Doherty
2026-02-24 15:00:23 -05:00
parent b6c373c5e4
commit 27faf64548
3 changed files with 452 additions and 4 deletions

View File

@@ -0,0 +1,190 @@
// Go: gateway.go:100-150 (InterestMode enum)
// Go: gateway.go:1500-1600 (switchToInterestOnlyMode)
using System.Collections.Concurrent;
using NATS.Server.Subscriptions;
namespace NATS.Server.Gateways;
/// <summary>
/// Tracks the interest mode for each account on a gateway connection.
/// In Optimistic mode, all messages are forwarded unless a subject is in the
/// no-interest set. Once the no-interest set exceeds the threshold (1000),
/// the account switches to InterestOnly mode where only subjects with tracked
/// RS+ interest are forwarded.
/// </summary>
public enum GatewayInterestMode
{
/// <summary>Forward everything (initial state). Track subjects with no interest.</summary>
Optimistic,
/// <summary>Mode transition in progress.</summary>
Transitioning,
/// <summary>Only forward subjects with known remote interest (RS+ received).</summary>
InterestOnly,
}
/// <summary>
/// Per-account interest state machine for a gateway connection.
/// Go reference: gateway.go:100-150 (struct srvGateway, interestMode fields),
/// gateway.go:1500-1600 (switchToInterestOnlyMode, processGatewayAccountUnsub).
/// </summary>
public sealed class GatewayInterestTracker
{
/// <summary>
/// Number of no-interest subjects before switching to InterestOnly mode.
/// Go: gateway.go:134 (defaultGatewayMaxRUnsubThreshold = 1000)
/// </summary>
public const int DefaultNoInterestThreshold = 1000;
private readonly int _noInterestThreshold;
// Per-account state: mode + no-interest set (Optimistic) or positive interest set (InterestOnly)
private readonly ConcurrentDictionary<string, AccountState> _accounts = new(StringComparer.Ordinal);
public GatewayInterestTracker(int noInterestThreshold = DefaultNoInterestThreshold)
{
_noInterestThreshold = noInterestThreshold;
}
/// <summary>
/// Returns the current interest mode for the given account.
/// Accounts default to Optimistic until the no-interest threshold is exceeded.
/// </summary>
public GatewayInterestMode GetMode(string account)
=> _accounts.TryGetValue(account, out var state) ? state.Mode : GatewayInterestMode.Optimistic;
/// <summary>
/// Track a positive interest (RS+ received from remote) for an account/subject.
/// Go: gateway.go:1540 (processGatewayAccountSub — adds to interest set)
/// </summary>
public void TrackInterest(string account, string subject)
{
var state = GetOrCreateState(account);
lock (state)
{
// In Optimistic mode, remove from no-interest set if present
if (state.Mode == GatewayInterestMode.Optimistic)
{
state.NoInterestSet.Remove(subject);
return;
}
// In InterestOnly mode, add to the positive interest set
if (state.Mode == GatewayInterestMode.InterestOnly)
{
state.InterestSet.Add(subject);
}
}
}
/// <summary>
/// Track a no-interest event (RS- received from remote) for an account/subject.
/// When the no-interest set crosses the threshold, switches to InterestOnly mode.
/// Go: gateway.go:1560 (processGatewayAccountUnsub — tracks no-interest, triggers switch)
/// </summary>
public void TrackNoInterest(string account, string subject)
{
var state = GetOrCreateState(account);
lock (state)
{
if (state.Mode == GatewayInterestMode.InterestOnly)
{
// In InterestOnly mode, remove from positive interest set
state.InterestSet.Remove(subject);
return;
}
if (state.Mode == GatewayInterestMode.Optimistic)
{
state.NoInterestSet.Add(subject);
if (state.NoInterestSet.Count >= _noInterestThreshold)
DoSwitchToInterestOnly(state);
}
}
}
/// <summary>
/// Determines whether a message should be forwarded to the remote gateway
/// for the given account and subject.
/// Go: gateway.go:2900 (shouldForwardMsg — checks mode and interest)
/// </summary>
public bool ShouldForward(string account, string subject)
{
if (!_accounts.TryGetValue(account, out var state))
return true; // Optimistic by default — no state yet means forward
lock (state)
{
return state.Mode switch
{
GatewayInterestMode.Optimistic =>
// Forward unless subject is in no-interest set
!state.NoInterestSet.Contains(subject),
GatewayInterestMode.Transitioning =>
// During transition, be conservative and forward
true,
GatewayInterestMode.InterestOnly =>
// Only forward if at least one interest pattern matches
MatchesAnyInterest(state, subject),
_ => true,
};
}
}
/// <summary>
/// Explicitly switch an account to InterestOnly mode.
/// Called when the remote signals it is in interest-only mode.
/// Go: gateway.go:1500 (switchToInterestOnlyMode)
/// </summary>
public void SwitchToInterestOnly(string account)
{
var state = GetOrCreateState(account);
lock (state)
{
if (state.Mode != GatewayInterestMode.InterestOnly)
DoSwitchToInterestOnly(state);
}
}
// ── Private helpers ────────────────────────────────────────────────
private AccountState GetOrCreateState(string account)
=> _accounts.GetOrAdd(account, _ => new AccountState());
private static void DoSwitchToInterestOnly(AccountState state)
{
// Go: gateway.go:1510-1530 — clear no-interest, build positive interest from what remains
state.Mode = GatewayInterestMode.InterestOnly;
state.NoInterestSet.Clear();
// InterestSet starts empty; subsequent RS+ events will populate it
}
private static bool MatchesAnyInterest(AccountState state, string subject)
{
foreach (var pattern in state.InterestSet)
{
// Use SubjectMatch.MatchLiteral to support wildcard patterns in the interest set
if (SubjectMatch.MatchLiteral(subject, pattern))
return true;
}
return false;
}
/// <summary>Per-account mutable state. All access must be under the instance lock.</summary>
private sealed class AccountState
{
public GatewayInterestMode Mode { get; set; } = GatewayInterestMode.Optimistic;
/// <summary>Subjects with no remote interest (used in Optimistic mode).</summary>
public HashSet<string> NoInterestSet { get; } = new(StringComparer.Ordinal);
/// <summary>Subjects/patterns with positive remote interest (used in InterestOnly mode).</summary>
public HashSet<string> InterestSet { get; } = new(StringComparer.Ordinal);
}
}